r"""Full game served through Gradio 6's `gr.Server` with a custom HTML frontend. The original Gradio Blocks UI (app.py / ui.py) is retired in favour of this custom frontend, which reproduces the same control layout while owning its own HTML/JS/CSS. Python game logic (engine.py) and HTML rendering (render.py) are reused unchanged; this module only exposes them as `@app.api` endpoints and a phase-aware view builder. Run: .\.venv\Scripts\python.exe server_app.py Then open http://127.0.0.1:7861/. AI generation runs synchronously (background_ai disabled); the frontend shows a loading overlay while a request is in flight. Backend respects MODEL_BACKEND. """ from __future__ import annotations import os import types import uuid from pathlib import Path from typing import Any import gradio as gr from fastapi.responses import FileResponse, Response from fastapi.staticfiles import StaticFiles from tower_game import render from tower_game import ui as game_ui from tower_game.ai import build_gateway, load_model_config from tower_game.assets import ASSET_ROOT from tower_game.engine import TowerGame from tower_game.ui import _tooltip_data ROOT = Path(__file__).resolve().parent FRONTEND = ROOT / "frontend" # Same gateway the live app builds (mock / local_openai / hf_inference), but we # force inline generation so boss intro / evolution / run-setup resolve within # the triggering request instead of via a background task system. game = TowerGame(build_gateway(load_model_config())) game.background_ai = False # --- Asset URLs -> /assets mount (prototype-only; assets.py untouched) --- def _proto_url(self: Any, asset_id: str) -> str: entry = self.entries.get(asset_id) or next(iter(self.entries.values())) rel = entry["source_path"] if rel.lower().startswith("assets/"): rel = rel[len("assets/"):] return f"/assets/{rel}" game.assets.url = types.MethodType(_proto_url, game.assets) # --- Session store: {sid: {"state": ..., "meta": ...}} --- SESSIONS: dict[str, dict[str, Any]] = {} def _previews(state: dict[str, Any]) -> dict[str, str]: return { skill["id"]: game.skill_preview(state, skill["id"]) for skill in state.get("active_skills", []) } def _combat_controls(state: dict[str, Any], previews: dict[str, str]) -> dict[str, Any]: cooldowns = state.get("cooldowns", {}) current_ap = int(state.get("current_ap", 0)) locked = bool(state.get("boss_thinking")) or bool(state.get("presentation_locked")) skills = [] for index, skill in enumerate(state.get("active_skills", [])): cooldown = int(cooldowns.get(skill["id"], 0)) ap_cost = int(skill.get("ap_cost", 0)) skills.append( { "index": index, "id": skill["id"], "label": render.skill_card_label(skill, state, previews.get(skill["id"], "")), "enabled": not locked and cooldown <= 0 and current_ap >= ap_cost, "elem_id": f"skill-button-{index}", } ) items = [ {"index": index, "label": render.item_card_label(item), "elem_id": f"item-button-{index}"} for index, item in enumerate(state.get("inventory", [])) ] return { "strike_enabled": not locked, "defend_enabled": not locked, "skills": skills, "items": items, } def _reward_controls(state: dict[str, Any]) -> list[dict[str, Any]]: selected = int(state.get("rewards_selected", 0)) picks = int(state.get("loot_picks_remaining", 0)) out = [] for index, reward in enumerate(state.get("pending_loot", [])): claimed = bool(reward.get("claimed")) icon = None if reward.get("kind") == "equipment": icon = game.assets.url(reward["item"]["asset_id"]) out.append( { "index": index, "label": render.render_reward_label(reward), "claimed": claimed, "enabled": (not claimed) and selected < picks, "icon": icon, "elem_id": f"reward-button-{index}", } ) return out def _view(sid: str, sess: dict[str, Any], event_start: int = 0) -> dict[str, Any]: state = sess["state"] phase = state.get("game_phase") previews = _previews(state) view: dict[str, Any] = { "session_id": sid, "phase": phase, "victory_step": state.get("victory_step") or "", "floor_label": state.get("floor_label", ""), "backend": game.gateway.backend_name, "stage_html": render.render_stage(state, game.assets), "sidebar_html": render.render_sidebar(state, game.assets, previews), "narration_html": render.render_stage_narration(state), "latest_log": render.render_latest_log(state), "full_log": render.render_log(state), "tooltips_html": _tooltip_data(state, previews), "hp": [int(state.get("current_hp", 0)), int(state.get("max_hp", 0))], "ap": [int(state.get("current_ap", 0)), int(state.get("max_ap", 0))], "sidebar_open": bool(state.get("_sidebar_open", True)), "combat_speed": state.get("combat_speed", "cinematic"), "is_boss_floor": int(state.get("current_floor", 0)) in (5, 10), } if event_start > 0: new_events = state.get("combat_events", [])[event_start:][:3] view["new_events"] = [ {"actor": e.get("actor", "System"), "text": e.get("text", "")} for e in new_events ] if phase == "allocation": draft = state.get("allocation_draft", {}) view["allocation"] = { "html": render.render_allocation(state), "draft": draft, "remaining": 24 - sum(int(v) for v in draft.values()), } elif phase in ("run_setup", "starter_skill"): view["setup"] = { "difficulty": state.get("difficulty_mode", "easy"), "needs_prepare": phase == "run_setup", "starters": [ {"index": i, "label": render.skill_card_label(s, state, active=False)} for i, s in enumerate(state.get("pending_starter_skills", [])) ] if phase == "starter_skill" else [], } elif phase == "combat": view["combat"] = _combat_controls(state, previews) view["boss_thinking"] = bool(state.get("boss_thinking")) elif phase == "victory": view["victory_html"] = render.render_victory(state) if state.get("victory_step") == "level_skill": view["level_skills"] = [ {"index": i, "label": render.skill_card_label(s, state, active=False)} for i, s in enumerate(state.get("pending_level_skills", [])) ] else: view["rewards"] = _reward_controls(state) view["proceed_ready"] = bool(state.get("proceed_ready")) view["picks"] = [ int(state.get("rewards_selected", 0)), int(state.get("loot_picks_remaining", 0)), ] if state.get("reward_preview_index") is not None: view["reward_preview_html"] = render.render_reward_preview(state) elif phase in ("evolution_reveal", "evolution_healing"): view["evolution_html"] = render.render_evolution(state, game.assets) view["evolution_step"] = "healing" if phase == "evolution_healing" else "reveal" elif phase in ("skill_replacement", "evolution_skill_replace"): pending = state.get("pending_skill") or {} view["replacement"] = { "pending_name": pending.get("name", "a new skill"), "skills": [ {"index": i, "label": render.skill_card_label(s, state, active=True)} for i, s in enumerate(state.get("active_skills", [])) ], } elif phase == "defeat": view["defeat_html"] = render.render_defeat(state) elif phase == "ascension": passive = (state.get("run_passives") or [{}])[-1] view["ascension"] = { "level": state.get("ascension_level", 0), "name": passive.get("name", ""), "description": passive.get("description", ""), } return view def _require(session_id: str) -> dict[str, Any] | None: return SESSIONS.get(session_id) app = gr.Server(title="The Tower Learns You") @app.api(name="get_tutorial") def get_tutorial() -> dict: return { "tutorial_panels": [ render.render_tutorial_panel(i, game.assets) for i in range(10) ] } @app.api(name="new_run") def new_run() -> dict: state, meta = game.new_character(seed=7) sid = uuid.uuid4().hex SESSIONS[sid] = {"state": state, "meta": meta} return _view(sid, SESSIONS[sid]) @app.api(name="allocate") def allocate(session_id: str, strength: int, agility: int, intelligence: int, endurance: int) -> dict: sess = _require(session_id) if sess is None: return {"error": "unknown session"} game.confirm_allocation(sess["state"], [int(strength), int(agility), int(intelligence), int(endurance)]) return _view(session_id, sess) @app.api(name="set_difficulty") def set_difficulty(session_id: str, mode: str) -> dict: sess = _require(session_id) if sess is None: return {"error": "unknown session"} game.set_difficulty(sess["state"], mode) return _view(session_id, sess) @app.api(name="set_combat_speed") def set_combat_speed(session_id: str, mode: str) -> dict: sess = _require(session_id) if sess is None: return {"error": "unknown session"} state, meta = game.set_combat_speed(sess["state"], sess["meta"], mode) sess["state"], sess["meta"] = state, meta return _view(session_id, sess) @app.api(name="prepare_run") def prepare_run(session_id: str) -> dict: sess = _require(session_id) if sess is None: return {"error": "unknown session"} game.prepare_run(sess["state"]) return _view(session_id, sess) @app.api(name="choose_starter") def choose_starter(session_id: str, index: int) -> dict: sess = _require(session_id) if sess is None: return {"error": "unknown session"} game.choose_starter_skill(sess["state"], int(index)) return _view(session_id, sess) @app.api(name="act") def act(session_id: str, action: str, target_id: str = "") -> dict: sess = _require(session_id) if sess is None: return {"error": "unknown session"} event_start = len(sess["state"].get("combat_events", [])) game.act(sess["state"], action, target_id or None) return _view(session_id, sess, event_start=event_start) @app.api(name="use_item") def use_item(session_id: str, index: int) -> dict: sess = _require(session_id) if sess is None: return {"error": "unknown session"} event_start = len(sess["state"].get("combat_events", [])) game.choose_item(sess["state"], int(index)) return _view(session_id, sess, event_start=event_start) @app.api(name="choose") def choose(session_id: str, kind: str, index: int) -> dict: sess = _require(session_id) if sess is None: return {"error": "unknown session"} state = sess["state"] index = int(index) if kind == "loot": game.preview_reward(state, index) elif kind == "level_skill": game.choose_level_skill(state, index) elif kind == "replacement": game.replace_skill(state, index) return _view(session_id, sess) @app.api(name="command") def command(session_id: str, name: str) -> dict: sess = _require(session_id) if sess is None: return {"error": "unknown session"} state = sess["state"] if name == "proceed": game.proceed(state) elif name == "embrace_evolution": game.embrace_evolution(state) elif name == "accept_heal": game.heal_choice(state, True) elif name == "refuse_heal": game.heal_choice(state, False) elif name == "claim_reward": game.claim_previewed_reward(state) elif name == "cancel_preview": game.cancel_reward_preview(state) elif name == "decline_rewards": game.decline_remaining_rewards(state) elif name == "decline_skill": game.decline_skill(state) elif name == "restart": sess["state"] = game.restart(state, sess["meta"]) elif name == "continue_ascension": new_state, meta = game.continue_ascension(state, sess["meta"]) sess["state"], sess["meta"] = new_state, meta elif name == "toggle_sidebar": state["_sidebar_open"] = not bool(state.get("_sidebar_open", True)) return _view(session_id, sess) # --- Static + frontend routes (same origin -> no CORS) --- app.mount("/assets", StaticFiles(directory=str(ASSET_ROOT)), name="assets") app.mount("/static", StaticFiles(directory=str(FRONTEND)), name="static") @app.get("/game.css") def game_css() -> Response: return Response(content=game_ui.CSS, media_type="text/css") @app.get("/") def index() -> FileResponse: return FileResponse(str(FRONTEND / "index.html")) if __name__ == "__main__": app.launch( server_name=os.getenv("SERVER_NAME", "127.0.0.1"), server_port=int(os.getenv("PORT", "7861")), share=False, )