the-apprentice / oracles /obstacles.py
AndrewRqy
Initial commit — The Apprentice for Build Small
5afb7b3
Raw
History Blame Contribute Delete
7.86 kB
"""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