File size: 8,246 Bytes
bc8b36a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""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