update
This commit is contained in:
92
backend/tests/test_combat.py
Normal file
92
backend/tests/test_combat.py
Normal file
@@ -0,0 +1,92 @@
|
||||
import random
|
||||
|
||||
from tft.sim import combat
|
||||
|
||||
|
||||
def prof(off, dfn, defensive=True, name="u"):
|
||||
return {"off": off, "def": dfn, "defensive": defensive,
|
||||
"unit": {"api_name": name, "stars": 1, "items": []}}
|
||||
|
||||
|
||||
CFG = {
|
||||
"combat": {"max_frames": 1800},
|
||||
"damage": {"stage_base": [0, 2, 5, 8, 10, 12, 17, 17],
|
||||
"per_surviving_unit": 1, "player_hp": 100},
|
||||
}
|
||||
|
||||
|
||||
def test_kill_time_matches_dps_at_60fps():
|
||||
# Def 10 gegen Gesamt-Off 5 -> 2 s = 120 Frames.
|
||||
a = [prof(5, 1000)]
|
||||
b = [prof(0, 10)]
|
||||
res = combat.fight(a, b, random.Random(1), CFG)
|
||||
assert res.winner == "a"
|
||||
assert res.frames == 120
|
||||
assert res.survivors_b == []
|
||||
|
||||
|
||||
def test_overflow_carries_to_next_unit():
|
||||
# Zwei Units à 10 Def gegen Off 5 -> exakt 4 s, kein Schaden verpufft.
|
||||
a = [prof(5, 1000)]
|
||||
b = [prof(0, 10), prof(0, 10)]
|
||||
res = combat.fight(a, b, random.Random(1), CFG)
|
||||
assert res.winner == "a"
|
||||
assert res.frames == 240
|
||||
|
||||
|
||||
def test_dead_units_stop_contributing_offense():
|
||||
# B killt As Front nach 2 s; danach kämpft A nur noch mit halber Offense.
|
||||
a = [prof(6, 12, defensive=True), prof(6, 1000, defensive=False)]
|
||||
b = [prof(6, 60)]
|
||||
res = combat.fight(a, b, random.Random(1), CFG)
|
||||
assert res.winner == "a"
|
||||
assert res.frames == 480 # 2 s + 36/6 s statt 60/12 s bei konstanter Offense
|
||||
assert len(res.survivors_a) == 1
|
||||
|
||||
|
||||
def test_queue_targets_defensive_units_first():
|
||||
team = [prof(1, 1, defensive=False, name="carry1"),
|
||||
prof(1, 1, defensive=True, name="tank1"),
|
||||
prof(1, 1, defensive=False, name="carry2"),
|
||||
prof(1, 1, defensive=True, name="tank2")]
|
||||
q = combat._queue(team, random.Random(5))
|
||||
assert [p["defensive"] for p in q] == [True, True, False, False]
|
||||
|
||||
|
||||
def test_simultaneous_last_kill_is_draw():
|
||||
a = [prof(5, 10)]
|
||||
b = [prof(5, 10)]
|
||||
res = combat.fight(a, b, random.Random(1), CFG)
|
||||
assert res.winner is None
|
||||
assert res.survivors_a == [] and res.survivors_b == []
|
||||
|
||||
|
||||
def test_empty_boards():
|
||||
res = combat.fight([], [], random.Random(1), CFG)
|
||||
assert res.winner is None and res.frames == 0
|
||||
res = combat.fight([prof(1, 1)], [], random.Random(1), CFG)
|
||||
assert res.winner == "a"
|
||||
|
||||
|
||||
def test_stall_hits_time_limit_more_defense_wins():
|
||||
a = [prof(0, 100)]
|
||||
b = [prof(0, 50)]
|
||||
res = combat.fight(a, b, random.Random(1), CFG)
|
||||
assert res.winner == "a"
|
||||
assert res.frames == CFG["combat"]["max_frames"]
|
||||
|
||||
|
||||
def test_same_seed_same_result():
|
||||
team_a = [prof(7, 300), prof(12, 150, defensive=False), prof(5, 400)]
|
||||
team_b = [prof(9, 250), prof(10, 200, defensive=False)]
|
||||
r1 = combat.fight(team_a, team_b, random.Random(42), CFG)
|
||||
r2 = combat.fight(team_a, team_b, random.Random(42), CFG)
|
||||
assert (r1.winner, r1.frames) == (r2.winner, r2.frames)
|
||||
assert [p["api_name"] for p in r1.survivors_a] == \
|
||||
[p["api_name"] for p in r2.survivors_a]
|
||||
|
||||
|
||||
def test_player_damage_counts_exact_survivors():
|
||||
survivors = [{"api_name": f"u{i}"} for i in range(4)]
|
||||
assert combat.player_damage(3, survivors, CFG) == 5 + 4
|
||||
assert combat.player_damage(1, [], CFG) == 0
|
||||
@@ -173,17 +173,24 @@ def test_pairing():
|
||||
|
||||
def test_ghost_source_takes_no_damage(art, cfg):
|
||||
game = Game(art, cfg, seed=3)
|
||||
game.bots[0].hp = 0
|
||||
game.bots[0].placement = 8
|
||||
game.bots[0].board, game.bots[0].bench = [], []
|
||||
# 7 Lebende -> jede PvP-Runde hat einen Ghost-Kampf; HP-Summe der
|
||||
# Ghost-Quelle darf nur durch ihren echten Kampf sinken (kein Doppelschaden).
|
||||
hp_before = {p.name: p.hp for p in game.players}
|
||||
while game.round["kind"] != "pvp":
|
||||
game.step()
|
||||
# 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()
|
||||
drops = sum(1 for p in game.players if p.hp < hp_before[p.name])
|
||||
assert drops <= 4 # max. 3 Paar-Verlierer + 1 Ghost-Verlierer
|
||||
# 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):
|
||||
@@ -279,13 +286,3 @@ def test_augment_effects_apply(art, cfg):
|
||||
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
|
||||
|
||||
|
||||
def test_team_buff_augment_raises_score(art, cfg):
|
||||
from tft.model.score import score_mechanical
|
||||
buffed_art = {**art, "augment_specs": {
|
||||
"AUG_BUFF": {"atoms": [{"kind": "team_buff", "stat": "hp_pct", "value": 0.3}],
|
||||
"offerable": True}}}
|
||||
board = [{"api_name": "U2_0", "stars": 2, "items": []}]
|
||||
assert score_mechanical(board, ["AUG_BUFF"], buffed_art) > \
|
||||
score_mechanical(board, [], buffed_art)
|
||||
|
||||
@@ -2,7 +2,7 @@ import pytest
|
||||
|
||||
from tft.model.baseline import classify_role, unit_value
|
||||
from tft.model.calibrate import spearman
|
||||
from tft.model.score import active_trait_tiers, score_board
|
||||
from tft.model.score import active_trait_tiers, board_profiles, score_board
|
||||
|
||||
STATIC = {
|
||||
"units": {
|
||||
@@ -28,8 +28,15 @@ STATIC = {
|
||||
},
|
||||
}
|
||||
|
||||
ARTIFACT = {"meta": {"set": 17}, "static": {**STATIC, "items": {}, "augments": {}},
|
||||
"roles": {}, "learned": {}}
|
||||
ARTIFACT = {
|
||||
"meta": {"set": 17},
|
||||
"static": {
|
||||
**STATIC,
|
||||
"items": {"TFT_Item_InfinityEdge": {"effects": {"AD": 0.35, "CritChance": 35.0}}},
|
||||
"augments": {},
|
||||
},
|
||||
"roles": {},
|
||||
}
|
||||
|
||||
|
||||
def test_unit_value():
|
||||
@@ -57,19 +64,30 @@ def test_score_ordering():
|
||||
assert score_board(strong, [], ARTIFACT) > score_board(weak, [], ARTIFACT)
|
||||
|
||||
|
||||
def test_active_trait_beats_inactive():
|
||||
pair = [{"api_name": "TFT17_A", "stars": 1, "items": []},
|
||||
{"api_name": "TFT17_B", "stars": 1, "items": []}]
|
||||
solo_sum = score_board(pair[:1], [], ARTIFACT) + score_board(pair[1:], [], ARTIFACT)
|
||||
assert score_board(pair, [], ARTIFACT) > solo_sum
|
||||
|
||||
|
||||
def test_items_increase_score():
|
||||
bare = [{"api_name": "TFT17_B", "stars": 1, "items": []}]
|
||||
with_item = [{"api_name": "TFT17_B", "stars": 1, "items": ["TFT_Item_InfinityEdge"]}]
|
||||
assert score_board(with_item, [], ARTIFACT) > score_board(bare, [], ARTIFACT)
|
||||
|
||||
|
||||
def test_augments_ignored():
|
||||
board = [{"api_name": "TFT17_B", "stars": 1, "items": []}]
|
||||
assert score_board(board, ["TFT17_Augment_X"], ARTIFACT) == \
|
||||
score_board(board, [], ARTIFACT)
|
||||
|
||||
|
||||
def test_board_profiles_roles_and_unknown_units():
|
||||
board = [{"api_name": "TFT17_A", "stars": 1, "items": []},
|
||||
{"api_name": "TFT17_B", "stars": 1, "items": []},
|
||||
{"api_name": "UNKNOWN", "stars": 1, "items": []}]
|
||||
profiles = board_profiles(board, ARTIFACT)
|
||||
assert len(profiles) == 2 # unbekannte Units fallen raus
|
||||
tank, carry = profiles
|
||||
assert tank["defensive"] and not carry["defensive"]
|
||||
assert tank["def"] > carry["def"]
|
||||
assert carry["off"] > tank["off"]
|
||||
|
||||
|
||||
def test_spearman():
|
||||
assert spearman([1, 2, 3, 4], [10, 20, 30, 40]) == pytest.approx(1.0)
|
||||
assert spearman([1, 2, 3, 4], [40, 30, 20, 10]) == pytest.approx(-1.0)
|
||||
@@ -88,39 +106,7 @@ def test_craftable_item_pool():
|
||||
assert craftable_items(items) == ["TFT_Item_InfinityEdge"]
|
||||
|
||||
|
||||
def test_stat_mults_rank_within_cost():
|
||||
from tft.model.baseline import compute_stat_mults
|
||||
|
||||
def unit(hp, armor, ad, aspd):
|
||||
return {"cost": 2, "traits": [], "role": "Tank",
|
||||
"stats": {"hp": hp, "armor": armor, "magicResist": armor,
|
||||
"damage": ad, "attackSpeed": aspd, "mana": 60,
|
||||
"initialMana": 0, "range": 1}}
|
||||
|
||||
units = {"tanky": unit(900, 60, 45, 0.6), "avg": unit(700, 40, 55, 0.7),
|
||||
"weak": unit(550, 25, 45, 0.6)}
|
||||
mults = compute_stat_mults(units)
|
||||
assert mults["tanky"] > mults["avg"] > mults["weak"]
|
||||
assert all(0.9 <= m <= 1.1 for m in mults.values())
|
||||
|
||||
|
||||
def test_stat_mults_identical_units_are_neutral():
|
||||
from tft.model.baseline import compute_stat_mults
|
||||
from tests.test_sim import make_static
|
||||
|
||||
mults = compute_stat_mults(make_static()["units"])
|
||||
assert all(m == 1.0 for m in mults.values())
|
||||
|
||||
|
||||
def test_stat_mult_raises_score_legacy():
|
||||
from tft.model.score import score_board_legacy as score_board
|
||||
|
||||
art = {**ARTIFACT, "stat_mults": {"TFT17_B": 1.1}}
|
||||
board = [{"api_name": "TFT17_B", "stars": 1, "items": []}]
|
||||
assert score_board(board, [], art) > score_board(board, [], ARTIFACT)
|
||||
|
||||
|
||||
def _mech_artifact():
|
||||
def _built_artifact():
|
||||
from tft.model import artifact as artifact_mod
|
||||
|
||||
static = {
|
||||
@@ -143,30 +129,18 @@ def _mech_artifact():
|
||||
return artifact_mod.build(static)
|
||||
|
||||
|
||||
def test_mechanical_balanced_beats_lopsided():
|
||||
from tft.model.score import score_mechanical
|
||||
art = _mech_artifact()
|
||||
def test_balanced_beats_lopsided():
|
||||
art = _built_artifact()
|
||||
balanced = [{"api_name": "TANK", "stars": 1, "items": []},
|
||||
{"api_name": "CARRY", "stars": 1, "items": []}]
|
||||
tanks = [{"api_name": "TANK", "stars": 1, "items": []} for _ in range(2)]
|
||||
assert score_mechanical(balanced, [], art) > score_mechanical(tanks, [], art)
|
||||
assert score_board(balanced, [], art) > score_board(tanks, [], art)
|
||||
|
||||
|
||||
def test_mechanical_items_and_stars_raise_strength():
|
||||
from tft.model.score import score_mechanical
|
||||
art = _mech_artifact()
|
||||
def test_items_and_stars_raise_strength():
|
||||
art = _built_artifact()
|
||||
base = [{"api_name": "CARRY", "stars": 1, "items": []}]
|
||||
starred = [{"api_name": "CARRY", "stars": 2, "items": []}]
|
||||
equipped = [{"api_name": "CARRY", "stars": 1, "items": ["SWORD"]}]
|
||||
assert score_mechanical(starred, [], art) > score_mechanical(base, [], art)
|
||||
assert score_mechanical(equipped, [], art) > score_mechanical(base, [], art)
|
||||
|
||||
|
||||
def test_mechanical_recognized_trait_buffs_stats():
|
||||
from tft.model.score import score_mechanical
|
||||
art = _mech_artifact()
|
||||
pair = [{"api_name": "TANK", "stars": 1, "items": []},
|
||||
{"api_name": "CARRY", "stars": 1, "items": []}]
|
||||
solo_sum = (score_mechanical(pair[:1], [], art)
|
||||
+ score_mechanical(pair[1:], [], art))
|
||||
assert score_mechanical(pair, [], art) > solo_sum
|
||||
assert score_board(starred, [], art) > score_board(base, [], art)
|
||||
assert score_board(equipped, [], art) > score_board(base, [], art)
|
||||
|
||||
Reference in New Issue
Block a user