| """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 |
| |
| |
| |
| |
| api = gr.Server(title="Brad Did Something") |
| app = api |
|
|
|
|
| |
|
|
| 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 |
|
|
|
|
| |
|
|
| @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) |
| panels = body.image_prompt.strip() |
| if not panels: |
| return {"image_b64": None} |
| |
| |
| |
| img = comic.generate_comic(f"{prompts.COMIC_STYLE}. {panels}") |
| return {"image_b64": img} |
|
|
|
|
| @api.post("/api/new_game") |
| def new_game() -> dict: |
| llm.warm() |
| 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} |
|
|
|
|
| |
|
|
| 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))) |
|
|