"""Obstacle generator for The Wizard's Oracles. Produces 5 ``Obstacle`` objects per game. Trials 1-4 are LLM-generated in the active theme's world. Trial 5 is always the fixed finale setup for that theme (or, for the fantasy theme, the canonical ``prompts/dragon_setup.txt``). The LLM is the source of truth — there is no offline mock bank. On LLM failure, :func:`generate_obstacles` raises ``RuntimeError`` so the caller can surface a clear error in the UI. """ from __future__ import annotations import os import random from functools import lru_cache from typing import Optional from .llm_client import LLMClient from .state import DRAGON_TRIAL, NUM_TRIALS, GameState, Obstacle from .themes import get_theme _THIS_DIR = os.path.dirname(os.path.abspath(__file__)) _PROMPTS_DIR = os.path.normpath(os.path.join(_THIS_DIR, "..", "prompts")) # --------------------------------------------------------------------------- # Cached prompt loaders # --------------------------------------------------------------------------- @lru_cache(maxsize=1) def _load_obstacles_prompt() -> str: path = os.path.join(_PROMPTS_DIR, "obstacles_system.txt") with open(path, "r", encoding="utf-8") as fh: return fh.read() @lru_cache(maxsize=1) def _load_dragon_template() -> str: path = os.path.join(_PROMPTS_DIR, "dragon_setup.txt") with open(path, "r", encoding="utf-8") as fh: return fh.read() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _call_llm_for_setups( state: GameState, client: LLMClient, language: str = "English", ) -> list[str]: """Call the live LLM for 4 obstacle setups in the active theme. Raises RuntimeError on any failure (network, malformed JSON, schema mismatch, duplicates). Never returns ``None``. """ template = _load_obstacles_prompt() theme = get_theme(getattr(state, "theme", "fantasy")) system = template.format( hero_name=state.hero_name, village_name=state.village_name, language=language, theme_name=theme.display_name, mentor_archetype=theme.mentor_archetype, finale_descriptor=theme.finale_descriptor, finale_short=theme.finale_short, goal_verb=theme.goal_verb, hero_label=theme.hero_label, style_cues=theme.style_cues, ) user = ( f"Generate 4 obstacles for the {theme.hero_label} {state.hero_name} of " f"{state.village_name}, in the world of {theme.display_name}. " f"Remember: vivid, lethal, varied across the four shapes, and do NOT " f"include {theme.finale_short} — that is the fifth trial, fixed elsewhere." ) try: from oracles.resolution import _model_for_lang, _wrap_with_language_force system = _wrap_with_language_force(system, language) payload = client.complete_json( system=system, user=user, model=_model_for_lang(language), ) except Exception as e: raise RuntimeError( f"generate_obstacles: LLM call failed " f"[{type(e).__name__}] {e}" ) from e if not isinstance(payload, dict): raise RuntimeError( f"generate_obstacles: LLM returned non-JSON " f"({type(payload).__name__}, first 200 chars: " f"{str(payload)[:200]!r})" ) items = payload.get("obstacles") if not isinstance(items, list) or len(items) < 4: raise RuntimeError( f"generate_obstacles: payload missing 4 entries (got " f"{type(items).__name__} of len " f"{len(items) if isinstance(items, list) else 'n/a'}, " f"keys={list(payload.keys())[:6]})" ) setups: list[str] = [] for entry in items[:4]: if not isinstance(entry, dict): raise RuntimeError("LLM obstacles entry not a dict") setup = entry.get("setup") if not isinstance(setup, str) or len(setup.strip()) < 30: raise RuntimeError("LLM obstacle setup missing or too short") setups.append(setup.strip()) if len(set(setups)) != 4: raise RuntimeError("LLM returned duplicate obstacle setups") return setups def _build_dragon(hero_name: str, theme_key: str = "fantasy", village_name: str = "the Hollow") -> Obstacle: """Trial 5: the themed finale. Fantasy uses the canonical file template; every other theme uses its in-code ``finale_setup``.""" theme = get_theme(theme_key) if theme.key == "fantasy" and not theme.finale_setup: template = _load_dragon_template() else: template = theme.finale_setup or _load_dragon_template() setup = ( template .replace("{hero_name}", hero_name or "the hero") .replace("{village_name}", village_name or "his village") .strip() ) return Obstacle(index=DRAGON_TRIAL, setup=setup, is_dragon=True) # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- def generate_obstacles( state: GameState, client: LLMClient, rng: Optional[random.Random] = None, language: str = "English", ) -> list[Obstacle]: """Return exactly 5 ``Obstacle`` objects with indices 1..5. Every theme now walks the branching ``story_graph`` from root to a leaf: * Each fork is picked by the LLM seeded with one of the player's inscribed oracles (``walk_story_tree``). * For the **fantasy** theme the node setups are the hand-authored ``setup_en`` / ``setup_zh`` strings — instant, no LLM call. * For every other theme the abstract ``concept`` on each visited node is rendered by the LLM in the active theme's world via ``render_themed_setups`` (parallel calls). The path is stored on ``state.story_path`` so the epilogue can pick the leaf node's ``ending_id`` and the chronicle tree viz can mark visited vs. unexplored nodes. Raises RuntimeError if the LLM client is unconfigured. Mutates ``state.story_path`` and ``state.story_node_setups``. """ if client is None or getattr(client, "using_mock", True): raise RuntimeError( "LLM client is not configured. Set MODAL_URL, MODAL_KEY and " "MODAL_SECRET so obstacles can be generated." ) from oracles.story_graph import walk_story_tree, render_themed_setups from oracles.themes import get_theme theme_key = getattr(state, "theme", "fantasy") or "fantasy" theme = get_theme(theme_key) oracle_texts = [o.text for o in state.oracles] if state.oracles else [] path = walk_story_tree(oracle_texts, client, language=language) state.story_path = [n.id for n in path] # Render each node's setup in the active theme. Fantasy uses hand- # authored text and short-circuits; other themes hit the LLM in # parallel (max 5 calls, one per node). setups_by_id = render_themed_setups( path, theme, client, language=language, hero_name=state.hero_name or "the hero", village_name=state.village_name or "his village", ) def _fill(s: str) -> str: return (s or "") \ .replace("{hero_name}", state.hero_name or "the hero") \ .replace("{village_name}", state.village_name or "his village") obstacles: list[Obstacle] = [] for i, node in enumerate(path): obstacles.append(Obstacle( index=i + 1, setup=_fill(setups_by_id.get(node.id, "")), is_dragon=node.is_dragon, )) assert len(obstacles) == NUM_TRIALS assert obstacles[-1].is_dragon assert obstacles[-1].index == DRAGON_TRIAL return obstacles