diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000..e7582a7 --- /dev/null +++ b/backend/tests/test_api.py @@ -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"]) diff --git a/backend/tft/api/app.py b/backend/tft/api/app.py index 154c69d..363bbf3 100644 --- a/backend/tft/api/app.py +++ b/backend/tft/api/app.py @@ -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") +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 == "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:], + }