| """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 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 |
|
|
| |
| |
| |
| 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": { |
| "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", |
| ], |
| }, |
| }, |
| }, |
| "required": ["adventure", "quests"], |
| } |
|
|
|
|
| |
| |
| |
| 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", |
| } |
|
|
|
|
| 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" |
| 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: |
| |
| 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", |
| }) |
|
|
| |
| 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 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 |
| added.append(cleaned) |
|
|
| return existing + added |
|
|