"""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 @dataclass 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 @dataclass class Obstacle: index: int # 1..NUM_TRIALS setup: str # vivid 2-3 sentence description is_dragon: bool = False @dataclass 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)", } @dataclass 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)], )