This commit is contained in:
team3
2026-09-07 07:44:09 +02:00
parent 848c8ea1aa
commit e12c2595fc
69 changed files with 7080 additions and 1165 deletions

View File

@@ -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
View 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

View 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)

View 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)]

View File

@@ -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

View 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

View File

@@ -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)

View 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

View 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")

View File

@@ -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

View 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
View 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)

View File

@@ -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

View 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

View File

@@ -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)

View File

@@ -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

View 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"}}) == {}

View 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) == {}