File size: 13,385 Bytes
a1afbca
 
 
 
 
 
 
 
 
 
c6815eb
a1afbca
 
 
 
 
 
 
 
 
c6815eb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a1afbca
 
 
 
 
 
 
 
 
 
 
 
 
 
c6815eb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a1afbca
c6815eb
a1afbca
c6815eb
a1afbca
c6815eb
a1afbca
 
 
2d2eed9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a1afbca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2d2eed9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c6815eb
2d2eed9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a1afbca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2d2eed9
 
a1afbca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2d2eed9
 
c6815eb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2d2eed9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
"""FrogQuest data contract: the JSON schema the LLM emits + server-side validate/clamp.

The LLM only ever writes DATA to this contract. Nothing it returns is trusted: every
field is validated and clamped here before the frontend renders it, so a malformed or
missing field can never break a quest card. See CLAUDE.md "Data contract".
"""
from __future__ import annotations

import random
import re
from datetime import datetime, timezone
from typing import Any

THEMES = ("cyberpunk", "fantasy", "space")
QUEST_TYPES = ("main", "bonus")
STATUSES = ("active", "success", "failure")
IMAGE_STATES = ("initial", "success", "failure")

MAX_QUESTS = 12
MAX_BONUS = 3
MAX_CAMPAIGNS = 5         # concurrent long-term goals
MAX_CAMPAIGN_QUESTS = 8   # steps per campaign chain

# JSON Schemas passed to llama-cpp-python via response_format to constrain generation.
# Kept deliberately permissive (the strict guarantees are enforced by validate_and_clamp /
# validate_campaign, not the grammar) because GBNF-from-schema chokes on overly nested constraints.
_QUEST_ITEM_SCHEMA: dict[str, Any] = {
    "type": "object",
    "properties": {
        "id": {"type": "string"},
        "task": {"type": "string"},
        "quest_title": {"type": "string"},
        "narrative": {"type": "string"},
        "type": {"type": "string", "enum": list(QUEST_TYPES)},
        "goal_group": {"type": "string"},
        "is_frog": {"type": "boolean"},
        "initial_image_prompt": {"type": "string"},
        "success_edit": {"type": "string"},
        "failure_edit": {"type": "string"},
        "xp": {"type": "integer"},
    },
    "required": [
        "task", "quest_title", "narrative", "type",
        "is_frog", "initial_image_prompt", "success_edit",
        "failure_edit", "xp",
    ],
}

RESPONSE_SCHEMA: dict[str, Any] = {
    "type": "object",
    "properties": {
        "adventure": {
            "type": "object",
            "properties": {
                "title": {"type": "string"},
                "theme": {"type": "string", "enum": list(THEMES)},
                "art_style": {"type": "string"},
                "seed": {"type": "integer"},
            },
            "required": ["title", "theme", "art_style", "seed"],
        },
        "quests": {"type": "array", "items": _QUEST_ITEM_SCHEMA},
    },
    "required": ["adventure", "quests"],
}

# Campaign forging: one long-term goal -> a themed campaign + an ORDERED chain of concrete steps.
CAMPAIGN_RESPONSE_SCHEMA: dict[str, Any] = {
    "type": "object",
    "properties": {
        "campaign": {
            "type": "object",
            "properties": {
                "title": {"type": "string"},
                "art_style": {"type": "string"},
                "seed": {"type": "integer"},
            },
            "required": ["title", "art_style", "seed"],
        },
        "quests": {"type": "array", "items": _QUEST_ITEM_SCHEMA},
    },
    "required": ["campaign", "quests"],
}


# JSON Schema for the Frog Master chat router. The LLM classifies each user message into one
# intent and (optionally) names a target task and/or a free-text reason. Kept tiny: the chat
# never sees images — only a short text context (see llm.route_intent).
INTENT_SCHEMA: dict[str, Any] = {
    "type": "object",
    "properties": {
        "intent": {
            "type": "string",
            "enum": ["forge", "add_tasks", "mark_done", "mark_couldnt", "unknown"],
        },
        "target_task": {"type": "string"},
        "reason": {"type": "string"},
    },
    "required": ["intent"],
}


def _s(value: Any, default: str = "") -> str:
    """Coerce to a stripped string."""
    if value is None:
        return default
    if isinstance(value, str):
        return value.strip()
    return str(value).strip()


def _clamp_int(value: Any, lo: int, hi: int, default: int) -> int:
    try:
        n = int(value)
    except (TypeError, ValueError):
        return default
    return max(lo, min(hi, n))


def _slugify(text: str, fallback: str) -> str:
    slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
    return slug or fallback


def _clean_quest(q: Any, idx: int, seen_ids: set[str], qtype: str) -> dict[str, Any]:
    """Coerce one raw quest dict into a render-ready quest with a unique non-empty id.

    `seen_ids` is mutated in place; `qtype` is the already-resolved type (so the caller owns
    the bonus-cap accounting). Runtime state (status/image_state) is always set fresh here and
    never trusted from the model.
    """
    title = _s(q.get("quest_title")) or _s(q.get("task")) or f"Quest {idx + 1}"
    qid = _s(q.get("id"))
    if not qid or qid in seen_ids:
        qid = _slugify(title, f"quest-{idx + 1}")
        base, n = qid, 2
        while qid in seen_ids:
            qid, n = f"{base}-{n}", n + 1
    seen_ids.add(qid)

    return {
        "id": qid,
        "task": _s(q.get("task")) or title,
        "quest_title": title,
        "narrative": _s(q.get("narrative")),
        "type": qtype,
        "goal_group": _s(q.get("goal_group")) or None,
        "is_frog": bool(q.get("is_frog")),
        "initial_image_prompt": _s(q.get("initial_image_prompt")),
        "success_edit": _s(q.get("success_edit")) or "Show the hero victorious.",
        "failure_edit": _s(q.get("failure_edit"))
        or "The hero retreats safely to rest and try again another day.",
        "xp": _clamp_int(q.get("xp"), 0, 100, 25),
        "status": "active",
        "image_state": "initial",
        "campaign_id": None,  # set by validate_campaign for campaign chains; None = standalone
    }


def _resolve_type(q: Any, bonus_count: int) -> tuple[str, int]:
    """Resolve a quest's type, demoting bonus quests to main once MAX_BONUS is reached.
    Returns (type, new_bonus_count)."""
    qtype = q.get("type") if isinstance(q, dict) else None
    qtype = qtype if qtype in QUEST_TYPES else "main"
    if qtype == "bonus":
        if bonus_count >= MAX_BONUS:
            qtype = "main"  # demote excess bonus quests to main
        else:
            bonus_count += 1
    return qtype, bonus_count


def validate_and_clamp(raw: Any, theme: str) -> dict[str, Any]:
    """Turn whatever the LLM returned into a safe, render-ready adventure object.

    Guarantees on return:
      - adventure has title/theme/art_style/seed (int)
      - 1..MAX_QUESTS quests, each with all schema fields and unique non-empty id
      - exactly one is_frog quest, and it is ordered FIRST
      - <= MAX_BONUS bonus quests
      - type/status/image_state are valid enum values; xp in 0..100
    """
    theme = theme if theme in THEMES else THEMES[0]
    raw = raw if isinstance(raw, dict) else {}

    adv_in = raw.get("adventure") if isinstance(raw.get("adventure"), dict) else {}
    seed = adv_in.get("seed")
    try:
        seed = int(seed)
    except (TypeError, ValueError):
        seed = random.randint(0, 2**31 - 1)

    adv_theme = adv_in.get("theme")
    adv_theme = adv_theme if adv_theme in THEMES else theme

    adventure = {
        "title": _s(adv_in.get("title")) or "Your Quest Log",
        "theme": adv_theme,
        "art_style": _s(adv_in.get("art_style"))
        or f"8-bit / 16-bit retro pixel art, {adv_theme} palette, NES RPG style",
        "seed": seed,
    }

    quests_in = raw.get("quests")
    quests_in = quests_in if isinstance(quests_in, list) else []

    cleaned: list[dict[str, Any]] = []
    seen_ids: set[str] = set()
    bonus_count = 0

    for idx, q in enumerate(quests_in):
        if len(cleaned) >= MAX_QUESTS:
            break
        if not isinstance(q, dict):
            continue
        qtype, bonus_count = _resolve_type(q, bonus_count)
        cleaned.append(_clean_quest(q, idx, seen_ids, qtype))

    if not cleaned:
        # Degenerate output: synthesise a single placeholder so the UI still renders.
        cleaned.append({
            "id": "quest-1", "task": "Add your first task", "quest_title": "The Journey Begins",
            "narrative": "Tell the oracle your goals to fill this log.", "type": "main",
            "goal_group": None, "is_frog": True, "initial_image_prompt": "",
            "success_edit": "Show the hero victorious.",
            "failure_edit": "The hero retreats to try again.", "xp": 10,
            "status": "active", "image_state": "initial",
        })

    # Enforce exactly one frog, ordered first. Prefer a main quest flagged by the model.
    frog_idx = next((i for i, q in enumerate(cleaned) if q["is_frog"] and q["type"] == "main"), None)
    if frog_idx is None:
        frog_idx = next((i for i, q in enumerate(cleaned) if q["is_frog"]), None)
    if frog_idx is None:
        frog_idx = next((i for i, q in enumerate(cleaned) if q["type"] == "main"), 0)
    for i, q in enumerate(cleaned):
        q["is_frog"] = (i == frog_idx)
    frog = cleaned.pop(frog_idx)
    cleaned.insert(0, frog)

    return {"adventure": adventure, "quests": cleaned}


def validate_campaign(
    raw: Any,
    theme: str,
    goal: str,
    existing_quest_ids: set[str] | None = None,
    existing_campaign_ids: set[str] | None = None,
) -> dict[str, Any]:
    """Turn the LLM's campaign JSON into a safe campaign entity + its ordered quest chain.

    Guarantees on return:
      - campaign has a unique id ("camp-..."), title, verbatim goal, status "active",
        theme/art_style/seed (its own cohesive world), empty sources, created_at
      - 1..MAX_CAMPAIGN_QUESTS quests, each fully cleaned (`_clean_quest` rules), tagged with the
        campaign id, forced type "main" and is_frog False (the day log owns the frog), and with
        ids unique against `existing_quest_ids`
    """
    theme = theme if theme in THEMES else THEMES[0]
    raw = raw if isinstance(raw, dict) else {}
    camp_in = raw.get("campaign") if isinstance(raw.get("campaign"), dict) else {}

    goal_s = _s(goal)
    title = _s(camp_in.get("title")) or (goal_s[:48] if goal_s else "New Campaign")
    existing_c = set(existing_campaign_ids or ())
    cid = "camp-" + _slugify(title, "campaign")
    base, n = cid, 2
    while cid in existing_c:
        cid, n = f"{base}-{n}", n + 1

    try:
        seed = int(camp_in.get("seed"))
    except (TypeError, ValueError):
        seed = random.randint(0, 2**31 - 1)
    art_style = (_s(camp_in.get("art_style"))
                 or f"8-bit / 16-bit retro pixel art, {theme} palette, NES RPG style")

    quests_in = raw.get("quests")
    quests_in = quests_in if isinstance(quests_in, list) else []
    seen_ids = set(existing_quest_ids or ())
    cleaned: list[dict[str, Any]] = []
    for idx, q in enumerate(quests_in):
        if len(cleaned) >= MAX_CAMPAIGN_QUESTS:
            break
        if not isinstance(q, dict):
            continue
        cq = _clean_quest(q, idx, seen_ids, "main")
        cq["is_frog"] = False
        cq["campaign_id"] = cid
        cleaned.append(cq)

    if not cleaned:
        # Degenerate output: one placeholder step so the campaign still renders.
        qid = f"{cid}-step-1"
        while qid in seen_ids:
            qid += "x"
        cleaned.append({
            "id": qid, "task": f"Plan the first step toward: {goal_s or title}",
            "quest_title": "Chart the First Step", "narrative":
            "Every campaign begins with a single decision: what to do first.", "type": "main",
            "goal_group": None, "is_frog": False, "initial_image_prompt": "",
            "success_edit": "Show the hero victorious.",
            "failure_edit": "The hero retreats to try again another day.", "xp": 15,
            "status": "active", "image_state": "initial", "campaign_id": cid,
        })

    campaign = {
        "id": cid,
        "title": title,
        "goal": goal_s,
        "status": "active",
        "theme": theme,
        "art_style": art_style,
        "seed": seed,
        "sources": [],
        "created_at": datetime.now(timezone.utc).isoformat(),
    }
    return {"campaign": campaign, "quests": cleaned}


def merge_quests(existing: list[dict[str, Any]], raw_new: Any, theme: str) -> list[dict[str, Any]]:
    """Append the LLM's freshly generated quests to an existing log (chat "add_tasks").

    New quests are cleaned through the same id-uniqueness / bonus-cap / MAX_QUESTS rules as
    validate_and_clamp, but the existing log's structure is preserved: the original frog stays
    first (new quests are forced is_frog=False) and existing quests are untouched. Returns the
    combined list (capped at MAX_QUESTS).
    """
    existing = list(existing or [])
    if len(existing) >= MAX_QUESTS:
        return existing

    raw = raw_new if isinstance(raw_new, dict) else {}
    quests_in = raw.get("quests")
    quests_in = quests_in if isinstance(quests_in, list) else []

    seen_ids = {q.get("id") for q in existing if q.get("id")}
    bonus_count = sum(1 for q in existing if q.get("type") == "bonus")
    base_idx = len(existing)

    added: list[dict[str, Any]] = []
    for i, q in enumerate(quests_in):
        if len(existing) + len(added) >= MAX_QUESTS:
            break
        if not isinstance(q, dict):
            continue
        qtype, bonus_count = _resolve_type(q, bonus_count)
        cleaned = _clean_quest(q, base_idx + i, seen_ids, qtype)
        cleaned["is_frog"] = False  # the original frog keeps its place at the front
        added.append(cleaned)

    return existing + added