Files
tft/backend/tests/test_game.py
2026-07-24 11:24:24 +02:00

301 lines
10 KiB
Python

import pytest
from tft.constants.loader import load_constants
from tft.model import artifact as artifact_mod
from tft.sim.autoplay import play_afk, play_econ
from tft.sim.game import Game, InvalidAction
from tests.test_sim import make_static
@pytest.fixture(scope="module")
def art():
static = make_static()
static["meta"]["patch"] = "test"
return artifact_mod.build(static)
@pytest.fixture(scope="module")
def cfg():
return load_constants(17)
def test_afk_game_terminates_last(art, cfg):
placements = [play_afk(Game(art, cfg, seed=s)) for s in range(20)]
assert all(1 <= p <= 8 for p in placements)
assert sum(placements) / len(placements) > 6.5
def test_econ_beats_afk(art, cfg):
afk = [play_afk(Game(art, cfg, seed=s)) for s in range(30)]
econ = [play_econ(Game(art, cfg, seed=1000 + s)) for s in range(30)]
assert sum(econ) / len(econ) < sum(afk) / len(afk)
def test_same_seed_same_outcome(art, cfg):
assert play_econ(Game(art, cfg, seed=7)) == play_econ(Game(art, cfg, seed=7))
def test_buy_and_merge(art, cfg):
game = Game(art, cfg, seed=1)
game.gold = 100
api = "U1_0"
unit = {"api_name": api, "stars": 1, "items": []}
game.bench = [dict(unit), dict(unit)]
game.pool.take(api, 2)
game.shop = [api] * 5
game.buy(0)
assert len(game.bench) == 1
assert game.bench[0]["stars"] == 2
def test_sell_refunds_pool_and_gold(art, cfg):
game = Game(art, cfg, seed=1)
game.gold = 100
game.shop[0] = "U1_0"
before_pool = game.pool.available("U1_0")
game.buy(0)
gold_after_buy = game.gold
game.sell("bench", len(game.bench) - 1)
assert game.gold == gold_after_buy + 1
assert game.pool.available("U1_0") == before_pool
def test_board_cap_is_level(art, cfg):
game = Game(art, cfg, seed=1)
game.bench = [{"api_name": "U1_0", "stars": 1, "items": []} for _ in range(3)]
game.level = 2
game.move("bench", 0)
game.move("bench", 0)
with pytest.raises(InvalidAction, match="board full"):
game.move("bench", 0)
def test_invalid_actions_raise(art, cfg):
game = Game(art, cfg, seed=1)
game.gold = 0
with pytest.raises(InvalidAction):
game.reroll()
with pytest.raises(InvalidAction):
game.buy_xp()
with pytest.raises(InvalidAction):
game.sell("bench", 99)
def test_shop_lock_survives_step(art, cfg):
game = Game(art, cfg, seed=2)
game.toggle_lock()
before = list(game.shop)
game.step()
assert game.shop == before
def test_carousel_never_a_planning_round(art, cfg):
game = Game(art, cfg, seed=5)
seen = []
while not game.over:
seen.append(game.round["kind"])
game.step()
assert "carousel" not in seen
assert seen[0] == "pve" # Spiel startet bei 1-2
def test_carousel_grants_unit_and_loot(art, cfg):
game = Game(art, cfg, seed=5)
assert len(game.bench) == 1 # Unit vom 1-1-Carousel
assert len(game.items) == 1 # Item-Komponente vom Carousel
def _pool_total(game, api):
n = game.pool.available(api)
for p in game.players:
for u in p.board + p.bench:
if u["api_name"] == api:
n += 3 ** (u["stars"] - 1)
return n
def test_step_keeps_player_board_setup(art, cfg):
"""Die manuelle Aufstellung wird nie durch stärkere Bank-Units ersetzt."""
game = Game(art, cfg, seed=8)
game.player.level = 2
game.player.board = [{"api_name": "U1_0", "stars": 1, "items": []},
{"api_name": "U1_1", "stars": 1, "items": []}]
game.player.bench = [{"api_name": "U5_0", "stars": 2, "items": []}]
before = [u["api_name"] for u in game.player.board]
game.step()
assert [u["api_name"] for u in game.player.board] == before
def test_pool_conservation(art, cfg):
for seed in (1, 2, 3):
game = Game(art, cfg, seed=seed)
initial = {api: cfg["pool"]["sizes"][art["static"]["units"][api]["cost"] - 1]
for api in art["static"]["units"]}
while not game.over:
game.step()
for api, total in initial.items():
assert _pool_total(game, api) == total, f"pool leak: {api}"
def test_bots_start_with_one_unit(art, cfg):
game = Game(art, cfg, seed=7)
for b in game.bots:
assert len(b.board) + len(b.bench) == 1 # nur die Carousel-Unit
assert all(u["stars"] == 1 for u in b.board + b.bench)
def test_streak_resets_on_win(art, cfg):
game = Game(art, cfg, seed=1)
game.player.streak = -3
# direkter Formel-Test wie in step():
won = True
streak = max(game.player.streak, 0) + 1 if won else 0
assert streak == 1
def test_pairing():
import random
from tft.sim.game import pair_players
from tft.sim.player import PlayerState
def mk(n):
return [PlayerState(name=f"P{i}", archetype="fast8", hp=100, gold=0, level=2)
for i in range(n)]
rng = random.Random(1)
for n in (8, 5, 3, 2):
players = mk(n)
pairs, odd, ghost_src = pair_players(players, rng)
paired = {p.name for a, b in pairs for p in (a, b)}
if odd:
assert odd.name not in paired
assert ghost_src is not odd
assert len(paired) + 1 == n
else:
assert len(paired) == n
# kein Wiederholungsgegner bei >2
players = mk(8)
rng = random.Random(2)
for _ in range(10):
pairs, _, _ = pair_players(players, rng)
for a, b in pairs:
assert a.last_opponent == b.name
def test_ghost_source_takes_no_damage(art, cfg):
game = Game(art, cfg, seed=3)
# Auf 3 Lebende reduzieren -> genau 1 Paar + 1 Ghost-Kampf.
for b in game.bots[2:]:
b.hp, b.placement, b.board, b.bench = 0, 8, [], []
boards = [
(game.player, [{"api_name": "U5_0", "stars": 3, "items": []}]),
(game.bots[0], [{"api_name": "U3_0", "stars": 2, "items": []}]),
(game.bots[1], [{"api_name": "U1_0", "stars": 1, "items": []}]),
]
for p, board in boards:
p.board, p.bench, p.gold = board, [], 0
game.idx = next(i for i, r in enumerate(game.rounds) if r["kind"] == "pvp")
hp_before = {p.name: p.hp for p in game._alive()}
game.step()
# Nur Paar-Verlierer + ggf. Ghost-Verlierer nehmen Schaden; die
# Ghost-Quelle darf durch ihren Klon-Kampf nicht getroffen werden.
drops = sum(1 for p in game.players if p.name in hp_before
and p.hp < hp_before[p.name])
assert drops <= 2
def test_pve_freezes_streak_and_pays_no_win_bonus(art, cfg):
game = Game(art, cfg, seed=4)
# Stage 1: drei PvE-Runden -> Streak bleibt 0
while game.round["stage"] == 1 and not game.over:
game.step()
assert game.player.streak == 0
def test_natural_level_progression(art, cfg):
"""Ohne XP-Kauf, laut VOD: 1-3 Lvl 2, 1-4 Lvl 3, 2-1 Lvl 3 (2/6), 2-5 Lvl 4 (2/10)."""
game = Game(art, cfg, seed=6)
assert game.player.level == 1 and game.player.xp == 0 # 1-1 gibt keine XP
checks = {}
while not game.over and game.round["label"] != "3-3":
checks[game.round["label"]] = (game.player.level, game.player.xp)
game.step()
assert checks["1-2"] == (1, 0)
assert checks["1-3"] == (2, 0)
assert checks["1-4"] == (3, 0)
assert checks["2-1"] == (3, 2)
assert checks["2-5"] == (4, 2)
assert checks["3-2"] == (5, 0)
def test_augment_offer_single_tier(art, cfg):
game = Game(art, cfg, seed=9)
mixed = {f"AUG_T{t}_{i}": {"tier": t} for t in (1, 2, 3) for i in range(4)}
game.artifact = {**art, "static": {**art["static"], "augments": mixed},
"augment_pool": []}
game.idx = next(i for i, r in enumerate(game.rounds) if r["augment"])
game.player.augment_offer = []
game._maybe_offer_augment()
tiers = {mixed[a]["tier"] for a in game.player.augment_offer}
assert len(game.player.augment_offer) == 3
assert len(tiers) == 1 # alle Angebote aus derselben Stufe
def test_equip_crafts_completed_item(art, cfg):
game = Game(art, cfg, seed=1)
craft_art = {**art, "component_pool": ["C_A", "C_B"],
"recipes": {"C_A|C_B": "COMPLETED"}}
game.artifact = craft_art
game.board = [{"api_name": "U1_0", "stars": 1, "items": ["C_A"]}]
game.items = ["C_B", "C_A"]
game.equip(0, 0) # C_B auf Unit mit C_A -> COMPLETED
assert game.board[0]["items"] == ["COMPLETED"]
game.equip(0, 0) # C_A ohne Partner -> eigener Slot
assert game.board[0]["items"] == ["COMPLETED", "C_A"]
def test_loot_drops_components(art, cfg):
comp_art = {**art, "component_pool": ["C_A", "C_B"]}
game = Game(comp_art, cfg, seed=2)
assert all(i in ("C_A", "C_B") for i in game.items) # Carousel-Drop
def test_bot_leveling_follows_targets(art, cfg):
from tft.sim import policy as policy_mod
game = Game(art, cfg, seed=12)
levels_at = {}
while not game.over and game.round["label"] != "4-3":
label = game.round["label"]
if label in ("2-6", "3-3", "4-2"):
levels_at[label] = [b.level for b in game.bots if b.alive]
policy_mod.act(game.player, game.pool, game.artifact, game.cfg, game.rng,
game.round, policy_mod.ARCHETYPES["fast8"])
game.step()
assert all(lvl >= 5 for lvl in levels_at["2-6"]) # L5 ab 2-5
# L6 ab 3-1/3-2 — goldarme Bots dürfen 1-2 Runden nachhinken
assert sum(1 for lvl in levels_at["3-3"] if lvl >= 6) >= len(levels_at["3-3"]) - 2
assert any(lvl >= 7 for lvl in levels_at["4-2"]) # fast8/streaker auf 7
def test_augment_effects_apply(art, cfg):
game = Game(art, cfg, seed=3)
game.artifact = {**art, "augment_specs": {
"AUG_GOLD": {"atoms": [{"kind": "gold_now", "amount": 7},
{"kind": "gold_per_stage", "amount": 7}], "offerable": True},
"AUG_UNIT": {"atoms": [{"kind": "unit_grant", "cost": 2, "count": 2}], "offerable": True},
}}
gold_before = game.player.gold
game.player.augment_offer = ["AUG_GOLD"]
game.pick_augment(0)
assert game.player.gold == gold_before + 7
assert game.player.gold_per_stage == 7
bench_before = len(game.player.bench)
pool_before = sum(game.pool.available(a) for a in game.pool.copies)
game.player.augment_offer = ["AUG_UNIT"]
game.pick_augment(0)
assert len(game.player.bench) == bench_before + 2
assert sum(game.pool.available(a) for a in game.pool.copies) == pool_before - 2