"""A tiny Flask server for the memory-loop game. Run: python app.py then open http://127.0.0.1:5000 The game ships several *arcs* (skins/backstories) that share one mechanic. Game state -- and, crucially, the answer to each loop -- lives on the server. The browser only ever receives the description, never whether a place is anomalous, so the page cannot be inspected to cheat. """ from __future__ import annotations import os import random import secrets from typing import Dict from flask import ( Flask, jsonify, render_template, request, session, ) from hallway import Hallway, GOAL, DEFAULT_ARC, list_arcs from memory import PlayerMemory app = Flask(__name__) app.secret_key = os.environ.get("SECRET_KEY", secrets.token_hex(32)) # Available arcs, and a cache of one Hallway per arc (each just holds parsed # JSON, so sharing them across requests is safe). ARCS = {a["id"]: a for a in list_arcs()} _HALLWAYS: Dict[str, Hallway] = {} def get_hallway(arc_id: str) -> Hallway: if arc_id not in ARCS: arc_id = DEFAULT_ARC if arc_id not in _HALLWAYS: _HALLWAYS[arc_id] = Hallway(arc_id) return _HALLWAYS[arc_id] # Server-side session store, keyed by a token kept in the signed cookie. # Holds the live PlayerMemory, the current (answer-bearing) room, the RNG, # and which arc is being played. In-memory is fine for a single process. STATE: Dict[str, dict] = {} def _slot() -> dict: """Return this browser's server-side state, creating it if needed.""" token = session.get("token") if not token or token not in STATE: token = secrets.token_hex(16) session["token"] = token STATE[token] = _fresh(DEFAULT_ARC) return STATE[token] def _fresh(arc_id: str) -> dict: return { "mem": PlayerMemory(), "room": None, "rng": random.Random(secrets.randbits(64)), "arc": arc_id if arc_id in ARCS else DEFAULT_ARC, } def _hall(slot: dict) -> Hallway: return get_hallway(slot["arc"]) def _next_room(slot: dict) -> dict: mem: PlayerMemory = slot["mem"] mem.loops += 1 room = _hall(slot).build(mem, slot["rng"]) slot["room"] = room return room def _payload(slot: dict, room: dict, extra: dict | None = None) -> dict: mem: PlayerMemory = slot["mem"] hall = _hall(slot) data = { "room": hall.public(room), "meta": hall.meta, "level": mem.level, "goal": GOAL, "best": mem.best_level, "loops": mem.loops, } if extra: data.update(extra) return jsonify(data) @app.route("/") def index(): return render_template("index.html") @app.route("/api/arcs", methods=["GET"]) def api_arcs(): return jsonify({"arcs": list(ARCS.values()), "default": DEFAULT_ARC}) @app.route("/api/new", methods=["POST"]) def api_new(): body = request.get_json(silent=True) or {} arc_id = body.get("arc", DEFAULT_ARC) if arc_id not in ARCS: arc_id = DEFAULT_ARC token = session.get("token") or secrets.token_hex(16) session["token"] = token STATE[token] = _fresh(arc_id) slot = STATE[token] room = _next_room(slot) return _payload(slot, room, {"intro": _hall(slot).intro()}) @app.route("/api/room", methods=["GET"]) def api_room(): slot = _slot() room = slot["room"] or _next_room(slot) return _payload(slot, room) @app.route("/api/inspect", methods=["POST"]) def api_inspect(): """The player lingers on one detail. The place may notice.""" slot = _slot() mem: PlayerMemory = slot["mem"] prop = (request.get_json(silent=True) or {}).get("prop", "") line = None if prop: mem.record_inspect(prop) # Occasionally, staring too long earns an unsettling aside. if slot["rng"].random() < 0.35: line = slot["rng"].choice(_hall(slot).data["adaptive"]["long_look"]) return jsonify({"line": line}) @app.route("/api/act", methods=["POST"]) def api_act(): """The player commits: 'continue' goes on, 'back' turns around.""" slot = _slot() mem: PlayerMemory = slot["mem"] room = slot["room"] if room is None: room = _next_room(slot) body = request.get_json(silent=True) or {} choice = body.get("choice") confidence = body.get("confidence") mem.record_confidence(confidence) has_anomaly = room["_has_anomaly"] # Correct play: turn back on a changed place, go on when nothing changed. correct = (choice == "back") == has_anomaly if choice == "back": mem.turn_backs += 1 else: mem.continues += 1 won = False if correct: mem.level += 1 mem.best_level = max(mem.best_level, mem.level) if mem.level >= GOAL: won = True else: if not has_anomaly and choice == "back": mem.false_reports += 1 elif has_anomaly and choice == "continue": mem.missed += 1 mem.level = 0 result = { "correct": correct, "had_anomaly": has_anomaly, "won": won, "confidence": confidence, } if won: result["win_text"] = _hall(slot).win() result["stats"] = mem.to_dict() # Reset progress so a fresh run starts clean, but keep the record. best = mem.best_level slot["mem"] = PlayerMemory(best_level=best) room = _next_room(slot) return _payload(slot, room, result) room = _next_room(slot) return _payload(slot, room, result) if __name__ == "__main__": # HOST/PORT are env-driven so the same file runs locally (127.0.0.1:5000) # and on a host like Hugging Face Spaces (0.0.0.0:7860). host = os.environ.get("HOST", "127.0.0.1") port = int(os.environ.get("PORT", "5000")) app.run(host=host, port=port, debug=False)