"""FastAPI server for the walkable game. Serves the canvas frontend and a small JSON API over a single `GameSession`. The movement loop lives in the browser; every server call is a discrete game action (talk / terminal / walk through door), so the round-trips stay coarse and the canvas stays smooth. Run for dev: python scripts/run_game.py (or MINDLOCK_FAKE=1 python scripts/run_game.py) """ from __future__ import annotations import os from fastapi import FastAPI, Request from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles from .session import GameSession _STATIC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static") # repo root → docs/, so the architecture + trace ship as live pages on the Space itself _DOCS = os.path.normpath(os.path.join(_STATIC, "..", "..", "..", "..", "docs")) def _doc_html(name: str, title: str) -> HTMLResponse: """Render a repo Markdown doc as a dark, self-contained HTML page (no JS, no CDN — stays fully offline). Minimal Markdown: headings, tables, bold/italic/code, hr, lists.""" import html import re path = os.path.join(_DOCS, name) if not os.path.exists(path): return HTMLResponse(f"

{html.escape(title)}

not found

", status_code=404) with open(path, encoding="utf-8") as fh: md = fh.read() def inline(s: str) -> str: s = html.escape(s) s = re.sub(r"`([^`]+)`", r"\1", s) s = re.sub(r"\*\*([^*]+)\*\*", r"\1", s) s = re.sub(r"(?\1", s) s = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'\1', s) return s out, in_tbl, in_code = [], False, False for ln in md.split("\n"): if ln.startswith("```"): in_code = not in_code out.append("
" if in_code else "
") continue if in_code: out.append(html.escape(ln)) continue if ln.startswith("|") and "|" in ln[1:]: cells = [c.strip() for c in ln.strip().strip("|").split("|")] if set("".join(cells)) <= set("-: "): continue if not in_tbl: out.append("") in_tbl = True tag = "th" if all(not c or c.isupper() or c[:1].isupper() for c in cells) and len(out) and out[-1] == "
" else "td" out.append("" + "".join(f"<{tag}>{inline(c)}" for c in cells) + "") continue if in_tbl: out.append("
") in_tbl = False m = re.match(r"(#{1,4})\s+(.*)", ln) if m: h = len(m.group(1)) out.append(f"{inline(m.group(2))}") elif ln.startswith("> "): out.append(f"
{inline(ln[2:])}
") elif ln.strip() in ("---", "***"): out.append("
") elif re.match(r"\s*[-*]\s+", ln): item = re.sub(r"^\s*[-*]\s+", "", ln) # extracted: 3.11 forbids backslashes in f-string {expr} out.append(f"
  • {inline(item)}
  • ") elif ln.strip(): out.append(f"

    {inline(ln)}

    ") if in_tbl: out.append("") body = "\n".join(out) css = ( "body{background:#0b0810;color:#c9c0ad;font:16px/1.6 ui-monospace,Menlo,monospace;" "max-width:860px;margin:0 auto;padding:40px 22px}" "h1,h2,h3,h4{color:#e8c25a;line-height:1.25}h1{border-bottom:2px solid #2a1d0e;padding-bottom:.3em}" "a{color:#8be9fd}code{background:#161019;color:#bd93f9;padding:1px 5px}" "pre{background:#120d07;border-left:3px solid #2a1d0e;padding:12px;overflow-x:auto;color:#9a927f}" "table{border-collapse:collapse;width:100%;margin:14px 0}th,td{border:1px solid #2a1d0e;padding:6px 10px;text-align:left}" "th{color:#e8c25a;background:#161019}blockquote{border-left:3px solid #e8c25a;margin:12px 0;padding:4px 14px;color:#b79ae0}" "hr{border:0;border-top:1px solid #2a1d0e;margin:22px 0}li{margin:3px 0}" "nav a{margin-right:16px}" ) nav = ('
    ') return HTMLResponse( f"" f"" f"mAIndlock — {html.escape(title)}" f"{nav}{body}") # One process == one player for now. Per-session state (cookies) is a later step; the slice # proves the canvas <-> engine loop first. SESSION = GameSession() def create_app() -> FastAPI: app = FastAPI(title="Mindlock") app.mount("/static", StaticFiles(directory=_STATIC), name="static") @app.get("/") def index(): return FileResponse(os.path.join(_STATIC, "index.html")) @app.get("/api/state") def state(): return SESSION.state() @app.get("/api/manifest") def manifest(): """Frame counts for every animation dir under static/ — the client loads sprite sequences from known counts instead of 404-probing for the end of each one.""" out = {} for root, _dirs, files in os.walk(_STATIC): n = sum(1 for f in files if f.startswith("frame_") and f.endswith(".png")) if n: out[os.path.relpath(root, _STATIC).replace(os.sep, "/")] = n return out @app.get("/favicon.ico") def favicon(): return FileResponse(os.path.join(_STATIC, "menu", "key.png")) @app.get("/docs/architecture", response_class=HTMLResponse) def doc_architecture(): """The engine, on the Space itself: the brain is real, not a metaphor.""" return _doc_html("ARCHITECTURE.md", "the engine") @app.get("/docs/trace", response_class=HTMLResponse) def doc_trace(): """One mind's full deliberation, recorded — readable without waiting on CPU.""" return _doc_html("TRACE.md", "a recorded mind") @app.post("/api/talk") async def talk(request: Request): body = await request.json() return SESSION.talk(int(body.get("char_id", -1)), str(body.get("message", ""))) @app.post("/api/terminal") async def terminal(request: Request): body = await request.json() return SESSION.terminal(str(body.get("code", ""))) @app.post("/api/next-room") def next_room(): return SESSION.next_room() @app.post("/api/reset") def reset(): return SESSION.reset() @app.post("/api/start") # title menu: begin a run in "story" or "endless" mode async def start(request: Request): body = await request.json() return SESSION.start(str(body.get("mode", "endless"))) @app.post("/api/editor/save") # layout editor: write the arranged layout into the level async def editor_save(request: Request): body = await request.json() return SESSION.save_layout(body.get("layout") or {}) @app.get("/api/editor/level") # character editor: the current level's dialogue halves def editor_level(): return SESSION.editor_level() @app.post("/api/editor/character") # character editor: write edited prompt fields into the level async def editor_character(request: Request): body = await request.json() return SESSION.save_character(int(body.get("char_id", -1)), body.get("fields") or {}) @app.post("/api/dev/room") # dev: jump to a specific room async def dev_room(request: Request): body = await request.json() return SESSION.goto_room(int(body.get("idx", 0))) @app.post("/api/dev/generate") # dev: generate a real procedural room (LLM narrative) def dev_generate(): return SESSION.dev_generate() @app.post("/api/dev/roster") # dev: assemble a room from minted roster members def dev_roster(): return SESSION.dev_roster() @app.get("/api/portrait/{char_id}") def portrait(char_id: int): path = SESSION.portrait_file(char_id) if not path: return JSONResponse({"error": "no portrait"}, status_code=404) return FileResponse(path) return app app = create_app()