maindlock / src /mindlock /generator.py
arbios's picture
Mindlock: 10-room story mode, llama.cpp brain cascade, custom front
bc8b36a verified
Raw
History Blame Contribute Delete
8.25 kB
"""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