update
This commit is contained in:
@@ -58,7 +58,8 @@ def test_state_shape(client):
|
||||
"opponents", "items", "over", "log", "shop_odds", "shop_locked"):
|
||||
assert key in state
|
||||
assert sum(state["shop_odds"]) == 100
|
||||
assert state["player"]["score"] == 0.0
|
||||
assert state["player"]["off"] == 0.0
|
||||
assert state["player"]["def"] == 0.0
|
||||
assert all(o["board"] is not None for o in state["opponents"])
|
||||
|
||||
|
||||
@@ -76,3 +77,84 @@ def test_upgrade_hint():
|
||||
assert upgrade_hint(game, "U1_0") == 3
|
||||
game.bench = [{"api_name": "U1_0", "stars": 3, "items": []}]
|
||||
assert upgrade_hint(game, "U1_0") == 0
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def group_client(monkeypatch):
|
||||
static = make_static()
|
||||
static["meta"]["patch"] = "test"
|
||||
art = artifact_mod.build(static)
|
||||
a, b = list(art["static"]["units"])[:2]
|
||||
# Schlüssel sind "api@itemzahl" — die Karte zeigt die Items mit an.
|
||||
# Schlüssel sind "api@sterne@itemzahl". Zwei Gruppen teilen sich
|
||||
# denselben Carry — nur die bessere darf durchkommen.
|
||||
art["groups"] = {
|
||||
"2": {f"{a}@2@3|{b}@2@0": {"edge": 1.2, "boards": 400},
|
||||
f"{a}@2@3|{b}@1@1": {"edge": 0.9, "boards": 700},
|
||||
f"{a}@1@0|{b}@2@3": {"edge": 0.4, "boards": 900}},
|
||||
"boards": 5000,
|
||||
}
|
||||
cfg = load_constants(17)
|
||||
monkeypatch.setattr(app_mod, "_load_game_deps", lambda: (art, cfg))
|
||||
return TestClient(app_mod.app)
|
||||
|
||||
|
||||
def test_groups_endpoint(group_client):
|
||||
"""Der Gruppen-Reiter braucht Name, Kosten und Icon je Unit."""
|
||||
r = group_client.get("/api/groups?size=2&limit=3")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["size"] == 2 and body["boards"] == 5000
|
||||
assert 0 < len(body["groups"]) <= 3
|
||||
first = body["groups"][0]
|
||||
assert len(first["units"]) == 2
|
||||
assert all({"name", "cost", "icon", "splash", "items", "stars", "traits", "tier"}
|
||||
<= set(u) for u in first["units"])
|
||||
assert [u["items"] for u in first["units"]] == [3, 0]
|
||||
assert [u["stars"] for u in first["units"]] == [2, 2]
|
||||
# Je Carry nur eine Gruppe: a@3 kommt einmal vor, dann b@3.
|
||||
carries = [g["units"][0]["name"] for g in body["groups"]]
|
||||
assert len(carries) == len(set(carries))
|
||||
# Nach Vorsprung sortiert, absteigend.
|
||||
edges = [g["edge"] for g in body["groups"]]
|
||||
assert edges == sorted(edges, reverse=True)
|
||||
assert group_client.get("/api/groups?size=2&limit=1").json()["groups"] == [first]
|
||||
|
||||
|
||||
def test_groups_without_data(client):
|
||||
# Ohne trainiertes Artefakt gibt es nichts zu zeigen — 404 statt leerer Liste.
|
||||
assert client.get("/api/groups?size=2").status_code == 404
|
||||
|
||||
|
||||
def test_groups_unknown_size(group_client):
|
||||
assert group_client.get("/api/groups?size=7").status_code == 404
|
||||
|
||||
|
||||
def test_roles_endpoint(monkeypatch):
|
||||
"""Beste Unit je Rolle und Kostenstufe, mit den Items dieses Champions."""
|
||||
static = make_static()
|
||||
static["meta"]["patch"] = "test"
|
||||
art = artifact_mod.build(static)
|
||||
api = next(a for a, u in art["static"]["units"].items() if "Tank" in (u.get("role") or ""))
|
||||
item = next(iter(art["static"]["items"]))
|
||||
art["groups"] = {"solo": {f"{api}@2@3": {"edge": 0.5, "boards": 300}},
|
||||
"unit_items": {f"{api}|{item}": {"edge": 0.3, "boards": 400},
|
||||
f"{api}|schlecht": {"edge": -0.1, "boards": 400}},
|
||||
"boards": 9000}
|
||||
art["unit_tiers"] = {api: {"tier": "S", "strength": 0.4, "boards": 1100}}
|
||||
monkeypatch.setattr(app_mod, "_load_game_deps", lambda: (art, load_constants(17)))
|
||||
body = TestClient(app_mod.app).get("/api/roles").json()
|
||||
tank = next(r for r in body["roles"] if r["label"] == "Tank")
|
||||
entry = next(c for c in tank["costs"] if c["unit"])
|
||||
# Buchstabe und Zahl kommen aus derselben Quelle.
|
||||
assert entry["unit"]["tier"] == "S"
|
||||
assert entry["unit"]["edge"] == 0.4
|
||||
# Items haengen am Champion, Negatives ist keine Empfehlung.
|
||||
assert [i["edge"] for i in entry["unit"]["items"]] == [0.3]
|
||||
assert {"name", "cost", "traits", "splash"} <= set(entry["unit"])
|
||||
# Kostenstufen ohne Daten bleiben leer statt zu raten.
|
||||
assert any(c["unit"] is None for c in tank["costs"])
|
||||
|
||||
|
||||
def test_roles_without_data(client):
|
||||
assert client.get("/api/roles").status_code == 404
|
||||
|
||||
103
backend/tests/test_arena.py
Normal file
103
backend/tests/test_arena.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""Arena: Boards auf das Kampffeld, eigene Positionen, leere Seiten."""
|
||||
|
||||
import random
|
||||
|
||||
from tft.sim.arena import BOARD_COLS
|
||||
from tft.sim import arena, hexgrid
|
||||
|
||||
STATS = {"hp": 800, "armor": 20, "magicResist": 20, "damage": 50,
|
||||
"damageByStar": [0, 50, 100, 200], "attackSpeed": 1.0, "range": 1,
|
||||
"mana": 0, "initialMana": 0, "critChance": 0.0, "critMultiplier": 1.4}
|
||||
ARTIFACT = {"static": {
|
||||
"units": {"A": {"traits": [], "stats": STATS},
|
||||
"B": {"traits": [], "stats": {**STATS, "range": 4}}},
|
||||
"items": {"HP": {"effects": {"Health": 500}}},
|
||||
"traits": {},
|
||||
}}
|
||||
|
||||
|
||||
def test_front_row_of_both_sides_faces_the_other():
|
||||
"""Reihe 0 des Spielers ist die vorderste — sonst laufen alle aneinander vorbei."""
|
||||
a = arena.from_hex(3, "a")
|
||||
b = arena.from_hex(3, "b")
|
||||
assert hexgrid.distance((a["col"], a["row"]), (b["col"], b["row"])) == 1
|
||||
|
||||
|
||||
def test_back_row_is_furthest_away():
|
||||
a = arena.from_hex(3 + 3 * BOARD_COLS, "a")
|
||||
b = arena.from_hex(3 + 3 * BOARD_COLS, "b")
|
||||
assert hexgrid.distance((a["col"], a["row"]), (b["col"], b["row"])) == 7
|
||||
|
||||
|
||||
def test_own_positions_are_used():
|
||||
board = [{"api_name": "A", "stars": 1, "hex": 3}]
|
||||
out = arena.placed(board, "a", ARTIFACT["static"]["units"])
|
||||
assert (out[0]["col"], out[0]["row"]) == (3, 3)
|
||||
|
||||
|
||||
def test_boards_without_positions_fall_back_to_the_lineup():
|
||||
"""Endboards aus der Riot-API tragen keine Felder."""
|
||||
board = [{"api_name": "A", "stars": 1}, {"api_name": "B", "stars": 1}]
|
||||
out = arena.placed(board, "a", ARTIFACT["static"]["units"])
|
||||
assert all("col" in e and "row" in e for e in out)
|
||||
# Nahkämpfer vorne, Fernkämpfer hinten.
|
||||
front = next(e for e in out if e["api_name"] == "A")
|
||||
back = next(e for e in out if e["api_name"] == "B")
|
||||
assert front["row"] > back["row"]
|
||||
|
||||
|
||||
def test_two_units_never_share_a_cell():
|
||||
board = [{"api_name": "A", "stars": 1, "hex": 3},
|
||||
{"api_name": "A", "stars": 1, "hex": 3}]
|
||||
out = arena.placed(board, "a", ARTIFACT["static"]["units"])
|
||||
assert len({(e["col"], e["row"]) for e in out}) == 2
|
||||
|
||||
|
||||
def test_an_empty_board_loses_at_once():
|
||||
board = [{"api_name": "A", "stars": 1, "hex": 0}]
|
||||
res, log = arena.fight([], board, ARTIFACT, random.Random(1))
|
||||
assert res.winner == "b" and res.frames == 0 and log is None
|
||||
res, _ = arena.fight(board, [], ARTIFACT, random.Random(1))
|
||||
assert res.winner == "a"
|
||||
assert arena.fight([], [], ARTIFACT, random.Random(1))[0].winner is None
|
||||
|
||||
|
||||
def test_items_reach_the_fighters():
|
||||
board = [{"api_name": "A", "stars": 1, "hex": 0, "items": ["HP"]}]
|
||||
plain = arena.side([{"api_name": "A", "stars": 1, "hex": 0}], "a", ARTIFACT)
|
||||
armed = arena.side(board, "a", ARTIFACT)
|
||||
assert armed[0].max_hp == plain[0].max_hp + 500
|
||||
|
||||
|
||||
def test_the_bigger_board_wins():
|
||||
big = [{"api_name": "A", "stars": 1, "hex": h} for h in range(4)]
|
||||
small = [{"api_name": "A", "stars": 1, "hex": 0}]
|
||||
assert arena.fight(big, small, ARTIFACT, random.Random(1))[0].winner == "a"
|
||||
|
||||
|
||||
def test_replay_returns_a_playable_log():
|
||||
"""Feldmaße, Kämpfer und Ereignisse — mehr braucht die Anzeige nicht."""
|
||||
a = [{"api_name": "A", "stars": 1, "hex": h} for h in range(3)]
|
||||
b = [{"api_name": "A", "stars": 1, "hex": h} for h in range(2)]
|
||||
_, log = arena.fight(a, b, ARTIFACT, random.Random(1), record=True)
|
||||
assert log["cols"] == hexgrid.COLS and log["rows"] == hexgrid.ROWS
|
||||
assert len(log["units"]) == 5
|
||||
assert {u["slot"] for u in log["units"]} == set(range(5))
|
||||
kinds = {e["e"] for e in log["events"]}
|
||||
assert "spawn" in kinds and "hit" in kinds
|
||||
assert log["winner"] == "a"
|
||||
|
||||
|
||||
def test_replay_is_none_without_a_fight():
|
||||
_, log = arena.fight([], [{"api_name": "A", "stars": 1, "hex": 0}],
|
||||
ARTIFACT, random.Random(1), record=True)
|
||||
assert log is None
|
||||
|
||||
|
||||
def test_replay_matches_the_fight():
|
||||
"""Sonst zeigt die Anzeige etwas anderes als das Spiel gewertet hat."""
|
||||
a = [{"api_name": "A", "stars": 1, "hex": h} for h in range(3)]
|
||||
b = [{"api_name": "B", "stars": 1, "hex": h} for h in range(3)]
|
||||
res, log = arena.fight(a, b, ARTIFACT, random.Random(3), record=True)
|
||||
plain, _ = arena.fight(a, b, ARTIFACT, random.Random(3))
|
||||
assert log["winner"] == res.winner == plain.winner
|
||||
206
backend/tests/test_battle.py
Normal file
206
backend/tests/test_battle.py
Normal file
@@ -0,0 +1,206 @@
|
||||
"""Kampf: Minderung, Reichweite, Bewegung, Zaubern, Determinismus."""
|
||||
|
||||
import random
|
||||
|
||||
from tft.sim import battle as B
|
||||
from tft.sim import hexgrid as H
|
||||
|
||||
UNITS = {
|
||||
"NAH": {"stats": {"hp": 1000, "armor": 0, "magicResist": 0, "damage": 100,
|
||||
"damageByStar": [0, 100, 200, 400], "attackSpeed": 1.0,
|
||||
"range": 1, "mana": 0, "initialMana": 0,
|
||||
"critChance": 0.0, "critMultiplier": 1.4}},
|
||||
"FERN": {"stats": {"hp": 500, "armor": 0, "magicResist": 0, "damage": 50,
|
||||
"damageByStar": [0, 50, 100, 200], "attackSpeed": 1.0,
|
||||
"range": 4, "mana": 0, "initialMana": 0,
|
||||
"critChance": 0.0, "critMultiplier": 1.4}},
|
||||
"PANZER": {"stats": {"hp": 1000, "armor": 100, "magicResist": 100,
|
||||
"damage": 10, "damageByStar": [0, 10, 20, 40],
|
||||
"attackSpeed": 1.0, "range": 1, "mana": 0,
|
||||
"initialMana": 0, "critChance": 0.0,
|
||||
"critMultiplier": 1.4}},
|
||||
"MAGIER": {"stats": {"hp": 600, "armor": 0, "magicResist": 0, "damage": 10,
|
||||
"damageByStar": [0, 10, 20, 40], "attackSpeed": 1.0,
|
||||
"range": 4, "mana": 30, "initialMana": 0,
|
||||
"critChance": 0.0, "critMultiplier": 1.4},
|
||||
"spell_damage": [0, 300, 600, 1200],
|
||||
"spell_damage_type": "magic"},
|
||||
}
|
||||
|
||||
|
||||
def make(key, side, col, row, stars=1):
|
||||
f = B.to_fighter({"api_name": key, "stars": stars, "col": col, "row": row},
|
||||
side, UNITS)
|
||||
assert f is not None
|
||||
return f
|
||||
|
||||
|
||||
def test_mitigation_halves_at_hundred_armor():
|
||||
assert B.mitigate(100, 0) == 100
|
||||
assert B.mitigate(100, 100) == 50
|
||||
assert B.mitigate(100, 300) == 25
|
||||
|
||||
|
||||
def test_stars_raise_health_and_damage():
|
||||
one, two = make("NAH", "a", 0, 0, 1), make("NAH", "a", 0, 0, 2)
|
||||
assert two.max_hp > one.max_hp
|
||||
assert two.attack_damage == 2 * one.attack_damage
|
||||
|
||||
|
||||
def test_the_stronger_side_wins():
|
||||
a = [make("NAH", "a", i, 3) for i in range(3)]
|
||||
b = [make("NAH", "b", i, 4) for i in range(1)]
|
||||
assert B.fight(a, b, random.Random(1)).winner == "a"
|
||||
|
||||
|
||||
def test_armour_buys_time():
|
||||
"""Gleiches Leben, aber einer mindert — der hält doppelt so lange durch."""
|
||||
def survives(defender):
|
||||
a = [make(defender, "a", 3, 3)]
|
||||
b = [make("NAH", "b", 3, 4)]
|
||||
return B.fight(a, b, random.Random(1), max_seconds=120).seconds
|
||||
|
||||
assert survives("PANZER") > 1.8 * survives("NAH")
|
||||
|
||||
|
||||
def test_ranged_units_close_the_gap_and_shoot_first():
|
||||
"""Der Fernkämpfer steht außer Reichweite und muss trotzdem treffen."""
|
||||
a = [make("FERN", "a", 3, 0)]
|
||||
b = [make("NAH", "b", 3, 7)]
|
||||
res = B.fight(a, b, random.Random(1), max_seconds=60)
|
||||
# Beide bewegen sich; der Fernkämpfer trifft aus vier Feldern.
|
||||
assert res.fighters[0].damage_done > 0
|
||||
|
||||
|
||||
def test_units_move_towards_the_enemy():
|
||||
one = make("NAH", "a", 3, 0)
|
||||
foe = make("NAH", "b", 3, 7)
|
||||
before = H.distance(one.pos, foe.pos)
|
||||
B._step_towards(one, foe, {one.pos, foe.pos})
|
||||
assert H.distance(one.pos, foe.pos) == before - 1
|
||||
|
||||
|
||||
def test_occupied_cells_are_not_entered():
|
||||
one = make("NAH", "a", 3, 3)
|
||||
foe = make("NAH", "b", 3, 4)
|
||||
taken = {one.pos, foe.pos} | set(H.neighbours(3, 3))
|
||||
B._step_towards(one, foe, taken)
|
||||
assert one.pos == (3, 3)
|
||||
|
||||
|
||||
def test_mana_fills_from_attacks_and_then_casts():
|
||||
caster = make("MAGIER", "a", 3, 3)
|
||||
foe = make("NAH", "b", 3, 4)
|
||||
assert caster.mana == 0
|
||||
B.fight([caster], [foe], random.Random(1), max_seconds=10)
|
||||
assert caster.casts >= 1
|
||||
|
||||
|
||||
def test_units_without_mana_never_cast():
|
||||
"""NAH hat keine Mana-Kosten — sonst zauberte er im ersten Tick."""
|
||||
one = make("NAH", "a", 3, 3)
|
||||
foe = make("PANZER", "b", 3, 4)
|
||||
B.fight([one], [foe], random.Random(1), max_seconds=10)
|
||||
assert one.casts == 0
|
||||
|
||||
|
||||
def test_same_seed_same_outcome():
|
||||
def run(seed):
|
||||
a = [make("NAH", "a", i, 3) for i in range(3)]
|
||||
b = [make("FERN", "b", i, 4) for i in range(3)]
|
||||
res = B.fight(a, b, random.Random(seed))
|
||||
return res.winner, res.seconds, len(res.survivors_a)
|
||||
|
||||
assert run(7) == run(7)
|
||||
|
||||
|
||||
def test_a_fight_always_ends():
|
||||
"""Zwei Panzer ohne Schaden dürfen nicht ewig laufen."""
|
||||
a = [make("PANZER", "a", 3, 3)]
|
||||
b = [make("PANZER", "b", 3, 4)]
|
||||
res = B.fight(a, b, random.Random(1), max_seconds=5)
|
||||
assert res.seconds <= 5
|
||||
|
||||
|
||||
def test_unknown_units_are_skipped():
|
||||
assert B.to_fighter({"api_name": "GIBTS_NICHT"}, "a", UNITS) is None
|
||||
|
||||
|
||||
AOE = {"stats": {"hp": 600, "armor": 0, "magicResist": 0, "damage": 10,
|
||||
"damageByStar": [0, 10, 20, 40], "attackSpeed": 1.0,
|
||||
"range": 4, "mana": 30, "initialMana": 30,
|
||||
"critChance": 0.0, "critMultiplier": 1.4},
|
||||
"spell_damage": [0, 300, 600, 1200], "spell_damage_type": "magic",
|
||||
"ability_vars": {"AoEHexRange": [0, 2, 2, 2]}}
|
||||
|
||||
|
||||
def test_area_spells_hit_the_neighbours():
|
||||
"""Radius aus den Fähigkeits-Variablen: alle im Umkreis nehmen Schaden."""
|
||||
UNITS["FLAECHE"] = AOE
|
||||
try:
|
||||
caster = make("FLAECHE", "a", 3, 3)
|
||||
near = [make("NAH", "b", c, 4) for c in (2, 3, 4)]
|
||||
B.fight([caster], near, random.Random(1), max_seconds=1)
|
||||
assert all(f.hp < f.max_hp for f in near)
|
||||
finally:
|
||||
del UNITS["FLAECHE"]
|
||||
|
||||
|
||||
def test_single_target_spells_stay_single():
|
||||
caster = make("MAGIER", "a", 3, 3)
|
||||
near = [make("NAH", "b", c, 4) for c in (2, 3, 4)]
|
||||
B.fight([caster], near, random.Random(1), max_seconds=1)
|
||||
assert sum(1 for f in near if f.hp < f.max_hp) == 1
|
||||
|
||||
|
||||
def test_spell_shape_reads_radius_and_count():
|
||||
assert B.spell_shape(AOE, 2) == (2, 1)
|
||||
assert B.spell_shape({"ability_vars": {"NumEnemiesHit": [0, 4, 4, 4]}}, 2) \
|
||||
== (0, 4)
|
||||
assert B.spell_shape({}, 2) == (0, 1)
|
||||
|
||||
|
||||
def test_physical_spells_scale_with_attack_damage():
|
||||
"""Ein Carry mit AD-Items soll auch härter zaubern."""
|
||||
UNITS["AD_ZAUBER"] = {
|
||||
"stats": {**UNITS["MAGIER"]["stats"], "initialMana": 30},
|
||||
"spell_damage": [0, 300, 600, 1200], "spell_damage_type": "physical"}
|
||||
ITEMS = {"AD": {"effects": {"AD": 1.0}}}
|
||||
try:
|
||||
plain = make("AD_ZAUBER", "a", 3, 3)
|
||||
armed = make("AD_ZAUBER", "a", 3, 3)
|
||||
B.apply_items(armed, ["AD"], ITEMS)
|
||||
foes = [make("PANZER", "b", 3, 4), make("PANZER", "b", 3, 4)]
|
||||
B.fight([plain], [foes[0]], random.Random(1), max_seconds=0.5)
|
||||
B.fight([armed], [foes[1]], random.Random(1), max_seconds=0.5)
|
||||
assert armed.damage_done > 1.5 * plain.damage_done
|
||||
finally:
|
||||
del UNITS["AD_ZAUBER"]
|
||||
|
||||
|
||||
def test_the_log_records_spawn_hit_and_death():
|
||||
a = [make("NAH", "a", 3, 3)]
|
||||
b = [make("FERN", "b", 3, 4)]
|
||||
res = B.fight(a, b, random.Random(1), max_seconds=30, record=True)
|
||||
kinds = {e["e"] for e in res.events}
|
||||
assert {"spawn", "hit", "die"} <= kinds
|
||||
# Zwei Kämpfer, zwei Spawns, und jeder Eintrag trägt einen Tick.
|
||||
assert sum(1 for e in res.events if e["e"] == "spawn") == 2
|
||||
assert all("t" in e for e in res.events)
|
||||
|
||||
|
||||
def test_the_log_stays_off_by_default():
|
||||
"""Die Auswertung läuft millionenfach — dort darf nichts mitgeschrieben werden."""
|
||||
a = [make("NAH", "a", 3, 3)]
|
||||
b = [make("NAH", "b", 3, 4)]
|
||||
assert B.fight(a, b, random.Random(1), max_seconds=5).events == []
|
||||
|
||||
|
||||
def test_recording_does_not_change_the_outcome():
|
||||
def run(record):
|
||||
a = [make("NAH", "a", i, 3) for i in range(3)]
|
||||
b = [make("FERN", "b", i, 4) for i in range(3)]
|
||||
res = B.fight(a, b, random.Random(5), record=record)
|
||||
return res.winner, res.seconds
|
||||
|
||||
assert run(False) == run(True)
|
||||
117
backend/tests/test_carries.py
Normal file
117
backend/tests/test_carries.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Carries: Kontrollen, Zielstufe, Zerlegung."""
|
||||
|
||||
from tft.model import carries
|
||||
|
||||
UNITS = {a: {"cost": 3, "traits": ["T"]} for a in ("A", "B", "CARRY", "TANK")}
|
||||
UNITS["TEUER"] = {"cost": 5, "traits": ["T"]}
|
||||
TRAITS = {"T": {"breakpoints": [{"min_units": 1}, {"min_units": 3}]}}
|
||||
|
||||
|
||||
def board(place, level, items_total, *ents):
|
||||
"""ents: (api, sterne, itemzahl)"""
|
||||
return {"place": place, "level": level, "items": items_total,
|
||||
"entities": list(ents)}
|
||||
|
||||
|
||||
def prepared(boards):
|
||||
return carries.prepare(boards, UNITS, TRAITS)
|
||||
|
||||
|
||||
def test_other_three_stars_are_part_of_the_comparison_group():
|
||||
"""Wer eine Unit auf 3★ hat, hatte Gold übrig — das Board ist deshalb gut.
|
||||
|
||||
Die eigenen 3★ zählen dabei nicht mit, sonst würde man den Effekt
|
||||
misst, den man erklären will.
|
||||
"""
|
||||
b = prepared([board(1, 9, 3, ("A", 3, 3), ("B", 3, 0))])[0]
|
||||
assert carries.cell(b, 3) == (9, 3, 1) # eigene 3★ abgezogen
|
||||
assert carries.cell(b, 2) == (9, 3, 2) # aus Sicht einer 2★-Unit beide
|
||||
|
||||
|
||||
def test_reroll_carries_are_shown_on_three_stars():
|
||||
"""Auf 2★ negativ, auf 3★ positiv: so spielt man diesen Champion."""
|
||||
boards = prepared([board(6, 9, 3, ("A", 2, 3)) for _ in range(60)]
|
||||
+ [board(1, 9, 3, ("A", 3, 3)) for _ in range(60)])
|
||||
base = carries.baseline(boards)
|
||||
out = carries.values(boards, base, UNITS, min_boards=10)
|
||||
assert out["A"]["stars"] == 3 and out["A"]["edge"] > 0
|
||||
|
||||
|
||||
def test_expensive_units_are_never_shown_on_three_stars():
|
||||
"""Ein 5-Kosten auf 3★ heißt: das Spiel war schon gewonnen.
|
||||
|
||||
In den Daten holen 5-Kosten auf 3★ in 98 % der Fälle Platz 1.
|
||||
"""
|
||||
boards = prepared([board(6, 9, 3, ("TEUER", 2, 3)) for _ in range(60)]
|
||||
+ [board(1, 9, 3, ("TEUER", 3, 3)) for _ in range(60)])
|
||||
base = carries.baseline(boards)
|
||||
out = carries.values(boards, base, UNITS, min_boards=10)
|
||||
assert out["TEUER"]["stars"] == 2
|
||||
|
||||
|
||||
def test_units_below_the_rate_are_kept_but_not_carries():
|
||||
"""Ein Tank ohne Items bleibt in der Tabelle — er ist der Maßstab.
|
||||
|
||||
Der Buchstabe im Reiter vergleicht mit allen Units, nicht nur mit
|
||||
den Carries.
|
||||
"""
|
||||
boards = prepared([board(1, 9, 3, ("CARRY", 2, 3), ("TANK", 2, 0))
|
||||
for _ in range(60)]
|
||||
+ [board(4, 9, 3, ("CARRY", 2, 3), ("TANK", 2, 3))
|
||||
for _ in range(20)])
|
||||
base = carries.baseline(boards)
|
||||
out = carries.values(boards, base, UNITS, min_boards=10)
|
||||
assert out["CARRY"]["carry"] and not out["TANK"]["carry"]
|
||||
|
||||
|
||||
def test_rare_units_are_dropped():
|
||||
boards = prepared([board(1, 9, 3, ("A", 2, 3)) for _ in range(9)])
|
||||
base = carries.baseline(boards)
|
||||
assert carries.values(boards, base, UNITS, min_boards=10) == {}
|
||||
|
||||
|
||||
def test_reasons_separate_unit_from_the_rest():
|
||||
"""Zwei Carries, gleiche Traits, aber einer steht neben besseren Units.
|
||||
|
||||
Der Unterschied muss im Topf "rest" landen, nicht in "unit".
|
||||
"""
|
||||
boards = prepared(
|
||||
[board(2, 9, 3, ("A", 2, 3), ("TANK", 2, 0)) for _ in range(400)]
|
||||
+ [board(2, 9, 3, ("B", 2, 3), ("CARRY", 2, 0)) for _ in range(400)]
|
||||
+ [board(7, 9, 3, ("TANK", 2, 3)) for _ in range(400)]
|
||||
+ [board(1, 9, 3, ("CARRY", 2, 3)) for _ in range(400)])
|
||||
base = carries.baseline(boards)
|
||||
beta, idx, own_of = carries._weights(boards, base, UNITS, carries.RIDGE)
|
||||
why = carries.reasons(boards, base, own_of, idx, beta, min_boards=100)
|
||||
a, b = why[("A", 2)], why[("B", 2)]
|
||||
# B steht neben CARRY (gut), A neben TANK (schlecht).
|
||||
assert b["rest"] > a["rest"]
|
||||
|
||||
|
||||
def test_build_is_empty_without_data():
|
||||
assert carries.build([], UNITS, TRAITS, min_boards=10) == {}
|
||||
|
||||
|
||||
def test_enabler_measures_the_same_carry_with_and_without():
|
||||
"""Stützt die Unit fremde Carries — oder steht sie nur daneben?
|
||||
|
||||
CARRY läuft mit B besser als ohne. B trägt selbst nie Items, kann den
|
||||
Vorsprung also nicht selbst erklären.
|
||||
"""
|
||||
boards = prepared(
|
||||
[board(2, 9, 3, ("CARRY", 2, 3), ("B", 2, 0)) for _ in range(400)]
|
||||
+ [board(6, 9, 3, ("CARRY", 2, 3), ("TANK", 2, 0)) for _ in range(400)])
|
||||
base = carries.baseline(boards)
|
||||
lift = carries.enabler_edge(boards, base, UNITS, min_pair=100, min_total=100)
|
||||
assert lift["B"] > 0 > lift["TANK"]
|
||||
|
||||
|
||||
def test_supported_is_relative_to_comparable_boards():
|
||||
"""Ein Carry mit starken Stützern liegt über dem Üblichen."""
|
||||
boards = prepared(
|
||||
[board(2, 9, 3, ("A", 2, 3), ("B", 2, 0)) for _ in range(400)]
|
||||
+ [board(6, 9, 3, ("CARRY", 2, 3), ("TANK", 2, 0)) for _ in range(400)])
|
||||
base = carries.baseline(boards)
|
||||
lift = {"B": 0.4, "TANK": -0.4}
|
||||
prop = carries.supported(boards, base, lift, min_boards=100)
|
||||
assert prop[("A", 2)] > prop[("CARRY", 2)]
|
||||
@@ -1,92 +0,0 @@
|
||||
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
|
||||
257
backend/tests/test_fullgame.py
Normal file
257
backend/tests/test_fullgame.py
Normal file
@@ -0,0 +1,257 @@
|
||||
"""Ein ganzes Spiel durchspielen und dabei die Regeln prüfen.
|
||||
|
||||
Die Einzeltests decken jede Funktion für sich ab. Hier geht es um das
|
||||
Zusammenspiel über 30 Runden: bleibt Gold nicht-negativ, verschwinden
|
||||
keine Units, endet das Spiel, stimmt der Pool.
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from tft.constants.loader import load_constants
|
||||
from tft.model import artifact as artifact_mod
|
||||
from tft.sim import player as sim_player
|
||||
from tft.sim.game import Game
|
||||
|
||||
SET = 18
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def deps():
|
||||
try:
|
||||
return artifact_mod.load(SET), load_constants(SET)
|
||||
except (FileNotFoundError, KeyError):
|
||||
pytest.skip("kein Artefakt für Set 18 gebaut")
|
||||
|
||||
|
||||
def act(game):
|
||||
"""Wie ein einfacher Spieler handeln: Level kaufen, Shop leeren, aufstellen.
|
||||
|
||||
Ohne das Level-Kaufen bleibt die Bank nach wenigen Runden voll und das
|
||||
Spiel prüft nichts mehr — Gold staut sich, das Board wächst nicht.
|
||||
"""
|
||||
while game.player.gold >= 12:
|
||||
try:
|
||||
game.buy_xp()
|
||||
except Exception:
|
||||
break
|
||||
for slot in range(len(game.player.shop or [])):
|
||||
try:
|
||||
game.buy(slot)
|
||||
except Exception:
|
||||
pass
|
||||
for _ in range(len(game.player.bench)):
|
||||
try:
|
||||
game.move("bench", 0)
|
||||
except Exception:
|
||||
break
|
||||
# Volle Bank blockiert jeden weiteren Kauf.
|
||||
while len(game.player.bench) >= sim_player.BENCH_SIZE:
|
||||
game.sell("bench", 0)
|
||||
|
||||
|
||||
def play(deps, seed, steps=60):
|
||||
artifact, cfg = deps
|
||||
game = Game(artifact, cfg, seed=seed)
|
||||
seen = []
|
||||
for _ in range(steps):
|
||||
if game.over:
|
||||
break
|
||||
act(game)
|
||||
seen.append({
|
||||
"round": game.round["label"],
|
||||
"hp": game.player.hp,
|
||||
"gold": game.player.gold,
|
||||
"level": game.player.level,
|
||||
"board": len(game.player.board),
|
||||
"bench": len(game.player.bench),
|
||||
})
|
||||
game.step()
|
||||
return game, seen
|
||||
|
||||
|
||||
def test_a_game_runs_to_the_end(deps):
|
||||
game, seen = play(deps, seed=1)
|
||||
assert len(seen) > 5
|
||||
assert game.over or game.round["label"]
|
||||
|
||||
|
||||
def test_gold_never_goes_negative(deps):
|
||||
_, seen = play(deps, seed=2)
|
||||
assert all(s["gold"] >= 0 for s in seen)
|
||||
|
||||
|
||||
def test_health_only_falls(deps):
|
||||
_, seen = play(deps, seed=3)
|
||||
hps = [s["hp"] for s in seen]
|
||||
assert hps == sorted(hps, reverse=True)
|
||||
|
||||
|
||||
def test_the_board_never_exceeds_the_level(deps):
|
||||
"""Mehr Units als Level wäre ein Regelbruch."""
|
||||
artifact, cfg = deps
|
||||
game = Game(artifact, cfg, seed=4)
|
||||
for _ in range(40):
|
||||
if game.over:
|
||||
break
|
||||
act(game)
|
||||
assert len(game.player.board) <= game.player.level
|
||||
game.step()
|
||||
|
||||
|
||||
def test_the_bench_has_a_limit(deps):
|
||||
artifact, cfg = deps
|
||||
game = Game(artifact, cfg, seed=5)
|
||||
for _ in range(30):
|
||||
if game.over:
|
||||
break
|
||||
act(game)
|
||||
assert len(game.player.bench) <= sim_player.BENCH_SIZE
|
||||
game.step()
|
||||
|
||||
|
||||
def test_every_unit_sits_on_its_own_hex(deps):
|
||||
artifact, cfg = deps
|
||||
game = Game(artifact, cfg, seed=6)
|
||||
for _ in range(40):
|
||||
if game.over:
|
||||
break
|
||||
act(game)
|
||||
hexes = [u.get("hex") for u in game.player.board]
|
||||
assert len(hexes) == len(set(hexes)), "zwei Units auf demselben Feld"
|
||||
assert all(h is not None for h in hexes)
|
||||
game.step()
|
||||
|
||||
|
||||
def test_the_same_seed_gives_the_same_game(deps):
|
||||
"""Ohne das lässt sich kein Kampf nachspielen und nichts vergleichen."""
|
||||
_, first = play(deps, seed=42, steps=20)
|
||||
_, again = play(deps, seed=42, steps=20)
|
||||
assert first == again
|
||||
|
||||
|
||||
def test_players_drop_out_over_time(deps):
|
||||
artifact, cfg = deps
|
||||
game = Game(artifact, cfg, seed=7)
|
||||
for _ in range(60):
|
||||
if game.over:
|
||||
break
|
||||
game.step()
|
||||
alive = [p for p in game.players if p.hp > 0]
|
||||
assert len(alive) < len(game.players)
|
||||
|
||||
|
||||
def test_fights_produce_a_result(deps):
|
||||
"""Jede PvP-Runde muss einen Sieger oder ein Unentschieden liefern."""
|
||||
artifact, cfg = deps
|
||||
game = Game(artifact, cfg, seed=8)
|
||||
seen = 0
|
||||
for _ in range(30):
|
||||
if game.over:
|
||||
break
|
||||
before = [p.hp for p in game.players]
|
||||
game.step()
|
||||
after = [p.hp for p in game.players]
|
||||
if before != after:
|
||||
seen += 1
|
||||
assert seen > 0, "in 30 Runden hat niemand Leben verloren"
|
||||
|
||||
|
||||
def test_the_pool_stays_complete(deps):
|
||||
"""Jede Kopie eines Champions ist entweder im Pool oder auf einem Board.
|
||||
|
||||
Verschwinden welche, sind Käufe oder Verkäufe kaputt. Auftauchen
|
||||
dürfen auch keine, sonst wäre der Pool unendlich.
|
||||
"""
|
||||
from collections import Counter
|
||||
|
||||
artifact, cfg = deps
|
||||
game = Game(artifact, cfg, seed=9)
|
||||
sizes = cfg["pool"]["sizes"]
|
||||
units = artifact["static"]["units"]
|
||||
start = {api: sizes[u["cost"] - 1] for api, u in units.items()
|
||||
if 1 <= u["cost"] <= len(sizes)}
|
||||
|
||||
def total():
|
||||
held = Counter()
|
||||
for p in game.players:
|
||||
for u in p.board + p.bench:
|
||||
held[u["api_name"]] += 3 ** (u["stars"] - 1)
|
||||
return {api: game.pool.available(api) + held[api] for api in start}
|
||||
|
||||
for _ in range(30):
|
||||
if game.over:
|
||||
break
|
||||
act(game)
|
||||
wrong = {api: (start[api], now) for api, now in total().items()
|
||||
if now != start[api]}
|
||||
assert not wrong, f"Kopien verschwunden oder erfunden: {list(wrong)[:3]}"
|
||||
game.step()
|
||||
|
||||
|
||||
def test_a_full_bench_blocks_buying(deps):
|
||||
"""Sonst kauft man Units ins Nichts."""
|
||||
artifact, cfg = deps
|
||||
game = Game(artifact, cfg, seed=10)
|
||||
for _ in range(40):
|
||||
if game.over:
|
||||
break
|
||||
if len(game.player.bench) >= sim_player.BENCH_SIZE and game.player.shop:
|
||||
before = len(game.player.bench)
|
||||
with pytest.raises(Exception):
|
||||
game.buy(0)
|
||||
assert len(game.player.bench) == before
|
||||
return
|
||||
act_without_selling(game)
|
||||
game.step()
|
||||
pytest.skip("Bank wurde in 40 Runden nie voll")
|
||||
|
||||
|
||||
def act_without_selling(game):
|
||||
for slot in range(len(game.player.shop or [])):
|
||||
try:
|
||||
game.buy(slot)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def test_the_players_fight_is_recorded(deps):
|
||||
"""Ohne Protokoll zeigt die Anzeige nichts oder den falschen Kampf."""
|
||||
artifact, cfg = deps
|
||||
game = Game(artifact, cfg, seed=11)
|
||||
for _ in range(20):
|
||||
if game.over:
|
||||
break
|
||||
act(game)
|
||||
game.step()
|
||||
if game.last_battle:
|
||||
log = game.last_battle
|
||||
assert log["units"] and log["events"]
|
||||
# Der Spieler steht in der Anzeige immer auf Seite "a".
|
||||
mine = [u for u in log["units"] if u["side"] == "a"]
|
||||
assert len(mine) == len(game.player.board) or game.over
|
||||
return
|
||||
pytest.skip("in 20 Runden kam kein Kampf mit Board zustande")
|
||||
|
||||
|
||||
def test_the_player_is_always_side_a(deps):
|
||||
"""Sonst zeichnet die Anzeige die eigenen Units beim Gegner."""
|
||||
artifact, cfg = deps
|
||||
game = Game(artifact, cfg, seed=12)
|
||||
seen = 0
|
||||
for _ in range(30):
|
||||
if game.over:
|
||||
break
|
||||
act(game)
|
||||
game.step()
|
||||
log = game.last_battle
|
||||
if not log:
|
||||
continue
|
||||
seen += 1
|
||||
rows = [e["row"] for e in log["events"]
|
||||
if e["e"] == "spawn"
|
||||
and next(u["side"] for u in log["units"] if u["slot"] == e["u"]) == "a"]
|
||||
# Eigene Units starten in der eigenen Hälfte, also Reihe 0 bis 3.
|
||||
assert all(r < log["rows"] // 2 for r in rows), rows
|
||||
assert seen > 0
|
||||
@@ -3,7 +3,7 @@ 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 tft.sim.game import MAX_ITEMS_PER_UNIT, Game, InvalidAction
|
||||
from tests.test_sim import make_static
|
||||
|
||||
|
||||
@@ -229,7 +229,20 @@ def test_natural_level_progression(art, cfg):
|
||||
assert checks["3-2"] == (5, 0)
|
||||
|
||||
|
||||
def test_no_augments_are_offered(art, cfg):
|
||||
"""Augments sind per Config abgeschaltet — weder Spieler noch Bots bekommen welche."""
|
||||
game = Game(art, cfg, seed=2)
|
||||
for _ in range(24):
|
||||
assert not game.player.augment_offer
|
||||
assert all(not p.augments for p in game.players)
|
||||
if game.over:
|
||||
break
|
||||
game.step()
|
||||
|
||||
|
||||
def test_augment_offer_single_tier(art, cfg):
|
||||
# Die Mechanik bleibt geprüft, auch wenn sie ausgeliefert deaktiviert ist.
|
||||
cfg = {**cfg, "rounds": {**cfg["rounds"], "augment_rounds": ["2-1"]}}
|
||||
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},
|
||||
@@ -255,6 +268,89 @@ def test_equip_crafts_completed_item(art, cfg):
|
||||
assert game.board[0]["items"] == ["COMPLETED", "C_A"]
|
||||
|
||||
|
||||
def test_merge_keeps_surplus_items_and_extra_copy(art, cfg):
|
||||
"""Verschmelzen darf weder Items vernichten noch die 4. Kopie schlucken."""
|
||||
from tft.sim import player
|
||||
|
||||
game = Game(art, cfg, seed=1)
|
||||
game.items = []
|
||||
game.board = [{"api_name": "U1_0", "stars": 1, "items": ["I_A", "I_B"]},
|
||||
{"api_name": "U1_0", "stars": 1, "items": ["I_C", "I_D"]}]
|
||||
game.bench = [{"api_name": "U1_0", "stars": 1, "items": ["I_E", "I_F"]},
|
||||
{"api_name": "U1_0", "stars": 1, "items": []}]
|
||||
player.merge(game.player, "U1_0", 1)
|
||||
|
||||
on_units = [i for u in game.board + game.bench for i in u["items"]]
|
||||
assert sorted(on_units + game.items) == ["I_A", "I_B", "I_C", "I_D", "I_E", "I_F"]
|
||||
assert len(on_units) == MAX_ITEMS_PER_UNIT
|
||||
# 3 Kopien verschmelzen, die 4. bleibt als eigene 1★-Unit stehen.
|
||||
stars = sorted(u["stars"] for u in game.board + game.bench)
|
||||
assert stars == [1, 2]
|
||||
|
||||
|
||||
def test_place_on_hex(art, cfg):
|
||||
from tft.sim.player import BOARD_HEXES
|
||||
|
||||
game = Game(art, cfg, seed=1)
|
||||
game.level = 3
|
||||
game.bench = [{"api_name": "U1_0", "stars": 1, "items": [], "hex": None},
|
||||
{"api_name": "U2_0", "stars": 1, "items": [], "hex": None}]
|
||||
game.board = []
|
||||
|
||||
game.place("bench", 0, 12)
|
||||
assert [(u["api_name"], u["hex"]) for u in game.board] == [("U1_0", 12)]
|
||||
|
||||
# Auf ein belegtes Hex ziehen -> der Belegte wandert auf die Bank.
|
||||
game.place("bench", 0, 12)
|
||||
assert [(u["api_name"], u["hex"]) for u in game.board] == [("U2_0", 12)]
|
||||
assert game.bench[0]["api_name"] == "U1_0" and game.bench[0]["hex"] is None
|
||||
|
||||
# Innerhalb des Felds verschieben.
|
||||
game.place("board", 0, 5)
|
||||
assert game.board[0]["hex"] == 5
|
||||
|
||||
with pytest.raises(InvalidAction):
|
||||
game.place("board", 0, BOARD_HEXES)
|
||||
|
||||
|
||||
def test_ranged_units_are_seated_in_the_back():
|
||||
"""Automatisches Aufstellen: Nahkämpfer nach vorn, Fernkämpfer nach hinten."""
|
||||
from tft.sim.player import (BOARD_COLS, BOARD_ROWS, PlayerState, fill_board)
|
||||
|
||||
ranged_art = {"static": {"units": {
|
||||
"MELEE": {"cost": 1, "stats": {"range": 1}},
|
||||
"RANGED": {"cost": 1, "stats": {"range": 4}},
|
||||
}}}
|
||||
p = PlayerState(name="t", archetype="x", hp=100, gold=0, level=4)
|
||||
p.bench = [{"api_name": "RANGED", "stars": 1, "items": []},
|
||||
{"api_name": "MELEE", "stars": 1, "items": []}]
|
||||
fill_board(p, ranged_art)
|
||||
rows = {u["api_name"]: u["hex"] // BOARD_COLS for u in p.board}
|
||||
assert rows["MELEE"] == 0
|
||||
assert rows["RANGED"] == BOARD_ROWS - 1
|
||||
|
||||
|
||||
def test_place_swaps_within_board(art, cfg):
|
||||
game = Game(art, cfg, seed=1)
|
||||
game.level = 3
|
||||
game.board = [{"api_name": "U1_0", "stars": 1, "items": [], "hex": 0},
|
||||
{"api_name": "U2_0", "stars": 1, "items": [], "hex": 9}]
|
||||
game.bench = []
|
||||
game.place("board", 0, 9)
|
||||
assert {u["api_name"]: u["hex"] for u in game.board} == {"U1_0": 9, "U2_0": 0}
|
||||
|
||||
|
||||
def test_autofill_assigns_unique_hexes(art, cfg):
|
||||
game = Game(art, cfg, seed=4)
|
||||
for _ in range(6):
|
||||
game.step()
|
||||
for p in game.players:
|
||||
hexes = [u["hex"] for u in p.board]
|
||||
assert all(h is not None for h in hexes)
|
||||
assert len(set(hexes)) == len(hexes)
|
||||
assert all(u.get("hex") is None for u in p.bench)
|
||||
|
||||
|
||||
def test_loot_drops_components(art, cfg):
|
||||
comp_art = {**art, "component_pool": ["C_A", "C_B"]}
|
||||
game = Game(comp_art, cfg, seed=2)
|
||||
|
||||
197
backend/tests/test_groups.py
Normal file
197
backend/tests/test_groups.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""Gruppen: Kontrolle nach Level und Item-Menge, Schwellen, Schlüsselformat."""
|
||||
|
||||
from tft.model import groups
|
||||
|
||||
|
||||
def board(place, level, *units):
|
||||
"""units: (api_name, itemzahl) — Sterne sind hier immer 2."""
|
||||
return {"place": place, "level": level,
|
||||
"items": sum(n for _, n in units),
|
||||
"entities": sorted((api, 2, n) for api, n in units)}
|
||||
|
||||
|
||||
def test_baseline_splits_by_level_and_items():
|
||||
boards = [board(1, 9, ("A", 3)), board(3, 9, ("B", 3)),
|
||||
board(7, 5, ("A", 0)), board(8, 5, ("B", 0))]
|
||||
base = groups.baseline(boards)
|
||||
assert base[(9, 3)] == 2.0
|
||||
assert base[(5, 0)] == 7.5
|
||||
|
||||
|
||||
def test_edge_measures_against_comparable_boards():
|
||||
"""Zwei Boards auf Level 9 mit gleichen Items: nur der Unterschied zählt."""
|
||||
boards = ([board(1, 9, ("A", 3), ("B", 3)) for _ in range(40)]
|
||||
+ [board(7, 9, ("C", 3), ("D", 3)) for _ in range(40)])
|
||||
base = groups.baseline(boards)
|
||||
table = groups.group_edge(boards, 2, base, min_boards=10)
|
||||
assert table["A@2@3|B@2@3"]["edge"] > 0
|
||||
assert table["C@2@3|D@2@3"]["edge"] < 0
|
||||
assert table["A@2@3|B@2@3"]["boards"] == 40
|
||||
|
||||
|
||||
def test_item_count_is_part_of_the_key():
|
||||
"""Derselbe Champion mit und ohne Items ist nicht dieselbe Einheit.
|
||||
|
||||
Beide Boards tragen sechs Items — nur anders verteilt. Genau dafür ist
|
||||
die Kontrolle da: die Frage ist, wer die Items tragen soll.
|
||||
"""
|
||||
boards = ([board(1, 9, ("A", 3), ("B", 3), ("C", 0)) for _ in range(40)]
|
||||
+ [board(8, 9, ("A", 0), ("B", 0), ("C", 3), ("D", 3)) for _ in range(40)])
|
||||
base = groups.baseline(boards)
|
||||
table = groups.group_edge(boards, 2, base, min_boards=10)
|
||||
assert "A@2@3|B@2@3" in table and "A@2@0|B@2@0" in table
|
||||
assert table["A@2@3|B@2@3"]["edge"] > table["A@2@0|B@2@0"]["edge"]
|
||||
|
||||
|
||||
def test_rare_groups_are_dropped():
|
||||
boards = [board(1, 9, ("A", 3), ("B", 3)) for _ in range(9)]
|
||||
base = groups.baseline(boards)
|
||||
assert groups.group_edge(boards, 2, base, min_boards=10) == {}
|
||||
|
||||
|
||||
def test_groups_smaller_than_size_are_skipped():
|
||||
boards = [board(1, 9, ("A", 3)) for _ in range(40)]
|
||||
base = groups.baseline(boards)
|
||||
assert groups.group_edge(boards, 2, base, min_boards=10) == {}
|
||||
|
||||
|
||||
def test_build_needs_enough_boards():
|
||||
import json
|
||||
|
||||
class Conn:
|
||||
"""Minimaler sqlite-Ersatz: execute(...).fetchall()."""
|
||||
|
||||
def __init__(self, n):
|
||||
row = (1, 9, json.dumps([{"character_id": "A", "tier": 2,
|
||||
"itemNames": []}]))
|
||||
self.rows = [row] * n
|
||||
|
||||
def execute(self, *_):
|
||||
return self
|
||||
|
||||
def fetchall(self):
|
||||
return self.rows
|
||||
|
||||
static = {"A": {"cost": 1, "traits": []}}
|
||||
assert groups.build(Conn(10), 18, static) == {}
|
||||
out = groups.build(Conn(groups.MIN_BOARDS * 20), 18, static)
|
||||
assert out["boards"] == groups.MIN_BOARDS * 20
|
||||
|
||||
|
||||
def test_only_groups_with_exactly_one_carry():
|
||||
"""Ein Champion trägt drei Items, der Rest stützt."""
|
||||
assert groups.one_carry("A@2@3|B@2@0")
|
||||
assert groups.one_carry("A@2@3|B@1@1|C@3@2")
|
||||
assert not groups.one_carry("A@2@3|B@2@3") # zwei Carries
|
||||
assert not groups.one_carry("A@2@1|B@2@0") # gar keiner
|
||||
|
||||
|
||||
def test_build_keeps_only_one_carry_groups():
|
||||
import json
|
||||
|
||||
class Conn:
|
||||
def __init__(self, rows):
|
||||
self.rows = rows
|
||||
|
||||
def execute(self, *_):
|
||||
return self
|
||||
|
||||
def fetchall(self):
|
||||
return self.rows
|
||||
|
||||
def raw(place, *pairs):
|
||||
units = [{"character_id": api, "tier": 1, "itemNames": ["x"] * n}
|
||||
for api, n in pairs]
|
||||
return (place, 9, json.dumps(units))
|
||||
|
||||
static = {a: {"cost": 1, "traits": []} for a in "ABCD"}
|
||||
rows = ([raw(1, ("A", 3), ("B", 0))] * (groups.MIN_BOARDS * 12)
|
||||
+ [raw(2, ("C", 3), ("D", 3))] * (groups.MIN_BOARDS * 12))
|
||||
out = groups.build(Conn(rows), 18, static)
|
||||
assert "C@1@3|D@1@3" not in out["2"]
|
||||
assert "A@1@3|B@1@0" in out["2"]
|
||||
|
||||
|
||||
def carrier(place, *pairs):
|
||||
"""pairs: (api, sterne, [items]) — ein Board mit Trägern."""
|
||||
return {"place": place, "level": 9, "items": 3, "_edge": 4.5 - place,
|
||||
"carried": [(api, st, min(len(items), 3), set(items))
|
||||
for api, st, items in pairs]}
|
||||
|
||||
|
||||
def test_only_craftable_items_are_ranked():
|
||||
"""Embleme, Artefakte und Radiant-Varianten kann man nicht planen."""
|
||||
boards = [carrier(1, ("T", 2, ["I_NORMAL", "I_EMBLEM"]))] * 200
|
||||
table = groups.unit_item_edge(boards, {"I_NORMAL"}, min_boards=10)
|
||||
assert set(table) == {"T|I_NORMAL"}
|
||||
|
||||
|
||||
def test_item_value_is_measured_against_the_same_champion():
|
||||
"""Ein gutes Board macht nicht jedes Item darauf gut.
|
||||
|
||||
Beide Items liegen auf derselben Unit, aber nur eines auf den guten
|
||||
Boards. Das andere darf davon nicht profitieren.
|
||||
"""
|
||||
boards = ([carrier(1, ("T", 2, ["GOOD"])) for _ in range(60)]
|
||||
+ [carrier(7, ("T", 2, ["BAD"])) for _ in range(60)])
|
||||
t = groups.unit_item_edge(boards, {"GOOD", "BAD"}, min_boards=10)
|
||||
assert t["T|GOOD"]["edge"] > 0 > t["T|BAD"]["edge"]
|
||||
|
||||
|
||||
def test_stars_and_item_count_are_the_comparison_group():
|
||||
"""Ein Item darf nicht gut aussehen, nur weil sein Träger der Carry ist.
|
||||
|
||||
Der Champion steht mit drei Items auf guten Boards, mit einem auf
|
||||
schlechten. Beide Items liegen nur auf je einer der beiden Gruppen —
|
||||
ohne die Kontrolle gewänne allein das Item der Drei-Item-Boards.
|
||||
"""
|
||||
boards = ([carrier(2, ("T", 2, ["A", "X", "Y"])) for _ in range(80)]
|
||||
+ [carrier(7, ("T", 2, ["B"])) for _ in range(80)])
|
||||
t = groups.unit_item_edge(boards, {"A", "B"}, min_boards=10)
|
||||
assert abs(t["T|A"]["edge"]) < 0.05 and abs(t["T|B"]["edge"]) < 0.05
|
||||
|
||||
|
||||
def test_items_are_kept_apart_per_champion():
|
||||
"""Dieselbe Rolle, anderer Champion: die Werte dürfen nicht verschmelzen."""
|
||||
boards = ([carrier(1, ("M", 2, ["AD_ITEM"])) for _ in range(60)]
|
||||
+ [carrier(1, ("N", 2, ["AP_ITEM"])) for _ in range(60)])
|
||||
t = groups.unit_item_edge(boards, {"AD_ITEM", "AP_ITEM"}, min_boards=10)
|
||||
assert set(t) == {"M|AD_ITEM", "N|AP_ITEM"}
|
||||
|
||||
|
||||
def test_role_of_maps_riot_names():
|
||||
assert groups.role_of({"role": "MagicTank"}) == "Tank"
|
||||
assert groups.role_of({"role": "AttackFighter"}) == "Bruiser"
|
||||
assert groups.role_of({"role": "MagicCaster"}) == "Mage"
|
||||
assert groups.role_of({"role": "AttackMarksman"}) == "Scharfschütze"
|
||||
assert groups.role_of({"role": ""}) is None
|
||||
|
||||
|
||||
def test_unit_edge_uses_one_star_level():
|
||||
"""Ohne feste Sternstufe gewinnt die billige Unit, die nur als 3★ überlebt."""
|
||||
boards = ([{"place": 1, "level": 9, "items": 0,
|
||||
"entities": [("CHEAP", 3, 0)]} for _ in range(60)]
|
||||
+ [{"place": 5, "level": 9, "items": 0,
|
||||
"entities": [("CHEAP", 2, 0), ("RICH", 2, 0)]} for _ in range(60)])
|
||||
base = groups.baseline(boards)
|
||||
two = groups.unit_edge(boards, base, min_boards=10, stars=2)
|
||||
three = groups.unit_edge(boards, base, min_boards=10, stars=3)
|
||||
# Auf 2★ zählen nur die schlechten Boards, auf 3★ nur die guten.
|
||||
assert set(two) == {"CHEAP", "RICH"}
|
||||
assert set(three) == {"CHEAP"}
|
||||
assert three["CHEAP"]["edge"] > two["CHEAP"]["edge"]
|
||||
|
||||
|
||||
def test_thiefs_gloves_carriers_are_excluded():
|
||||
"""Die Handschuhe würfeln ihren Inhalt — das hat niemand so gebaut.
|
||||
|
||||
Riot listet die gewürfelten Items daneben, als wären sie gelegt worden.
|
||||
Hier stehen sie nur auf den guten Boards; ohne den Ausschluss würden
|
||||
sie dadurch gut aussehen.
|
||||
"""
|
||||
boards = ([carrier(1, ("T", 2, ["DA_ThiefsGloves", "ZUFALL", "X"]))
|
||||
for _ in range(200)]
|
||||
+ [carrier(6, ("T", 2, ["ECHT", "X", "Y"])) for _ in range(200)])
|
||||
t = groups.unit_item_edge(boards, {"ZUFALL", "ECHT", "X"}, min_boards=10)
|
||||
assert "T|ZUFALL" not in t
|
||||
assert "T|ECHT" in t
|
||||
46
backend/tests/test_hexgrid.py
Normal file
46
backend/tests/test_hexgrid.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Das Spielfeld: Entfernungen und Nachbarn."""
|
||||
|
||||
from tft.sim import hexgrid as H
|
||||
|
||||
|
||||
def test_neighbours_are_all_one_step_away():
|
||||
"""Wenn das nicht gilt, bewegen sich Units in Sprüngen."""
|
||||
for col in range(H.COLS):
|
||||
for row in range(H.ROWS):
|
||||
for n in H.neighbours(col, row):
|
||||
assert H.distance((col, row), n) == 1
|
||||
|
||||
|
||||
def test_offset_rows_are_not_a_grid():
|
||||
"""Versetzte Reihen: gleicher Spaltenabstand, andere Entfernung.
|
||||
|
||||
Genau dafür ist die Umrechnung da — mit rohen Spalten wäre beides 1.
|
||||
"""
|
||||
assert H.distance((2, 0), (1, 1)) == 1
|
||||
assert H.distance((2, 0), (3, 1)) == 2
|
||||
|
||||
|
||||
def test_distance_along_a_row_counts_columns():
|
||||
assert H.distance((0, 0), (6, 0)) == 6
|
||||
assert H.distance((3, 4), (3, 4)) == 0
|
||||
|
||||
|
||||
def test_neighbour_count_is_six_in_the_middle():
|
||||
assert len(H.neighbours(3, 3)) == 6
|
||||
# Am Rand fallen Felder weg.
|
||||
assert len(H.neighbours(0, 0)) < 6
|
||||
|
||||
|
||||
def test_neighbour_order_is_stable():
|
||||
"""Sonst hängt der Ausgang eines Kampfes an der Iterationsreihenfolge."""
|
||||
assert H.neighbours(3, 3) == H.neighbours(3, 3)
|
||||
|
||||
|
||||
def test_mirror_swaps_the_halves():
|
||||
assert H.mirror(0, 0) == (H.COLS - 1, H.ROWS - 1)
|
||||
assert H.mirror(*H.mirror(2, 5)) == (2, 5)
|
||||
|
||||
|
||||
def test_halves_are_split_evenly():
|
||||
assert H.own_half(0, "a") and not H.own_half(4, "a")
|
||||
assert H.own_half(7, "b") and not H.own_half(3, "b")
|
||||
@@ -1,6 +1,9 @@
|
||||
import json
|
||||
|
||||
from tft.model.learn import learn
|
||||
from tft.model.learn import MEAN_PLACEMENT, learn
|
||||
|
||||
# Bester Fall: alle Boards auf Platz 1, also 3.5 Plätze über dem Schnitt.
|
||||
MEAN_PLACEMENT_DELTA = MEAN_PLACEMENT - 1
|
||||
|
||||
|
||||
def board_row(placement, unit="TFT17_A", item="TFT_Item_X", trait_tier=1):
|
||||
@@ -18,9 +21,19 @@ def test_strong_item_gets_multiplier_above_1():
|
||||
|
||||
|
||||
def test_shrinkage_with_thin_data():
|
||||
"""Drei Boards dürfen den Multiplikator kaum bewegen.
|
||||
|
||||
Der Vergleich läuft gegen DELTA_SCALE, nicht gegen eine feste Zahl:
|
||||
3 gegen 30 Pseudo-Boards lassen höchstens ein Zehntel des vollen
|
||||
Effekts durch, egal wie stark die Skala eingestellt ist.
|
||||
"""
|
||||
from tft.model.learn import DELTA_SCALE
|
||||
|
||||
rows = [board_row(1, item="TFT_Item_Rare") for _ in range(3)]
|
||||
learned = learn(rows)
|
||||
assert abs(learned["items"]["TFT_Item_Rare"] - 1.0) < 0.06
|
||||
thin = learn(rows)["items"]["TFT_Item_Rare"]
|
||||
full = learn([board_row(1, item="TFT_Item_Rare") for _ in range(2000)])
|
||||
assert abs(thin - 1.0) < 0.1 * MEAN_PLACEMENT_DELTA * DELTA_SCALE
|
||||
assert full["items"]["TFT_Item_Rare"] > thin
|
||||
|
||||
|
||||
def test_trait_keys_match_score_format():
|
||||
@@ -29,20 +42,38 @@ def test_trait_keys_match_score_format():
|
||||
assert "TFT17_T@3" in learned["traits"]
|
||||
|
||||
|
||||
def test_pair_lift_for_cooccurring_units():
|
||||
def pair_row(placement):
|
||||
units = [
|
||||
{"character_id": "TFT17_A", "tier": 2, "itemNames": []},
|
||||
{"character_id": "TFT17_B", "tier": 2, "itemNames": []},
|
||||
]
|
||||
def test_pair_surplus_finds_real_synergy():
|
||||
"""Zwei Units, die einzeln mittelmäßig sind, aber zusammen gewinnen."""
|
||||
def row(placement, *ids):
|
||||
units = [{"character_id": i, "tier": 1, "itemNames": []} for i in ids]
|
||||
return (placement, json.dumps(units), json.dumps([]), json.dumps([]))
|
||||
|
||||
# A und B stehen immer zusammen in Top-4-Boards; C ist überall.
|
||||
rows = [pair_row(1) for _ in range(100)]
|
||||
solo = [{"character_id": "TFT17_C", "tier": 1, "itemNames": []}]
|
||||
rows += [(1, json.dumps(solo), json.dumps([]), json.dumps([])) for _ in range(100)]
|
||||
learned = learn(rows)
|
||||
assert learned["pairs"]["TFT17_A|TFT17_B"] > 0.5
|
||||
rows = []
|
||||
# A und B einzeln mit Füllern: Mittelfeld.
|
||||
rows += [row(4, "A", "X") for _ in range(120)]
|
||||
rows += [row(5, "A", "Y") for _ in range(120)]
|
||||
rows += [row(4, "B", "X") for _ in range(120)]
|
||||
rows += [row(5, "B", "Y") for _ in range(120)]
|
||||
# Zusammen: Platz 1.
|
||||
rows += [row(1, "A", "B") for _ in range(120)]
|
||||
pairs = learn(rows)["pairs"]
|
||||
assert pairs["A|B"]["surplus"] > pairs["A|X"]["surplus"]
|
||||
assert pairs["A|B"]["surplus"] > 0
|
||||
|
||||
|
||||
def test_pair_surplus_cannot_separate_units_that_never_split():
|
||||
"""Bekannte Grenze: stehen zwei Units immer zusammen, zählt der
|
||||
Einzelwert beide Male dieselbe Stärke — der Überschuss kippt ins Minus.
|
||||
|
||||
In echten Daten fällt das kaum ins Gewicht: nur 9 von 1784 Paaren
|
||||
hängen zu über 50 % aneinander, der Median liegt bei 2 %.
|
||||
"""
|
||||
def row(placement):
|
||||
units = [{"character_id": i, "tier": 1, "itemNames": []} for i in ("A", "B")]
|
||||
return (placement, json.dumps(units), json.dumps([]), json.dumps([]))
|
||||
|
||||
pairs = learn([row(1) for _ in range(120)])["pairs"]
|
||||
assert pairs["A|B"]["surplus"] < 0
|
||||
|
||||
|
||||
def test_empty_rows():
|
||||
@@ -86,3 +117,62 @@ def test_augment_spec_builder():
|
||||
|
||||
junk = build_spec({"desc": "", "effects": {}}, units)
|
||||
assert junk["offerable"] is False
|
||||
|
||||
|
||||
def test_conditional_clause_does_not_leak_into_atoms():
|
||||
"""Nur der unbedingte Satz zählt — effects["Gold"] ist der bedingte Nachschlag."""
|
||||
from tft.model.augments import build_spec
|
||||
|
||||
units = {}
|
||||
survivor = build_spec(
|
||||
{"desc": "Gain 4 gold. After 3 players are eliminated, gain 100 more gold.",
|
||||
"effects": {"Gold": 100.0, "InitialGold": 4.0, "PlayersDead": 3.0}}, units)
|
||||
assert survivor["atoms"] == [{"kind": "gold_now", "amount": 4}]
|
||||
|
||||
avenge = build_spec(
|
||||
{"desc": "After 3 allies die, your team gains 28% Attack Damage.",
|
||||
"effects": {"AD": 28.0}}, units)
|
||||
assert avenge["atoms"] == []
|
||||
|
||||
# Wiederkehrend, aber unbedingt: bleibt erhalten.
|
||||
each_round = build_spec(
|
||||
{"desc": "Your team gains 2% Attack Damage per level. "
|
||||
"Gain 2 XP each round for the rest of the game.", "effects": {}}, units)
|
||||
assert each_round["atoms"] == [{"kind": "xp_per_round", "amount": 2}]
|
||||
|
||||
|
||||
def test_pair_surplus_isolates_the_synergy():
|
||||
"""Ein Paar zählt nur, was es über seine zwei Units hinaus bringt.
|
||||
|
||||
A ist stark, B schwach. Zusammen laufen sie genau so, wie die Summe
|
||||
erwarten lässt — also kein Überschuss.
|
||||
"""
|
||||
from tft.model.learn import pair_surplus, value
|
||||
|
||||
a_only = [1] * 100
|
||||
b_only = [8] * 100
|
||||
together = [1, 8] * 50
|
||||
units = {"A": value(a_only + together[::2]), "B": value(b_only + together[1::2])}
|
||||
pairs = pair_surplus({("A", "B"): together}, units)
|
||||
assert abs(pairs["A|B"]["surplus"]) < 0.5
|
||||
assert pairs["A|B"]["boards"] == 100
|
||||
|
||||
|
||||
def test_pair_surplus_needs_enough_boards():
|
||||
from tft.model.learn import PAIR_MIN_N, pair_surplus, value
|
||||
|
||||
units = {"A": 0.0, "B": 0.0}
|
||||
thin = pair_surplus({("A", "B"): [1] * (PAIR_MIN_N - 1)}, units)
|
||||
thick = pair_surplus({("A", "B"): [1] * PAIR_MIN_N}, units)
|
||||
assert thin == {}
|
||||
assert "A|B" in thick
|
||||
# Unbekannte Units fliegen raus statt still als 0 zu zählen.
|
||||
assert pair_surplus({("A", "X"): [1] * PAIR_MIN_N}, units) == {}
|
||||
|
||||
|
||||
def test_value_shrinks_towards_average():
|
||||
from tft.model.learn import value
|
||||
|
||||
assert value([1] * 3) < value([1] * 300) # wenige Boards -> naher an 0
|
||||
assert value([8] * 300) < 0 < value([1] * 300)
|
||||
assert abs(value([])) < 1e-9
|
||||
|
||||
203
backend/tests/test_metatft.py
Normal file
203
backend/tests/test_metatft.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""Der MetaTFT-Parser muss dieselben Tabellen liefern wie der cdragon-Parser.
|
||||
|
||||
Die Fixtures sind gekürzte, aber echte Ausschnitte aus
|
||||
data.metatft.com/lookups/TFTSet18_latest_en_us.json.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from tft.staticdata import metatft
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def raw():
|
||||
return {
|
||||
"_metadata": {"set": "TFTSet18", "setName": "Enchanted Wilds", "patch": "pbe"},
|
||||
"roles": {"DA_Role_AttackCaster": "Attack Caster"},
|
||||
"units": [
|
||||
{
|
||||
"apiName": "TFT18_Ashe", "characterName": "TFT18_Ashe", "name": "Ashe",
|
||||
"cost": 5, "shopUnit": True, "role": "DA_Role_AttackCaster",
|
||||
"traits": ["Blossom", "Hunter"],
|
||||
"traitApiNames": ["DA_18_Blossom", "DA_18_Hunter"],
|
||||
"stats": {"hp": 900, "damage": 75, "armor": 45, "magicResist": 45,
|
||||
"attackSpeed": 0.8, "mana": 80, "initialMana": 20,
|
||||
"critChance": 0.25, "critMultiplier": 1.4, "range": 5},
|
||||
"curveValues": {"AbilityShield": [[1, 60], [2, 90], [3, 200], [4, 310]],
|
||||
"ChillPercent": [[1, 20], [4, 20]]},
|
||||
"ability": {
|
||||
"name": "Spirit Rift",
|
||||
# Wie im Original: erst ein Mana-Tag, dann zwei Schadenszahlen.
|
||||
"desc": ('Costs <TFTAttribute attributeID="TFTCalculationAttributes.ManaCalc1"'
|
||||
' style="colorStat" icon="icon.AP"/> mana. Deals'
|
||||
' <TFTAttribute attributeID="TFTCalculationAttributes.PhysicalDamageCalc2"'
|
||||
' style="colorPhysical" icon="icon.AD"/> then'
|
||||
' <TFTAttribute attributeID="TFTCalculationAttributes.PhysicalDamageCalc1"'
|
||||
' style="colorPhysical" icon="icon.AD"/> physical damage,'
|
||||
' shields for <TFTCurveTable row="AbilityShield"/>, slowing by'
|
||||
' <TFTCurveTable row="ChillPercent"/>%.'),
|
||||
"variables": [],
|
||||
"attributeValues": {
|
||||
"TFTCalculationAttributes.ManaCalc1": [3, 3, 100, 100],
|
||||
"TFTCalculationAttributes.PhysicalDamageCalc1": [440, 660, 1000, 1000],
|
||||
"TFTCalculationAttributes.PhysicalDamageCalc2": [7, 11, 220, 220],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ # Verwandlungsform: kein shopUnit, gehört nicht in den Pool.
|
||||
"apiName": "TFT18_EliseSpider", "name": "Elise", "cost": 2,
|
||||
"shopUnit": False, "traits": ["Coven"], "traitApiNames": ["DA_18_Coven"],
|
||||
"stats": {"hp": 700}, "ability": {},
|
||||
},
|
||||
{ # Lücke im PBE-Dump: ohne HP wäre die eHP 0.
|
||||
"apiName": "TFT18_Ivern", "name": "Ivern", "cost": 5, "shopUnit": True,
|
||||
"traits": ["Greenfather"], "traitApiNames": ["DA_18_Greenfather"],
|
||||
"stats": {"damage": 60, "armor": 55}, "ability": {},
|
||||
},
|
||||
],
|
||||
"traits": [
|
||||
{"apiName": "DA_18_Brawler", "name": "Brawler", "type": "class",
|
||||
"icon": "T_TraitIcon_18_Brawler",
|
||||
"effects": [
|
||||
{"minUnits": 2, "style": 1,
|
||||
"desc": '(2) <TFTCurveTable row="BrawlerHealthPercent" column="1"'
|
||||
' format="percent"/>'},
|
||||
{"minUnits": 4, "style": 3,
|
||||
"desc": '(4) <TFTCurveTable row="BrawlerHealthPercent" column="2"'
|
||||
' format="percent"/>'},
|
||||
],
|
||||
"curveValues": {"BrawlerHealthPercent": [[0, 0], [1, 0.25], [2, 0.4]]}},
|
||||
{"apiName": "DA_18_Blossom", "name": "Blossom", "type": "origin",
|
||||
"icon": "T_TraitIcon_18_Blossom", "effects": [], "curveValues": {}},
|
||||
{"apiName": "DA_18_Hunter", "name": "Hunter", "type": "class",
|
||||
"icon": "T_TraitIcon_18_Hunter", "effects": [], "curveValues": {}},
|
||||
],
|
||||
"items": [
|
||||
{"apiName": "DA_Component_RecurveBow", "name": "Recurve Bow", "composition": [],
|
||||
"icon": "T_RecurveBow",
|
||||
"statLine": '<TFTCurveTable row="AS" icon="icon.AS" format="percentMinusOne"/>',
|
||||
"curveValues": {"AS": [[1, 1.1]]}},
|
||||
{"apiName": "DA_Component_NeedlesslyLargeRod", "name": "Rod", "composition": [],
|
||||
"icon": "T_Rod",
|
||||
"statLine": '<TFTCurveTable row="AP" icon="icon.AP" format="percent"/>',
|
||||
"curveValues": {"AP": [[1, 0.1]]}},
|
||||
{"apiName": "DA_Component_GiantsBelt", "name": "Belt", "composition": [],
|
||||
"icon": "T_Belt",
|
||||
"statLine": '<TFTCurveTable row="Health" icon="icon.Health"/>',
|
||||
"curveValues": {"Health": [[1, 150]]}},
|
||||
],
|
||||
"augments": [
|
||||
{"apiName": "DA_AdvancedLoan", "name": "Advanced Loan", "rarity": "Gold",
|
||||
"icon": "T_AugmentIcon_AdvancedLoan",
|
||||
"desc": 'Gain <TFTCurveTable row="GoldInstantBasic"/> Gold.',
|
||||
"curveValues": {"GoldInstantBasic": [[1, 20]]}},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_meta_and_unit_filter(raw):
|
||||
tables = metatft.parse(raw)
|
||||
assert tables["meta"]["set"] == 18
|
||||
assert tables["meta"]["set_name"] == "Enchanted Wilds"
|
||||
# EliseSpider ist kein Shop-Unit, Ivern hat keine HP.
|
||||
assert list(tables["units"]) == ["TFT18_Ashe"]
|
||||
assert tables["meta"]["incomplete_units"] == ["Ivern (hp)"]
|
||||
|
||||
|
||||
def test_missing_attack_damage_is_reported_but_kept(raw):
|
||||
"""Ohne AD ist der Auto-DPS 0 — die Unit wird trotzdem gespielt.
|
||||
|
||||
Anders als bei fehlender HP fliegt sie deshalb nicht aus dem Pool.
|
||||
"""
|
||||
del raw["units"][0]["stats"]["damage"]
|
||||
tables = metatft.parse(raw)
|
||||
assert "TFT18_Ashe" in tables["units"]
|
||||
assert "Ashe (damage)" in tables["meta"]["incomplete_units"]
|
||||
|
||||
|
||||
def test_unit_matches_cdragon_shape(raw):
|
||||
unit = metatft.parse(raw)["units"]["TFT18_Ashe"]
|
||||
assert unit["traits"] == ["DA_18_Blossom", "DA_18_Hunter"]
|
||||
assert unit["role"] == "AttackCaster"
|
||||
assert unit["stats"]["hp"] == 900
|
||||
# Der erste Tag im Text ist die Manazahl 3 — der Schaden ist 440.
|
||||
# cdragon zählt Sterne ab Index 1; MetaTFT ab 0.
|
||||
assert unit["spell_damage"] == [0, 440, 660, 1000]
|
||||
assert unit["spell_damage_type"] == "physical"
|
||||
assert unit["spell_scaling"] == "ad"
|
||||
# Kurven sind Treppen: Stufe 4 fehlt, Stufe 1 gilt weiter.
|
||||
assert unit["ability_vars"]["AbilityShield"] == [0, 60, 90, 200]
|
||||
assert unit["ability_vars"]["ChillPercent"] == [0, 20, 20, 20]
|
||||
|
||||
|
||||
def test_trait_breakpoints_carry_their_curve_values(raw):
|
||||
brawler = metatft.parse(raw)["traits"]["DA_18_Brawler"]
|
||||
assert [b["min_units"] for b in brawler["breakpoints"]] == [2, 4]
|
||||
assert brawler["breakpoints"][0]["variables"] == {"BrawlerHealthPercent": 0.25}
|
||||
assert brawler["breakpoints"][1]["variables"] == {"BrawlerHealthPercent": 0.4}
|
||||
|
||||
|
||||
def test_unknown_traits_are_dropped(raw):
|
||||
# Greenfather steht in keiner Trait-Tabelle; ein Verweis darauf würde
|
||||
# active_trait_tiers durchlaufen und im UI als Geist-Trait auftauchen.
|
||||
raw["units"][0]["traitApiNames"].append("DA_18_DoesNotExist")
|
||||
assert metatft.parse(raw)["units"]["TFT18_Ashe"]["traits"] == [
|
||||
"DA_18_Blossom", "DA_18_Hunter"]
|
||||
|
||||
|
||||
def test_item_effects_follow_statsheet_conventions(raw):
|
||||
items = metatft.parse(raw)["items"]
|
||||
# AS und AP sind Prozentzahlen, Health ist flach — wie in Set 17.
|
||||
assert items["DA_Component_RecurveBow"]["effects"] == {"AS": pytest.approx(10.0)}
|
||||
assert items["DA_Component_NeedlesslyLargeRod"]["effects"] == {"AP": pytest.approx(10.0)}
|
||||
assert items["DA_Component_GiantsBelt"]["effects"] == {"Health": 150}
|
||||
|
||||
|
||||
def test_augment_tier_and_rendered_desc(raw):
|
||||
aug = metatft.parse(raw)["augments"]["DA_AdvancedLoan"]
|
||||
assert aug["tier"] == 2 # Gold
|
||||
assert aug["desc"] == "Gain 20 Gold."
|
||||
assert aug["effects"] == {"GoldInstantBasic": 20}
|
||||
|
||||
|
||||
def test_curve_steps_hold_the_last_value():
|
||||
curve = [[1, 5], [3, 9]]
|
||||
assert [metatft.curve_at(curve, i) for i in (1, 2, 3, 4)] == [5, 5, 9, 9]
|
||||
assert metatft.curve_at([], 1) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value,fmt,want", [
|
||||
(0.25, "percent", 0.25),
|
||||
(1.10, "percentMinusOne", pytest.approx(0.10)),
|
||||
# Durability wird als Resthärte angegeben: 0.96 = 4% weniger Schaden.
|
||||
(0.96, "invertedPercent", pytest.approx(0.04)),
|
||||
(175, None, 175),
|
||||
])
|
||||
def test_format_drives_the_conversion(value, fmt, want):
|
||||
assert metatft.as_fraction(value, fmt) == want
|
||||
|
||||
|
||||
def test_spell_picks_the_biggest_damage_not_the_first_tag(raw):
|
||||
"""Lux nennt im Tooltip zuerst 3 Mana, dann 330 Schaden.
|
||||
|
||||
Wer den ersten <TFTAttribute> nimmt, modelliert die Manazahl als
|
||||
Schaden — das betraf 33 von 63 Units.
|
||||
"""
|
||||
unit = metatft.parse(raw)["units"]["TFT18_Ashe"]
|
||||
assert unit["spell_damage"][1] == 440
|
||||
assert unit["spell_scaling"] == "ad"
|
||||
assert unit["spell_damage_type"] == "physical"
|
||||
|
||||
|
||||
def test_on_attack_damage_is_flagged(raw):
|
||||
ability = raw["units"][0]["ability"]
|
||||
ability["desc"] = ('Attacks deal <TFTAttribute'
|
||||
' attributeID="TFTCalculationAttributes.PhysicalDamageCalc1"'
|
||||
' style="colorPhysical" icon="icon.AD"/> bonus damage.')
|
||||
assert metatft.parse(raw)["units"]["TFT18_Ashe"]["spell_on_attack"] is True
|
||||
|
||||
|
||||
def test_asset_names_are_kept_for_the_match_api(raw):
|
||||
# Riot nennt Units beim Asset-Namen; ohne die Liste findet learn.py nichts.
|
||||
raw["units"][0]["assetNames"] = ["DA_18_Ashe"]
|
||||
assert metatft.parse(raw)["units"]["TFT18_Ashe"]["asset_names"] == ["DA_18_Ashe"]
|
||||
113
backend/tests/test_pairs.py
Normal file
113
backend/tests/test_pairs.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Gruppen: Bindung aus den Pro-Comps, Stärke aus den Boards."""
|
||||
|
||||
from tft.model import pairs
|
||||
from tft.staticdata import procomps
|
||||
|
||||
UNITS = {a: {"cost": 1, "traits": ["T"], "asset_names": [f"DA_{a}"]}
|
||||
for a in "ABCDEF"}
|
||||
|
||||
|
||||
def board(place, *units):
|
||||
return {"place": place, "cell": (9, 3),
|
||||
"entities": [(u, 2, 0) for u in units]}
|
||||
|
||||
|
||||
def cell(b):
|
||||
return b["cell"]
|
||||
|
||||
|
||||
def test_bond_asks_about_the_rarer_unit():
|
||||
"""Nicht "wie oft zusammen", sondern "wie oft zusammen, wenn möglich".
|
||||
|
||||
B steht nie ohne A. C steht halb so oft bei A. Rohe Zählung sähe C
|
||||
vorne, weil C öfter vorkommt.
|
||||
"""
|
||||
groups = [["A", "B", "X", "Y"]] * 3 + [["A", "C", "X", "Y"]] * 2 \
|
||||
+ [["C", "D", "X", "Y"]] * 2
|
||||
w = pairs.weights(groups, pseudo=0)
|
||||
assert w[("A", "B")] == 1.0
|
||||
assert w[("A", "C")] == 0.5
|
||||
|
||||
|
||||
def test_pseudo_counts_hold_back_single_sightings():
|
||||
"""Ein Paar mit einer Nennung darf nicht wie eine Regel aussehen."""
|
||||
groups = [["A", "B", "C", "D"]]
|
||||
assert pairs.weights(groups, pseudo=0)[("A", "B")] == 1.0
|
||||
assert pairs.weights(groups, pseudo=3)[("A", "B")] == 0.25
|
||||
|
||||
|
||||
def test_every_pair_in_a_group_must_clear_the_threshold():
|
||||
"""Ein Dritter darf nicht an nur einem der beiden hängen.
|
||||
|
||||
C bindet stark an A, aber gar nicht an B. Also kein Trio.
|
||||
"""
|
||||
bond = {("A", "B"): 0.9, ("A", "C"): 0.9, ("B", "C"): 0.1}
|
||||
out = pairs.candidates(bond, UNITS, min_bond=0.4)
|
||||
assert ("A", "B") in out and ("A", "C") in out
|
||||
assert ("A", "B", "C") not in out
|
||||
|
||||
|
||||
def test_a_group_needs_a_shared_trait():
|
||||
"""Ohne das kommt Unsinn heraus: eine Comp hat acht bis neun Units,
|
||||
zwei davon stehen nebeneinander, ohne etwas miteinander zu tun.
|
||||
"""
|
||||
units = {"A": {"traits": ["T"]}, "B": {"traits": ["T"]},
|
||||
"C": {"traits": ["U"]}}
|
||||
bond = {("A", "B"): 0.9, ("A", "C"): 0.9, ("B", "C"): 0.9}
|
||||
out = pairs.candidates(bond, units, min_bond=0.4)
|
||||
assert ("A", "B") in out
|
||||
assert ("A", "C") not in out and ("A", "B", "C") not in out
|
||||
|
||||
|
||||
def test_strength_is_measured_against_comparable_boards():
|
||||
boards = [board(1, "A", "B", "C") for _ in range(400)] \
|
||||
+ [board(7, "D", "E", "F") for _ in range(400)]
|
||||
out = pairs.strength(boards, {(9, 3): 4.0}, cell,
|
||||
[("A", "B"), ("D", "E")], min_boards=10)
|
||||
assert out[("A", "B")]["edge"] > 0 > out[("D", "E")]["edge"]
|
||||
assert out[("A", "B")]["boards"] == 400
|
||||
|
||||
|
||||
def test_rare_groups_are_dropped():
|
||||
boards = [board(1, "A", "B") for _ in range(5)]
|
||||
assert pairs.strength(boards, {(9, 3): 4.0}, cell, [("A", "B")],
|
||||
min_boards=10) == {}
|
||||
|
||||
|
||||
def test_select_drops_overlapping_and_negative_groups():
|
||||
"""Sonst steht dieselbe Gruppe zwanzigmal leicht abgewandelt da."""
|
||||
scored = {("A", "B", "C"): {"edge": 0.5}, ("A", "B"): {"edge": 0.4},
|
||||
("D", "E"): {"edge": 0.2}, ("A", "F"): {"edge": -0.1}}
|
||||
assert pairs.select(scored) == [("A", "B", "C"), ("D", "E")]
|
||||
|
||||
|
||||
def test_build_is_empty_without_input():
|
||||
assert pairs.build([], [board(1, "A", "B")], {}, cell, UNITS) == {}
|
||||
assert pairs.build([["A", "B", "C", "D"]], [], {}, cell, UNITS) == {}
|
||||
|
||||
|
||||
def test_comps_reads_every_stage():
|
||||
"""Level 4 bis 9 sind eigene Stufen — jede zählt als Beobachtung."""
|
||||
doc = {
|
||||
"content": {"comps": [{"data": {"$ref": "#/comp/x"}, "tier": "S"}]},
|
||||
"refs": [{
|
||||
"content_id": "x",
|
||||
"metadata": {"title": "Test"},
|
||||
"content": {
|
||||
"unit_positions": {str(i): {"apiName": f"DA_{a}"}
|
||||
for i, a in enumerate("ABCD")},
|
||||
"variations": [
|
||||
{"type": "comp",
|
||||
"unit_positions": {str(i): {"apiName": f"DA_{a}"}
|
||||
for i, a in enumerate("ABCDE")}},
|
||||
{"type": "tierlist"},
|
||||
# zu klein, fliegt raus
|
||||
{"type": "comp",
|
||||
"unit_positions": {"0": {"apiName": "DA_A"}}},
|
||||
],
|
||||
},
|
||||
}],
|
||||
}
|
||||
out = procomps.comps(doc, UNITS)
|
||||
assert [len(c["units"]) for c in out] == [4, 5]
|
||||
assert all(c["tier"] == "S" for c in out)
|
||||
@@ -13,7 +13,8 @@ RAW = {
|
||||
"role": "Caster",
|
||||
"stats": {"hp": 600, "armor": 25, "magicResist": 25, "damage": 40,
|
||||
"attackSpeed": 0.7, "mana": 60, "initialMana": 10},
|
||||
"squareIcon": "ASSETS/x.TFT_Set17.tex",
|
||||
"squareIcon": "ASSETS/x_splash_tile.TFT_Set17.tex",
|
||||
"tileIcon": "ASSETS/HUD/x_Square.TFT_Set17.tex",
|
||||
"ability": {
|
||||
"name": "Blast",
|
||||
"desc": "Deal <magicDamage>@ModifiedDamage@ (%i:scaleAP%)</magicDamage> magic damage.",
|
||||
@@ -92,6 +93,23 @@ def test_resolve_spell_adaptive_and_fallback():
|
||||
assert resolve_spell(utility)["spell_damage"] is None
|
||||
|
||||
|
||||
def test_icon_url_drops_set_suffix():
|
||||
"""cdragon serviert ohne Set-Suffix — mit Suffix gab es stumme 404er."""
|
||||
from tft.staticdata.parse import icon_url
|
||||
|
||||
assert icon_url("ASSETS/Characters/TFT17_Illaoi/HUD/TFT17_Illaoi_Square.TFT_Set17.tex") == (
|
||||
"https://raw.communitydragon.org/latest/game"
|
||||
"/assets/characters/tft17_illaoi/hud/tft17_illaoi_square.png"
|
||||
)
|
||||
assert icon_url("ASSETS/UX/TraitIcons/Trait_Icon_17_Doomer.TFT_Set17.tex").endswith(
|
||||
"/assets/ux/traiticons/trait_icon_17_doomer.png"
|
||||
)
|
||||
# Ohne Suffix bleibt der Name unverändert.
|
||||
assert icon_url("ASSETS/Maps/TFT/Icons/Items/TFT_Item_BFSword.tex").endswith(
|
||||
"/tft_item_bfsword.png"
|
||||
)
|
||||
|
||||
|
||||
def test_augment_tier_from_icon():
|
||||
from tft.staticdata.parse import augment_tier
|
||||
assert augment_tier("ASSETS/x/GodAugmentEkko_II.TFT_Set17.tex") == 2
|
||||
|
||||
79
backend/tests/test_rating.py
Normal file
79
backend/tests/test_rating.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Boardbewertung: Merkmale, Baumdurchlauf, Grenzfälle."""
|
||||
|
||||
import pytest
|
||||
|
||||
from tft.model import rating
|
||||
|
||||
STATIC = {
|
||||
"units": {
|
||||
"U_TANK": {"cost": 1, "traits": ["T_BRAWL"], "name": "Tank"},
|
||||
"U_CARRY": {"cost": 4, "traits": ["T_BRAWL", "T_SHOT"], "name": "Carry"},
|
||||
"U_FIVE": {"cost": 5, "traits": ["T_SHOT"], "name": "Five"},
|
||||
},
|
||||
"traits": {
|
||||
"T_BRAWL": {"breakpoints": [{"min_units": 2}, {"min_units": 4}]},
|
||||
"T_SHOT": {"breakpoints": [{"min_units": 2}]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def board(*specs):
|
||||
return [{"api_name": a, "stars": s, "items": list(i)} for a, s, i in specs]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def art():
|
||||
return {"static": STATIC, "component_pool": ["C_SWORD"], "item_pool": ["I_BLADE"],
|
||||
"rating": {"tables": {}}}
|
||||
|
||||
|
||||
def test_trait_counts_and_tiers():
|
||||
b = board(("U_TANK", 1, []), ("U_CARRY", 1, []), ("U_FIVE", 1, []))
|
||||
counts = rating.trait_counts(b, STATIC["units"])
|
||||
assert counts == {"T_BRAWL": 2, "T_SHOT": 2}
|
||||
assert rating.trait_tiers(counts, STATIC["traits"]) == {"T_BRAWL": 1, "T_SHOT": 1}
|
||||
# Eine Unit unter dem Breakpoint aktiviert nichts.
|
||||
single = rating.trait_counts(board(("U_TANK", 1, [])), STATIC["units"])
|
||||
assert rating.trait_tiers(single, STATIC["traits"]) == {}
|
||||
|
||||
|
||||
def test_features_are_stable_in_length(art):
|
||||
empty = rating.features([], 1, art)
|
||||
full = rating.features(board(("U_CARRY", 3, ["I_BLADE", "C_SWORD"])), 9, art)
|
||||
assert len(empty) == len(full)
|
||||
assert all(isinstance(x, (int, float)) for x in empty + full)
|
||||
|
||||
|
||||
def test_unknown_units_are_ignored(art):
|
||||
known = rating.features(board(("U_TANK", 1, [])), 5, art)
|
||||
mixed = rating.features(board(("U_TANK", 1, []), ("NOPE", 3, [])), 5, art)
|
||||
assert known == mixed
|
||||
|
||||
|
||||
def test_no_model_means_no_prediction(art):
|
||||
assert rating.predict_placement(board(("U_TANK", 1, [])), 5, art) is None
|
||||
assert rating.board_rating(board(("U_TANK", 1, [])), 5, art) is None
|
||||
|
||||
|
||||
def test_tree_walk_and_clamping(art):
|
||||
# Ein Baum, der nur auf die Unit-Zahl schaut (Merkmal 4).
|
||||
art["rating"] = {"tables": {}, "base": 4.0, "lr": 1.0,
|
||||
"trees": [[4, 1.5, 2.0, -2.0]]}
|
||||
few = rating.predict_placement(board(("U_TANK", 1, [])), 3, art)
|
||||
many = rating.predict_placement(
|
||||
board(("U_TANK", 1, []), ("U_CARRY", 1, []), ("U_FIVE", 1, [])), 9, art)
|
||||
assert few == 6.0 and many == 2.0
|
||||
assert rating.board_rating(board(("U_TANK", 1, [])), 3, art) == pytest.approx(-1.5)
|
||||
|
||||
# Ausreißer werden auf 1 bis 8 gestutzt — es gibt keinen Platz 0.
|
||||
art["rating"]["trees"] = [[4, 1.5, 99.0, -99.0]]
|
||||
assert rating.predict_placement(board(("U_TANK", 1, [])), 3, art) == 8.0
|
||||
assert rating.predict_placement(
|
||||
board(("U_TANK", 1, []), ("U_CARRY", 1, [])), 9, art) == 1.0
|
||||
|
||||
|
||||
def test_learned_tables_reach_the_features(art):
|
||||
art["rating"]["tables"] = {"unit": {"U_CARRY": 1.5}}
|
||||
plain = rating.features(board(("U_TANK", 1, [])), 5, art)
|
||||
good = rating.features(board(("U_CARRY", 1, [])), 5, art)
|
||||
assert good != plain
|
||||
@@ -1,146 +0,0 @@
|
||||
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, board_profiles, score_board
|
||||
|
||||
STATIC = {
|
||||
"units": {
|
||||
"TFT17_A": {
|
||||
"cost": 1,
|
||||
"traits": ["TFT17_Tank"],
|
||||
"role": "Tank",
|
||||
"stats": {"hp": 650, "armor": 40, "magicResist": 40, "damage": 50,
|
||||
"attackSpeed": 0.6, "mana": 60, "initialMana": 0, "range": 1},
|
||||
},
|
||||
"TFT17_B": {
|
||||
"cost": 4,
|
||||
"traits": ["TFT17_Tank"],
|
||||
"role": "Marksman",
|
||||
"stats": {"hp": 700, "armor": 25, "magicResist": 25, "damage": 75,
|
||||
"attackSpeed": 0.75, "mana": 100, "initialMana": 20, "range": 4},
|
||||
},
|
||||
},
|
||||
"traits": {
|
||||
"TFT17_Tank": {
|
||||
"breakpoints": [{"min_units": 2, "style": 1}, {"min_units": 4, "style": 3}],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ARTIFACT = {
|
||||
"meta": {"set": 17},
|
||||
"static": {
|
||||
**STATIC,
|
||||
"items": {"TFT_Item_InfinityEdge": {"effects": {"AD": 0.35, "CritChance": 35.0}}},
|
||||
"augments": {},
|
||||
},
|
||||
"roles": {},
|
||||
}
|
||||
|
||||
|
||||
def test_unit_value():
|
||||
assert unit_value(1, 1) == 1
|
||||
assert unit_value(4, 2) == 12
|
||||
assert unit_value(3, 3) == 27
|
||||
|
||||
|
||||
def test_classify_role_from_cdragon_role():
|
||||
assert classify_role(STATIC["units"]["TFT17_A"]) == "frontline"
|
||||
assert classify_role(STATIC["units"]["TFT17_B"]) == "ad_carry"
|
||||
|
||||
|
||||
def test_trait_activation():
|
||||
board = [{"api_name": "TFT17_A", "stars": 1, "items": []},
|
||||
{"api_name": "TFT17_B", "stars": 1, "items": []}]
|
||||
tiers = active_trait_tiers(board, STATIC["traits"], STATIC["units"])
|
||||
assert tiers == {"TFT17_Tank": 1}
|
||||
assert active_trait_tiers(board[:1], STATIC["traits"], STATIC["units"]) == {}
|
||||
|
||||
|
||||
def test_score_ordering():
|
||||
weak = [{"api_name": "TFT17_A", "stars": 1, "items": []}]
|
||||
strong = [{"api_name": "TFT17_B", "stars": 2, "items": []}]
|
||||
assert score_board(strong, [], ARTIFACT) > score_board(weak, [], ARTIFACT)
|
||||
|
||||
|
||||
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)
|
||||
assert spearman([1, 2, 3, 4], [10, 10, 10, 10]) == 0.0
|
||||
|
||||
|
||||
def test_craftable_item_pool():
|
||||
from tft.model.artifact import craftable_items
|
||||
items = {
|
||||
"TFT_Item_InfinityEdge": {"composition": ["TFT_Item_BFSword", "TFT_Item_SparringGloves"]},
|
||||
"TFT_Item_BFSword": {"composition": []},
|
||||
"TFT_Item_Emblem": {"composition": ["TFT_Item_Spatula", "TFT_Item_BFSword"]},
|
||||
"TFT17_Item_Weird": {"composition": ["TFT_Item_BFSword", "TFT_Item_BFSword"]},
|
||||
"TFT_Item_Radiant": {"composition": None},
|
||||
}
|
||||
assert craftable_items(items) == ["TFT_Item_InfinityEdge"]
|
||||
|
||||
|
||||
def _built_artifact():
|
||||
from tft.model import artifact as artifact_mod
|
||||
|
||||
static = {
|
||||
"meta": {"set": 17, "patch": "test"},
|
||||
"units": {
|
||||
"TANK": {"cost": 2, "traits": ["T_AD"], "role": "Tank",
|
||||
"stats": {"hp": 900, "armor": 50, "magicResist": 50, "damage": 45,
|
||||
"attackSpeed": 0.6, "mana": 60, "initialMana": 0},
|
||||
"spell_damage": None, "spell_scaling": None},
|
||||
"CARRY": {"cost": 2, "traits": ["T_AD"], "role": "Marksman",
|
||||
"stats": {"hp": 600, "armor": 20, "magicResist": 20, "damage": 70,
|
||||
"attackSpeed": 0.8, "mana": 50, "initialMana": 0},
|
||||
"spell_damage": [0, 250, 375, 560, 0, 0, 0], "spell_scaling": "ad"},
|
||||
},
|
||||
"traits": {"T_AD": {"breakpoints": [
|
||||
{"min_units": 2, "style": 1, "variables": {"BonusAP": 20.0}}]}},
|
||||
"items": {"SWORD": {"effects": {"AD": 0.35}}},
|
||||
"augments": {},
|
||||
}
|
||||
return artifact_mod.build(static)
|
||||
|
||||
|
||||
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_board(balanced, [], art) > score_board(tanks, [], art)
|
||||
|
||||
|
||||
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_board(starred, [], art) > score_board(base, [], art)
|
||||
assert score_board(equipped, [], art) > score_board(base, [], art)
|
||||
@@ -1,84 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from tft.model import statsheet
|
||||
|
||||
|
||||
def unit(spell=None, scaling="ap", **overrides):
|
||||
stats = {"hp": 700, "armor": 30, "magicResist": 30, "damage": 50,
|
||||
"attackSpeed": 0.7, "mana": 60, "initialMana": 10,
|
||||
"critChance": 0.25, "critMultiplier": 1.4}
|
||||
stats.update(overrides)
|
||||
return {"stats": stats, "spell_damage": spell, "spell_scaling": scaling}
|
||||
|
||||
|
||||
ITEMS = {
|
||||
"sword": {"effects": {"AD": 0.35, "CritChance": 35.0}},
|
||||
"vest": {"effects": {"Armor": 20.0}},
|
||||
"rod": {"effects": {"AP": 10.0}},
|
||||
"unmodeled": {"effects": {"BurnPercent": 1.0}},
|
||||
}
|
||||
|
||||
|
||||
def test_star_scaling():
|
||||
one = statsheet.unit_stats(unit(), 1, [], ITEMS, None, None)
|
||||
two = statsheet.unit_stats(unit(), 2, [], ITEMS, None, None)
|
||||
assert two["ehp"] == pytest.approx(one["ehp"] * 1.8)
|
||||
assert two["dps"] == pytest.approx(one["dps"] * 1.5)
|
||||
|
||||
|
||||
def test_spell_damage_uses_star_index_and_ap():
|
||||
spell = [0, 200, 300, 450, 0, 0, 0]
|
||||
bare = statsheet.unit_stats(unit(spell=spell), 1, [], ITEMS, None, None)
|
||||
with_rod = statsheet.unit_stats(unit(spell=spell), 1, ["rod"], ITEMS, None, None)
|
||||
no_spell = statsheet.unit_stats(unit(), 1, [], ITEMS, None, None)
|
||||
assert bare["dps"] > no_spell["dps"]
|
||||
assert with_rod["dps"] > bare["dps"] # +10 AP skaliert den Spell
|
||||
|
||||
|
||||
def test_items_change_profile():
|
||||
base = statsheet.unit_stats(unit(), 1, [], ITEMS, None, None)
|
||||
sword = statsheet.unit_stats(unit(), 1, ["sword"], ITEMS, None, None)
|
||||
vest = statsheet.unit_stats(unit(), 1, ["vest"], ITEMS, None, None)
|
||||
assert sword["dps"] > base["dps"]
|
||||
assert vest["ehp"] > base["ehp"]
|
||||
assert statsheet.item_is_modeled(ITEMS["sword"])
|
||||
assert not statsheet.item_is_modeled(ITEMS["unmodeled"])
|
||||
|
||||
|
||||
def test_on_attack_spell_scales_with_attack_speed():
|
||||
# Pro-Attacke-Passive: DPS = Schaden × AS, unabhängig vom Mana-Zyklus.
|
||||
spell = [0, 50, 75, 110, 0, 0, 0]
|
||||
u = unit(spell=spell)
|
||||
u["spell_on_attack"] = True
|
||||
on_attack = statsheet.unit_stats(u, 1, [], ITEMS, None, None)
|
||||
assert on_attack["spell_dps"] == pytest.approx(50 * 0.7)
|
||||
cast = statsheet.unit_stats(unit(spell=spell), 1, [], ITEMS, None, None)
|
||||
assert on_attack["spell_dps"] > cast["spell_dps"]
|
||||
|
||||
|
||||
def test_frontline_casts_more():
|
||||
spell = [0, 300, 450, 700, 0, 0, 0]
|
||||
tank = statsheet.unit_stats(unit(spell=spell), 1, [], ITEMS, None, "frontline")
|
||||
carry = statsheet.unit_stats(unit(spell=spell), 1, [], ITEMS, None, "ad_carry")
|
||||
assert tank["dps"] > carry["dps"]
|
||||
|
||||
|
||||
def test_trait_buffs_keyword_mapping():
|
||||
traits = {
|
||||
"T_AD": {"breakpoints": [{"min_units": 2, "style": 1,
|
||||
"variables": {"BonusAD": 0.15}}]},
|
||||
"T_Mech": {"breakpoints": [{"min_units": 2, "style": 1,
|
||||
"variables": {"Wolf_Gold": 1.0}}]},
|
||||
"T_Skip": {"breakpoints": [{"min_units": 2, "style": 1,
|
||||
"variables": {"StunDuration": 1.5}}]},
|
||||
}
|
||||
buffs, recognized = statsheet.trait_buffs(
|
||||
{"T_AD": 1, "T_Mech": 1, "T_Skip": 1}, traits
|
||||
)
|
||||
assert recognized == {"T_AD"}
|
||||
assert buffs["ad_pct"] == pytest.approx(0.15)
|
||||
|
||||
|
||||
def test_fraction_normalization():
|
||||
assert statsheet._fraction(0.15) == 0.15
|
||||
assert statsheet._fraction(15.0) == 0.15
|
||||
42
backend/tests/test_tierlist.py
Normal file
42
backend/tests/test_tierlist.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Tierlist-Parser: Rauschzeilen raus, Meta-Stärke auf 1.0 zentriert."""
|
||||
|
||||
from tft.staticdata import tierlist
|
||||
|
||||
PAGE = """
|
||||
<div class="TableItemRow">Unit</div><div>Tier</div><div>Avg Place</div>
|
||||
<div class="TableItemRow"><a>Lux (Blossom)</a><span>S</span><span>1.20</span>
|
||||
<span>80.0%</span><span>5</span><span>0.0%</span></div>
|
||||
<div class="TableItemRow"><a>Lux</a><span>S</span><span>2.62</span>
|
||||
<span>33.6%</span><span>134,219</span><span>29.3%</span></div>
|
||||
<div class="TableItemRow"><a>Shen</a><span>B</span><span>4.59</span>
|
||||
<span>10.0%</span><span>139,563</span><span>30.4%</span></div>
|
||||
<div class="TableItemRow"><a>Elise</a><span>B</span><span>4.55</span>
|
||||
<span>9.1%</span><span>11</span><span>0.0%</span></div>
|
||||
<div class="TableItemRow"><a>Elise</a><span>B</span><span>4.46</span>
|
||||
<span>10.3%</span><span>67,541</span><span>14.7%</span></div>
|
||||
"""
|
||||
|
||||
|
||||
def test_small_samples_and_variants_are_dropped():
|
||||
rows = tierlist.parse(PAGE)
|
||||
# "Lux (Blossom)" mit 5 Spielen ist Rauschen, die Elise-Zeile mit 11 auch.
|
||||
assert sorted(rows) == ["Elise", "Lux", "Shen"]
|
||||
assert rows["Lux"]["avg_place"] == 2.62
|
||||
assert rows["Lux"]["games"] == 134219
|
||||
assert rows["Elise"]["avg_place"] == 4.46 # die größere Stichprobe gewinnt
|
||||
|
||||
|
||||
def test_meta_strength_centres_on_one():
|
||||
rows = tierlist.parse(PAGE)
|
||||
units = {"TFT18_Lux": {"name": "Lux"}, "TFT18_Shen": {"name": "Shen"},
|
||||
"TFT18_Elise": {"name": "Elise"}, "TFT18_Ivern": {"name": "Ivern"}}
|
||||
meta = tierlist.meta_strength(rows, units)
|
||||
# Ivern steht in keiner Zeile und bekommt keinen Wert.
|
||||
assert set(meta) == {"TFT18_Lux", "TFT18_Shen", "TFT18_Elise"}
|
||||
assert abs(sum(meta.values()) / len(meta) - 1.0) < 0.001
|
||||
assert meta["TFT18_Lux"] > 1 > meta["TFT18_Shen"]
|
||||
|
||||
|
||||
def test_no_rows_means_no_meta():
|
||||
assert tierlist.parse("<html>nichts</html>") == {}
|
||||
assert tierlist.meta_strength({}, {"X": {"name": "X"}}) == {}
|
||||
98
backend/tests/test_traits.py
Normal file
98
backend/tests/test_traits.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Trait-Boni: Zählung, Stufen, Reichweite des Bonus."""
|
||||
|
||||
from tft.sim import battle, traits
|
||||
|
||||
UNITS = {
|
||||
"A": {"traits": ["PANZER"], "stats": {"hp": 1000, "armor": 20, "damage": 50,
|
||||
"attackSpeed": 1.0, "range": 1}},
|
||||
"B": {"traits": ["PANZER"], "stats": {"hp": 1000, "armor": 20, "damage": 50,
|
||||
"attackSpeed": 1.0, "range": 1}},
|
||||
"C": {"traits": ["ANDERE"], "stats": {"hp": 1000, "armor": 20, "damage": 50,
|
||||
"attackSpeed": 1.0, "range": 1}},
|
||||
}
|
||||
TRAITS = {
|
||||
"PANZER": {"breakpoints": [
|
||||
{"min_units": 2, "variables": {"DefenderDefenseGain": 25}},
|
||||
{"min_units": 3, "variables": {"DefenderDefenseGain": 60}},
|
||||
]},
|
||||
"ANDERE": {"breakpoints": [
|
||||
{"min_units": 1, "variables": {"TeamManaRegen": 2}},
|
||||
]},
|
||||
}
|
||||
|
||||
|
||||
def board(*keys):
|
||||
return [{"api_name": k, "stars": 1} for k in keys]
|
||||
|
||||
|
||||
def fighters(*keys):
|
||||
return [battle.to_fighter({"api_name": k, "stars": 1, "col": 0, "row": 0},
|
||||
"a", UNITS) for k in keys]
|
||||
|
||||
|
||||
def test_a_trait_needs_enough_units():
|
||||
assert traits.active(board("A"), UNITS, TRAITS) == {}
|
||||
assert "PANZER" in traits.active(board("A", "B"), UNITS, TRAITS)
|
||||
|
||||
|
||||
def test_copies_of_the_same_champion_count_once():
|
||||
"""Zwei Kopien von A sind ein Trait-Träger, nicht zwei."""
|
||||
assert traits.active(board("A", "A"), UNITS, TRAITS) == {}
|
||||
|
||||
|
||||
def test_the_highest_reached_step_wins():
|
||||
step = traits.active(board("A", "B", "C"), UNITS, TRAITS)["PANZER"]
|
||||
assert step["variables"]["DefenderDefenseGain"] == 25
|
||||
UNITS["C"]["traits"].append("PANZER")
|
||||
try:
|
||||
step = traits.active(board("A", "B", "C"), UNITS, TRAITS)["PANZER"]
|
||||
assert step["variables"]["DefenderDefenseGain"] == 60
|
||||
finally:
|
||||
UNITS["C"]["traits"].remove("PANZER")
|
||||
|
||||
|
||||
def test_the_bonus_only_reaches_its_own_units():
|
||||
fa, fb, fc = fighters("A", "B", "C")
|
||||
traits.apply([fa, fb, fc], board("A", "B", "C"), UNITS, TRAITS)
|
||||
assert fa.armor == 45 and fb.armor == 45 # 20 + 25
|
||||
assert fc.armor == 20
|
||||
|
||||
|
||||
def test_team_bonuses_reach_everyone():
|
||||
"""Steht "Team" im Namen, gilt der Bonus für das ganze Board."""
|
||||
fa, fb, fc = fighters("A", "B", "C")
|
||||
traits.apply([fa, fb, fc], board("A", "B", "C"), UNITS, TRAITS)
|
||||
assert fa.mana_regen == 2 and fc.mana_regen == 2
|
||||
|
||||
|
||||
def test_percent_and_flat_are_told_apart():
|
||||
"""Derselbe Name trägt beide Skalen — unter 1 Anteil, darüber flach."""
|
||||
assert traits.effects_of({"ADAP": 0.3})["attack_damage_pct"] == 0.3
|
||||
assert traits.effects_of({"ADAP": 30})["attack_damage_pct"] == 0.3
|
||||
|
||||
|
||||
def test_unknown_variables_are_ignored():
|
||||
assert traits.effects_of({"OverrunCombats": 3, "RengarGold": 5}) == {}
|
||||
|
||||
|
||||
def test_durability_reduces_incoming_damage():
|
||||
gains = traits.effects_of({"JuggernautDurability": 0.2})
|
||||
assert gains["durability"] == 0.2
|
||||
|
||||
|
||||
def test_the_panel_and_the_fight_count_the_same():
|
||||
"""Zwei Kopien desselben Champions sind ein Trait-Träger.
|
||||
|
||||
Zählt das UI anders als der Kampf, zeigt es Stufen an, die im
|
||||
Gefecht nicht wirken.
|
||||
"""
|
||||
board = [{"api_name": "A", "stars": 1}, {"api_name": "A", "stars": 2},
|
||||
{"api_name": "B", "stars": 1}]
|
||||
assert traits.counts(board, UNITS)["PANZER"] == 2
|
||||
step = traits.active(board, UNITS, TRAITS)["PANZER"]
|
||||
assert step["units"] == 2 and step["step"] == 1
|
||||
|
||||
|
||||
def test_unknown_units_do_not_count():
|
||||
board = [{"api_name": "GIBTS_NICHT", "stars": 1}]
|
||||
assert traits.counts(board, UNITS) == {}
|
||||
Reference in New Issue
Block a user