From 326c8b7ba8a8278b14eab3d3b78f406331022ae8 Mon Sep 17 00:00:00 2001 From: Dejvino Date: Thu, 25 Jun 2026 19:53:57 +0200 Subject: [PATCH] fix journal_update bugs, persist last prompt across restarts - Coerce string add/done to list in journal_update tool - Rewrite _update_journal with section-based parsing (no broken index tracking) - Add duplicate prevention, blank line collapsing - Save last DM prompt to session/last_prompt.md so game resumes from last scene on restart instead of regenerating --- tools/engine.py | 229 +++++++++++++++++++++++++++++++----------------- tools/run.py | 28 +++++- 2 files changed, 176 insertions(+), 81 deletions(-) diff --git a/tools/engine.py b/tools/engine.py index b13e436..3b4993f 100644 --- a/tools/engine.py +++ b/tools/engine.py @@ -53,10 +53,6 @@ class TurnResult: book_log: str = "" user_prompt: str = "" ambience: Optional[str] = None - character_updates: Optional[str] = None - world_updates: Optional[str] = None - journal_add: list[str] = field(default_factory=list) - journal_done: list[str] = field(default_factory=list) error: Optional[str] = None debug_info: str = "" @@ -109,21 +105,26 @@ The **finalize_turn** tool produces all data for this turn: - **book_log** — Narrative of what happened this turn. Appended to the story book. - **user_prompt** — What the player sees next: describe the situation and ask what they do. - **ambience** — One of: silence, calm, combat, dungeon, forest, tavern, tension, town, wilds. -- **character_updates** — Full character sheet (ONLY if HP/cash/gear/stats changed, otherwise omit). -- **world_updates** — Full world state (ONLY if NPCs/locations/threads changed, otherwise omit). -- **journal_add** — New TODO items (quests, goals, leads). -- **journal_done** — Completed TODO items. ### Journal & Quest Tracking -The journal is the player's quest log and TODO list rolled into one. Use it to track the bigger picture: +The journal is the player's quest log and TODO list. Use dedicated tools to manage it: -- **Add quests** as they arise: `journal_add: ["Investigate the Weeper beneath the mill"]` -- **Mark sub-tasks** as they emerge: `journal_add: ["Find a way to open the iron grate", "Question Rina about the cult"]` -- **Mark completed** when resolved: `journal_done: ["Investigate the Weeper beneath the mill"]` +- **`journal_get`** — Read the full journal to review quests. +- **`journal_update`** — Add new quests/goals via `"add"` and mark completed via `"done"`. +- **Add quests** as they arise: `{"add": ["Investigate the Weeper beneath the mill"]}` +- **Mark sub-tasks** as they emerge: `{"add": ["Find a way to open the iron grate", "Question Rina about the cult"]}` +- **Mark completed** when resolved: `{"done": ["Investigate the Weeper beneath the mill"]}` - **Keep descriptions specific** — vague entries like "Explore the dungeon" are not helpful. -- **Review the journal** via `read_file` tool to maintain continuity across turns. -- Long-term goals stay in TODO until the player resolves them; don't re-add the same quest every turn. +- **Review the journal** regularly to maintain continuity. +- Long-term goals stay in TODO until resolved; don't re-add the same quest every turn. + +### Character & World State + +To read or update state files, use the dedicated tools: + +- **`character_get`** / **`character_update`** — Read or replace the full character sheet. ONLY update when HP/cash/gear/stats change. +- **`world_get`** / **`world_update`** — Read or replace the full world state. ONLY update when NPCs/locations/threads change. IMPORTANT: You MUST call **finalize_turn** to end the turn. Until then you will be called again to continue thinking and gathering information. @@ -148,7 +149,13 @@ Every tool call **must** include a `"dm_status"` string in `args` — a short, p - **read_file** — Read a game state file. `{"file": "character|world|book|log|journal", "dm_status": "..."}` - **roll** — Auto-roll dice (outcome shown in status). `{"dice": "2d6", "modifier": "-1", "dm_status": "..."}` - **player_roll** — Ask the player to roll physical dice. **Use when the outcome is uncertain.** `{"dice": "2d6", "reason": "why", "dm_status": "..."}` -- **finalize_turn** — **Complete the turn.** Provide all turn data as args. **Must include** `"dm_status"`. +- **character_get** — Read the full character sheet. `{"dm_status": "..."}` +- **character_update** — Replace the character sheet (full content). `{"content": "...", "dm_status": "..."}` +- **world_get** — Read the full world state. `{"dm_status": "..."}` +- **world_update** — Replace the world state (full content). `{"content": "...", "dm_status": "..."}` +- **journal_get** — Read the journal (TODO / DONE). `{"dm_status": "..."}` +- **journal_update** — Add or complete journal entries. `{"add": [...], "done": [...], "dm_status": "..."}` +- **finalize_turn** — **Complete the turn.** Provide all turn data as args. When the player makes a choice, resolve it with the dice mechanics above. Describe the action, roll dice implicitly (describe the outcome, don't say "rolling dice"), apply damage/effects, and update state. @@ -437,16 +444,36 @@ class GameEngine: "description": "Ask the player to physically roll dice and enter the result.", "args": {"dice": "e.g. 2d6+1", "reason": "Why the roll is needed (shown to player)"}, }, + "character_get": { + "description": "Read the full character sheet.", + "args": {}, + }, + "character_update": { + "description": "Replace the character sheet with a new full version (ONLY if HP/cash/gear/stats changed).", + "args": {"content": "Full character sheet markdown"}, + }, + "world_get": { + "description": "Read the full world state.", + "args": {}, + }, + "world_update": { + "description": "Replace the world state with a new full version (ONLY if NPCs/locations/threads changed).", + "args": {"content": "Full world state markdown"}, + }, + "journal_get": { + "description": "Read the journal (TODO / DONE).", + "args": {}, + }, + "journal_update": { + "description": "Add or complete journal entries.", + "args": {"add": "Optional: list of new TODO items", "done": "Optional: list of completed items"}, + }, "finalize_turn": { "description": "Complete the turn with all required data.", "args": { "book_log": "Narrative of what happened (appended to story book)", "user_prompt": "What the player sees next — describe and ask what they do", "ambience": "Optional: soundscape name", - "character_updates": "Optional: full character sheet if changed", - "world_updates": "Optional: full world state if changed", - "journal_add": "Optional: list of new TODO items", - "journal_done": "Optional: list of completed TODO items", }, }, } @@ -490,6 +517,45 @@ class GameEngine: mod_str = f" {'+' if mod >= 0 else ''}{mod}" if mod != 0 else "" return f"Roll: {dice_str}{mod_str} → [{', '.join(str(r) for r in rolls)}] = {total}" + def _tool_character_get(self, args: dict) -> str: + return self._read_file(CHAR_PATH) or "*Character sheet is empty.*" + + def _tool_character_update(self, args: dict) -> str: + content = (args or {}).get("content", "") + if not content: + return "**Error:** `content` is required." + if not self._validate_update_size("character", content, CHAR_PATH): + return "**Error:** Update rejected — content is too short (likely a partial paste)." + CHAR_PATH.write_text(content.strip() + "\n") + return "Character sheet updated." + + def _tool_world_get(self, args: dict) -> str: + return self._read_file(WORLD_PATH) or "*World state is empty.*" + + def _tool_world_update(self, args: dict) -> str: + content = (args or {}).get("content", "") + if not content: + return "**Error:** `content` is required." + if not self._validate_update_size("world", content, WORLD_PATH): + return "**Error:** Update rejected — content is too short (likely a partial paste)." + WORLD_PATH.write_text(content.strip() + "\n") + return "World state updated." + + def _tool_journal_get(self, args: dict) -> str: + return self._read_file(JOURNAL_PATH) or "*Journal is empty.*" + + def _tool_journal_update(self, args: dict) -> str: + add = (args or {}).get("add", []) + done = (args or {}).get("done", []) + if isinstance(add, str): + add = [add] + if isinstance(done, str): + done = [done] + if not add and not done: + return "**Error:** Provide at least one of `add` or `done`." + self._update_journal(add=add, done=done) + return "Journal updated." + @staticmethod def _describe_tool_action(tool_name: str, args: dict) -> str: """Return a user-facing status message for a tool call. @@ -508,6 +574,13 @@ class GameEngine: if tool_name == "read_file": file = (args or {}).get("file", "") desc = read_descriptions.get(file, f"reading {file}") + elif tool_name in ("character_get", "world_get", "journal_get"): + file = tool_name.replace("_get", "") + desc = read_descriptions.get(file, f"reading {file}") + elif tool_name in ("character_update", "world_update"): + desc = "updating the records" + elif tool_name == "journal_update": + desc = "updating the journal" elif tool_name == "roll": dice = (args or {}).get("dice", "1d6") mod = (args or {}).get("modifier") @@ -526,6 +599,12 @@ class GameEngine: "read_file": self._tool_read_file, "roll": self._tool_roll, "think": self._tool_think, + "character_get": self._tool_character_get, + "character_update": self._tool_character_update, + "world_get": self._tool_world_get, + "world_update": self._tool_world_update, + "journal_get": self._tool_journal_get, + "journal_update": self._tool_journal_update, } fn = fn_map.get(tool_name) if not fn: @@ -664,10 +743,6 @@ class GameEngine: book_log=args.get("book_log", ""), user_prompt=args.get("user_prompt", ""), ambience=args.get("ambience"), - character_updates=args.get("character_updates"), - world_updates=args.get("world_updates"), - journal_add=args.get("journal_add", []), - journal_done=args.get("journal_done", []), ) # Execute other tools @@ -797,25 +872,9 @@ class GameEngine: def apply_state(self, result: TurnResult) -> None: """Write state changes from a TurnResult to disk.""" - - if result.character_updates and self._validate_update_size( - "character", result.character_updates, CHAR_PATH - ): - CHAR_PATH.write_text(result.character_updates.strip() + "\n") - - if result.world_updates and self._validate_update_size( - "world", result.world_updates, WORLD_PATH - ): - WORLD_PATH.write_text(result.world_updates.strip() + "\n") - if result.ambience: AMBIENCE_PATH.write_text(result.ambience.strip().lower() + "\n") - if result.journal_add or result.journal_done: - self._update_journal( - add=result.journal_add, done=result.journal_done - ) - def archive_turn(self, narrative: str) -> None: """Append the narrative as a new turn in book.md.""" timestamp = datetime.now().strftime("%Y-%m-%d %H:%M") @@ -840,54 +899,68 @@ class GameEngine: if not JOURNAL_PATH.exists(): JOURNAL_PATH.write_text("# Journal\n\n## TODO\n\n## DONE\n\n") lines = JOURNAL_PATH.read_text().splitlines() - new_lines = [] - in_todo = False - in_done = False + + # Parse into sections: everything before TODO, TODO items, between, DONE items, after + todo_items: list[str] = [] + done_items: list[str] = [] + before_todo: list[str] = [] + between: list[str] = [] + after_done: list[str] = [] + section = "before_todo" for line in lines: - stripped = line.strip() - if stripped.startswith("## TODO"): - in_todo = True - in_done = False - elif stripped.startswith("## DONE"): - in_todo = False - in_done = True - new_lines.append(line) - - # Find insertion points - todo_idx = None - done_idx = None - for i, line in enumerate(lines): stripped = line.strip() if stripped == "## TODO": - todo_idx = i + section = "todo" + before_todo.append(line) elif stripped == "## DONE": - done_idx = i + section = "done" + between.append(line) + elif section == "before_todo": + before_todo.append(line) + elif section == "todo": + if stripped.startswith("- "): + todo_items.append(stripped[2:]) + else: + between.append(line) + elif section == "done": + if stripped.startswith("- "): + done_items.append(stripped[2:]) + else: + after_done.append(line) + # Apply changes if done: - for item in done: - # Remove from TODO if present - new_lines = [ - l for l in new_lines - if l.strip().lstrip("- ").lstrip("☐ ") != item - ] - # Find DONE section and add - if done_idx is not None: - done_entry = f"- {item}" - if done_idx + 1 < len(new_lines): - new_lines.insert(done_idx + 1, done_entry) - else: - new_lines.append(done_entry) + done_set = set(done) + todo_items = [i for i in todo_items if i not in done_set] + new_done = [i for i in done if i not in done_items] + done_items.extend(new_done) if add: - for item in add: - entry = f"- {item}" - if entry not in new_lines: - if todo_idx is not None: - new_lines.insert(todo_idx + 1, entry) - else: - new_lines.append(entry) + todo_set = set(todo_items) + new_todo = [i for i in add if i not in todo_set] + # Insert new items at the top of TODO + todo_items = new_todo + todo_items - JOURNAL_PATH.write_text("\n".join(new_lines) + "\n") + # Reconstruct + out = list(before_todo) + for item in todo_items: + out.append(f"- {item}") + out.extend(between) + for item in done_items: + out.append(f"- {item}") + out.extend(after_done) + + # Clean up: collapse multiple blank lines + cleaned = [] + prev_blank = False + for line in out: + is_blank = line.strip() == "" + if is_blank and prev_blank: + continue + cleaned.append(line) + prev_blank = is_blank + # Ensure trailing newline + JOURNAL_PATH.write_text("\n".join(cleaned) + "\n") # ── CLI entry point (for testing) ───────────────────────────────────────── diff --git a/tools/run.py b/tools/run.py index c03f9cb..4b2fc6b 100755 --- a/tools/run.py +++ b/tools/run.py @@ -44,6 +44,7 @@ JOURNAL_PATH = SESSION / 'journal.md' AMBIENCE_PATH = SESSION / 'ambience.md' AMBIENCE_OPTIONS_PATH = SESSION / 'ambience_options.md' BOOK_PATH = SESSION / 'book.md' +LAST_PROMPT_PATH = SESSION / 'last_prompt.md' AUDIO_DIR = SESSION / 'audio' TODAY = date.today().isoformat() LOG_PATH = LOG_DIR / f'{TODAY}.md' @@ -596,7 +597,19 @@ class ChaosTUI(App): self.call_after_refresh(self._begin_game) def _begin_game(self): - """Generate the first scene of the game.""" + """Resume from last saved prompt or generate an opening scene.""" + if LAST_PROMPT_PATH.exists(): + saved = LAST_PROMPT_PATH.read_text().strip() + if saved: + self._last_prompt = saved + pages = load_book_pages() + parts = [] + if pages: + parts.append(pages[-1]) + parts.append(f"---\n\n{saved}") + self._set_narrative("\n\n".join(parts)) + self._enable_input() + return self._call_llm() # ── Ambience ───────────────────────────────────────── @@ -735,13 +748,22 @@ class ChaosTUI(App): # Display the next user prompt self._display_scene(result) + # Persist the prompt so the game resumes here on restart + if result.user_prompt: + LAST_PROMPT_PATH.write_text(result.user_prompt.strip()) + # Store for next turn self._last_prompt = result.user_prompt self._last_result = result def _display_scene(self, result: TurnResult) -> None: - """Update the UI with the next user prompt.""" - self._set_narrative(result.user_prompt) + """Update the UI with the last story entry followed by the DM prompt.""" + parts = [] + if result.book_log: + parts.append(result.book_log) + if result.user_prompt: + parts.append(f"---\n\n{result.user_prompt}") + self._set_narrative("\n\n".join(parts) if parts else "") self._enable_input() def _enable_input(self) -> None: