the-apprentice / oracles /state.py
AndrewRqy
Add visual-mode dropdown on step 0 — live toggle between lean and full
744bc9b
Raw
History Blame Contribute Delete
7.85 kB
"""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)],
)