Spaces:
Running
Running
| """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]) | |