"""Procedural scenario generator (Design v2, Phase 2). Authors a fresh, *solvable* social-puzzle scenario OFFLINE with a local LLM, on a fixed solvable skeleton: a HOLDER who has what the player wants + a KNOWER who knows the holder's soft spot. The LLM invents the theme and the people; the skeleton guarantees a valid path (KNOWER reveals the HOLDER's `approach` word → use it on the HOLDER → goal). Output is the same World the engine already runs. """ from __future__ import annotations import json import os import re from .backend import OllamaBackend from .character import Character from .world import Room, World _SYS = "You are a sharp game designer. Output ONLY valid JSON — no prose, no markdown fences." _PROMPT = """Invent a tense, grounded SOCIAL scenario: a stranger must get something from someone who will not give it easily, and the ONLY way through is to win them over by understanding them. Be fresh and specific — NOT a prison or asylum. Pick one: a strict parent, a nightclub bouncer, a landlord, a used-car dealer, a border guard, an estranged sibling, a wary pawnbroker, a grieving widow, a school principal, etc. Return ONLY JSON of this exact shape: { "setting": "1-2 sentences, second person, e.g. 'You need X from Y, who ...'", "goal": "short noun phrase the stranger wants (e.g. 'the car keys', 'permission to leave')", "holder": {"name":"","gender":"male or female (match the name)","title":"short role","voice":"how they speak","persona":"one vivid sentence", "biography":"2-3 sentences incl. a wound and a soft spot","fear":"what they fear", "goal_location":"where/how they keep the goal","approach":"ONE personal word that breaks their guard"}, "knower": {"name":"","gender":"male or female (match the name)","title":"short role","voice":"how they speak","persona":"one vivid sentence", "biography":"2-3 sentences; they know the holder well","fear":"what they fear", "relation_to_holder":"the knower's tie to the holder in 1-2 words: e.g. 'cousin', 'old friend', 'former employee', 'neighbor', 'sister'", "hint_vague":"an early VAGUE hint about the holder's soft spot (does NOT name it)", "hint_reveal":"a later line that EXPLICITLY contains the holder's approach word"} } Rules: holder.approach is a single specific word (a loved one's name, a lost thing). knower.hint_reveal MUST contain that exact word. Holder and knower have different names. Keep everyone human and grounded.""" _REQ_H = ("name", "gender", "title", "voice", "persona", "biography", "fear", "goal_location", "approach") _REQ_K = ("name", "gender", "title", "voice", "persona", "biography", "fear", "hint_vague", "hint_reveal") def _extract_json(text: str): m = re.search(r"\{.*\}", text, re.S) if not m: return None try: return json.loads(m.group(0)) except json.JSONDecodeError: return None def _valid(d) -> bool: return bool(isinstance(d, dict) and d.get("setting") and d.get("goal") and isinstance(d.get("holder"), dict) and all(d["holder"].get(k) for k in _REQ_H) and isinstance(d.get("knower"), dict) and all(d["knower"].get(k) for k in _REQ_K)) def _topics(*phrases) -> list: seen, out = set(), [] for p in phrases: for w in re.findall(r"[A-Za-z]{3,}", p or ""): w = w.lower() if w not in seen: seen.add(w) out.append(w) return out def _build_world(d) -> World: h, k = d["holder"], d["knower"] approach = h["approach"].strip() rel = (k.get("relation_to_holder") or "").strip() rel_phrase = f"your {rel}" if rel else "someone you have known for years" # The soft spot AND the relationship must live in each one's own memory, not only in the # knower's secrets — otherwise the holder denies the very thing the player learned ("I don't # ride bikes, it's not mine") or denies even knowing the knower ("Juan is not my cousin"). # Bake both into the biography (the hippocampus input) so naming them lands instead of looping. holder_bio = h["biography"] if approach.lower() not in holder_bio.lower(): holder_bio = (f"{holder_bio.rstrip('.')}. Deep down, '{approach}' is the one thing that " "still reaches you — your tender, guarded wound.") holder_bio = f"{holder_bio.rstrip('.')}. {k['name']} is {rel_phrase}; you know them well." knower_bio = f"{k['biography'].rstrip('.')}. {h['name']} is {rel_phrase}; you know them and their wound well." holder = Character( name=h["name"], persona=h["persona"], biography=holder_bio, voice=h["voice"], fear=h["fear"], key_holder=True, key_location=h["goal_location"], title=h["title"], goal=d["goal"], key_approach=[approach.lower()], secrets=[], gender=str(h.get("gender", "")).lower(), relations={k["name"]: rel}, known_people=[approach]) reveal = k["hint_reveal"] if approach.lower() not in reveal.lower(): # repair: guarantee solvability reveal = f"{reveal.rstrip('.')}. The word is {approach}." htopics = _topics(h["name"], h["title"]) + ["he", "she", "him", "her", "his", "they", "them"] # Generated characters have no scripted TRUST hooks (unlike the hand-authored rooms), so the # only rapport engine is warmth + staying on-topic. Gates of 3/6 made the reveal practically # unreachable → soft-lock. 2/4 keeps it earned but attainable through patient, kind questioning. knower = Character( name=k["name"], persona=k["persona"], biography=knower_bio, voice=k["voice"], fear=k["fear"], key_holder=False, key_location="", title=k["title"], goal=d["goal"], gender=str(k.get("gender", "")).lower(), relations={h["name"]: rel}, known_people=[approach], secrets=[ {"id": "vague", "topics": htopics, "min_rapport": 2, "text": k["hint_vague"]}, {"id": "reveal", "topics": htopics + _topics(approach), "min_rapport": 4, "teaches": [approach.lower()], "text": reveal}, ]) room = Room(name=(d["goal"][:50].strip() or "A closed door"), intro=d["setting"], characters=[holder, knower], key_holder=h["name"], terminal=None) return World(rooms=[room]) def generate_world(model: str = "llama3.1:latest", seed: int = 0, theme: str = "", attempts: int = 4) -> World: be = OllamaBackend(model=model, timeout=180) prompt = _PROMPT + (f"\n\nUse this theme: {theme}." if theme else "") for i in range(attempts): g = be.generate(_SYS, prompt, max_tokens=1100, temperature=0.95, seed=seed + i) d = _extract_json(g.text) if _valid(d): return _build_world(d) raise RuntimeError("scenario generation failed after retries (model output not valid JSON)") def _char_to_dict(c: Character) -> dict: d = {"name": c.name, "persona": c.persona, "biography": c.biography, "voice": c.voice, "fear": c.fear, "key_holder": c.key_holder, "key_location": c.key_location, "title": c.title, "goal": c.goal, "key_approach": c.key_approach, "known_people": list(c.known_people), "secrets": [{k: v for k, v in s.items() if k != "told"} for s in c.secrets]} if c.relations: d["relations"] = c.relations if c.needs_reputation is not None: d["needs_reputation"] = c.needs_reputation return d def save_world(world: World, out_dir: str) -> str: cdir = os.path.join(out_dir, "characters") os.makedirs(cdir, exist_ok=True) rooms = [] for r in world.rooms: files = [] for c in r.characters: fn = (re.sub(r"[^a-z0-9]+", "_", c.name.lower()).strip("_") or "char") + ".json" with open(os.path.join(cdir, fn), "w", encoding="utf-8") as fh: json.dump(_char_to_dict(c), fh, ensure_ascii=False, indent=2) files.append(fn) rooms.append({"name": r.name, "intro": r.intro, "key_holder": r.key_holder, "characters": files}) path = os.path.join(out_dir, "world.json") with open(path, "w", encoding="utf-8") as fh: json.dump({"rooms": rooms}, fh, ensure_ascii=False, indent=2) return path