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
This commit is contained in:
Dejvino 2026-06-25 19:53:57 +02:00
parent 35c04bdbca
commit 326c8b7ba8
2 changed files with 176 additions and 81 deletions

View File

@ -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) ─────────────────────────────────────────

View File

@ -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: