| """Per-session game state for The Wizard's Oracles. | |
| Shared dataclasses used by every module. Subagents may extend with optional | |
| fields but must not change existing names/types — this is the contract. | |
| """ | |
| from __future__ import annotations | |
| import random | |
| from dataclasses import dataclass, field | |
| from typing import Optional | |
| NUM_ORACLES = 5 | |
| NUM_TRIALS = 5 | |
| DRAGON_TRIAL = 5 # always the last; uses the fixed dragon template | |
| class Oracle: | |
| index: int # 1-based, 1..NUM_ORACLES | |
| text: str # raw user input — could be anything | |
| opened: bool = False | |
| opened_at_trial: Optional[int] = None | |
| class Obstacle: | |
| index: int # 1..NUM_TRIALS | |
| setup: str # vivid 2-3 sentence description | |
| is_dragon: bool = False | |
| class Resolution: | |
| trial_index: int # 1..NUM_TRIALS | |
| obstacle: Obstacle | |
| oracle: Oracle | |
| narration: str # 2-paragraph passage from the LLM | |
| tactic: str # 1-line summary for the chronicle | |
| image_path: str = "" # filesystem path to a PNG/SVG (or "") | |
| image_caption: str = "" # always-available text fallback | |
| # Supported languages. The keys are passed into LLM prompts via a | |
| # "{language}" placeholder so the model produces narrative in the chosen | |
| # language. The mock fallback templates remain English-only (translating | |
| # them all is its own project); a banner explains this in the UI when the | |
| # user picks a non-English language but the live LLM is unavailable. | |
| LANG_LABELS = { | |
| "en": "English", | |
| "zh": "中文 (Simplified Chinese)", | |
| } | |
| # Narration length presets — player picks one on grimoire spread 0. | |
| # Maps a short key to (display label dict, min words, max words, max_tokens). | |
| # The display label is a `{"en": str, "zh": str}` dict so the dropdown | |
| # can show the same key in the player's chosen language. | |
| # The resolution prompt template's "{narration_min}" / "{narration_max}" | |
| # placeholders are filled from these; max_tokens caps the LLM call so | |
| # longer narrations get enough headroom. | |
| NARRATION_LENGTHS = { | |
| "short": ( | |
| {"en": "Short — sharp & punchy (~120 words)", | |
| "zh": "短——简洁有力(约 120 字)"}, | |
| 90, 140, 500, | |
| ), | |
| "medium": ( | |
| {"en": "Medium — vivid scene (~200 words)", | |
| "zh": "中——情景生动(约 200 字)"}, | |
| 180, 240, 900, | |
| ), | |
| "long": ( | |
| {"en": "Long — full vignette (~320 words)", | |
| "zh": "长——完整小品(约 320 字)"}, | |
| 280, 360, 1300, | |
| ), | |
| "epic": ( | |
| {"en": "Epic — sprawling tale (~480 words)", | |
| "zh": "史诗——汪洋大篇(约 480 字)"}, | |
| 420, 540, 1800, | |
| ), | |
| } | |
| LANG_PROMPT_NAME = { | |
| "en": "English", | |
| "zh": "Simplified Chinese (use 简体中文 throughout; do not write in English)", | |
| } | |
| class GameState: | |
| mode: str = "inscribe" # inscribe | send_off | trial | epilogue | done | |
| current_trial: int = 0 # 0 before any; 1..5 during; 6 after | |
| oracles: list[Oracle] = field(default_factory=list) | |
| obstacles: list[Obstacle] = field(default_factory=list) | |
| resolutions: list[Resolution] = field(default_factory=list) | |
| hero_name: str = "Tobin" | |
| village_name: str = "the Hollow" | |
| epilogue: str = "" | |
| lang: str = "en" # language code, see LANG_LABELS | |
| theme: str = "fantasy" # theme key, see oracles.themes.THEMES | |
| # Per-session visual mode override. "lean" skips heavy decorative PNGs | |
| # (parallax banner, parchment, phase backdrops, scene landscapes, etc.) | |
| # for fast loading. "full" enables them. Empty string ⇒ fall back to | |
| # the ORACLES_VISUAL_MODE env var. The session picks the initial value | |
| # at fresh_state() time and the player can flip it from the inscribe | |
| # step-0 dropdown next to the language picker. | |
| visual_mode: str = "" | |
| # Precomputed resolution cache: (oracle_index, obstacle_index) -> Resolution. | |
| # Populated by a background thread kicked off in send_off so that clicking | |
| # "open oracle" is instant. Skipped in mock mode (mock is already instant). | |
| resolution_cache: dict = field(default_factory=dict) | |
| precompute_in_flight: bool = False # true while the background thread is running | |
| precompute_total: int = 0 # total cells we plan to compute | |
| precompute_done: int = 0 # cells already completed | |
| # Interludes: short narrative passages bridging consecutive trials. | |
| # interludes[i] is the passage shown ABOVE the trial (i+1)'s setup — | |
| # i.e. interludes[0] bridges trial 1's resolution to trial 2's setup, | |
| # interludes[3] is the special "approach to the dragon" lead-in. | |
| # Generated lazily on "Continue" (handle_continue), not precomputed. | |
| interludes: list[str] = field(default_factory=lambda: ["", "", "", ""]) | |
| # Set by generator handlers right before they do slow LLM work — the | |
| # UI yields once with a walking-hero overlay matching this caption, | |
| # then yields again with the caption cleared when the work finishes. | |
| # Empty string = no overlay shown. | |
| processing_msg: str = "" | |
| # Player-controlled narration length per trial. One of the keys in | |
| # NARRATION_LENGTHS below — drives the word-range injected into the | |
| # resolution prompt. Default "medium" matches the prior 180-240 range. | |
| narration_length: str = "medium" | |
| # The grimoire (inscribe phase) advances spread-by-spread through the | |
| # wizard's soliloquy. inscribe_step ∈ [0, 8]: | |
| # 0 = pick language | |
| # 1 = name the hero | |
| # 2 = name the village | |
| # 3..7 = inscribe oracle I..V | |
| # 8 = closing spread; "Let the journey begin" | |
| inscribe_step: int = 0 | |
| # Surfaced through the bundle so per-spread textareas can show an error. | |
| grimoire_error: str = "" | |
| # Branching story graph (fantasy theme only). Populated by | |
| # ``generate_obstacles`` when the player chooses fantasy: walks the | |
| # graph from root to a leaf, records the 5 node ids visited. Empty | |
| # for other themes (those still use the old random-obstacles flow). | |
| # The leaf node's ``ending_id`` selects which epilogue plays. | |
| story_path: list = field(default_factory=list) | |
| # The epilogue mode shows two cards: first the cinematic ENDING card | |
| # (title + banner + image + LLM narrative), then a Continue button | |
| # flips to the SUMMARY card (chronicle list + story-tree viz). | |
| # Substate ∈ {"ending", "summary"}. | |
| epilogue_substate: str = "ending" | |
| def unopened(self) -> list[Oracle]: | |
| return [o for o in self.oracles if not o.opened] | |
| def draw_random_oracle(self, rng: Optional[random.Random] = None) -> Oracle: | |
| rng = rng or random | |
| pool = self.unopened() | |
| if not pool: | |
| raise RuntimeError("no oracles left to draw") | |
| return rng.choice(pool) | |
| def current_obstacle(self) -> Optional[Obstacle]: | |
| """Return the obstacle for ``self.current_trial``, or None. | |
| Returns None when current_trial is 0 (before trials start) or | |
| > NUM_TRIALS (after the epilogue starts) — callers must | |
| always guard against the None return. | |
| """ | |
| if 1 <= self.current_trial <= NUM_TRIALS and self.obstacles: | |
| for ob in self.obstacles: | |
| if ob.index == self.current_trial: | |
| return ob | |
| return None | |
| def fresh_state( | |
| hero_name: str = "Tobin", | |
| village_name: str = "the Hollow", | |
| theme: str = "fantasy", | |
| ) -> GameState: | |
| return GameState( | |
| hero_name=hero_name, | |
| village_name=village_name, | |
| theme=theme, | |
| oracles=[Oracle(index=i, text="") for i in range(1, NUM_ORACLES + 1)], | |
| ) | |