maindlock / src /mindlock /roster.py
arbios's picture
Mindlock: 10-room story mode, llama.cpp brain cascade, custom front
bc8b36a verified
Raw
History Blame Contribute Delete
8.85 kB
"""The character roster — Mindlock's offline character factory output.
One *minted* character is a self-contained, reusable identity: a story (biography, fear, a
soft spot, a guarded secret) PLUS the two generation prompts that produce its look (a FLUX
oil-painting portrait + a Pixellab top-down sprite). Minting is offline (the `scripts/forge`
pipeline); the game then SAMPLES ready roster members to populate rooms — instant, no load
screen, real faces.
A roster entry is portable: it carries no puzzle role. The room assembler (`build_world`) is
what wires two members into a solvable scenario (one becomes the HOLDER whose guard breaks on
their soft spot; the other the KNOWER who reveals that word). So the same minted person can be
a holder in one run and a bystander in the next.
Asset paths are derived from the slug, not stored, so the roster JSON stays a pure spec:
config/roster/{slug}.json — the spec
src/mindlock/game/static/sprites/npc/{slug}/{dir}.png — 8-direction sprite (Pixellab)
src/mindlock/game/static/sprites/npc/{slug}/portrait.png — portrait (FLUX)
"""
from __future__ import annotations
import json
import os
import re
from .character import Character
from .world import Room, World
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
ROSTER_DIR = os.path.join(_ROOT, "config", "roster")
NPC_DIR = os.path.join(_ROOT, "src", "mindlock", "game", "static", "sprites", "npc")
DIRS8 = ("east", "south-east", "south", "south-west", "west", "north-west", "north", "north-east")
# Stored on every entry; absence means "spec only, no asset yet".
_DEFAULT_STATUS = {"portrait": False, "sprite": False}
def slugify(name: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") or "char"
# ----------------------------------------------------------------------- asset path helpers
def sprite_dir(slug: str) -> str:
return os.path.join(NPC_DIR, slug)
def portrait_path(slug: str) -> str:
return os.path.join(NPC_DIR, slug, "portrait.png")
def portrait_rel(slug: str) -> str:
"""Project-root-relative path the server resolves for /api/portrait/{id}."""
return os.path.relpath(portrait_path(slug), _ROOT)
def has_portrait(slug: str) -> bool:
return os.path.exists(portrait_path(slug))
def has_sprite(slug: str) -> bool:
d = sprite_dir(slug)
return all(os.path.exists(os.path.join(d, f"{x}.png")) for x in DIRS8)
# --------------------------------------------------------------------------- roster CRUD
def _entry_path(slug: str) -> str:
return os.path.join(ROSTER_DIR, f"{slug}.json")
def save_entry(entry: dict) -> str:
os.makedirs(ROSTER_DIR, exist_ok=True)
entry.setdefault("status", dict(_DEFAULT_STATUS))
path = _entry_path(entry["slug"])
with open(path, "w", encoding="utf-8") as fh:
json.dump(entry, fh, ensure_ascii=False, indent=2)
return path
def load_entry(slug: str) -> dict | None:
path = _entry_path(slug)
if not os.path.exists(path):
return None
with open(path, encoding="utf-8") as fh:
return json.load(fh)
def list_entries() -> list[dict]:
if not os.path.isdir(ROSTER_DIR):
return []
out = []
for fn in sorted(os.listdir(ROSTER_DIR)):
if fn.endswith(".json"):
with open(os.path.join(ROSTER_DIR, fn), encoding="utf-8") as fh:
out.append(json.load(fh))
return out
def mark(slug: str, asset: str, value: bool = True) -> None:
e = load_entry(slug)
if not e:
raise KeyError(f"no roster entry: {slug}")
e.setdefault("status", dict(_DEFAULT_STATUS))[asset] = value
save_entry(e)
def sync_status(entry: dict) -> dict:
"""Reconcile the stored status with what's actually on disk (assets may land out-of-band)."""
st = entry.setdefault("status", dict(_DEFAULT_STATUS))
st["portrait"] = has_portrait(entry["slug"])
st["sprite"] = has_sprite(entry["slug"])
return entry
def pending(asset: str) -> list[dict]:
"""Entries still missing the given asset (`portrait` or `sprite`), checked against disk."""
return [e for e in (sync_status(x) for x in list_entries()) if not e["status"].get(asset)]
def ready_entries() -> list[dict]:
"""Fully-realized members (sprite present) the game can place. Portrait is a soft requirement —
the VN box falls back to the sprite, so a sprite-only member is still playable."""
return [e for e in (sync_status(x) for x in list_entries()) if e["status"].get("sprite")]
# ----------------------------------------------------------------- roster -> engine Character
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 to_holder(entry: dict, *, goal: str, knower_name: str, relation: str) -> Character:
"""Assign a roster member the HOLDER role: their soft spot becomes the approach word."""
approach = (entry.get("soft_spot") or "").strip()
bio = entry["biography"]
if approach and approach.lower() not in bio.lower():
bio = (f"{bio.rstrip('.')}. Deep down, '{approach}' is the one thing that still reaches "
"you — your tender, guarded wound.")
bio = f"{bio.rstrip('.')}. {knower_name} is your {relation}; you know them well."
return Character(
name=entry["name"], persona=entry["persona"], biography=bio, voice=entry["voice"],
fear=entry["fear"], key_holder=True, key_location=entry.get("key_location", "on your person"),
title=entry["title"], goal=goal, key_approach=[approach.lower()] if approach else [],
gender=entry.get("gender", ""), portrait=portrait_rel(entry["slug"]),
sprite_key=entry["slug"], relations={knower_name: relation},
known_people=[approach] if approach else [])
def to_knower(entry: dict, *, goal: str, holder_name: str, holder_soft_spot: str,
relation: str) -> Character:
"""Assign a roster member the KNOWER role: they reveal the holder's soft-spot word."""
approach = (holder_soft_spot or "").strip()
bio = f"{entry['biography'].rstrip('.')}. {holder_name} is your {relation}; you know them and their wound well."
vague = entry.get("secret") or f"There's a name {holder_name} never says aloud."
reveal = (f"If you really want to reach {holder_name}, say the word: {approach}."
if approach else f"{holder_name} has a soft spot, but I can't put it to words.")
htopics = _topics(holder_name, entry["title"]) + ["he", "she", "him", "her", "his", "they", "them"]
return Character(
name=entry["name"], persona=entry["persona"], biography=bio, voice=entry["voice"],
fear=entry["fear"], key_holder=False, key_location="", title=entry["title"], goal=goal,
gender=entry.get("gender", ""), portrait=portrait_rel(entry["slug"]),
sprite_key=entry["slug"], relations={holder_name: relation},
known_people=[approach] if approach else [],
secrets=[
{"id": "vague", "topics": htopics, "min_rapport": 2, "text": vague},
{"id": "reveal", "topics": htopics + _topics(approach), "min_rapport": 4,
"teaches": [approach.lower()] if approach else [], "text": reveal},
])
def build_world(seed: int = 0, *, goal: str = "the key", relation: str = "old acquaintance") -> World:
"""Assemble a solvable room from two ready roster members (sprite present). Picks a HOLDER with a
soft spot + a KNOWER who reveals it. Raises if fewer than two members are ready."""
ready = ready_entries()
if len(ready) < 2:
raise RuntimeError(f"roster has {len(ready)} ready member(s); need at least 2 (mint + assets)")
n = len(ready)
holder_e = ready[seed % n]
# prefer a holder that actually has a soft spot, else any
if not (holder_e.get("soft_spot") or "").strip():
for cand in ready:
if (cand.get("soft_spot") or "").strip():
holder_e = cand
break
knower_e = ready[(seed + 1) % n]
if knower_e["slug"] == holder_e["slug"]:
knower_e = ready[(seed + 2) % n]
holder = to_holder(holder_e, goal=goal, knower_name=knower_e["name"], relation=relation)
knower = to_knower(knower_e, goal=goal, holder_name=holder_e["name"],
holder_soft_spot=holder_e.get("soft_spot", ""), relation=relation)
room = Room(name=goal[:50].strip() or "A closed door",
intro=f"You need {goal}. {holder.name} has it and will not part with it easily.",
characters=[holder, knower], key_holder=holder.name, terminal=None)
return World(rooms=[room])