Spaces:
Sleeping
Sleeping
| """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 | |
| # --------------------------------------------------------------------------- | |
| 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() | |
| 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 | |