maindlock / src /mindlock /story.py
arbios's picture
Update: forgotten/epitaph/conviction, instant demo, VoxCPM2 voices, docs, card
9a41b58 verified
Raw
History Blame Contribute Delete
5.3 kB
"""Story mode β€” a finite campaign of hand-authored levels.
Each level is ONE self-contained JSON file in `config/story/` carrying both halves of a room:
- the DIALOGUE: a `holder` (controls the goal) and an optional `knower` (reveals the holder's
soft-spot word). Their fields ARE the dialogue β€” biography feeds the hippocampus, `secrets`
are the staged disclosures, `approach` is the word that unlocks the holder.
- the LAYOUT (optional): `theme` + placed `objects` + NPC `stations`, produced by the in-browser
placement mode and pasted in. If omitted, the client renders the room by name (so a level named
like an authored room reuses its curated look, and a new name falls back to a procedural theme).
The server only parses the dialogue half into `Character`s; the `layout` is passed through verbatim
to the client renderer (see GameSession.state).
"""
from __future__ import annotations
import json
import os
import re
from .character import Character
from .world import Room, Terminal, World
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
STORY_DIR = os.path.join(_ROOT, "config", "story")
def load_levels(path: str = STORY_DIR) -> list[dict]:
"""Every *.json in the story dir, in filename order (so 01-…, 02-… sequence the campaign)."""
if not os.path.isdir(path):
return []
levels = []
for fn in sorted(os.listdir(path)):
if fn.endswith(".json"):
full = os.path.join(path, fn)
with open(full, encoding="utf-8") as fh:
data = json.load(fh)
data["_path"] = full # remembered so the editor's Save can write layout back
levels.append(data)
return levels
def _character(d: dict, *, holder: bool, peer: str = "", relation: str = "") -> Character:
"""Build one Character from a level's holder/knower block. Bakes the approach word and the
relationship into the biography (the hippocampus input) so naming them LANDS instead of the
holder denying its own wound β€” exactly as the procedural generator does."""
bio = (d.get("biography") or "").rstrip(".")
ap = d.get("approach") or [] # one word ("Mara") or several ("elias","guilt")
if isinstance(ap, str):
ap = [ap]
ap = [w.strip().lower() for w in ap if w and w.strip()]
primary = ap[0] if ap else ""
if holder and primary and primary not in bio.lower():
bio = f"{bio}. Deep down, '{primary}' is the one thing that still reaches you β€” your wound"
# relation_to_holder comes in two shapes: a possessive noun ("his night nurse" β†’ "your night
# nurse") and a verb clause ("drove the train" β†’ reads as "<peer> drove the train"). Only the
# noun form survives into peers/relations (where it renders as "your X"); the clause lives in
# the biography alone.
rel_noun = ""
if peer:
m = re.match(r"^(?:his|her|their)\s+(.*)$", relation or "")
if m:
rel_noun = m.group(1)
line = f"{peer} is your {rel_noun}"
elif relation:
line = f"{peer} {relation}"
else:
line = f"{peer} is someone you have known a long time"
bio = (f"{bio.rstrip('.')}. {line}; you know them well" if bio
else f"{line}; you know them well")
return Character(
name=d["name"], persona=d.get("persona", ""), biography=bio + ".",
voice=d.get("voice", ""), fear=d.get("fear", ""),
key_holder=holder, key_location=d.get("key_location", ""),
title=d.get("title", ""), goal=d.get("goal", "the key"),
key_approach=ap if holder else [],
secrets=[dict(s) for s in d.get("secrets", [])],
gender=str(d.get("gender", "")).lower(), sprite_key=d.get("sprite_key", ""),
portrait=d.get("portrait", ""),
relations=({peer: rel_noun} if (peer and rel_noun) else {}),
needs_reputation=d.get("needs_reputation"),
known_people=list(d.get("known_people", [])),
life_max=int(d.get("life_max", 1000)),
scripted=list(d.get("scripted", [])),
yield_line=d.get("yield_line", ""),
)
def build_world(level: dict) -> World:
"""One level dict β†’ a one-room World the engine runs (holder [+ knower] [+ terminal])."""
h = level["holder"]
k = level.get("knower")
rel = (k.get("relation_to_holder") if k else "") or ""
chars = [_character(h, holder=True, peer=(k["name"] if k else ""), relation=rel)]
if k:
# the relation string describes the KNOWER from the holder's side; inverting it
# automatically is impossible, and the knower's authored biography already carries
# their side of the bond β€” so the knower only learns the holder is someone familiar.
chars.append(_character(k, holder=False, peer=h["name"], relation=""))
term = None
t = level.get("terminal")
if t and t.get("answer"):
term = Terminal(prompt=t.get("prompt", "ENTER CODE β€” a name unlocks it."),
answer=t["answer"], hint_source=t.get("hint_source", ""))
room = Room(name=level.get("name", "A locked door"), intro=level.get("intro", ""),
characters=chars, key_holder=h["name"], terminal=term)
return World(rooms=[room])