"""Brad Did Something — HF Space entry point. Built on `gr.Server` (Gradio's FastAPI-based server, ARCHITECTURE.md D1): it owns the /api routes and serves a fully custom canvas/DOM frontend from static/ — no default Gradio widgets. Run: python app.py (HF Docker: uvicorn app:app) """ from __future__ import annotations import os import pathlib import gradio as gr import uvicorn from fastapi import HTTPException from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field from game import (comic, economy, events, idle, llm, presentation, prompts, relationships) from game.schemas import GIFT_TIERS, NPC_IDS, RESPONSE_TYPES from game.state import STORE, GameState from game.trace import trace ROOT = pathlib.Path(__file__).parent # gr.Server is Gradio's own FastAPI-based server (Gradio 5.x+). Using it as the # base means the whole app is a first-class Gradio app while every route below is # plain FastAPI — so we serve a fully custom canvas/DOM frontend (static/) with # no default Gradio widgets. (Replaces the old FastAPI + mount_gradio_app shell.) api = gr.Server(title="Brad Did Something") app = api # the ASGI app served by `uvicorn app:app` (local + HF Docker) # ------------------------------------------------------------- request models class SessionBody(BaseModel): session_id: str = Field(min_length=8, max_length=64) class RespondBody(SessionBody): response_type: str text: str = Field(default="", max_length=400) class GiftBody(SessionBody): npc_id: str tier: str class ChatBody(SessionBody): npc_id: str text: str = Field(default="", max_length=400) class ComicBody(SessionBody): image_prompt: str = Field(default="", max_length=400) def _get_state(session_id: str) -> GameState: state = STORE.get(session_id) if state is None: raise HTTPException(404, "unknown session — start a new game") return state # --------------------------------------------------------------------- routes @api.post("/api/warm") def warm() -> dict: """Wake the Modal containers early (called on page load) so the first real event doesn't eat a cold start.""" llm.warm() comic.warm() return {"ok": True} @api.post("/api/comic") def comic_panel(body: ComicBody) -> dict: """Render the crisis comic from an AI-written image_prompt. Returns {image_b64: null} when FLUX is unavailable / fails / times out — the client then simply skips the overlay and opens the dialogue as usual.""" _get_state(body.session_id) # session must exist panels = body.image_prompt.strip() if not panels: return {"image_b64": None} # prepend the art style here so the model spends its whole image_prompt # budget on panel descriptions (else the ~230-char style tag ate the budget # and the panels truncated to a single panel) img = comic.generate_comic(f"{prompts.COMIC_STYLE}. {panels}") return {"image_b64": img} @api.post("/api/new_game") def new_game() -> dict: llm.warm() # belt-and-suspenders prewarm at game start comic.warm() state = STORE.create() state.phase = "free_roam" trace("flow", f"=== NEW GAME {state.session_id[:8]} " f"({'LIVE ' + os.environ.get('MODAL_URL', '') if os.environ.get('MODAL_URL') else 'MOCK mode'})") return {"session_id": state.session_id, "state": state.snapshot()} @api.post("/api/next_event") def next_event(body: SessionBody) -> dict: state = _get_state(body.session_id) if state.game_over: raise HTTPException(409, "quarter is over") event = events.next_event(state) return {"event": event, "state": state.snapshot()} @api.post("/api/respond") def respond(body: RespondBody) -> dict: state = _get_state(body.session_id) if body.response_type not in RESPONSE_TYPES: raise HTTPException(422, "bad response_type") if state.phase != "crisis" or not state.current_event: raise HTTPException(409, "no active crisis") outcome = events.respond(state, body.response_type, body.text.strip()) return {"outcome": outcome, "state": state.snapshot()} @api.post("/api/presentation_round") def presentation_round(body: RespondBody) -> dict: state = _get_state(body.session_id) if state.phase != "presentation" or state.presentation is None: raise HTTPException(409, "no active presentation") round_data = presentation.advance(state, body.response_type, body.text.strip()) return {"round_data": round_data, "state": state.snapshot()} @api.post("/api/gift") def gift(body: GiftBody) -> dict: state = _get_state(body.session_id) if state.phase != "free_roam": raise HTTPException(409, "gifts only between crises") cost = economy.gift_cost(body.tier) if cost is None: raise HTTPException(422, "bad tier") if state.pocket_money < cost: raise HTTPException(409, "insufficient pocket money") if body.tier == "coffee": state.pocket_money -= cost relationships.coffee_round(state) result = {"kind": "coffee", "cost": cost} trace("flow", f"coffee round -$50 -> morale {state.morale}") else: if body.npc_id not in NPC_IDS: raise HTTPException(422, "bad npc_id") state.pocket_money -= cost result = relationships.give_gift(state, body.npc_id, cost) result.update({"kind": "gift", "cost": cost, "npc_id": body.npc_id}) trace("flow", f"gift {body.tier} -> {body.npc_id} " f"rel+{result['relationship_delta']}" f"{' (halved)' if result['halved'] else ''}" f"{' UNLOCKED' if result['unlocked'] else ''} " f"-> {state.npc(body.npc_id).relationship}") return {"result": result, "state": state.snapshot()} @api.post("/api/chat") def chat(body: ChatBody) -> dict: state = _get_state(body.session_id) if state.phase != "free_roam": raise HTTPException(409, "chat only between crises") if body.npc_id not in NPC_IDS: raise HTTPException(422, "bad npc_id") try: if body.text.strip(): result = idle.reply_chat(state, body.npc_id, body.text.strip()) else: result = idle.open_chat(state, body.npc_id) except idle.IdleError as exc: raise HTTPException(409, str(exc)) return {"chat": result, "state": state.snapshot()} @api.post("/api/idle") def idle_roll(body: SessionBody) -> dict: state = _get_state(body.session_id) if state.phase != "free_roam": raise HTTPException(409, "idle moments only between crises") try: result = idle.roll_idle(state) except idle.IdleError as exc: raise HTTPException(409, str(exc)) return {"idle": result, "state": state.snapshot()} @api.post("/api/read_email") def read_email(body: SessionBody) -> dict: state = _get_state(body.session_id) try: email = idle.read_email(state) except idle.IdleError as exc: raise HTTPException(409, str(exc)) return {"email": email, "state": state.snapshot()} @api.post("/api/review") def review(body: SessionBody) -> dict: state = _get_state(body.session_id) if state.phase != "review": raise HTTPException(409, "quarter not finished") data = state.review or presentation.quarterly_review(state) return {"review": data, "state": state.snapshot()} @api.get("/healthz") def healthz() -> dict: return {"ok": True} # ------------------------------------------------------------- custom frontend api.mount("/static", StaticFiles(directory=ROOT / "static"), name="static") @api.get("/") @api.get("/game") def game_page() -> FileResponse: """Serve the fully custom canvas/DOM game frontend (no Gradio widgets).""" return FileResponse(ROOT / "static" / "index.html") if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", 7860)))