| """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")) |
|
|
|
|
| |
| |
| |
|
|
|
|
| @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() |
|
|
|
|
| |
| |
| |
|
|
|
|
| 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) |
|
|
|
|
| |
| |
| |
|
|
|
|
| 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] |
|
|
| |
| |
| |
| 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 |
|
|