| |
| |
| """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, |
| ) |
|
|
| import i18n |
| from hallway import ( |
| Hallway, GOAL, DEFAULT_ARC, DEFAULT_DIFFICULTY, norm_difficulty, list_arcs, |
| ) |
| from memory import PlayerMemory |
|
|
| app = Flask(__name__) |
| app.secret_key = os.environ.get("SECRET_KEY", secrets.token_hex(32)) |
|
|
| |
| |
| 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] |
|
|
|
|
| |
| |
| |
| |
| |
| |
| STATE: Dict[str, dict] = {} |
|
|
| |
| |
| |
| |
| |
| MAX_SLOTS = 20000 |
|
|
|
|
| def _evict_if_full() -> None: |
| while len(STATE) >= MAX_SLOTS: |
| try: |
| oldest = next(iter(STATE)) |
| except StopIteration: |
| break |
| STATE.pop(oldest, None) |
|
|
|
|
| def _resolve_sid() -> str | None: |
| """Find the caller's session id: X-Sid header, then JSON body, then query, |
| then the cookie fallback.""" |
| sid = request.headers.get("X-Sid") |
| if not sid: |
| body = request.get_json(silent=True) or {} |
| sid = body.get("sid") |
| if not sid: |
| sid = request.args.get("sid") |
| if not sid: |
| sid = session.get("token") |
| |
| |
| return sid if isinstance(sid, str) else None |
|
|
|
|
| def _resolve_locale() -> str: |
| """Language for this request: X-Lang header, then body, then query.""" |
| lang = request.headers.get("X-Lang") |
| if not lang: |
| lang = (request.get_json(silent=True) or {}).get("lang") |
| if not lang: |
| lang = request.args.get("lang") |
| return lang if i18n.is_locale(lang) else i18n.SOURCE_LOCALE |
|
|
|
|
| def _resolve_difficulty() -> str: |
| """Difficulty for this request: X-Diff header, then body, then query. The |
| client owns unlock/selection; the server just honours what it is told (like |
| the locale), and clamps anything unknown to normal.""" |
| d = request.headers.get("X-Diff") |
| if not d: |
| d = (request.get_json(silent=True) or {}).get("difficulty") |
| if not d: |
| d = request.args.get("difficulty") |
| return norm_difficulty(d) |
|
|
|
|
| def _slot() -> dict: |
| """Return the caller's server-side state, creating it if the sid is unknown.""" |
| sid = _resolve_sid() |
| if not sid or sid not in STATE: |
| _evict_if_full() |
| sid = secrets.token_hex(16) |
| STATE[sid] = _fresh(DEFAULT_ARC) |
| session["token"] = sid |
| STATE[sid]["sid"] = sid |
| |
| STATE[sid]["locale"] = _resolve_locale() |
| STATE[sid]["difficulty"] = _resolve_difficulty() |
| return STATE[sid] |
|
|
|
|
| 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, |
| "sid": None, |
| "locale": i18n.SOURCE_LOCALE, |
| "difficulty": DEFAULT_DIFFICULTY, |
| } |
|
|
|
|
| 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.get("locale"), |
| slot.get("difficulty", DEFAULT_DIFFICULTY)) |
| slot["room"] = room |
| return room |
|
|
|
|
| def _payload(slot: dict, room: dict, extra: dict | None = None) -> dict: |
| mem: PlayerMemory = slot["mem"] |
| hall = _hall(slot) |
| locale = slot.get("locale") |
| data = { |
| "sid": slot.get("sid"), |
| "locale": locale, |
| "room": hall.public(room), |
| "meta": hall.meta_for(locale), |
| "level": mem.level, |
| "goal": GOAL, |
| "best": mem.best_level, |
| "loops": mem.loops, |
| } |
| if extra: |
| data.update(extra) |
| return jsonify(data) |
|
|
|
|
| def _localized_arcs(locale: str) -> list: |
| """The arc list with card title/tagline localized for the picker.""" |
| out = [] |
| for a in ARCS.values(): |
| hall = get_hallway(a["id"]) |
| flashbacks = hall.act2.get("flashbacks", []) |
| |
| |
| |
| flash_texts = [hall._loc(locale, f"act2|flashback|{i}", t) |
| for i, t in enumerate(flashbacks)] |
| out.append({ |
| "id": a["id"], |
| "skin": a["skin"], |
| "title": i18n.localize(locale, f"a:{a['id']}|title_main", a["title"]), |
| "tagline": i18n.localize(locale, f"a:{a['id']}|tagline", a["tagline"]), |
| "flashbacks": len(flashbacks), |
| "flash_texts": flash_texts, |
| }) |
| return out |
|
|
|
|
| @app.route("/") |
| def index(): |
| return render_template("index.html") |
|
|
|
|
| @app.route("/api/langs", methods=["GET"]) |
| def api_langs(): |
| return jsonify({"langs": i18n.available_locales(), |
| "default": i18n.SOURCE_LOCALE, |
| "unlock": i18n.unlock_map()}) |
|
|
|
|
| @app.route("/api/arcs", methods=["GET"]) |
| def api_arcs(): |
| return jsonify({ |
| "arcs": _localized_arcs(_resolve_locale()), |
| "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 |
|
|
| |
| sid = _resolve_sid() or secrets.token_hex(16) |
| if sid not in STATE: |
| _evict_if_full() |
| session["token"] = sid |
| STATE[sid] = _fresh(arc_id) |
| STATE[sid]["sid"] = sid |
| STATE[sid]["locale"] = _resolve_locale() |
| |
| |
| |
| STATE[sid]["difficulty"] = _resolve_difficulty() |
| slot = STATE[sid] |
|
|
| room = _next_room(slot) |
| return _payload(slot, room, { |
| "intro": _hall(slot).intro(slot["locale"]), |
| "cutscene": _hall(slot).cutscene(slot["locale"]), |
| }) |
|
|
|
|
| @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) |
| |
| if slot["rng"].random() < 0.35: |
| line = _hall(slot).long_look(slot.get("locale"), slot["rng"]) |
| return jsonify({"line": line}) |
|
|
|
|
| @app.route("/api/interact", methods=["POST"]) |
| def api_interact(): |
| """Act 2: the player touches a detail (optional). This never decides the |
| forward/back call. Most touches are pure flavour; rarely a touch folds the |
| run to level 0, and the gated false exit ends it in an alternate ending. |
| The answer (`_has_anomaly`) is never read or returned.""" |
| slot = _slot() |
| mem: PlayerMemory = slot["mem"] |
| body = request.get_json(silent=True) or {} |
| hall = _hall(slot) |
| locale = slot.get("locale") |
|
|
| |
| if body.get("action") == "exit": |
| ending = hall.take_exit(locale) |
| best = mem.best_level |
| slot["mem"] = PlayerMemory(best_level=best) |
| room = _next_room(slot) |
| return _payload(slot, room, {"kind": "exit", "ending": ending}) |
|
|
| prop = body.get("prop", "") |
| |
| |
| if prop and prop == hall.npc_prop: |
| mem.seen_by_npc += 1 |
| res = hall.interact(prop, mem, slot["rng"], locale, GOAL) |
| if not res: |
| return jsonify({"sid": slot.get("sid"), "kind": "none"}) |
|
|
| if res["kind"] == "fold": |
| mem.level = 0 |
| mem.attempts += 1 |
| room = _next_room(slot) |
| return _payload(slot, room, {"kind": "fold", "line": res["line"]}) |
|
|
| |
| return jsonify({"sid": slot.get("sid"), "kind": res["kind"], |
| "line": res["line"], "idx": res.get("idx"), "prop": prop, |
| "level": mem.level, "goal": GOAL}) |
|
|
|
|
| @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 = (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 |
| mem.attempts += 1 |
|
|
| result = { |
| "correct": correct, |
| "had_anomaly": has_anomaly, |
| "won": won, |
| "confidence": confidence, |
| } |
| |
| |
| if has_anomaly: |
| result["anomaly"] = {"prop": room.get("_anomaly_prop"), |
| "val": room.get("_anomaly_val")} |
| |
| |
| |
| |
| props = room.get("_anomaly_props", []) |
| if correct and choice == "back" and len(props) >= 2: |
| result["flare_props"] = list(props) |
|
|
| if won: |
| result["win_text"] = _hall(slot).win(slot.get("locale")) |
| result["attempts"] = mem.attempts |
| |
| result["attempt_text"] = _hall(slot).attempt_line( |
| slot.get("locale"), mem.attempts) |
| result["stats"] = mem.to_dict() |
| |
| 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 = os.environ.get("HOST", "127.0.0.1") |
| port = int(os.environ.get("PORT", "5000")) |
| app.run(host=host, port=port, debug=False) |
|
|