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")
|
||||
|
||||
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:],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user