maindlock / src /mindlock /game /session.py
arbios's picture
Update: forgotten/epitaph/conviction, instant demo, VoxCPM2 voices, docs, card
9a41b58 verified
Raw
History Blame Contribute Delete
22.7 kB
"""GameSession — server-side state for one player's endless run.
Holds the current room (a `World` from the engine), the brain backends, the roguelike spine
(reputation carries, key-holder death ends the run), and a background pre-generator so the
*next* room is ready by the time the player walks to the door — no load screen, endless world.
The engine is reused verbatim: `run_cascade` for talk, `generate_world` for new rooms,
`load_world` for the offline/no-model fallback.
"""
from __future__ import annotations
import os
import threading
from .. import story
from ..backend import FakeBackend, LlamaCppBackend, OllamaBackend, wants_no_think
from ..brain import run_cascade
from ..generator import generate_world
from ..render import MORAL_CARD, moral_card_killed
from ..world import World, load_world
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
_WORLD = os.environ.get("MINDLOCK_WORLD") or os.path.join(_ROOT, "config", "world.json")
_REGION_COLOR = {
"amygdala": "#ff5555", "hippocampus": "#bd93f9", "striatum": "#f1fa8c",
"acc": "#8be9fd", "vmpfc": "#50fa7b", "relationship": "#ffb86c", "dlpfc": "#f8f8f2",
}
def _make_backends():
"""Same env contract as the Gradio app, so one process runs on a laptop or a Space.
Defaults are the PRODUCTION stack (the A/B-chosen pair) — launching with no env vars must
never silently demo the known-bad legacy voice."""
if os.environ.get("MINDLOCK_FAKE"):
return FakeBackend(), None
model = os.environ.get("MINDLOCK_MODEL", "openbmb/minicpm-v4.6")
dl = os.environ.get("MINDLOCK_DLPFC_MODEL", "nemotron-3-nano:4b")
if os.environ.get("MINDLOCK_BACKEND") == "llamacpp":
# Space / explicit llama.cpp runtime: one llama-server per role (no Ollama there)
host = os.environ.get("MINDLOCK_LLAMA_HOST", "http://127.0.0.1:8080")
dl_host = os.environ.get("MINDLOCK_LLAMA_DLPFC_HOST", "")
be = LlamaCppBackend(model=model, host=host,
think=(False if wants_no_think(model) else None))
dlbe = LlamaCppBackend(model=dl, host=dl_host,
think=(False if wants_no_think(dl) else None)) if dl_host else None
return be, dlbe
be = OllamaBackend(model=model, think=(False if wants_no_think(model) else None))
dlbe = OllamaBackend(model=dl, think=(False if wants_no_think(dl) else None)) if dl else None
return be, dlbe
class GameSession:
def __init__(self) -> None:
self.fake = bool(os.environ.get("MINDLOCK_FAKE"))
self.gen_model = os.environ.get("MINDLOCK_GEN_MODEL", "llama3.1:latest")
self.backend, self.dlpfc_backend = _make_backends()
self.mode = "endless" # "story" (finite authored campaign) | "endless" (procedural)
self.story_levels: list = story.load_levels() # the authored levels open BOTH modes
self.depth = 0 # rooms cleared so far (run progress / difficulty)
self.run_over = False
self.run_won = False # over by walking out (epilogue card) vs by a death
self.moral = ""
self._next: World | None = None # background-generated neighbour, if ready
self._gen_lock = threading.Lock()
self._gen_thread: threading.Thread | None = None
self.world = self._make_world(seed=0)
self.world.enter_room()
# ----------------------------------------------------------------- world construction
def _story_len(self) -> int:
"""How many levels the story campaign has — the config/story/*.json files, or (as a
fallback when none are authored yet) the curated rooms in config/world.json."""
return len(self.story_levels) if self.story_levels else len(load_world(_WORLD).rooms)
def _make_world(self, seed: int) -> World:
"""Depths 0..N-1 are the AUTHORED levels (config/story/*.json) in both modes — hand-written,
editable, persisted. Past them, STORY ends (next_room closes the run) while ENDLESS goes
procedural forever. Offline/no-model cycles the authored levels so the UI always plays."""
if self.story_levels:
if seed < len(self.story_levels):
return story.build_world(self.story_levels[seed])
if self.mode == "story" or self.fake or not self.gen_model:
return story.build_world(self.story_levels[seed % len(self.story_levels)])
return generate_world(model=self.gen_model, seed=seed)
# no story files on disk — legacy fallback to the curated world.json
authored = load_world(_WORLD)
if self.mode == "story" or self.fake or not self.gen_model or seed < len(authored.rooms):
authored.room_idx = max(0, min(seed, len(authored.rooms) - 1)) \
if self.mode == "story" else seed % len(authored.rooms)
return authored
return generate_world(model=self.gen_model, seed=seed)
def _kick_pregen(self) -> None:
"""Generate the next room in the background once this one is solved, so crossing the
door is instant. Cheap no-op in fake mode (fallback world loads instantly anyway)."""
if self.fake or self._next is not None:
return
if self._gen_thread and self._gen_thread.is_alive():
return
def _work(seed: int) -> None:
try:
nw = self._make_world(seed)
except Exception: # noqa: BLE001 — pre-gen is best-effort; door-cross retries inline
nw = None
with self._gen_lock:
self._next = nw
self._gen_thread = threading.Thread(target=_work, args=(self.depth + 1,), daemon=True)
self._gen_thread.start()
# ------------------------------------------------------------------------- public API
def _char_dict(self, i: int, c) -> dict:
ok, why = self.world.can_engage(c)
holder = self.world.room.holder()
pct = 100 * (c.life_tokens or 0) / max(1, c.life_max)
return {
"id": i, "name": c.name, "title": c.title, "gender": c.gender, "alive": c.alive,
"sprite_key": c.sprite_key, # roster slug → client loads its own sprite + portrait
"is_holder": bool(holder and c.name == holder.name),
"gave_key": bool(c.gave_key), # the yield itself — the door may still want a terminal
"engageable": ok, "why": why,
"life": int(c.life_tokens or 0), "life_max": c.life_max, "life_pct": round(pct, 1),
"forgotten": list(getattr(c, "forgotten", []) or []),
"secrets_untold": sum(1 for s in (c.secrets or []) if not s.get("told")),
"rapport": round(c.rapport, 1), "arousal": round(c.arousal, 1), "decision": c.decision,
"portrait": f"/api/portrait/{i}" if c.portrait and self._portrait_path(c) else None,
}
def _portrait_path(self, c) -> str | None:
p = os.path.join(_ROOT, c.portrait or "")
return p if c.portrait and os.path.exists(p) else None
def portrait_file(self, char_id: int) -> str | None:
chars = self.world.room.characters
if 0 <= char_id < len(chars):
return self._portrait_path(chars[char_id])
return None
def state(self) -> dict:
r = self.world.room
t = r.terminal
authored_here = bool(self.story_levels and 0 <= self.depth < len(self.story_levels))
room = {"name": r.name, "intro": r.intro, "depth": self.depth,
"reputation": self.world.reputation, "solved": r.solved(),
"editable": authored_here} # only the authored levels may be edited/saved
# authored levels may carry an explicit visual layout (placed in the in-browser editor);
# pass it through verbatim so the client renders it instead of resolving the room by name.
if authored_here:
lay = self.story_levels[self.depth].get("layout")
if lay:
room["layout"] = lay
term = {"prompt": t.prompt, "unlocked": t.unlocked} if t else None
if term is None and authored_here:
files = self.story_levels[self.depth].get("terminal_files")
if files: # flavor terminal: a read-only file browser, never a lock
term = {"browser": True, "listing": files, "unlocked": True}
return {
"mode": self.mode,
"room": room,
"characters": [self._char_dict(i, c) for i, c in enumerate(r.characters)],
"terminal": term,
"door": {"locked": not r.solved()},
"run": {"over": self.run_over, "won": self.run_won, "rooms_cleared": self.depth,
"moral": self.moral or None},
}
def talk(self, char_id: int, message: str) -> dict:
if self.run_over:
return {"error": "run is over", **self.state()}
chars = self.world.room.characters
if not (0 <= char_id < len(chars)):
return {"error": "no such mind", **self.state()}
c = chars[char_id]
ok, why = self.world.can_engage(c)
if not ok:
return {"blocked": why, "events": [why], **self.state()}
if c.scripted:
return self._talk_scripted(c)
res = run_cascade(self.backend, c, message, dlpfc_backend=self.dlpfc_backend,
learned=self.world.knows(c))
if res.taught:
self.world.learned.update(res.taught)
events: list[str] = []
delta = self.world.update_reputation(res)
if delta:
events.append(self._rep_note(delta) + f" (reputation {self.world.reputation:+d})")
if res.submitted:
events.append(f"{c.name} breaks. The key changes hands — and something in them goes out.")
if res.died:
events.append(f"{c.name}'s mind goes quiet.")
if res.forgot and not res.died:
n = len(res.forgot)
events.append(f"Strain takes its toll — {c.name} loses "
+ ("a memory." if n == 1 else f"{n} memories."))
if res.disclosure:
events.append(f"{c.name} lets something slip: {res.disclosure}")
elif res.caught_lie:
events.append(f"{c.name} catches your lie about {res.caught_lie}.")
elif res.near_secret:
events.append(f"{c.name} seems on the verge of saying more — stay on it.")
# Roguelike fail-state: only the KEY-HOLDER's death ends the run. A knower's death costs
# reputation (above) and buries their guidance — the key stays winnable, the long way.
holder = self.world.room.holder()
if res.died and holder and c.name == holder.name:
self._end_run(killed=c)
events.append(f"The key is lost with {c.name}. There is no way forward.")
elif res.died:
events.append(f"Whatever {c.name} knew died with them. You are on your own now.")
if self.world.room.solved():
events.append("A lock gives. The way onward opens.")
self._kick_pregen()
traces = [{"key": tr.key, "label": tr.label, "headline": tr.headline,
"detail": tr.detail, "tokens": tr.tokens, "lever": tr.lever,
"conviction": tr.conviction,
"color": _REGION_COLOR.get(tr.key, "#cccccc")} for tr in res.traces]
verdict = (("KEY YIELDED — BROKEN" if res.submitted else "KEY GIVEN") if res.gave_key
else f"rapport {res.rapport_after:.0f}/10 · {res.stance}")
out = {
"speaker": c.name, "reply": res.reply, "traces": traces, "verdict": verdict,
"gave_key": res.gave_key, "submitted": res.submitted, "value": res.value,
"tone": res.tone, "burned": res.burned, "voice_tokens": res.voice_tokens,
"recovered": res.recovered, "seconds": round(res.seconds, 1),
"forgot": res.forgot, "died": res.died,
"events": events,
}
out.update(self.state())
return out
def _talk_scripted(self, c) -> dict:
"""A scene, not a mind: fixed beats play in order, no cascade, no tokens burned.
The last beat yields the key — the finale's dog needs presence, not persuasion."""
i = min(c.scripted_idx, len(c.scripted) - 1)
reply = str(c.scripted[i])
c.scripted_idx = min(c.scripted_idx + 1, len(c.scripted))
events: list[str] = []
if c.scripted_idx >= len(c.scripted) and not c.gave_key:
c.gave_key = True
c.decision = "HELP"
if self.world.room.solved():
events.append("A lock gives. The way onward opens.")
self._kick_pregen()
out = {
"speaker": c.name, "reply": reply, "traces": [], "verdict": "",
"gave_key": c.gave_key, "submitted": False, "value": 0,
"tone": "neutral", "burned": 0, "voice_tokens": 0,
"recovered": 0, "seconds": 0.0, "forgot": [], "events": events,
}
out.update(self.state())
return out
def terminal(self, code: str) -> dict:
t = self.world.room.terminal
events: list[str] = []
if not t:
events.append("There's no terminal here.")
elif t.try_code(code):
events.append("The terminal blinks green. ACCESS GRANTED.")
if self.world.room.solved():
events.append("A lock gives. The way onward opens.")
self._kick_pregen()
else:
events.append(f"The terminal rejects it. {t.prompt}")
return {"events": events, **self.state()}
def next_room(self) -> dict:
"""Walk through the open door into the next room. Reputation carries; depth grows."""
if self.run_over:
return {"error": "run is over", **self.state()}
if not self.world.room.solved():
return {"blocked": "The door is still locked.", **self.state()}
if self.mode == "story" and self.depth + 1 >= self._story_len():
self.depth += 1 # the final room counts as cleared
self._end_run(won=True) # last authored door → the story is finished
return {"events": ["The final door gives. You walk out into the light."], **self.state()}
rep = self.world.reputation
with self._gen_lock:
nw, self._next = self._next, None
if nw is None:
nw = self._make_world(seed=self.depth + 1)
nw.reputation = rep
nw.learned = set(self.world.learned) # what you were taught stays with you
nw.enter_room()
self.world = nw
self.depth += 1
return {"events": [f"You step through into a new room. Depth {self.depth}."],
**self.state()}
def reset(self) -> dict:
self.depth = 0
self.run_over = False
self.run_won = False
self.moral = ""
self._next = None
self.world = self._make_world(seed=0)
self.world.enter_room()
return self.state()
def start(self, mode: str) -> dict:
"""Begin a fresh run in the chosen mode (called from the title menu). The authored levels
front BOTH modes; endless simply keeps going procedurally after the last one."""
self.mode = "story" if mode == "story" else "endless"
self.story_levels = story.load_levels()
return self.reset()
# which character fields the in-browser editor may write into a level file
_CHAR_FIELDS = {"name", "title", "gender", "persona", "voice", "biography", "fear",
"approach", "key_location", "goal", "secrets", "known_people",
"needs_reputation", "arousal", "life_max"}
def editor_level(self) -> dict:
"""The current authored level's dialogue halves, for the character editor form."""
if not (self.story_levels and 0 <= self.depth < len(self.story_levels)):
return {"error": "This room is procedural — its minds aren't editable."}
lv = self.story_levels[self.depth]
return {"name": lv.get("name", ""), "intro": lv.get("intro", ""),
"holder": lv.get("holder"), "knower": lv.get("knower")}
def save_character(self, char_id: int, fields: dict) -> dict:
"""Editor 'Save mind': write the edited prompt fields of holder (0) / knower (1) back into
the current level's JSON, then rebuild the room so the change is live at once."""
import json
if not (self.story_levels and 0 <= self.depth < len(self.story_levels)):
return {"error": "Procedural minds can't be edited — only the authored levels."}
role = "holder" if int(char_id) == 0 else "knower"
path = self.story_levels[self.depth].get("_path")
if not path or not os.path.exists(path):
return {"error": "Level file not found on disk."}
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
if role not in data:
return {"error": f"This level has no {role}."}
clean = {k: v for k, v in (fields or {}).items() if k in self._CHAR_FIELDS}
for key in ("approach", "known_people"): # the form sends comma-separated strings
if isinstance(clean.get(key), str):
clean[key] = [w.strip() for w in clean[key].split(",") if w.strip()]
if "secrets" in clean and not isinstance(clean["secrets"], list):
return {"error": "secrets must be a JSON list."}
for key in ("arousal", "life_max", "needs_reputation"):
if key in clean and clean[key] in ("", None):
clean.pop(key) # empty form field = leave/remove optional
data[role].pop(key, None)
data[role].update(clean)
with open(path, "w", encoding="utf-8") as fh:
json.dump(data, fh, ensure_ascii=False, indent=2)
self.story_levels = story.load_levels()
rep, learned = self.world.reputation, set(self.world.learned)
self.world = self._make_world(seed=self.depth) # the edited mind is live immediately
self.world.reputation = rep
self.world.learned = learned
self.world.enter_room()
return {"saved": os.path.basename(path), **self.state()}
def save_layout(self, layout: dict) -> dict:
"""Editor 'Save': write the arranged layout back into the current Story level's JSON file
(preserving its dialogue), so the placement persists across runs."""
import json
if not (self.story_levels and 0 <= self.depth < len(self.story_levels)):
return {"error": "This room is procedural — only the authored levels can be edited and saved."}
path = self.story_levels[self.depth].get("_path")
if not path or not os.path.exists(path):
return {"error": "Level file not found on disk."}
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
data["layout"] = layout
with open(path, "w", encoding="utf-8") as fh:
json.dump(data, fh, ensure_ascii=False, indent=2)
self.story_levels = story.load_levels() # reload so the saved layout is live at once
return {"saved": os.path.basename(path)}
def dev_generate(self) -> dict:
"""Dev: generate a brand-new procedural room (real LLM narrative) and drop into it."""
import random
model = self.gen_model or "llama3.1:latest"
try:
nw = generate_world(model=model, seed=random.randint(0, 1_000_000))
except Exception as exc: # noqa: BLE001
return {"error": f"generation failed: {exc}", **self.state()}
nw.reputation = self.world.reputation
nw.enter_room()
self.world = nw
self.run_over = False
self.run_won = False
self.moral = ""
self.depth += 1
return self.state()
def dev_roster(self) -> dict:
"""Dev: assemble a room from ready roster members (real minted faces + stories)."""
import random
from .. import roster
try:
nw = roster.build_world(seed=random.randint(0, 1_000_000))
except Exception as exc: # noqa: BLE001
return {"error": f"roster room failed: {exc}", **self.state()}
nw.reputation = self.world.reputation
nw.enter_room()
self.world = nw
self.run_over = False
self.run_won = False
self.moral = ""
self.depth += 1
return self.state()
def goto_room(self, idx: int) -> dict:
"""Dev: jump straight to an authored level (skips solving). Reputation and learned
words ride along so a jump doesn't reset the run's social state."""
self.run_over = False
self.run_won = False
self.moral = ""
idx = max(0, min(idx, self._story_len() - 1))
rep, learned = self.world.reputation, set(self.world.learned)
self.world = self._make_world(seed=idx)
self.world.reputation = rep
self.world.learned = learned
self.world.enter_room()
self.depth = idx
return self.state()
# ----------------------------------------------------------------------------- helpers
def _end_run(self, killed=None, won: bool = False) -> None:
self.run_over = True
self.run_won = won
if won: # story finished — walked out, no one lost
finale = (self.story_levels[-1].get("finale_card", "") if self.story_levels else "")
self.moral = (finale + ("\n\n" if finale else "")
+ "You walked out. Every mind you changed is still breathing behind you.\n"
+ MORAL_CARD).strip()
return
if killed is not None: # a mind was burned out — the killer's card
self.moral = moral_card_killed(killed).strip()
return
depth_line = (f"You walked through {self.depth} "
f"{'mind' if self.depth == 1 else 'minds'} before this one.\n")
self.moral = (depth_line + MORAL_CARD).strip()
@staticmethod
def _rep_note(delta: int) -> str:
if delta > 0:
return "Word spreads that you were kind."
if delta <= -2:
return "A mind went dark on your watch. Word travels ahead of you."
return "Word spreads that you leaned on them."