Spaces:
Running
Running
| """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"<h1>{html.escape(title)}</h1><p>not found</p>", 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"<code>\1</code>", s) | |
| s = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", s) | |
| s = re.sub(r"(?<!\*)\*([^*]+)\*(?!\*)", r"<em>\1</em>", s) | |
| s = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'<a href="\2">\1</a>', 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("<pre>" if in_code else "</pre>") | |
| 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("<table>") | |
| 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] == "<table>" else "td" | |
| out.append("<tr>" + "".join(f"<{tag}>{inline(c)}</{tag}>" for c in cells) + "</tr>") | |
| continue | |
| if in_tbl: | |
| out.append("</table>") | |
| in_tbl = False | |
| m = re.match(r"(#{1,4})\s+(.*)", ln) | |
| if m: | |
| h = len(m.group(1)) | |
| out.append(f"<h{h}>{inline(m.group(2))}</h{h}>") | |
| elif ln.startswith("> "): | |
| out.append(f"<blockquote>{inline(ln[2:])}</blockquote>") | |
| elif ln.strip() in ("---", "***"): | |
| out.append("<hr>") | |
| 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"<li>{inline(item)}</li>") | |
| elif ln.strip(): | |
| out.append(f"<p>{inline(ln)}</p>") | |
| if in_tbl: | |
| out.append("</table>") | |
| 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 = ('<nav><a href="/">▶ play</a><a href="/about">about</a>' | |
| '<a href="/docs/architecture">architecture</a><a href="/docs/trace">trace</a></nav><hr>') | |
| return HTMLResponse( | |
| f"<!doctype html><html><head><meta charset=utf-8>" | |
| f"<meta name=viewport content='width=device-width,initial-scale=1'>" | |
| f"<title>mAIndlock — {html.escape(title)}</title><style>{css}</style></head>" | |
| f"<body>{nav}{body}</body></html>") | |
| # 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") | |
| def index(): | |
| return FileResponse(os.path.join(_STATIC, "index.html")) | |
| def state(): | |
| return SESSION.state() | |
| 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 | |
| def favicon(): | |
| return FileResponse(os.path.join(_STATIC, "menu", "key.png")) | |
| def doc_architecture(): | |
| """The engine, on the Space itself: the brain is real, not a metaphor.""" | |
| return _doc_html("ARCHITECTURE.md", "the engine") | |
| def doc_trace(): | |
| """One mind's full deliberation, recorded — readable without waiting on CPU.""" | |
| return _doc_html("TRACE.md", "a recorded mind") | |
| async def talk(request: Request): | |
| body = await request.json() | |
| return SESSION.talk(int(body.get("char_id", -1)), str(body.get("message", ""))) | |
| async def terminal(request: Request): | |
| body = await request.json() | |
| return SESSION.terminal(str(body.get("code", ""))) | |
| def next_room(): | |
| return SESSION.next_room() | |
| def reset(): | |
| return SESSION.reset() | |
| # 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"))) | |
| # 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 {}) | |
| # character editor: the current level's dialogue halves | |
| def editor_level(): | |
| return SESSION.editor_level() | |
| # 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 {}) | |
| # 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))) | |
| # dev: generate a real procedural room (LLM narrative) | |
| def dev_generate(): | |
| return SESSION.dev_generate() | |
| # dev: assemble a room from minted roster members | |
| def dev_roster(): | |
| return SESSION.dev_roster() | |
| 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() | |