From 25fb5fd7293aa133b3cac0dcdf23fbb8de2bc3e3 Mon Sep 17 00:00:00 2001 From: Dejvino Date: Sun, 28 Jun 2026 18:53:33 +0200 Subject: [PATCH] Settings store --- session/settings.json | 5 +++ tools/engine.py | 102 ++++++++++++++++++++++++++++++++++++++++-- tools/run.py | 54 ++++++++++++++++++++++ 3 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 session/settings.json diff --git a/session/settings.json b/session/settings.json new file mode 100644 index 0000000..37ed6d6 --- /dev/null +++ b/session/settings.json @@ -0,0 +1,5 @@ +{ + "active_tab": "play-tab", + "music_muted": true, + "book_page": 16 +} diff --git a/tools/engine.py b/tools/engine.py index 3c5e0c1..b3248cb 100644 --- a/tools/engine.py +++ b/tools/engine.py @@ -691,6 +691,84 @@ class GameEngine: return "; ".join(parts) if parts else "" return "" + @staticmethod + def _parse_changes_block(changes_block: str) -> list[dict]: + """Parse a ### Changes block into tool call dicts. + + Handles the standard format: + - Current Health: N + - Cash: N + - Max Health: N + - Added to inventory: item1, item2 + - Removed from inventory: item1, item2 + - Replaced gear: X → Y + - Note: text + - Journal add: item1, item2 + - Journal done: item1, item2 + """ + calls = [] + for raw_line in changes_block.split("\n"): + line = raw_line.strip() + if not line.startswith("- "): + continue + content = line[2:].strip() + + m = re.match(r"Current Health:\s*(\d+)", content, re.IGNORECASE) + if m: + calls.append({"tool": "modify_vitals", "args": {"current_hp": m.group(1)}}) + continue + + m = re.match(r"Cash:\s*(\d+)", content, re.IGNORECASE) + if m: + calls.append({"tool": "modify_vitals", "args": {"cash": m.group(1)}}) + continue + + m = re.match(r"Max Health:\s*(\d+)", content, re.IGNORECASE) + if m: + calls.append({"tool": "modify_vitals", "args": {"max_hp": m.group(1)}}) + continue + + m = re.match(r"Add(?:ed)? to inventory:\s*(.+)", content, re.IGNORECASE) + if m: + for item in [i.strip() for i in m.group(1).split(",") if i.strip()]: + calls.append({"tool": "add_to_inventory", "args": {"item": item}}) + continue + + m = re.match(r"Remov(?:e|ed) from inventory:\s*(.+)", content, re.IGNORECASE) + if m: + for item in [i.strip() for i in m.group(1).split(",") if i.strip()]: + calls.append({"tool": "remove_from_inventory", "args": {"item": item}}) + continue + + m = re.match(r"Replace(?:d)? gear:\s*(.+?)\s*[→➜]\s*(.+)", content, re.IGNORECASE) + if m: + calls.append({"tool": "replace_gear", "args": {"before": m.group(1).strip(), "after": m.group(2).strip()}}) + continue + + m = re.match(r"Note:\s*(.+)", content, re.IGNORECASE) + if m: + calls.append({"tool": "add_note", "args": {"note": m.group(1).strip()}}) + continue + + m = re.match(r"Journal add:\s*(.+)", content, re.IGNORECASE) + if m: + calls.append({"tool": "journal_update", "args": {"add": [i.strip() for i in m.group(1).split(",") if i.strip()]}}) + continue + + m = re.match(r"Journal done:\s*(.+)", content, re.IGNORECASE) + if m: + calls.append({"tool": "journal_update", "args": {"done": [i.strip() for i in m.group(1).split(",") if i.strip()]}}) + continue + + # Looted from X: Y — narrative fallback, extract what we can + m = re.match(r"Looted from .+:\s*(.+)", content, re.IGNORECASE) + if m: + items_text = m.group(1).strip() + calls.append({"tool": "add_note", "args": {"note": f"Looted: {items_text}"}}) + continue + + return calls + def _execute_tool(self, tool_name: str, args: dict) -> str: fn_map = { "roll": self._tool_roll, @@ -945,7 +1023,24 @@ class GameEngine: user_prompt = self._auto_prompt(book_log) ambience = None phase3_errors = [] + changes = [] # Reset for this outer attempt + # Step 1: Parse ### Changes block directly (deterministic, no LLM) + if changes_block.strip(): + for tc in self._parse_changes_block(changes_block): + name = tc["tool"] + args = tc.get("args", {}) + if name == "finalize_turn": + continue + result = self._execute_tool(name, args) + if result.startswith("**Error:") or result.startswith("Tool error") or result.startswith("Unknown"): + phase3_errors.append(f"{name}: {result}") + else: + desc = self._describe_change(name, args) + if desc: + changes.append(desc) + + # Step 2: LLM Phase 3 for finalize_turn + any extra changes previous_attempt = None # {output, feedback} phase3_ok = False for p3_attempt in range(5): @@ -959,8 +1054,9 @@ class GameEngine: ) if changes_block.strip(): phase3_prompt += ( - f"## Changes to apply\n{changes_block}\n\n" - f"Convert the listed changes into tool calls:\n\n" + f"## Changes already applied\n{changes_block}\n\n" + f"Output the finalize_turn tool to end the turn. " + f"Add extra tool calls if you spot changes the list above missed.\n\n" ) else: phase3_prompt += ( @@ -1043,7 +1139,7 @@ class GameEngine: if not errors: phase3_ok = True debug_info = "" - changes = attempt_changes + changes.extend(attempt_changes) if on_debug: on_debug("phase", {"phase": 3, "status": "done", "applied": len([tc for tc in tool_calls if tc.get("tool") != "finalize_turn"])}) break diff --git a/tools/run.py b/tools/run.py index 7f49596..8775dc9 100755 --- a/tools/run.py +++ b/tools/run.py @@ -47,6 +47,7 @@ AMBIENCE_OPTIONS_PATH = SESSION / 'ambience_options.md' BOOK_PATH = SESSION / 'book.md' LAST_PROMPT_PATH = SESSION / 'last_prompt.md' CHANGES_PATH = SESSION / 'changes.md' +SETTINGS_PATH = SESSION / 'settings.json' AUDIO_DIR = SESSION / 'audio' TODAY = date.today().isoformat() LOG_PATH = LOG_DIR / f'{TODAY}.md' @@ -640,6 +641,36 @@ class ChaosTUI(App): # Debug log self._debug_lines: list[str] = [] + # Settings guard — prevent save during init before restore + self._settings_loaded = False + + # ── Settings persistence ────────────────────────────── + def _load_settings(self) -> dict: + defaults = {"active_tab": "play-tab", "music_muted": False, "book_page": 0} + if SETTINGS_PATH.exists(): + try: + data = json.loads(SETTINGS_PATH.read_text()) + if isinstance(data, dict): + defaults.update(data) + except (json.JSONDecodeError, OSError): + pass + return defaults + + def _save_settings(self) -> None: + if not self._settings_loaded: + return + tabs = self.query_one("#main-tabs", TabbedContent) + active = tabs.active if tabs else "play-tab" + data = { + "active_tab": active, + "music_muted": app_ambience_player.is_muted if app_ambience_player else False, + "book_page": self._book_page, + } + try: + SETTINGS_PATH.write_text(json.dumps(data, indent=2) + "\n") + except OSError: + pass + # ── Compose ────────────────────────────────────────── def compose(self): yield Static(f"⚔ The Chaos ╎ {TODAY}", id="banner") @@ -685,9 +716,26 @@ class ChaosTUI(App): self.set_interval(REFRESH_SECS, self._check_ambience) self.set_interval(REFRESH_SECS, self._reload_book) self.call_after_refresh(self._update_mute_button) + self.call_after_refresh(self._apply_settings) # Start the game self.call_after_refresh(self._begin_game) + def _apply_settings(self) -> None: + """Load and apply persisted UI settings.""" + settings = self._load_settings() + self._book_page = settings.get("book_page", 0) + self._prev_page_count = len(self._book_pages) + self._reload_book() + if settings.get("music_muted") and app_ambience_player and not app_ambience_player.is_muted: + app_ambience_player.toggle_mute() + self._update_mute_button() + try: + tabs = self.query_one("#main-tabs", TabbedContent) + tabs.active = settings.get("active_tab", "play-tab") + except Exception: + pass + self._settings_loaded = True + def _begin_game(self): """Resume from last saved prompt or generate an opening scene.""" if LAST_PROMPT_PATH.exists(): @@ -719,6 +767,7 @@ class ChaosTUI(App): if app_ambience_player: app_ambience_player.toggle_mute() self._update_mute_button() + self._save_settings() def _update_mute_button(self) -> None: btn = self.query_one("#mute-btn", Button) @@ -1099,10 +1148,15 @@ class ChaosTUI(App): @on(Button.Pressed, "#book-prev") def on_book_prev(self): self.action_prev_page() + self._save_settings() @on(Button.Pressed, "#book-next") def on_book_next(self): self.action_next_page() + self._save_settings() + + def on_tabbed_content_tab_activated(self, event: TabbedContent.TabActivated) -> None: + self._save_settings() def main():