M9: FastAPI game API — new-game, state, actions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
team3
2026-07-23 00:36:58 +02:00
parent 8a474e37a4
commit 7f78c965cc
2 changed files with 236 additions and 1 deletions

61
backend/tests/test_api.py Normal file
View File

@@ -0,0 +1,61 @@
import pytest
from fastapi.testclient import TestClient
from tft.api import app as app_mod
from tft.constants.loader import load_constants
from tft.model import artifact as artifact_mod
from tests.test_sim import make_static
@pytest.fixture
def client(monkeypatch):
static = make_static()
static["meta"]["patch"] = "test"
art = artifact_mod.build(static)
cfg = load_constants(17)
monkeypatch.setattr(app_mod, "_load_game_deps", lambda: (art, cfg))
return TestClient(app_mod.app)
def test_full_game_via_api(client):
state = client.post("/api/game", params={"seed": 3}).json()
game_id = state["game_id"]
assert state["round"]["label"] == "1-1"
assert state["player"]["hp"] == 100
assert len(state["opponents"]) == 7
steps = 0
while not state["over"]:
if state["augment_offer"]:
client.post(f"/api/game/{game_id}/action",
json={"action": "pick_augment", "choice": 0})
affordable = [i for i, s in enumerate(state["shop"])
if s and s["cost"] <= state["player"]["gold"]]
if affordable:
client.post(f"/api/game/{game_id}/action",
json={"action": "buy", "slot": affordable[0]})
resp = client.post(f"/api/game/{game_id}/action", json={"action": "next"})
assert resp.status_code == 200
state = resp.json()
steps += 1
assert steps < 100
assert 1 <= state["placement"] <= 8
def test_invalid_action_is_400(client):
game_id = client.post("/api/game", params={"seed": 1}).json()["game_id"]
resp = client.post(f"/api/game/{game_id}/action",
json={"action": "sell", "where": "bench", "idx": 99})
assert resp.status_code == 400
resp = client.post(f"/api/game/{game_id}/action", json={"action": "dance"})
assert resp.status_code == 400
assert client.post("/api/game/nope/action", json={"action": "next"}).status_code == 404
def test_state_shape(client):
state = client.post("/api/game", params={"seed": 5}).json()
for key in ("round", "player", "board", "bench", "shop", "traits",
"opponents", "items", "over", "log"):
assert key in state
assert state["player"]["score"] == 0.0
assert all(o["board"] is not None for o in state["opponents"])

View File

@@ -1,8 +1,182 @@
from fastapi import FastAPI 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.game import Game, InvalidAction
app = FastAPI(title="tft-sim") 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") @app.get("/health")
def health() -> dict: def health() -> dict:
return {"status": "ok"} 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 == "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 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"]},
"player": {
"hp": game.hp, "gold": game.gold, "level": game.level, "xp": game.xp,
"xp_needed": game.cfg["xp"]["to_next"][game.level - 1]
if game.level < game.cfg["xp"]["max_level"] else None,
"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": [static["traits"][t].get("name", t) for t in static["units"][api]["traits"]
if t in static["traits"]],
"icon": static["units"][api].get("icon"),
}
for api in game.shop
],
"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(stage) if b.alive else None,
"score": round(b.score(stage, 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:],
}