Spaces:
Running
Running
| """Ravenmoor — gr.Server backend for the god-view town frontend. | |
| The custom HTML5-canvas frontend (frontend/) renders a living top-down town: | |
| NPCs walk their own schedules, the camera follows whoever you choose, and the | |
| detective only you command. These endpoints are the authoritative game state. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import threading | |
| import time | |
| from pathlib import Path | |
| from fastapi.responses import FileResponse, HTMLResponse | |
| from fastapi.staticfiles import StaticFiles | |
| from gradio import Server | |
| ROOT = Path(__file__).resolve().parent | |
| if not os.environ.get("CARTBRAWL_API_KEY"): | |
| _kf = ROOT / ".cartbrawl_api_key" | |
| if _kf.exists(): | |
| os.environ["CARTBRAWL_API_KEY"] = _kf.read_text().strip() | |
| from ravenmoor import npc as npcmod | |
| from concurrent.futures import TimeoutError as FuturesTimeout | |
| from ravenmoor import scores, weaver | |
| from ravenmoor.casegen import LOCATIONS, MOTIVES, TOWN_FACTS | |
| from ravenmoor.flavor import generate_flavor | |
| from ravenmoor.game import ( | |
| COST_ASK, | |
| COST_CONFRONT, | |
| COST_SEARCH, | |
| COST_SKILL, | |
| COST_STATEMENT, | |
| SKILLS, | |
| Game, | |
| new_game, | |
| ) | |
| MAX_SESSIONS = 60 | |
| _sessions: dict[str, tuple[Game, float]] = {} | |
| _lock = threading.Lock() | |
| def _warm_backend(): | |
| """Poke the Modal endpoint at startup so the first player skips the cold start.""" | |
| try: | |
| import httpx | |
| base = os.environ.get("CARTBRAWL_LLM_URL", "https://wuzhenze--cartbrawl-llm-serve.modal.run/v1") | |
| httpx.get( | |
| base.rstrip("/") + "/models", | |
| headers={"Authorization": f"Bearer {os.environ.get('CARTBRAWL_API_KEY', '')}"}, | |
| timeout=600, | |
| ) | |
| except Exception: | |
| pass | |
| threading.Thread(target=_warm_backend, daemon=True).start() | |
| def _get(sid: str) -> Game | None: | |
| with _lock: | |
| row = _sessions.get((sid or "anon")[:64]) | |
| return row[0] if row else None | |
| def _put(sid: str, g: Game): | |
| sid = (sid or "anon")[:64] | |
| with _lock: | |
| if len(_sessions) >= MAX_SESSIONS and sid not in _sessions: | |
| oldest = min(_sessions, key=lambda k: _sessions[k][1]) | |
| del _sessions[oldest] | |
| _sessions[sid] = (g, time.time()) | |
| def render_state(g: Game) -> dict: | |
| lang = g.lang | |
| return { | |
| "lang": lang, | |
| "day": g.day, | |
| "clock": g.clock_min, | |
| "over": g.over, | |
| "won": g.won, | |
| "npcs": [ | |
| { | |
| "id": n.id, "name": n.name, "avatar": n.avatar, | |
| "role": n.role[lang], "persona": n.persona[lang], | |
| "trait": n.trait[lang], "look": n.look[lang], "story": n.story[lang], "base": n.base, | |
| "exposed": g.exposed.get(n.id, []), | |
| "secret_out": n.id in g.secret_out, | |
| "statement_taken": n.id in g.statements, | |
| "attrs": n.attrs, | |
| "skills_used": {s: g.skill_used.get(f"{n.id}:{s}") == g.day for s in SKILLS}, | |
| **g.behavior(n.id), | |
| } | |
| for n in g.case.npcs.values() | |
| ], | |
| "cards": [{"id": c["id"], "kind": c["kind"], "text": c["text"][lang]} for c in g.cards], | |
| "facts": {k: v[lang] for k, v in g.case.town_facts.items()}, | |
| "started_at": g.started_at, | |
| "scene_loc": g.case.scene_loc, | |
| "town_name": g.case.town_name[lang], | |
| "layout": g.case.layout, | |
| "locations": {k: {"name": v[lang], "emoji": LOCATIONS[k]["emoji"]} for k, v in g.case.locations.items()}, | |
| "motives": [ | |
| {"key": k, "label": v[lang].split(":" if lang == "zh" else ":")[0]} | |
| for k, v in MOTIVES.items() | |
| ], | |
| "clues": [c["desc"][lang] for c in g.case.clues if c["id"] in g.clues_found], | |
| "searched": list(g.searched), | |
| "events": [e[lang] for e in g.events[-6:]], | |
| "victim_name": g.case.victim.get("name", ""), | |
| "intro": g.flavor.get("intro", ""), | |
| } | |
| app = Server() | |
| def api_new_game(sid: str, lang: str) -> dict: | |
| """Streams weaving progress (a cold backend can take 1-2 min), retries once, | |
| and never falls back silently — the player is told when the classic cast is used.""" | |
| lang = "zh" if str(lang).startswith("zh") or str(lang).startswith("中") else "en" | |
| g = new_game(lang) | |
| woven = None | |
| elapsed = 0 | |
| for attempt in range(2): | |
| ex, fut = weaver.weave_async(g.case, lang) | |
| budget = 150 if attempt == 0 else 60 | |
| waited = 0.0 | |
| try: | |
| while waited < budget: | |
| try: | |
| data = fut.result(timeout=3) | |
| woven = data if weaver._valid(data, g.case) else None | |
| break | |
| except FuturesTimeout: | |
| waited += 3 | |
| elapsed += 3 | |
| yield {"kind": "progress", "seconds": elapsed} | |
| except Exception as e: | |
| print(f"[weaver] attempt {attempt} error: {type(e).__name__}", flush=True) | |
| break | |
| finally: | |
| ex.shutdown(wait=False, cancel_futures=True) | |
| if woven: | |
| print(f"[weaver] ok attempt {attempt} ~{elapsed}s", flush=True) | |
| break | |
| if woven: | |
| g.flavor = weaver.apply(g.case, woven, lang) | |
| else: | |
| print("[weaver] fell back to handwritten cast", flush=True) | |
| g.flavor = generate_flavor(g.case, lang) # handwritten cast stays | |
| g.events.append({"day": 1, "zh": g.flavor["intro"], "en": g.flavor["intro"]}) | |
| _put(sid, g) | |
| yield {"kind": "state", "woven": bool(woven), "state": render_state(g)} | |
| def api_state(sid: str) -> dict: | |
| g = _get(sid) | |
| if not g: | |
| return {"kind": "error", "error": "no_game"} | |
| return {"kind": "state", "state": render_state(g)} | |
| def api_ask(sid: str, npc_id: str, text: str) -> dict: | |
| """Streams the NPC's reply token by token, then the updated state. | |
| Annotated `-> dict` so gr.Server registers ONE streamed output; without it | |
| Gradio infers zero outputs and silently drops every yielded event. | |
| """ | |
| g = _get(sid) | |
| text = (text or "").strip()[:300] | |
| if not g or g.over or npc_id not in g.case.npcs or not text: | |
| yield {"kind": "error", "error": "bad_request"} | |
| return | |
| hist = g.histories.setdefault(npc_id, []) | |
| hist.append({"role": "detective", "text": text}) | |
| flags = {"secret_out": npc_id in g.secret_out, "exposed": g.exposed.get(npc_id, [])} | |
| answer = "" | |
| try: | |
| for delta in npcmod.reply(g.case, npc_id, hist[:-1], text, g.lang, flags): | |
| answer += delta | |
| yield {"kind": "delta", "text": delta} | |
| except Exception: | |
| fallback = "……(他沉默地看着你)" if g.lang == "zh" else "…(they stare at you in silence)" | |
| answer = answer or fallback | |
| yield {"kind": "delta", "text": fallback if not answer else ""} | |
| hist.append({"role": "npc", "text": answer}) | |
| behavior = g.register_pressure(npc_id, text, answer) | |
| rolled = g.spend_time(COST_ASK) | |
| out = {"kind": "done", "behavior": behavior, "rollover": rolled, "state": render_state(g)} | |
| if g.over: | |
| out["reveal"] = g.reveal() | |
| yield out | |
| def api_statement(sid: str, npc_id: str) -> dict: | |
| """Formal statement: structured evidence cards + the NPC reciting their | |
| evening in character (streamed).""" | |
| g = _get(sid) | |
| if not g or g.over or npc_id not in g.case.npcs: | |
| yield {"kind": "error", "error": "bad_request"} | |
| return | |
| if npc_id in g.statements: | |
| yield {"kind": "error", "error": "taken"} | |
| return | |
| new = g.take_statement(npc_id) | |
| yield { | |
| "kind": "cards", | |
| "cards": [{"id": c["id"], "kind": c["kind"], "text": c["text"][g.lang]} for c in new], | |
| } | |
| time.sleep(0.25) | |
| flags = {"secret_out": npc_id in g.secret_out, "exposed": g.exposed.get(npc_id, [])} | |
| narration = "" | |
| try: | |
| for delta in npcmod.statement_narration(g.case, npc_id, g.lang, flags): | |
| narration += delta | |
| yield {"kind": "delta", "text": delta} | |
| except Exception: | |
| pass | |
| hist = g.histories.setdefault(npc_id, []) | |
| hist.append({"role": "detective", "text": "【索取口供】" if g.lang == "zh" else "[takes formal statement]"}) | |
| hist.append({"role": "npc", "text": narration}) | |
| rolled = g.spend_time(COST_STATEMENT) | |
| out = {"kind": "done", "rollover": rolled, "state": render_state(g)} | |
| if g.over: | |
| out["reveal"] = g.reveal() | |
| yield out | |
| def api_confront(sid: str, npc_id: str, card_id: str) -> dict: | |
| """Present a card to an NPC's face (⚡1). The truth graph decides whether | |
| it's a real contradiction; the LLM only acts out the verdict.""" | |
| g = _get(sid) | |
| if not g or g.over or npc_id not in g.case.npcs: | |
| yield {"kind": "error", "error": "bad_request"} | |
| return | |
| card = next((c for c in g.cards if c["id"] == card_id), None) | |
| if not card: | |
| yield {"kind": "error", "error": "no_card"} | |
| return | |
| result = g.confront_check(npc_id, card_id) | |
| n = g.case.npcs[npc_id] | |
| if result.get("break") == "alibi": | |
| g.events.append({ | |
| "day": g.day, | |
| "zh": f"❗ {n.name}的不在场证明已被铁证全面戳穿!", | |
| "en": f"❗ {n.name}'s alibi has completely collapsed under hard evidence!", | |
| }) | |
| elif result.get("break") == "secret": | |
| g.events.append({ | |
| "day": g.day, | |
| "zh": f"💔 {n.name}崩溃了,坦白了一桩与命案无关的秘密。", | |
| "en": f"💔 {n.name} broke down and confessed a secret — unrelated to the murder.", | |
| }) | |
| verdict_ev = { | |
| "kind": "verdict", "valid": result["valid"], "break": result.get("break"), | |
| "refuter": result.get("refuter"), "mode": result.get("mode"), | |
| "card": card["text"][g.lang], | |
| } | |
| yield verdict_ev | |
| time.sleep(0.25) | |
| card_text = card["text"][g.lang] | |
| if result.get("refuter"): | |
| card_text += ( | |
| f"(可{result['refuter']}当时整段时间都在那里,却从没见过你)" if g.lang == "zh" | |
| else f" (yet {result['refuter']} was there the whole time and never saw you)" | |
| ) | |
| hist = g.histories.setdefault(npc_id, []) | |
| hist.append({"role": "detective", "text": ("【出示证据】" if g.lang == "zh" else "[presents evidence] ") + card_text}) | |
| flags = {"secret_out": npc_id in g.secret_out, "exposed": g.exposed.get(npc_id, [])} | |
| answer = "" | |
| try: | |
| for delta in npcmod.confront_reply(g.case, npc_id, card_text, result, g.lang, flags): | |
| answer += delta | |
| yield {"kind": "delta", "text": delta} | |
| except Exception: | |
| pass | |
| hist.append({"role": "npc", "text": answer}) | |
| rolled = g.spend_time(COST_CONFRONT) | |
| out = {"kind": "done", "rollover": rolled, "verdict": verdict_ev, "state": render_state(g)} | |
| if g.over: | |
| out["reveal"] = g.reveal() | |
| yield out | |
| def api_skill(sid: str, npc_id: str, skill: str) -> dict: | |
| """Detective skill attempt: server rolls 2d6 vs the resisting attribute; | |
| success effects come straight from the truth graph.""" | |
| g = _get(sid) | |
| if not g or g.over or npc_id not in g.case.npcs or skill not in SKILLS: | |
| yield {"kind": "error", "error": "bad_request"} | |
| return | |
| result = g.skill_check(npc_id, skill) | |
| if result.get("error"): | |
| yield {"kind": "error", "error": result["error"]} | |
| return | |
| # the dice line is formatted server-side and shipped as FLAT fields only: | |
| # the browser gradio client mangles nested arrays in streamed json diffs | |
| ATTR_LABELS = {"nerve": {"zh": "胆量", "en": "Nerve"}, | |
| "guard": {"zh": "戒心", "en": "Guard"}, | |
| "poise": {"zh": "沉稳", "en": "Poise"}} | |
| attr_label = ATTR_LABELS[result["attr"]][g.lang] | |
| verdict_txt = ("✅ 成功!" if result["success"] else "❌ 失败") if g.lang == "zh" else ("✅ Success!" if result["success"] else "❌ Failed") | |
| dice_text = (f"🎲 {result['dice'][0]}+{result['dice'][1]}={result['roll']} " | |
| f"vs {result['dc']}({attr_label}{result['attr_value']}+4) → {verdict_txt}") | |
| card = (result.get("payload") or {}).get("card") | |
| card_text = card["text"][g.lang] if card else "" | |
| dice_ev = {"kind": "dice", "text": dice_text, "success": bool(result["success"]), | |
| "effect": result.get("effect") or "", "card_text": card_text} | |
| yield dice_ev | |
| time.sleep(0.25) # let the SSE consumer receive the roll as its own chunk | |
| hist = g.histories.setdefault(npc_id, []) | |
| label = { | |
| "intimidate": ("【严刑逼问】", "[intimidates]"), | |
| "befriend": ("【套近乎】", "[buys a drink]"), | |
| "bluff": ("【诈唬】「我们已经全都知道了」", '[bluffs] "We already know everything"'), | |
| }[skill] | |
| hist.append({"role": "detective", "text": label[0] if g.lang == "zh" else label[1]}) | |
| flags = {"secret_out": npc_id in g.secret_out, "exposed": g.exposed.get(npc_id, [])} | |
| answer = "" | |
| try: | |
| for delta in npcmod.skill_reply(g.case, npc_id, skill, result, g.lang, flags): | |
| answer += delta | |
| yield {"kind": "delta", "text": delta} | |
| except Exception: | |
| pass | |
| if not answer and not result["success"]: | |
| # the LLM died before reacting and nothing happened: refund the daily use | |
| g.skill_used.pop(f"{npc_id}:{skill}", None) | |
| hist.pop() | |
| yield {"kind": "error", "error": "llm_down"} | |
| return | |
| hist.append({"role": "npc", "text": answer}) | |
| rolled = g.spend_time(COST_SKILL) | |
| out = {"kind": "done", "rollover": rolled, "state": render_state(g), | |
| "dice_text": dice_text, "dice_success": dice_ev["success"], | |
| "dice_effect": dice_ev["effect"], "dice_card": card_text} | |
| if g.over: | |
| out["reveal"] = g.reveal() | |
| yield out | |
| def api_pin(sid: str, npc_id: str, text: str) -> dict: | |
| """Pin any line an NPC said in free chat as a quotable evidence card. Free.""" | |
| g = _get(sid) | |
| text = (text or "").strip()[:200] | |
| if not g or g.over or npc_id not in g.case.npcs or not text: | |
| return {"kind": "error", "error": "bad_request"} | |
| n = g.case.npcs[npc_id] | |
| cid = f"quote:{npc_id}:{abs(hash(text)) % 10**8}" | |
| card = { | |
| "id": cid, "kind": "quote", "witness": npc_id, "subject": None, | |
| "text": { | |
| "zh": f"📌 {n.name}亲口说:「{text}」", | |
| "en": f'📌 {n.name} said to your face: "{text}"', | |
| }, | |
| } | |
| g._add_card(card) | |
| return {"kind": "pinned", "state": render_state(g), "card_text": card["text"][g.lang]} | |
| def api_leaderboard(sid: str) -> dict: | |
| return {"kind": "board", "rows": scores.top(15)} | |
| def api_submit_score(sid: str, name: str) -> dict: | |
| g = _get(sid) | |
| if not g or not g.won or g.solve_seconds is None: | |
| return {"kind": "error", "error": "no_win"} | |
| if g.score_submitted: | |
| return {"kind": "error", "error": "already"} | |
| try: | |
| rows = scores.submit(name, g.solve_seconds, min(g.day, 3), g.clock_used(), g.lang, g.case.seed) | |
| g.score_submitted = True | |
| return {"kind": "board", "rows": rows} | |
| except Exception as e: | |
| return {"kind": "error", "error": f"submit_failed: {e}"} | |
| def api_search(sid: str, loc: str) -> dict: | |
| g = _get(sid) | |
| if not g or g.over or loc not in LOCATIONS: | |
| return {"kind": "error", "error": "bad_request"} | |
| if loc in g.searched: | |
| return {"kind": "error", "error": "searched"} | |
| found = g.search(loc) | |
| texts = [] | |
| if loc == g.case.scene_loc and g.flavor.get("scene"): | |
| texts.append("🏰 " + g.flavor["scene"]) | |
| g.events.append({"day": g.day, "zh": "🏰 " + g.flavor["scene"], "en": "🏰 " + g.flavor["scene"]}) | |
| for c in found: | |
| texts.append("🧩 " + c["desc"][g.lang]) | |
| g.events.append({"day": g.day, "zh": "🧩 " + c["desc"]["zh"], "en": "🧩 " + c["desc"]["en"]}) | |
| if not texts: | |
| msg = "搜查完毕,没有发现新东西。" if g.lang == "zh" else "You search thoroughly and find nothing new." | |
| texts.append(msg) | |
| rolled = g.spend_time(COST_SEARCH) | |
| out = {"kind": "found", "texts": texts, "rollover": rolled, "state": render_state(g)} | |
| if g.over: | |
| out["reveal"] = g.reveal() | |
| return out | |
| def api_end_day(sid: str) -> dict: | |
| g = _get(sid) | |
| if not g or g.over: | |
| return {"kind": "error", "error": "bad_request"} | |
| g.end_day() | |
| out = {"kind": "day", "state": render_state(g)} | |
| if g.over: # culprit fled | |
| out["reveal"] = g.reveal() | |
| return out | |
| def api_accuse(sid: str, npc_id: str, motive: str) -> dict: | |
| """Streams the verdict, then the culprit's final speech.""" | |
| g = _get(sid) | |
| if not g or g.over or npc_id not in g.case.npcs or motive not in MOTIVES: | |
| yield {"kind": "error", "error": "bad_request"} | |
| return | |
| result = g.accuse(npc_id, motive) | |
| culprit = g.case.npcs[g.case.culprit] | |
| yield { | |
| "kind": "verdict", | |
| "correct_npc": result["correct_npc"], | |
| "correct_motive": result["correct_motive"], | |
| "reveal": result["reveal"], | |
| "solve_seconds": g.solve_seconds, | |
| "culprit": {"id": culprit.id, "name": culprit.name, "avatar": culprit.avatar}, | |
| "state": render_state(g), | |
| } | |
| time.sleep(0.25) | |
| try: | |
| for delta in npcmod.final_speech(g.case, npc_id, g.lang): | |
| yield {"kind": "delta", "text": delta} | |
| except Exception: | |
| pass | |
| try: | |
| for delta in npcmod.epilogue_narration(g.case, result["reveal"], g.lang): | |
| yield {"kind": "epi_delta", "text": delta} | |
| except Exception: | |
| pass | |
| yield {"kind": "done", "state": render_state(g)} | |
| _last_warm = {"t": 0.0} | |
| async def homepage(): | |
| # start waking the inference backend while the player reads the title screen | |
| if time.time() - _last_warm["t"] > 300: | |
| _last_warm["t"] = time.time() | |
| threading.Thread(target=_warm_backend, daemon=True).start() | |
| return (ROOT / "frontend" / "index.html").read_text(encoding="utf-8") | |
| class FreshStatic(StaticFiles): | |
| """Small files, always revalidated — UI updates reach every browser.""" | |
| def file_response(self, *args, **kwargs): | |
| resp = super().file_response(*args, **kwargs) | |
| resp.headers["Cache-Control"] = "no-cache, must-revalidate" | |
| return resp | |
| app.mount("/static", FreshStatic(directory=ROOT / "frontend" / "static"), name="static") | |
| if __name__ == "__main__": | |
| app.launch(show_error=True) | |