qFelix's picture
Brad Did Something - Gradio/FastAPI Space on HF
78c0f6e
Raw
History Blame Contribute Delete
8.02 kB
"""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)))