| 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"
|
|
|
|
|
|
|
|
|
| game = TowerGame(build_gateway(load_model_config()))
|
| game.background_ai = False
|
|
|
|
|
|
|
| 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)
|
|
|
|
|
|
|
| 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)
|
|
|
|
|
|
|
| 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,
|
| )
|
|
|