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