hallway8 / hallway.py
alvations's picture
Deploy Hallway 8 (multi-arc memory game)
161d0a9 verified
Raw History Blame
5.19 kB
"""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"]