"""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)}{tag}>" 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"
")
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()