Files
tft/backend/tests/test_learn.py
2026-09-07 07:44:09 +02:00

179 lines
6.9 KiB
Python

import json
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):
units = [{"character_id": unit, "tier": 2, "itemNames": [item]}]
traits = [{"name": "TFT17_T", "num_units": 2, "tier_current": trait_tier}]
return (placement, json.dumps(units), json.dumps(traits), json.dumps([]))
def test_strong_item_gets_multiplier_above_1():
rows = [board_row(1, item="TFT_Item_Good") for _ in range(200)]
rows += [board_row(8, item="TFT_Item_Bad") for _ in range(200)]
learned = learn(rows)
assert learned["items"]["TFT_Item_Good"] > 1.05
assert learned["items"]["TFT_Item_Bad"] < 0.95
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)]
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():
rows = [board_row(2, trait_tier=3) for _ in range(50)]
learned = learn(rows)
assert "TFT17_T@3" in learned["traits"]
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([]))
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():
learned = learn([])
assert learned["traits"] == {}
assert learned["pairs"] == {}
assert learned["n_boards"] == 0
def test_unit_multipliers_learned():
def row(placement, unit):
units = [{"character_id": unit, "tier": 2, "itemNames": []}]
return (placement, json.dumps(units), json.dumps([]), json.dumps([]))
rows = [row(1, "TFT17_Strong") for _ in range(150)]
rows += [row(8, "TFT17_Weak") for _ in range(150)]
learned = learn(rows)
assert learned["units"]["TFT17_Strong"] > 1.05
assert learned["units"]["TFT17_Weak"] < 0.95
def test_augment_spec_builder():
from tft.model.augments import build_spec
units = {"TFT17_Briar": {"name": "Briar", "cost": 1}}
gold = build_spec({"desc": "Gain 6 gold now.", "effects": {"Gold": 6.0}}, units)
assert gold["atoms"] == [{"kind": "gold_now", "amount": 6}]
buff = build_spec({"desc": "Your team gains 35 Health and 10% Attack Speed.",
"effects": {"Health": 35.0, "AS": 0.10}}, units)
kinds = {(a["kind"], a.get("stat")) for a in buff["atoms"]}
assert ("team_buff", "hp_flat") in kinds
assert ("team_buff", "as_pct") in kinds
cond = build_spec({"desc": "Your team gains 10 Health for each item.",
"effects": {"Health": 10.0}}, units)
assert not any(a["kind"] == "team_buff" for a in cond["atoms"])
unit = build_spec({"desc": "Gain a Briar and 2 gold.", "effects": {"Gold": 2.0}}, units)
assert {"kind": "unit_grant", "units": ["TFT17_Briar"]} in unit["atoms"]
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