File size: 7,862 Bytes
5afb7b3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 | """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
|