Files
tft/backend/tft/api/app.py

211 lines
7.2 KiB
Python

import json
import uuid
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from tft import paths
from tft.model.score import active_trait_tiers
from tft.sim import economy
from tft.sim import player as sim_player
from tft.sim.game import Game, InvalidAction
app = FastAPI(title="tft-sim")
GAMES: dict[str, Game] = {}
class ActionRequest(BaseModel):
action: str
slot: int | None = None
where: str | None = None
idx: int | None = None
item_idx: int | None = None
board_idx: int | None = None
choice: int | None = None
@app.get("/health")
def health() -> dict:
return {"status": "ok"}
def _load_game_deps():
from tft.constants.loader import load_constants
from tft.model import artifact as artifact_mod
pointer = paths.latest_static_pointer()
if not pointer.exists():
raise HTTPException(503, "no static data: run `tft.cli fetch-static` first")
set_number = json.loads(pointer.read_text())["set"]
return artifact_mod.load(set_number), load_constants(set_number)
@app.post("/api/game")
def new_game(seed: int | None = None) -> dict:
artifact, cfg = _load_game_deps()
game_id = uuid.uuid4().hex[:8]
GAMES[game_id] = Game(artifact, cfg, seed=seed)
return {"game_id": game_id, **state(game_id)}
@app.get("/api/game/{game_id}")
def state(game_id: str) -> dict:
game = GAMES.get(game_id)
if game is None:
raise HTTPException(404, "unknown game")
return serialize(game)
@app.post("/api/game/{game_id}/action")
def action(game_id: str, req: ActionRequest) -> dict:
game = GAMES.get(game_id)
if game is None:
raise HTTPException(404, "unknown game")
try:
if req.action == "buy":
game.buy(req.slot)
elif req.action == "sell":
game.sell(req.where, req.idx)
elif req.action == "reroll":
game.reroll()
elif req.action == "buy_xp":
game.buy_xp()
elif req.action == "move":
game.move(req.where, req.idx)
elif req.action == "equip":
game.equip(req.item_idx, req.board_idx)
elif req.action == "pick_augment":
game.pick_augment(req.choice)
elif req.action == "lock":
game.toggle_lock()
elif req.action == "next":
game.step()
else:
raise HTTPException(400, f"unknown action {req.action}")
except InvalidAction as e:
raise HTTPException(400, str(e))
except TypeError:
raise HTTPException(400, f"missing parameters for action {req.action}")
return serialize(game)
def upgrade_hint(game: Game, api: str) -> int:
"""0 = kein Upgrade, 2/3 = Kauf ergibt einen 2-/3-Sterner."""
owned = [u for u in game.board + game.bench
if u["api_name"] == api and u["stars"] < 3]
copies = sum(3 ** (u["stars"] - 1) for u in owned)
if copies + 1 >= 9:
return 3
if sum(1 for u in owned if u["stars"] == 1) == 2:
return 2
return 0
def unit_view(game: Game, u: dict) -> dict:
info = game.artifact["static"]["units"][u["api_name"]]
return {
"api_name": u["api_name"],
"name": info.get("name", u["api_name"]),
"cost": info["cost"],
"stars": u["stars"],
"traits": u.get("shown_traits", info["traits"]),
"icon": info.get("icon"),
"items": [
{"api_name": i, "name": game.artifact["static"]["items"].get(i, {}).get("name", i),
"icon": game.artifact["static"]["items"].get(i, {}).get("icon")}
for i in u["items"]
],
}
def serialize(game: Game) -> dict:
static = game.artifact["static"]
tiers = active_trait_tiers(game.board, static["traits"], static["units"])
counts: dict[str, int] = {}
for u in game.board:
for t in set(static["units"][u["api_name"]]["traits"]):
counts[t] = counts.get(t, 0) + 1
traits_panel = sorted(
(
{
"api_name": t,
"name": static["traits"][t].get("name", t),
"count": n,
"tier": tiers.get(t, 0),
"breakpoints": [bp["min_units"] for bp in static["traits"][t]["breakpoints"]],
"icon": static["traits"][t].get("icon"),
}
for t, n in counts.items()
),
key=lambda x: (-x["tier"], -x["count"]),
)
stage = game.round["stage"]
return {
"round": {"label": game.round["label"], "stage": stage,
"kind": game.round["kind"]},
"stage_rounds": [
{"label": r["label"], "kind": r["kind"], "augment": r["augment"],
"done": i < game.idx, "current": i == game.idx}
for i, r in enumerate(game.rounds) if r["stage"] == stage
],
"player": {
"hp": game.hp, "gold": game.gold, "level": game.level, "xp": game.xp,
"xp_needed": economy.xp_to_next(game.level, game.cfg),
"streak": game.streak,
"score": round(game.player_score(), 1),
},
"board": [unit_view(game, u) for u in game.board],
"bench": [unit_view(game, u) for u in game.bench],
"items": [
{"api_name": i, "name": static["items"].get(i, {}).get("name", i),
"icon": static["items"].get(i, {}).get("icon")}
for i in game.items
],
"shop": [
None if api is None else {
"api_name": api,
"name": static["units"][api].get("name", api),
"cost": static["units"][api]["cost"],
"traits": [
{"name": static["traits"][t].get("name", t),
"icon": static["traits"][t].get("icon")}
for t in static["units"][api]["traits"] if t in static["traits"]
],
"icon": static["units"][api].get("icon"),
"upgrade": upgrade_hint(game, api),
}
for api in game.shop
],
"shop_locked": game.shop_locked,
"shop_odds": game.cfg["shop"]["odds"][game.level - 1],
"traits": traits_panel,
"augment_offer": [
{"api_name": a, "name": static["augments"].get(a, {}).get("name", a),
"icon": static["augments"].get(a, {}).get("icon")}
for a in game.augment_offer
],
"augments": [static["augments"].get(a, {}).get("name", a) for a in game.augments],
"opponents": sorted(
(
{
"name": b.name, "archetype": b.archetype, "hp": max(b.hp, 0),
"alive": b.alive, "placement": b.placement,
"level": b.level if b.alive else None,
"gold": b.gold if b.alive else None,
"streak": b.streak if b.alive else None,
"score": round(sim_player.score(b, game.artifact), 1) if b.alive else None,
"board": [unit_view(game, u) for u in b.board],
}
for b in game.bots
),
key=lambda x: -x["hp"],
),
"reroll_cost": game.cfg["shop"]["reroll_cost"],
"xp_cost": game.cfg["xp"]["buy_cost"],
"over": game.over,
"placement": game.placement,
"log": game.log[-8:],
}