"""The corridor: building a loop and describing it. A "room" is not a paragraph. It is a set of properties, each currently at its baseline value or (at most one of them) at an anomalous value. The describer turns that into a handful of sentences drawn from rotating pools, so the same place reads differently every time while still being the same place. Content is organised into *arcs* (skins/backstories) under data/arcs/. Each arc file shares the same schema; only the theme, story, and vocabulary change. """ from __future__ import annotations import glob import json import os import random from typing import Dict, List, Optional import anomalies from memory import PlayerMemory ARCS_DIR = os.path.join(os.path.dirname(__file__), "data", "arcs") DEFAULT_ARC = "hallway-eight" # How many hallways/landings you must clear. Reaching this level wins. GOAL = 8 # How many details are described each loop. Enough to overload memory a little, # few enough to keep it readable. MIN_DETAILS = 4 MAX_DETAILS = 6 def _arc_path(arc_id: str) -> str: return os.path.join(ARCS_DIR, f"{arc_id}.json") def list_arcs() -> List[dict]: """Return the selectable arcs, described by their meta block. The default arc is listed first; the rest follow alphabetically. """ arcs = [] for path in sorted(glob.glob(os.path.join(ARCS_DIR, "*.json"))): with open(path, "r", encoding="utf-8") as f: meta = json.load(f).get("meta", {}) if meta.get("id"): arcs.append({ "id": meta["id"], "title": meta.get("title", meta["id"]), "tagline": meta.get("tagline", ""), "skin": meta.get("skin", "hallway"), }) arcs.sort(key=lambda a: (a["id"] != DEFAULT_ARC, a["id"])) return arcs class Hallway: def __init__(self, arc_id: str = DEFAULT_ARC): path = _arc_path(arc_id) if not os.path.exists(path): path = _arc_path(DEFAULT_ARC) with open(path, "r", encoding="utf-8") as f: self.data = json.load(f) self.properties: Dict[str, dict] = self.data["properties"] self.meta: dict = self.data.get("meta", {}) self.labels: Dict[str, str] = self.meta.get("labels", {}) def build(self, mem: PlayerMemory, rng: random.Random) -> dict: """Generate the next place for the player's current level. The probability of an anomaly stays near a coin-flip so neither "always continue" nor "always turn back" is a winning strategy. """ has_anomaly = rng.random() < 0.5 anomaly_prop: Optional[str] = None anomaly_val: Optional[str] = None if has_anomaly: anomaly_prop, anomaly_val = anomalies.pick_anomaly(self.properties, rng) # Decide which properties get described this loop. order = list(self.data["anchor_order"]) rng.shuffle(order) n = rng.randint(MIN_DETAILS, MAX_DETAILS) shown = order[:n] # If there is an anomaly, it must be visible this loop -- otherwise # the player is asked to notice something they were never shown. if anomaly_prop and anomaly_prop not in shown: shown[rng.randrange(len(shown))] = anomaly_prop # Re-sort shown into the canonical order so the "camera" of the prose # feels consistent even as content rotates. shown = [p for p in self.data["anchor_order"] if p in shown] sign_text = anomalies.drift_sign(self.data["sign_variants"], rng) sentences: List[str] = [] for prop in shown: spec = self.properties[prop] if prop == anomaly_prop: pool = spec["anomalies"][anomaly_val] else: baseline = spec["baseline"] pool = spec["values"][baseline] line = anomalies.render_sentence(rng.choice(pool), sign_text) sentences.append(line) # Adaptive touch: if the player keeps inspecting one thing, and it is # on show this loop, let the place notice. fav = mem.favorite adaptive_line = None if fav and fav in shown and rng.random() < 0.5: tmpl = rng.choice(self.data["adaptive"]["repeat_inspect"]) adaptive_line = tmpl.replace("{THING}", self.labels.get(fav, fav)) heading = anomalies.drift_heading( self.data["title_variants"], mem.loops, rng ) return { "heading": heading, "sentences": sentences, "adaptive": adaptive_line, "sign_text": sign_text, "shown": shown, # server-authoritative truth, never sent to the client: "_has_anomaly": has_anomaly, "_anomaly_prop": anomaly_prop, "_anomaly_val": anomaly_val, } def public(self, room: dict) -> dict: """Strip the answer before sending a room to the browser.""" return {k: v for k, v in room.items() if not k.startswith("_")} def intro(self) -> List[str]: return self.data["framing"]["intro"] def win(self) -> List[str]: return self.data["framing"]["win"]