tower-learns-you / server_app.py
vknt's picture
Deploy The Tower Learns You (custom gr.Server frontend, hf_inference + mock fallback)
22a027e verified
Raw
History Blame Contribute Delete
13.6 kB
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,
)