M9: FastAPI game API — new-game, state, actions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
61
backend/tests/test_api.py
Normal file
61
backend/tests/test_api.py
Normal 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"])
|
||||||
@@ -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:],
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user