update
This commit is contained in:
@@ -7,6 +7,8 @@ dependencies = [
|
||||
"fastapi",
|
||||
"uvicorn",
|
||||
"requests",
|
||||
# nur fürs Training der Boardbewertung; die Auswertung läuft ohne
|
||||
"numpy",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -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) == {}
|
||||
@@ -1,11 +1,17 @@
|
||||
import json
|
||||
import random
|
||||
from collections import defaultdict
|
||||
import uuid
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from tft import paths
|
||||
from tft.model.score import active_trait_tiers
|
||||
from tft.model import artifact as artifact_mod
|
||||
from tft.model import groups as groups_mod
|
||||
from tft.model.rating import predict_placement
|
||||
from tft.sim import traits as sim_traits
|
||||
from tft.sim import arena as arena_mod
|
||||
from tft.sim import economy
|
||||
from tft.sim import player as sim_player
|
||||
from tft.sim.game import Game, InvalidAction
|
||||
@@ -23,6 +29,7 @@ class ActionRequest(BaseModel):
|
||||
item_idx: int | None = None
|
||||
board_idx: int | None = None
|
||||
choice: int | None = None
|
||||
hex: int | None = None
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
@@ -32,7 +39,6 @@ def health() -> dict:
|
||||
|
||||
def _load_game_deps():
|
||||
from tft.constants.loader import load_constants
|
||||
from tft.model import artifact as artifact_mod
|
||||
|
||||
pointer = paths.latest_static_pointer()
|
||||
if not pointer.exists():
|
||||
@@ -41,6 +47,221 @@ def _load_game_deps():
|
||||
return artifact_mod.load(set_number), load_constants(set_number)
|
||||
|
||||
|
||||
@app.get("/api/groups")
|
||||
def groups(size: int = 2, limit: int = 10) -> dict:
|
||||
"""Stärkste Champion-Gruppen, gemessen an echten Endboards."""
|
||||
artifact, _ = _load_game_deps()
|
||||
table = (artifact.get("groups") or {}).get(str(size))
|
||||
if not table:
|
||||
raise HTTPException(404, f"keine {size}er-Gruppen im Artefakt")
|
||||
static = artifact["static"]
|
||||
units, traits = static["units"], static["traits"]
|
||||
tiers = artifact.get("unit_tiers") or {}
|
||||
|
||||
# Je Carry nur die beste Gruppe — sonst steht derselbe Champion
|
||||
# fünfmal in der Liste, nur mit anderem Beiwerk.
|
||||
rows, seen = [], set()
|
||||
for key, v in sorted(table.items(), key=lambda x: -x[1]["edge"]):
|
||||
carry = next((t.split("@")[0] for t in key.split("|") if t.endswith("@3")), None)
|
||||
if carry in seen:
|
||||
continue
|
||||
seen.add(carry)
|
||||
rows.append((key, v))
|
||||
if len(rows) >= limit:
|
||||
break
|
||||
|
||||
def card(token: str) -> dict | None:
|
||||
# Schlüssel sind "api@sterne@items".
|
||||
api, stars, items = (token.split("@") + ["0", "0"])[:3]
|
||||
if api not in units:
|
||||
return None
|
||||
return {**_unit_card(artifact, api),
|
||||
"stars": int(stars), "items": int(items)}
|
||||
|
||||
return {
|
||||
"size": size,
|
||||
"boards": artifact["groups"].get("boards"),
|
||||
"sizes": sorted(int(k) for k in artifact["groups"] if k.isdigit()),
|
||||
"groups": [
|
||||
# Carry zuerst: er trägt die Comp, der Rest stützt.
|
||||
{"edge": round(v["edge"], 2), "boards": v["boards"],
|
||||
"units": sorted((c for c in map(card, key.split("|")) if c),
|
||||
key=lambda c: -c["items"])}
|
||||
for key, v in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/roles")
|
||||
def roles() -> dict:
|
||||
"""Stärkste Unit je Rolle und Kostenstufe, mit und ohne Items."""
|
||||
artifact, _ = _load_game_deps()
|
||||
solo = (artifact.get("groups") or {}).get("solo")
|
||||
if not solo:
|
||||
raise HTTPException(404, "keine Einzelwerte im Artefakt")
|
||||
units = artifact["static"]["units"]
|
||||
|
||||
# Der Buchstabe und die Zahl müssen dasselbe messen — sonst steht auf
|
||||
# einer Karte "S" neben einem negativen Wert. Beide kommen aus
|
||||
# unit_tiers: drei Items, zwei Sterne.
|
||||
rated = artifact.get("unit_tiers") or {}
|
||||
|
||||
def best(role_needle: str, cost: int) -> dict | None:
|
||||
table = rated
|
||||
# Unter dem Schnitt ist keine Empfehlung. Ist die ganze Kostenstufe
|
||||
# einer Rolle schwach, bleibt der Platz lieber leer.
|
||||
pool = [(table[api]["strength"], api)
|
||||
for api, unit in units.items()
|
||||
if unit["cost"] == cost
|
||||
and role_needle in (unit.get("role") or "")
|
||||
and api in table
|
||||
and table[api]["strength"] > 0]
|
||||
if not pool:
|
||||
return None
|
||||
edge, api = max(pool)
|
||||
return {
|
||||
**_unit_card(artifact, api),
|
||||
"edge": round(edge, 2),
|
||||
"boards": table[api]["boards"],
|
||||
"stars": groups_mod.REFERENCE_STARS,
|
||||
}
|
||||
|
||||
static_items = artifact["static"]["items"]
|
||||
# Items hängen am Champion, nicht an der Rolle: das AD/AP-Label aus
|
||||
# Riots Rollennamen traf nur in 68 % der Fälle.
|
||||
items_by_unit = defaultdict(list)
|
||||
for key, v in (artifact["groups"].get("unit_items") or {}).items():
|
||||
api, _, item = key.partition("|")
|
||||
items_by_unit[api].append((v["edge"], v["boards"], item))
|
||||
|
||||
def items_for(api: str) -> list[dict]:
|
||||
# Unter null ist keine Empfehlung — wie bei den Units selbst.
|
||||
best_items = [x for x in sorted(items_by_unit.get(api, []), reverse=True)
|
||||
if x[0] > 0][:groups_mod.TOP_ITEMS]
|
||||
return [{"name": static_items.get(i, {}).get("name", i),
|
||||
"icon": static_items.get(i, {}).get("icon"),
|
||||
"edge": round(e, 2), "boards": n}
|
||||
for e, n, i in best_items]
|
||||
|
||||
def card(needle: str, cost: int) -> dict | None:
|
||||
unit = best(needle, cost)
|
||||
if unit:
|
||||
unit["items"] = items_for(unit["api_name"])
|
||||
return unit
|
||||
|
||||
return {
|
||||
"boards": artifact["groups"].get("boards"),
|
||||
"roles": [
|
||||
{"label": label,
|
||||
"costs": [{"cost": c, "unit": card(needle, c)} for c in range(1, 6)]}
|
||||
for label, needle in groups_mod.ROLE_GROUPS
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/carries")
|
||||
def carries() -> dict:
|
||||
"""Units, die als Carry gespielt werden — mit drei Items.
|
||||
|
||||
Sortiert nach Vorsprung. "why" zerlegt ihn in drei Töpfe: liegt es am
|
||||
Champion selbst, an seinen Traits oder an den Units daneben?
|
||||
"""
|
||||
artifact, _ = _load_game_deps()
|
||||
table = (artifact.get("groups") or {}).get("carries")
|
||||
if not table:
|
||||
raise HTTPException(404, "keine Carry-Werte im Artefakt")
|
||||
|
||||
static_items = artifact["static"]["items"]
|
||||
items_by_unit = defaultdict(list)
|
||||
for key, v in (artifact["groups"].get("unit_items") or {}).items():
|
||||
api, _, item = key.partition("|")
|
||||
items_by_unit[api].append((v["edge"], v["boards"], item))
|
||||
|
||||
def items_for(api: str) -> list[dict]:
|
||||
best = [x for x in sorted(items_by_unit.get(api, []), reverse=True)
|
||||
if x[0] > 0][:groups_mod.TOP_ITEMS]
|
||||
return [{"name": static_items.get(i, {}).get("name", i),
|
||||
"icon": static_items.get(i, {}).get("icon"),
|
||||
"edge": round(e, 2), "boards": n} for e, n, i in best]
|
||||
|
||||
# Der Buchstabe vergleicht mit ALLEN Units, nicht nur mit den Carries —
|
||||
# sonst bekäme der schwächste Carry ein D, obwohl er über dem Schnitt
|
||||
# liegt. Die globalen Tiers taugen dafür nicht: sie messen 2★, Rerolls
|
||||
# werden aber auf 3★ ausgewiesen. Hier stehen 2★ und 3★ auf derselben
|
||||
# Skala, weil die übrigen 3★-Units mitkontrolliert sind.
|
||||
# Sortiert wird nach dem Gesamtwert — die Frage ist "worauf spiele ich".
|
||||
# Der Solo-Wert steht daneben und sagt, wie sehr der Carry an seiner
|
||||
# Comp hängt: Elder Dragon liegt gesamt bei +0.26, mit schwacher
|
||||
# Begleitung aber bei -0.16. Danach zu sortieren wäre falsch, dann
|
||||
# fielen die stärksten Optionen ganz raus.
|
||||
known = [api for api in table if api in artifact["static"]["units"]]
|
||||
scale = sorted(known, key=lambda api: -table[api]["edge"])
|
||||
tier_of = {api: artifact_mod.tier_at((i + 0.5) / len(scale))
|
||||
for i, api in enumerate(scale)}
|
||||
# Unter dem Schnitt ist keine Empfehlung — wie bei den Rollen.
|
||||
ranked = [api for api in scale
|
||||
if table[api]["carry"] and table[api]["edge"] > 0]
|
||||
return {
|
||||
"boards": artifact["groups"].get("boards"),
|
||||
"carries": [
|
||||
{**_unit_card(artifact, api),
|
||||
"tier": tier_of[api],
|
||||
"edge": round(table[api]["edge"], 2),
|
||||
"rate": table[api]["rate"],
|
||||
"boards": table[api]["boards"],
|
||||
"stars": table[api]["stars"],
|
||||
"why": table[api].get("why"),
|
||||
"items": items_for(api)}
|
||||
for api in ranked
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/game/{game_id}/replay")
|
||||
def replay(game_id: str) -> dict:
|
||||
"""Der letzte Kampf des Spielers als Ereignis-Protokoll.
|
||||
|
||||
Er wird während der Runde mitgeschrieben, nicht nachträglich neu
|
||||
gerechnet — sonst zeigte die Anzeige einen anderen Kampf als den,
|
||||
der das Leben gekostet hat.
|
||||
"""
|
||||
game = GAMES.get(game_id)
|
||||
if game is None:
|
||||
raise HTTPException(404, "no such game")
|
||||
if not game.last_battle:
|
||||
raise HTTPException(409, "noch kein Kampf gelaufen")
|
||||
return game.last_battle
|
||||
|
||||
|
||||
@app.get("/api/pairs")
|
||||
def pairs() -> dict:
|
||||
"""Kleine Gruppen, die zusammengehören und stark sind.
|
||||
|
||||
Bindung aus den Pro-Comps: wenn der seltenere zweier Champions
|
||||
gespielt wird, wie oft ist der andere dabei? Stärke aus unseren
|
||||
Endboards, mit derselben Vergleichsgruppe wie die Carries.
|
||||
"""
|
||||
artifact, _ = _load_game_deps()
|
||||
table = artifact.get("pairs")
|
||||
if not table:
|
||||
raise HTTPException(404, "keine Paare im Artefakt")
|
||||
known = artifact["static"]["units"]
|
||||
traits = artifact["static"]["traits"]
|
||||
return {
|
||||
"comps": table["comps"],
|
||||
"candidates": table["candidates"],
|
||||
"min_bond": table["min_bond"],
|
||||
"groups": [{"edge": g["edge"], "bond": g["bond"], "boards": g["boards"],
|
||||
"links": g["links"],
|
||||
"traits": [{"name": traits[t].get("name", t),
|
||||
"icon": traits[t].get("icon")}
|
||||
for t in g.get("traits") or [] if t in traits],
|
||||
"units": [_unit_card(artifact, api) for api in g["units"]]}
|
||||
for g in table["groups"]
|
||||
if all(api in known for api in g["units"])],
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/game")
|
||||
def new_game(seed: int | None = None) -> dict:
|
||||
artifact, cfg = _load_game_deps()
|
||||
@@ -73,6 +294,8 @@ def action(game_id: str, req: ActionRequest) -> dict:
|
||||
game.buy_xp()
|
||||
elif req.action == "move":
|
||||
game.move(req.where, req.idx)
|
||||
elif req.action == "place":
|
||||
game.place(req.where, req.idx, req.hex)
|
||||
elif req.action == "equip":
|
||||
game.equip(req.item_idx, req.board_idx)
|
||||
elif req.action == "pick_augment":
|
||||
@@ -109,6 +332,8 @@ def unit_view(game: Game, u: dict) -> dict:
|
||||
"name": info.get("name", u["api_name"]),
|
||||
"cost": info["cost"],
|
||||
"stars": u["stars"],
|
||||
"hex": u.get("hex"),
|
||||
**unit_strength(game.artifact, u),
|
||||
"traits": u.get("shown_traits", info["traits"]),
|
||||
"icon": info.get("icon"),
|
||||
"items": [
|
||||
@@ -119,29 +344,94 @@ def unit_view(game: Game, u: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _unit_card(artifact: dict, api: str) -> dict:
|
||||
"""Karten-Daten einer Unit — dieselbe Darstellung wie im Shop.
|
||||
|
||||
Das Tier beantwortet: mit passenden Items auf 2 Sternen — gehört der
|
||||
Champion dann zu den besten Units im Spiel?
|
||||
"""
|
||||
static = artifact["static"]
|
||||
unit = static["units"][api]
|
||||
traits = static["traits"]
|
||||
rated = (artifact.get("unit_tiers") or {}).get(api) or {}
|
||||
return {
|
||||
"api_name": api,
|
||||
"name": unit.get("name", api),
|
||||
"cost": unit["cost"],
|
||||
"icon": unit.get("icon"),
|
||||
"splash": unit.get("splash"),
|
||||
"tier": rated.get("tier"),
|
||||
"traits": [{"name": traits[t].get("name", t), "icon": traits[t].get("icon")}
|
||||
for t in unit["traits"] if t in traits],
|
||||
}
|
||||
|
||||
|
||||
def lobby_rank(game: Game) -> int | None:
|
||||
"""Platz des Spielers, wenn die Runde jetzt entschieden würde."""
|
||||
mine = predict_placement(game.board, game.level, game.artifact)
|
||||
if mine is None:
|
||||
return None
|
||||
alive = [b for b in game.bots if b.alive]
|
||||
better = sum(1 for b in alive
|
||||
if (predict_placement(b.board, b.level, game.artifact) or 9) < mine)
|
||||
# Ausgeschiedene Gegner stehen schon fest hinter allen Lebenden.
|
||||
return better + 1
|
||||
|
||||
|
||||
def unit_strength(artifact: dict, u: dict) -> dict:
|
||||
"""Angriff und Widerstand einer Unit, gleiche Skala wie das Board.
|
||||
|
||||
Beides kommt aus der Kampf-Engine. tier/strength dagegen aus echten
|
||||
Endboards; sie fehlen, solange zu wenige Boards vorliegen.
|
||||
"""
|
||||
s = arena_mod.strength([u], artifact)
|
||||
rated = (artifact.get("unit_tiers") or {}).get(u["api_name"]) or {}
|
||||
return {"off": round(s["off"], 1), "def": round(s["def"], 1),
|
||||
"tier": rated.get("tier"), "strength": rated.get("strength")}
|
||||
|
||||
|
||||
def carry_icon(game: Game, board: list[dict]) -> str | None:
|
||||
"""Teuerste Unit des Boards — steht in der Spielerliste fürs Portrait."""
|
||||
if not board:
|
||||
return None
|
||||
best = max(board, key=lambda u: sim_player.unit_worth(game.artifact, u))
|
||||
return game.artifact["static"]["units"][best["api_name"]].get("icon")
|
||||
|
||||
|
||||
def trait_view(info: dict, api: str, count: int, tier: int) -> dict:
|
||||
"""Ein Trait fürs Panel: erreichte Stufe plus alle Schwellen mit ihrem Stil.
|
||||
|
||||
style folgt Riot: 1 Bronze, 3 Silber, 4 Chromatisch, 5 Gold, 6 Prismatisch.
|
||||
"""
|
||||
breakpoints = info.get("breakpoints") or []
|
||||
return {
|
||||
"api_name": api,
|
||||
"name": info.get("name", api),
|
||||
"count": count,
|
||||
"tier": tier,
|
||||
"style": breakpoints[tier - 1]["style"] if tier else 0,
|
||||
"breakpoints": [{"units": bp["min_units"], "style": bp["style"]}
|
||||
for bp in breakpoints],
|
||||
"icon": info.get("icon"),
|
||||
}
|
||||
|
||||
|
||||
def serialize(game: Game) -> dict:
|
||||
static = game.artifact["static"]
|
||||
tiers = active_trait_tiers(game.board, static["traits"], static["units"])
|
||||
counts: dict[str, int] = {}
|
||||
for u in game.board:
|
||||
for t in set(static["units"][u["api_name"]]["traits"]):
|
||||
counts[t] = counts.get(t, 0) + 1
|
||||
# Dieselbe Zählung wie im Kampf — sonst zeigt das Panel Stufen an,
|
||||
# die im Gefecht gar nicht wirken.
|
||||
tiers = {t: step["step"]
|
||||
for t, step in sim_traits.active(game.board, static["units"],
|
||||
static["traits"]).items()}
|
||||
counts = sim_traits.counts(game.board, static["units"])
|
||||
traits_panel = sorted(
|
||||
(
|
||||
{
|
||||
"api_name": t,
|
||||
"name": static["traits"][t].get("name", t),
|
||||
"count": n,
|
||||
"tier": tiers.get(t, 0),
|
||||
"breakpoints": [bp["min_units"] for bp in static["traits"][t]["breakpoints"]],
|
||||
"icon": static["traits"][t].get("icon"),
|
||||
}
|
||||
for t, n in counts.items()
|
||||
),
|
||||
(trait_view(static["traits"][t], t, n, tiers.get(t, 0))
|
||||
for t, n in counts.items()),
|
||||
key=lambda x: (-x["tier"], -x["count"]),
|
||||
)
|
||||
|
||||
stage = game.round["stage"]
|
||||
strength = game.player_strength()
|
||||
return {
|
||||
"round": {"label": game.round["label"], "stage": stage,
|
||||
"kind": game.round["kind"]},
|
||||
@@ -154,7 +444,13 @@ def serialize(game: Game) -> dict:
|
||||
"hp": game.hp, "gold": game.gold, "level": game.level, "xp": game.xp,
|
||||
"xp_needed": economy.xp_to_next(game.level, game.cfg),
|
||||
"streak": game.streak,
|
||||
"score": round(game.player_score(), 1),
|
||||
"off": round(strength["off"], 1),
|
||||
"def": round(strength["def"], 1),
|
||||
# Welchen Platz würde das Board jetzt machen? Nicht die rohe
|
||||
# Vorhersage — die ist mitten im Spiel bei jedem ~8, weil
|
||||
# Endboards mit fünf Units immer zu Ausgeschiedenen gehören.
|
||||
# Verglichen wird deshalb mit den sieben Gegnern der Lobby.
|
||||
"expected_place": lobby_rank(game),
|
||||
},
|
||||
"board": [unit_view(game, u) for u in game.board],
|
||||
"bench": [unit_view(game, u) for u in game.bench],
|
||||
@@ -174,10 +470,15 @@ def serialize(game: Game) -> dict:
|
||||
for t in static["units"][api]["traits"] if t in static["traits"]
|
||||
],
|
||||
"icon": static["units"][api].get("icon"),
|
||||
"splash": static["units"][api].get("splash"),
|
||||
**unit_strength(game.artifact,
|
||||
{"api_name": api, "stars": 1, "items": []}),
|
||||
"upgrade": upgrade_hint(game, api),
|
||||
}
|
||||
for api in game.shop
|
||||
],
|
||||
"board_grid": {"rows": sim_player.BOARD_ROWS, "cols": sim_player.BOARD_COLS},
|
||||
"bench_size": sim_player.BENCH_SIZE,
|
||||
"shop_locked": game.shop_locked,
|
||||
"shop_odds": game.cfg["shop"]["odds"][game.level - 1],
|
||||
"traits": traits_panel,
|
||||
@@ -197,7 +498,10 @@ def serialize(game: Game) -> dict:
|
||||
"level": b.level if b.alive else None,
|
||||
"gold": b.gold if b.alive else None,
|
||||
"streak": b.streak if b.alive else None,
|
||||
"score": round(sim_player.score(b, game.artifact), 1) if b.alive else None,
|
||||
**({k: round(v, 1)
|
||||
for k, v in sim_player.strength(b, game.artifact).items()}
|
||||
if b.alive else {"off": None, "def": None}),
|
||||
"carry": carry_icon(game, b.board),
|
||||
"board": [unit_view(game, u) for u in b.board],
|
||||
}
|
||||
for b in game.bots
|
||||
|
||||
@@ -17,13 +17,27 @@ def main() -> None:
|
||||
|
||||
p_fetch = sub.add_parser("fetch-static", help="download static data from Community Dragon")
|
||||
p_fetch.add_argument("--set", type=int, default=None, help="override set number")
|
||||
# cdragon veröffentlicht die Champions eines neuen Sets erst spät;
|
||||
# metatft hat sie ab PBE. --set ist dann Pflicht, es gibt keine Erkennung.
|
||||
p_fetch.add_argument("--source", choices=["cdragon", "metatft"], default="cdragon")
|
||||
|
||||
sub.add_parser("crawl", help="crawl ranked matches from Challenger/GM ladder (max 200 neue pro Lauf)")
|
||||
p_crawl = sub.add_parser("crawl", help="crawl ranked matches from the ladder")
|
||||
p_crawl.add_argument("--limit", type=int, default=200, help="neue Matches pro Lauf")
|
||||
p_crawl.add_argument("--since", type=int, default=None, metavar="TAGE",
|
||||
help="nur Matches der letzten N Tage anfragen")
|
||||
|
||||
sub.add_parser("extract", help="extract endboards from raw matches")
|
||||
sub.add_parser("build-artifact", help="build analysis.json from static data + endboards")
|
||||
|
||||
p_tier = sub.add_parser("import-tierlist",
|
||||
help="read a saved MetaTFT champion tier list for unit meta strength")
|
||||
p_tier.add_argument("path", help="saved 'TFT Champion Tier List.html'")
|
||||
sub.add_parser("calibrate", help="backtest score vs real placements (holdout)")
|
||||
|
||||
p_pairs = sub.add_parser("pairs", help="Champion-Paare, die mehr bringen als ihre Teile")
|
||||
p_pairs.add_argument("--top", type=int, default=15)
|
||||
p_pairs.add_argument("--unit", default=None, help="nur Paare mit dieser Unit")
|
||||
|
||||
p_auto = sub.add_parser("autoplay", help="run scripted games headless")
|
||||
p_auto.add_argument("--policy", choices=["afk", "econ"], default="econ")
|
||||
p_auto.add_argument("--games", type=int, default=200)
|
||||
@@ -33,18 +47,27 @@ def main() -> None:
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "fetch-static":
|
||||
from tft.staticdata.fetch import fetch_static
|
||||
from tft.staticdata.fetch import fetch_metatft, fetch_static
|
||||
|
||||
parsed = fetch_static(set_override=args.set)
|
||||
if args.source == "metatft":
|
||||
if args.set is None:
|
||||
raise SystemExit("--source metatft needs an explicit --set")
|
||||
parsed = fetch_metatft(args.set)
|
||||
else:
|
||||
parsed = fetch_static(set_override=args.set)
|
||||
meta = parsed["meta"]
|
||||
print(f"set {meta['set']} ({meta['set_name']}), patch {meta['patch']}")
|
||||
for name in ("units", "traits", "items", "augments"):
|
||||
print(f" {name}: {len(parsed[name])}")
|
||||
if meta.get("incomplete_units"):
|
||||
print(" ohne Basiswerte, ausgelassen: "
|
||||
+ ", ".join(meta["incomplete_units"]))
|
||||
|
||||
elif args.command == "crawl":
|
||||
from tft.matches.crawl import crawl
|
||||
|
||||
added = crawl(set_number=current_set())
|
||||
added = crawl(set_number=current_set(), limit=args.limit,
|
||||
since_days=args.since)
|
||||
print(f"{added} new matches stored")
|
||||
|
||||
elif args.command == "extract":
|
||||
@@ -69,14 +92,113 @@ def main() -> None:
|
||||
from tft.model.learn import learn_from_db
|
||||
from tft.staticdata.fetch import load_static
|
||||
|
||||
from tft.model.baseline import unit_aliases
|
||||
|
||||
static = load_static()
|
||||
conn = db.connect()
|
||||
learned = learn_from_db(conn, static["meta"]["set"])
|
||||
learned = learn_from_db(conn, static["meta"]["set"],
|
||||
aliases=unit_aliases(static["units"]))
|
||||
conn.close()
|
||||
n_boards = learned.pop("n_boards", 0)
|
||||
art = artifact_mod.build(static, learned, {"boards_learned_from": n_boards})
|
||||
|
||||
from tft.model import carries as carries_mod
|
||||
from tft.model import groups as groups_mod
|
||||
from tft.model import pairs as pairs_mod
|
||||
from tft.model.train_rating import train as train_rating
|
||||
from tft.staticdata import procomps
|
||||
|
||||
conn = db.connect()
|
||||
art["rating"] = train_rating(conn, static["meta"]["set"], art)
|
||||
art["groups"] = groups_mod.build(conn, static["meta"]["set"],
|
||||
static["units"], art["item_pool"])
|
||||
# Carries brauchen eine eigene Kontrolle für die übrigen 3★-Units,
|
||||
# deshalb eine eigene Tabelle statt eines Eintrags in groups.
|
||||
boards = groups_mod.boards_from_db(conn, static["meta"]["set"],
|
||||
static["units"])
|
||||
# Paare kommen aus den Pro-Comps, nicht aus den Boards: dort steht
|
||||
# alles neben allem, was gerade im Shop kam.
|
||||
art["groups"]["carries"] = carries_mod.build(
|
||||
boards, static["units"], static["traits"],
|
||||
artifact_mod.TIER_MIN_BOARDS)
|
||||
# Bindung aus den Pro-Comps, Stärke aus denselben Boards und mit
|
||||
# demselben Maßstab wie die Carries.
|
||||
pro = procomps.comps(procomps.fetch(), static["units"])
|
||||
art["pairs"] = pairs_mod.build(
|
||||
[c["units"] for c in pro], boards,
|
||||
carries_mod.baseline(boards),
|
||||
lambda b: carries_mod.cell(b, carries_mod.REFERENCE_STARS),
|
||||
static["units"])
|
||||
conn.close()
|
||||
# Tier = "drei passende Items, zwei Sterne" — die Definition, nach der
|
||||
# man im Spiel plant. Eine unit-eigene Zielstufe wurde probiert und
|
||||
# verworfen: der 3★-Wert misst nicht die Unit, sondern dass genug
|
||||
# Gold und Leben da war, um sie zu treffen. Auf Level 9 steht Rengar
|
||||
# auf 2★ bei -0.30 und auf 3★ bei +1.22 — das ist die Auswahl, nicht
|
||||
# die Stärke. Auf fester Stufe liegen die 5-Kosten vorne.
|
||||
carry = {}
|
||||
for key, v in (art["groups"].get("solo") or {}).items():
|
||||
api, star, items = (key.split("@") + ["0", "0"])[:3]
|
||||
if int(items) == groups_mod.MAX_ITEMS \
|
||||
and int(star) == groups_mod.REFERENCE_STARS:
|
||||
carry[api] = v
|
||||
art["unit_tiers"] = artifact_mod.unit_tiers(
|
||||
{k: v["edge"] for k, v in carry.items()},
|
||||
{k: v["boards"] for k, v in carry.items()},
|
||||
static["units"])
|
||||
path = artifact_mod.save(art)
|
||||
print(f"artifact written: {path} ({n_boards} boards learned from)")
|
||||
if art["rating"]:
|
||||
print(f" Boardbewertung: {len(art['rating']['trees'])} Bäume "
|
||||
f"aus {art['rating']['boards']} Boards")
|
||||
else:
|
||||
print(" Boardbewertung: zu wenige Boards, übersprungen")
|
||||
if art["groups"]:
|
||||
print(" Gruppen: " + ", ".join(
|
||||
f"{len(v)}x {k}er" for k, v in art["groups"].items() if k.isdigit()))
|
||||
print(f" Tiers: {len(art['unit_tiers'])} Units")
|
||||
|
||||
elif args.command == "import-tierlist":
|
||||
import json as _json
|
||||
from pathlib import Path
|
||||
|
||||
from tft.staticdata import tierlist
|
||||
from tft.staticdata.fetch import static_dir_of_latest
|
||||
|
||||
rows = tierlist.parse(Path(args.path).read_text(errors="replace"))
|
||||
if not rows:
|
||||
raise SystemExit(f"no tier rows found in {args.path}")
|
||||
out = static_dir_of_latest() / "tierlist.json"
|
||||
out.write_text(_json.dumps(rows, indent=1, ensure_ascii=False))
|
||||
best = min(rows.items(), key=lambda r: r[1]["avg_place"])
|
||||
worst = max(rows.items(), key=lambda r: r[1]["avg_place"])
|
||||
print(f"{len(rows)} units -> {out}")
|
||||
print(f" bester {best[0]} {best[1]['avg_place']}, "
|
||||
f"schwächster {worst[0]} {worst[1]['avg_place']}")
|
||||
print(" jetzt `make artifact` laufen lassen")
|
||||
|
||||
elif args.command == "pairs":
|
||||
from tft.model import artifact as artifact_mod
|
||||
|
||||
art = artifact_mod.load(current_set())
|
||||
units = art["static"]["units"]
|
||||
name = {api: u.get("name", api) for api, u in units.items()}
|
||||
rows = [(name.get(k.split("|")[0], "?"), name.get(k.split("|")[1], "?"),
|
||||
v["surplus"], v["boards"])
|
||||
for k, v in art["unit_pairs"].items()]
|
||||
if args.unit:
|
||||
needle = args.unit.lower()
|
||||
rows = [r for r in rows if needle in r[0].lower() or needle in r[1].lower()]
|
||||
rows.sort(key=lambda r: -r[2])
|
||||
if not rows:
|
||||
raise SystemExit("keine Paare — erst crawl, extract und build-artifact")
|
||||
print(f"{len(rows)} Paare mit genug Boards\n")
|
||||
print(f"{'Paar':<30}{'Überschuss':>12}{'Boards':>9}")
|
||||
for group, take in (("passen zusammen", rows[:args.top]),
|
||||
("passen nicht", rows[-args.top:])):
|
||||
print(f"\n{group}:")
|
||||
for a, b, s_, n in take:
|
||||
print(f" {a + ' + ' + b:<28}{s_:>+12.3f}{n:>9}")
|
||||
|
||||
elif args.command == "calibrate":
|
||||
from tft import db
|
||||
|
||||
@@ -62,7 +62,10 @@ player_hp = 100
|
||||
stage1 = ["carousel", "pve", "pve", "pve"]
|
||||
# Stages 2+: 7 Runden, Carousel bei x-4, PvE bei x-7.
|
||||
stage_n = ["pvp", "pvp", "pvp", "carousel", "pvp", "pvp", "pve"]
|
||||
augment_rounds = ["2-1", "3-2", "4-2"]
|
||||
# Augments sind abgeschaltet. Von 117 wirksamen Specs sind viele nur halb
|
||||
# umgesetzt — Nachteile im Text fehlen, der Spieler bekommt reinen Vorteil.
|
||||
# Zum Wiedereinschalten: ["2-1", "3-2", "4-2"].
|
||||
augment_rounds = []
|
||||
|
||||
[augments]
|
||||
# Tier-Gewichte [Silber, Gold, Prismatisch] pro Augment-Runde.
|
||||
|
||||
88
backend/tft/constants/set18.toml
Normal file
88
backend/tft/constants/set18.toml
Normal file
@@ -0,0 +1,88 @@
|
||||
# Spielregeln für Set 18 (Enchanted Wilds).
|
||||
# Quelle, wo nicht anders vermerkt: MetaTFT-Regeltabellen
|
||||
# data.metatft.com/lookups/pbe_TFTSet18_tables.json (Stand PBE, 16.08.2026).
|
||||
# Die Tabellen sind Treppenfunktionen [ab_wert, wert]; hier ausgeschrieben.
|
||||
|
||||
set = 18
|
||||
|
||||
[shop]
|
||||
# Odds pro Level: [1-cost, 2-cost, 3-cost, 4-cost, 5-cost], Summe = 100.
|
||||
# Direkt aus shopOdds. Nur Level 7 weicht von Set 17 ab (16/30/43 statt 19/30/40).
|
||||
odds = [
|
||||
[100, 0, 0, 0, 0], # Level 1
|
||||
[100, 0, 0, 0, 0], # Level 2
|
||||
[75, 25, 0, 0, 0], # Level 3
|
||||
[55, 30, 15, 0, 0], # Level 4
|
||||
[45, 33, 20, 2, 0], # Level 5
|
||||
[30, 40, 25, 5, 0], # Level 6
|
||||
[16, 30, 43, 10, 1], # Level 7
|
||||
[15, 20, 32, 30, 3], # Level 8
|
||||
[10, 17, 25, 33, 15],# Level 9
|
||||
[5, 10, 20, 40, 25], # Level 10
|
||||
]
|
||||
reroll_cost = 2
|
||||
slots = 5
|
||||
|
||||
[pool]
|
||||
# shopPool.copiesByCost — 65 Units, 1227 Kopien insgesamt.
|
||||
sizes = [30, 25, 18, 10, 9]
|
||||
copies_for_2star = 3
|
||||
copies_for_3star = 9
|
||||
|
||||
[xp]
|
||||
# expPerLevel.ExpBreakpoints; identisch mit Set 17.
|
||||
start_level = 1
|
||||
to_next = [2, 2, 6, 10, 20, 36, 60, 68, 68]
|
||||
passive_per_round = 2
|
||||
buy_amount = 4
|
||||
buy_cost = 4
|
||||
max_level = 10
|
||||
|
||||
[gold]
|
||||
# passiveIncome.IncomeBasedOnRoundIndex, auf die Rundenfolge abgebildet.
|
||||
# Stage 1 gibt weniger als in Set 17: 1-1 und 1-2 sind gänzlich golddfrei.
|
||||
base_income = 5
|
||||
early_income = { "1-1" = 0, "1-2" = 0, "1-3" = 2, "1-4" = 2, "2-1" = 3, "2-2" = 4 }
|
||||
# TODO: Zins steht nicht in den Tabellen — aus Set 17 übernommen.
|
||||
interest_per_10 = 1
|
||||
interest_cap = 5
|
||||
win_bonus = 1
|
||||
# winStreakIncome/lossStreakIncome: ab 2er-Streak +1, ab 6er +3.
|
||||
streak_gold = [[2, 1], [6, 3]]
|
||||
starting_gold = 0
|
||||
|
||||
[damage]
|
||||
# TODO: playerDamage fehlt in den Set-18-Tabellen — aus Set 17 übernommen.
|
||||
stage_base = [0, 2, 5, 8, 10, 12, 17, 17] # Index 0 = Stage 1
|
||||
per_surviving_unit = 1
|
||||
player_hp = 100
|
||||
|
||||
[rounds]
|
||||
# stages[]: Stage 1 ist 1x Encounter + 3 Intro-PvE, Stage 2+ sind
|
||||
# 7 Runden mit Carousel bei x-4 und PvE bei x-7.
|
||||
stage1 = ["carousel", "pve", "pve", "pve"]
|
||||
stage_n = ["pvp", "pvp", "pvp", "carousel", "pvp", "pvp", "pve"]
|
||||
# Augments bleiben abgeschaltet, solange die Specs nur halb umgesetzt sind.
|
||||
# Zum Wiedereinschalten: augmentRounds aus den Tabellen = ["2-1", "3-2", "4-2"].
|
||||
augment_rounds = []
|
||||
|
||||
[augments]
|
||||
# augmentTierDistribution nennt Kombinationen statt Gewichten pro Runde;
|
||||
# ohne aktive Augments ungenutzt. Werte aus Set 17 als Platzhalter.
|
||||
tier_weights = { "2-1" = [50, 45, 5], "3-2" = [35, 50, 15], "4-2" = [20, 55, 25] }
|
||||
|
||||
[loot]
|
||||
# TODO: lootTables sind deutlich feiner aufgebaut als unser Modell —
|
||||
# vorerst die Set-17-Näherung.
|
||||
stage1_pve = [1, 1]
|
||||
stage_n_pve = [1, 2]
|
||||
carousel = [1, 0]
|
||||
|
||||
[stars]
|
||||
# starMultipliers.Health = 1.8 exakt bestätigt.
|
||||
# AD: damageByStar der Units ist durchgehend x1.5 je Stufe.
|
||||
hp_multiplier = 1.8
|
||||
ad_multiplier = 1.5
|
||||
|
||||
[combat]
|
||||
max_frames = 1800
|
||||
@@ -22,7 +22,10 @@ CREATE TABLE IF NOT EXISTS endboards (
|
||||
units TEXT NOT NULL,
|
||||
traits TEXT NOT NULL,
|
||||
augments TEXT NOT NULL,
|
||||
PRIMARY KEY (match_id, puuid)
|
||||
-- Nicht (match_id, puuid): Riot anonymisiert einzelne Spieler und gibt
|
||||
-- dann mehrfach dieselbe puuid zurück. Mit puuid im Schlüssel fielen
|
||||
-- deren Boards still unter den Tisch. Die Platzierung ist je Match eindeutig.
|
||||
PRIMARY KEY (match_id, placement)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS crawl_log (
|
||||
day TEXT PRIMARY KEY,
|
||||
@@ -35,4 +38,23 @@ def connect() -> sqlite3.Connection:
|
||||
paths.DATA.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(paths.MATCHES_DB)
|
||||
conn.executescript(SCHEMA)
|
||||
_migrate_endboard_key(conn)
|
||||
return conn
|
||||
|
||||
|
||||
def _migrate_endboard_key(conn: sqlite3.Connection) -> None:
|
||||
"""Alte Tabellen hatten puuid im Primärschlüssel und verloren Boards.
|
||||
|
||||
endboards ist reiner Cache — die Wahrheit steht in matches.raw. Deshalb
|
||||
genügt es, die Tabelle zu leeren; `extract` füllt sie wieder.
|
||||
"""
|
||||
sql = conn.execute(
|
||||
"SELECT sql FROM sqlite_master WHERE type='table' AND name='endboards'"
|
||||
).fetchone()
|
||||
if not sql or "PRIMARY KEY (match_id, puuid)" not in sql[0]:
|
||||
return
|
||||
conn.executescript(
|
||||
"DROP TABLE endboards;" + SCHEMA.split("CREATE TABLE IF NOT EXISTS endboards")[1]
|
||||
.split(");")[0].join(["CREATE TABLE IF NOT EXISTS endboards", ");"])
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Crawl ranked TFT matches of Challenger/GM players into sqlite."""
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import date
|
||||
|
||||
from tft import db
|
||||
@@ -9,20 +10,32 @@ from tft.matches.riot import RiotClient
|
||||
RANKED_QUEUE = 1100
|
||||
|
||||
|
||||
def crawl(set_number: int, limit: int = 200) -> int:
|
||||
def crawl(set_number: int, limit: int = 200, since_days: int | None = None) -> int:
|
||||
"""since_days: nur Matches der letzten N Tage anfragen.
|
||||
|
||||
Nach einem Set-Start ist das der billigste Filter — die alten Matches der
|
||||
Spieler gehören noch zum Vorgänger-Set und würden je eine Anfrage kosten,
|
||||
nur um dann verworfen zu werden.
|
||||
"""
|
||||
client = RiotClient()
|
||||
conn = db.connect()
|
||||
seen = {row[0] for row in conn.execute("SELECT match_id FROM matches")}
|
||||
added = 0
|
||||
|
||||
entries = client.ladder_entries()
|
||||
# Ein Spieler liefert ~20 Match-Ids, aber die überschneiden sich stark:
|
||||
# dieselben acht sitzen im selben Spiel. Lieber viele Spieler abklappern.
|
||||
start_time = (int(time.time()) - since_days * 86400) if since_days else None
|
||||
entries = client.ladder_entries(want=max(limit * 2, 200))
|
||||
print(f"ladder: {len(entries)} players")
|
||||
if not entries:
|
||||
print("ladder empty — new set, nobody ranked yet?")
|
||||
return 0
|
||||
|
||||
for entry in entries:
|
||||
if added >= limit:
|
||||
break
|
||||
puuid = client.puuid_for_entry(entry)
|
||||
for match_id in client.match_ids(puuid):
|
||||
for match_id in client.match_ids(puuid, start_time=start_time):
|
||||
if match_id in seen:
|
||||
continue
|
||||
if added >= limit:
|
||||
|
||||
@@ -48,13 +48,18 @@ def validate_ids(rows: list[tuple], static: dict) -> Counter:
|
||||
Summons/PvE-Units und Sonder-Items (Radiant, Ornn, ...) sind erwartbar
|
||||
unbekannt und werden ignoriert.
|
||||
"""
|
||||
from tft.model.baseline import unit_aliases
|
||||
|
||||
unknown: Counter = Counter()
|
||||
known_items = set(static["items"]) | set(static["augments"])
|
||||
# Riot nennt Units beim Asset-Namen — ohne die Zuordnung wäre jede
|
||||
# Set-18-Unit "unbekannt" und die Drift-Warnung nur Rauschen.
|
||||
known_units = unit_aliases(static["units"])
|
||||
set_prefix = f"TFT{static['meta']['set']}_"
|
||||
for row in rows:
|
||||
for u in json.loads(row[8]):
|
||||
api = u["character_id"]
|
||||
if api not in static["units"]:
|
||||
if api not in known_units:
|
||||
if not any(h in api.lower() for h in BENIGN_UNIT_HINTS):
|
||||
unknown[f"unit:{api}"] += 1
|
||||
for item in u.get("itemNames", []):
|
||||
|
||||
@@ -9,7 +9,7 @@ from tft import paths
|
||||
|
||||
PLATFORM = os.environ.get("RIOT_PLATFORM", "euw1")
|
||||
REGION = os.environ.get("RIOT_REGION", "europe")
|
||||
SLEEP = 1.3 # dev key: 100 req / 2 min
|
||||
SLEEP = 1.2 # dev key: 100 req / 2 min
|
||||
|
||||
|
||||
def _load_env() -> None:
|
||||
@@ -59,11 +59,31 @@ class RiotClient:
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
def ladder_entries(self) -> list[dict]:
|
||||
# Nach einem Set-Start ist die Leiter oben leer — niemand hat sich
|
||||
# hochgespielt. Dann muss der Crawler tiefer greifen, sonst bekommt er
|
||||
# nichts. Reihenfolge = absteigende Datenqualität.
|
||||
LADDER_TIERS = ("challenger", "grandmaster", "master")
|
||||
LADDER_DIVISIONS = ("DIAMOND", "EMERALD", "PLATINUM", "GOLD")
|
||||
|
||||
def ladder_entries(self, want: int = 200) -> list[dict]:
|
||||
"""Spieler von der Ranglisten-Spitze abwärts, bis want erreicht ist."""
|
||||
entries = []
|
||||
for tier in ("challenger", "grandmaster"):
|
||||
league = self.get(PLATFORM, f"/tft/league/v1/{tier}")
|
||||
entries.extend(league["entries"])
|
||||
for tier in self.LADDER_TIERS:
|
||||
entries.extend(self.get(PLATFORM, f"/tft/league/v1/{tier}")["entries"])
|
||||
if len(entries) >= want:
|
||||
return entries
|
||||
# Eine Liga-Seite hat ~200 Spieler. Erst alle Ränge einer Liga, dann
|
||||
# die nächste — je näher an der Spitze, desto sauberer die Daten.
|
||||
for tier in self.LADDER_DIVISIONS:
|
||||
for rank in ("I", "II", "III", "IV"):
|
||||
for page in range(1, 6):
|
||||
rows = self.get(PLATFORM, f"/tft/league/v1/entries/{tier}/{rank}",
|
||||
{"page": page})
|
||||
entries.extend(rows)
|
||||
if not rows or len(entries) >= want:
|
||||
break
|
||||
if len(entries) >= want:
|
||||
return entries
|
||||
return entries
|
||||
|
||||
def puuid_for_entry(self, entry: dict) -> str:
|
||||
@@ -74,9 +94,18 @@ class RiotClient:
|
||||
)
|
||||
return summoner["puuid"]
|
||||
|
||||
def match_ids(self, puuid: str, count: int = 20) -> list[str]:
|
||||
def match_ids(self, puuid: str, count: int = 20,
|
||||
start_time: int | None = None) -> list[str]:
|
||||
"""Match-Ids eines Spielers, optional erst ab einem Zeitpunkt.
|
||||
|
||||
start_time filtert bei Riot, nicht bei uns: jede Id, die wir gar nicht
|
||||
erst bekommen, spart eine Anfrage aus dem Ratenlimit.
|
||||
"""
|
||||
params = {"count": count}
|
||||
if start_time is not None:
|
||||
params["startTime"] = start_time
|
||||
return self.get(
|
||||
REGION, f"/tft/match/v1/matches/by-puuid/{puuid}/ids", {"count": count}
|
||||
REGION, f"/tft/match/v1/matches/by-puuid/{puuid}/ids", params
|
||||
)
|
||||
|
||||
def match(self, match_id: str) -> dict:
|
||||
|
||||
@@ -5,11 +5,14 @@ from datetime import date
|
||||
|
||||
from tft import paths
|
||||
from tft.model import baseline
|
||||
from tft.staticdata import tierlist
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
SCHEMA_VERSION = 2
|
||||
|
||||
# Spatula/Pfanne-Rezepte (Embleme) fallen aus dem normalen Loot raus.
|
||||
SPATULA_COMPONENTS = {"TFT_Item_Spatula", "TFT_Item_FryingPan"}
|
||||
# Am Namensende erkannt, nicht am Präfix: das wechselt je Set
|
||||
# (TFT_Item_Spatula in Set 17, DA_Component_Spatula in Set 18).
|
||||
SPATULA_COMPONENTS = ("Spatula", "FryingPan")
|
||||
|
||||
|
||||
def craftable_items(static_items: dict) -> list[str]:
|
||||
@@ -17,11 +20,7 @@ def craftable_items(static_items: dict) -> list[str]:
|
||||
pool = []
|
||||
for api, item in static_items.items():
|
||||
comp = item.get("composition") or []
|
||||
if (
|
||||
len(comp) == 2
|
||||
and api.startswith("TFT_Item_")
|
||||
and not set(comp) & SPATULA_COMPONENTS
|
||||
):
|
||||
if len(comp) == 2 and not any(c.endswith(SPATULA_COMPONENTS) for c in comp):
|
||||
pool.append(api)
|
||||
return sorted(pool)
|
||||
|
||||
@@ -47,32 +46,79 @@ def component_pool(static_items: dict) -> list[str]:
|
||||
return sorted(c for c, n in counts.items() if n >= 3)
|
||||
|
||||
|
||||
def spell_dps_cap(static: dict, roles: dict) -> float | None:
|
||||
"""90. Perzentil der Spell-DPS aller Units (1★, itemlos).
|
||||
|
||||
Dient im Score als Floor des relativen Spell-Deckels — rettet Caster
|
||||
ohne Auto-Schaden (siehe statsheet.unit_stats).
|
||||
"""
|
||||
from tft.model import statsheet
|
||||
|
||||
values = sorted(
|
||||
p["spell_dps"]
|
||||
for api, unit in static["units"].items()
|
||||
if (p := statsheet.unit_stats(
|
||||
unit, 1, [], static["items"], None, roles.get(api)
|
||||
))["spell_dps"] > 0
|
||||
)
|
||||
if not values:
|
||||
return None
|
||||
return values[int(len(values) * 0.9)]
|
||||
|
||||
|
||||
def _augment_specs(static: dict) -> dict:
|
||||
from tft.model.augments import build_specs
|
||||
|
||||
return build_specs(static["augments"], static["units"])
|
||||
|
||||
|
||||
def unit_multiplier(learned: dict, static_units: dict) -> dict:
|
||||
"""Gelernte Stärke je Unit, innerhalb der Kostenstufe auf 1.0 zentriert.
|
||||
|
||||
Roh misst der Multiplikator vor allem den Preis: 5-Kosten stehen nur auf
|
||||
Boards, die Level 9 erreicht haben. Die Normierung je Kostenstufe nimmt
|
||||
genau diesen Survivorship-Bias raus — übrig bleibt "gut für seinen Preis".
|
||||
"""
|
||||
raw = (learned or {}).get("units") or {}
|
||||
by_cost: dict[int, list[str]] = {}
|
||||
for api, unit in static_units.items():
|
||||
if api in raw:
|
||||
by_cost.setdefault(unit["cost"], []).append(api)
|
||||
|
||||
out = {}
|
||||
for apis in by_cost.values():
|
||||
mean = sum(raw[a] for a in apis) / len(apis)
|
||||
if mean <= 0:
|
||||
continue
|
||||
for api in apis:
|
||||
out[api] = round(raw[api] / mean, 4)
|
||||
return out
|
||||
|
||||
|
||||
# Tier-Grenzen als Perzentil über alle Units. S heißt: mit passenden Items
|
||||
# auf 2 Sternen gehört dieser Champion zu den stärksten im Spiel. Dass dabei
|
||||
# fast nur 5-Kosten oben stehen, ist keine Verzerrung — es ist die Antwort.
|
||||
TIER_BOUNDS = ((0.15, "S"), (0.40, "A"), (0.70, "B"), (0.88, "C"), (1.01, "D"))
|
||||
# Unter so vielen Boards ist der Standardfehler so groß wie die echten
|
||||
# Unterschiede. Dann lieber kein Buchstabe als ein geratener: Akali kam mit
|
||||
# 210 Boards auf S, ihr Fehlerbalken war ±0.16 bei einem Wert von +0.18.
|
||||
#
|
||||
# 300 ist gemessen, nicht geschätzt. Im Halbierungstest über beide Hälften
|
||||
# vergeben zwei Läufe damit in 82 % der Fälle denselben Buchstaben, bei 100
|
||||
# waren es 76 %. Höhere Schwellen wurden wieder schlechter, weil zu wenige
|
||||
# Units übrig bleiben. Ein Filter auf "Wert über zwei Fehlerbalken" fiel auf
|
||||
# 65 % — er entfernt gerade die Units in der Mitte und verschiebt dadurch
|
||||
# alle Perzentil-Grenzen.
|
||||
TIER_MIN_BOARDS = 300
|
||||
|
||||
|
||||
def tier_at(quantile: float) -> str:
|
||||
"""Perzentil -> Buchstabe. 0 ist der stärkste Rang."""
|
||||
return next(t for bound, t in TIER_BOUNDS if quantile < bound)
|
||||
|
||||
|
||||
def unit_tiers(strengths: dict, boards: dict, static_units: dict) -> dict:
|
||||
"""api_name -> {"tier", "strength", "boards"}; Rang über alle Units.
|
||||
|
||||
strength ist der Vorsprung in Plätzen gegenüber Boards mit gleichem
|
||||
Level und gleicher Item-Menge, gemessen auf 2 Sternen mit drei Items.
|
||||
Beide Kontrollen sind nötig: sonst misst man, wer weit gekommen ist.
|
||||
|
||||
Der Buchstabe beantwortet: "Wenn ich passende Items draufpacke und ihn
|
||||
auf 2 Sterne bringe — gehört er dann zu den besten Units im Spiel?"
|
||||
Deshalb wird über alle Kosten hinweg verglichen, nicht innerhalb.
|
||||
"""
|
||||
apis = [api for api in static_units
|
||||
if boards.get(api, 0) >= TIER_MIN_BOARDS and api in strengths]
|
||||
ranked = sorted(apis, key=lambda a: strengths[a], reverse=True)
|
||||
out = {}
|
||||
for i, api in enumerate(ranked):
|
||||
tier = tier_at((i + 0.5) / len(ranked))
|
||||
out[api] = {"tier": tier, "strength": round(strengths[api], 3),
|
||||
"boards": boards[api]}
|
||||
return out
|
||||
|
||||
|
||||
def build(static: dict, learned: dict | None = None, extra_meta: dict | None = None) -> dict:
|
||||
roles = {
|
||||
api: baseline.classify_role(unit) for api, unit in static["units"].items()
|
||||
@@ -92,7 +138,17 @@ def build(static: dict, learned: dict | None = None, extra_meta: dict | None = N
|
||||
"augments": static["augments"],
|
||||
},
|
||||
"roles": roles,
|
||||
"spell_dps_cap": spell_dps_cap(static, roles),
|
||||
# Aus eigenen Endboards gelernte Stärke; 1.0 wo die Daten dünn sind.
|
||||
"unit_multiplier": unit_multiplier(learned or {}, static["units"]),
|
||||
# Paar-Überschuss: was zwei Units zusammen mehr bringen als einzeln.
|
||||
"unit_pairs": (learned or {}).get("pairs") or {},
|
||||
# Tiers füllt der Aufrufer nach, sobald groups.build durch ist —
|
||||
# sie brauchen dieselben bereinigten Werte wie der Rollen-Reiter.
|
||||
"unit_tiers": {},
|
||||
# Meta-Stärke aus echten Platzierungen; leer, solange keine
|
||||
# Tierlist importiert wurde.
|
||||
"unit_meta": tierlist.meta_strength(static.get("tierlist") or {},
|
||||
static["units"]),
|
||||
"item_pool": craftable_items(static["items"]),
|
||||
"component_pool": component_pool(static["items"]),
|
||||
"recipes": recipes(static["items"]),
|
||||
@@ -101,6 +157,10 @@ def build(static: dict, learned: dict | None = None, extra_meta: dict | None = N
|
||||
# der Sim nutzt alle geparsten Augments als Pool.
|
||||
"augment_pool": [],
|
||||
"learned": learned or {},
|
||||
# Gelernte Boardbewertung; füllt train_rating nach dem Bauen.
|
||||
"rating": {},
|
||||
# Starke Champion-Gruppen; füllt groups.build nach dem Bauen.
|
||||
"groups": {},
|
||||
}
|
||||
|
||||
|
||||
@@ -124,4 +184,10 @@ def load(set_number: int) -> dict:
|
||||
f"no artifact for set {set_number}: run `build-artifact` first"
|
||||
)
|
||||
path = json.loads(pointer.read_text())["path"]
|
||||
return json.loads(open(path).read())
|
||||
art = json.loads(open(path).read())
|
||||
if art["meta"].get("schema_version") != SCHEMA_VERSION:
|
||||
raise SystemExit(
|
||||
f"artifact {path} has schema v{art['meta'].get('schema_version')}, "
|
||||
f"expected v{SCHEMA_VERSION}: run `build-artifact` again"
|
||||
)
|
||||
return art
|
||||
|
||||
@@ -6,9 +6,10 @@ Spieler-HP. Bedingte und Kampf-Spezialeffekte bleiben beim gelernten Fallback.
|
||||
|
||||
import re
|
||||
|
||||
from tft.model.statsheet import _fraction
|
||||
|
||||
GOLD_KEYS = ("Gold", "InstantGold", "GoldNow", "GoldAmount")
|
||||
# Reihenfolge = Priorität: "InitialGold" ist der Sofort-Betrag, "Gold" oft der
|
||||
# bedingte Nachschlag (z.B. Survivor: InitialGold 4, Gold 100 nach 3 Toten).
|
||||
GOLD_KEYS = ("InitialGold", "UpfrontGold", "InstantGold", "GoldNow", "GoldAmount", "Gold")
|
||||
XP_KEYS = ("XP", "InstantXP", "Experience", "UpfrontXP")
|
||||
COMPONENT_KEYS = ("NumComponents", "ComponentsToGive")
|
||||
ANVIL_KEYS = ("CompletedAnvils",)
|
||||
@@ -29,15 +30,23 @@ STAT_KEYS = {
|
||||
TEAM_RE = re.compile(r"(your team|allies|units|champions|army) gain", re.I)
|
||||
CONDITIONAL_RE = re.compile(
|
||||
r"per |for each|each level|per level|combat start|when|whenever|casting"
|
||||
r"|(?:on|after) attack|takedown|dies|each round|after each", re.I)
|
||||
r"|(?:on|after) attack|takedown|dies|\bdie\b|eliminated|\bif\b"
|
||||
r"|each round|after each", re.I)
|
||||
SENTENCE_RE = re.compile(r"(?<=[.!])\s+")
|
||||
GOLD_RE = re.compile(r"[Gg]ain (\d+) gold(?! each| per)")
|
||||
GOLD_PER_STAGE_RE = re.compile(r"(\d+) gold (?:at the start of|each) (?:every )?stage", re.I)
|
||||
XP_RE = re.compile(r"[Gg]ain (\d+) XP")
|
||||
XP_PER_ROUND_RE = re.compile(r"[Gg]ain (\d+) XP each round", re.I)
|
||||
COMP_RE = re.compile(r"(\d+) random(?:\w| )*components?", re.I)
|
||||
COST_UNITS_RE = re.compile(r"[Gg]ain (\d+) (\d)-cost champions?")
|
||||
TACTICIAN_HP_RE = re.compile(r"[Gg]ain (\d+) Tactician Health")
|
||||
|
||||
|
||||
def _fraction(value: float) -> float:
|
||||
"""cdragon mischt Fraction (0.15) und Prozentzahl (15.0) — normalisieren."""
|
||||
return value if abs(value) <= 1.0 else value / 100
|
||||
|
||||
|
||||
def _num(effects: dict, keys: tuple) -> float | None:
|
||||
for key in keys:
|
||||
val = effects.get(key)
|
||||
@@ -46,47 +55,67 @@ def _num(effects: dict, keys: tuple) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
def unconditional_text(desc: str) -> str:
|
||||
"""Nur die bedingungsfreien Sätze — eine Bedingung gilt immer für ihren Satz.
|
||||
|
||||
"Gain 4 gold. After 3 players are eliminated, gain 100 more gold."
|
||||
-> "Gain 4 gold."
|
||||
"""
|
||||
return " ".join(s for s in SENTENCE_RE.split(desc)
|
||||
if s.strip() and not CONDITIONAL_RE.search(s))
|
||||
|
||||
|
||||
def build_spec(aug: dict, static_units: dict) -> dict:
|
||||
desc = aug.get("desc") or ""
|
||||
full = aug.get("desc") or ""
|
||||
effects = aug.get("effects") or {}
|
||||
atoms = []
|
||||
|
||||
offerable = len(desc) >= 10 and "_Desc" not in desc and "@" not in desc
|
||||
offerable = len(full) >= 10 and "_Desc" not in full and "@" not in full
|
||||
if not offerable:
|
||||
return {"atoms": [], "offerable": False}
|
||||
|
||||
# Ab hier zählt nur der unbedingte Teil. Steht irgendwo eine Bedingung, sind
|
||||
# die effects-Zahlen nicht mehr eindeutig dem Sofort-Effekt zuzuordnen
|
||||
# (Survivor: InitialGold 4 vs. Gold 100) — dann gilt nur der Text.
|
||||
desc = unconditional_text(full)
|
||||
num_effects = effects if desc == full.strip() else {}
|
||||
|
||||
if TACTICIAN_HP_RE.search(desc):
|
||||
m = TACTICIAN_HP_RE.search(desc)
|
||||
atoms.append({"kind": "player_hp",
|
||||
"amount": int(_num(effects, PLAYER_HP_KEYS) or m.group(1))})
|
||||
"amount": int(_num(num_effects, PLAYER_HP_KEYS) or m.group(1))})
|
||||
|
||||
gold = _num(effects, GOLD_KEYS)
|
||||
gold = _num(num_effects, GOLD_KEYS)
|
||||
m = GOLD_RE.search(desc)
|
||||
if gold is None and m:
|
||||
gold = int(m.group(1))
|
||||
if gold:
|
||||
atoms.append({"kind": "gold_now", "amount": int(gold)})
|
||||
|
||||
m = GOLD_PER_STAGE_RE.search(desc)
|
||||
# Wiederkehrend, aber unbedingt ("each round"/"each stage"): eigene Atom-Art,
|
||||
# daher am vollen Text — der Satzfilter wirft solche Sätze sonst raus.
|
||||
m = GOLD_PER_STAGE_RE.search(full)
|
||||
if m:
|
||||
atoms.append({"kind": "gold_per_stage", "amount": int(m.group(1))})
|
||||
per_round = XP_PER_ROUND_RE.search(full)
|
||||
if per_round:
|
||||
atoms.append({"kind": "xp_per_round", "amount": int(per_round.group(1))})
|
||||
|
||||
xp = _num(effects, XP_KEYS)
|
||||
m = XP_RE.search(desc)
|
||||
xp = None if per_round else _num(num_effects, XP_KEYS)
|
||||
m = XP_RE.search(XP_PER_ROUND_RE.sub("", desc))
|
||||
if xp is None and m:
|
||||
xp = int(m.group(1))
|
||||
if xp:
|
||||
kind = "xp_per_round" if "each round" in desc.lower() else "xp_now"
|
||||
atoms.append({"kind": kind, "amount": int(xp)})
|
||||
atoms.append({"kind": "xp_now", "amount": int(xp)})
|
||||
|
||||
comps = _num(effects, COMPONENT_KEYS)
|
||||
comps = _num(num_effects, COMPONENT_KEYS)
|
||||
m = COMP_RE.search(desc)
|
||||
if comps is None and m:
|
||||
comps = int(m.group(1))
|
||||
if comps:
|
||||
atoms.append({"kind": "components", "count": int(comps)})
|
||||
|
||||
anvils = _num(effects, ANVIL_KEYS)
|
||||
anvils = _num(num_effects, ANVIL_KEYS)
|
||||
if anvils:
|
||||
atoms.append({"kind": "completed_items", "count": int(anvils)})
|
||||
|
||||
@@ -100,10 +129,10 @@ def build_spec(aug: dict, static_units: dict) -> dict:
|
||||
if m:
|
||||
atoms.append({"kind": "unit_grant", "cost": int(m.group(2)), "count": int(m.group(1))})
|
||||
|
||||
# Team-Stat-Buffs nur bei flachem "your team gains ..." ohne Bedingung.
|
||||
if TEAM_RE.search(desc) and not CONDITIONAL_RE.search(desc):
|
||||
# Team-Stat-Buffs nur, wenn "your team gains" im unbedingten Teil steht.
|
||||
if TEAM_RE.search(desc):
|
||||
for key, (stat, norm) in STAT_KEYS.items():
|
||||
val = effects.get(key)
|
||||
val = num_effects.get(key)
|
||||
if not isinstance(val, (int, float)) or val == 0:
|
||||
continue
|
||||
if norm == "frac":
|
||||
|
||||
@@ -18,3 +18,18 @@ def classify_role(unit: dict) -> str:
|
||||
return "utility"
|
||||
stats = unit["stats"]
|
||||
return "frontline" if stats["range"] <= 1 else "ad_carry"
|
||||
|
||||
|
||||
def unit_aliases(static_units: dict) -> dict:
|
||||
"""Alias -> api_name für Unit-Ids aus der Riot-Match-API.
|
||||
|
||||
Riot nennt Units beim Asset-Namen: 'DA_18_GnarSmall' statt 'TFT18_Gnar',
|
||||
'DA_Karma18' statt 'TFT18_Karma'. Ohne die Zuordnung findet nichts aus
|
||||
den Endboards zu unseren Statikdaten zurück.
|
||||
"""
|
||||
aliases = {}
|
||||
for api, unit in static_units.items():
|
||||
aliases[api] = api
|
||||
for alias in unit.get("asset_names") or []:
|
||||
aliases[alias] = api
|
||||
return aliases
|
||||
|
||||
@@ -30,10 +30,23 @@ def spearman(a: list[float], b: list[float]) -> float:
|
||||
return cov / (va * vb)
|
||||
|
||||
|
||||
def board_from_row(units_json: str, augments_json: str) -> tuple[list[dict], list[str]]:
|
||||
def _level_of(board: list[dict]) -> int:
|
||||
"""Das Level hängt an den Board-Units; ohne Angabe ist die Unit-Zahl
|
||||
die beste Schätzung."""
|
||||
return board[0].get("level", len(board)) if board else 1
|
||||
|
||||
|
||||
def board_from_row(units_json: str, augments_json: str,
|
||||
aliases: dict | None = None) -> tuple[list[dict], list[str]]:
|
||||
"""Endboard-Zeile -> Board in unserer Schreibweise.
|
||||
|
||||
Riot nennt Units beim Asset-Namen; ohne die Zuordnung findet score_board
|
||||
keine einzige Unit wieder und jeder Score wäre 0.
|
||||
"""
|
||||
resolve = (aliases or {}).get
|
||||
units = [
|
||||
{
|
||||
"api_name": u["character_id"],
|
||||
"api_name": resolve(u["character_id"], u["character_id"]),
|
||||
"stars": u["tier"],
|
||||
"items": u.get("itemNames", []),
|
||||
}
|
||||
@@ -43,19 +56,25 @@ def board_from_row(units_json: str, augments_json: str) -> tuple[list[dict], lis
|
||||
|
||||
|
||||
def _holdout_scores(conn, artifact: dict, holdout_only: bool, score_fn) -> dict:
|
||||
from tft.model.baseline import unit_aliases
|
||||
|
||||
set_number = artifact["meta"]["set"]
|
||||
aliases = unit_aliases(artifact["static"]["units"])
|
||||
where = "WHERE set_number = ?"
|
||||
if holdout_only:
|
||||
# Holdout pro MATCH (nicht pro Board), sonst gibt es keine vollständigen 8er.
|
||||
where += " AND substr(match_id, -1) IN ('0', '5')"
|
||||
rows = conn.execute(
|
||||
f"SELECT match_id, placement, units, augments FROM endboards {where}",
|
||||
f"SELECT match_id, placement, level, units, augments FROM endboards {where}",
|
||||
(set_number,),
|
||||
).fetchall()
|
||||
|
||||
by_match: dict[str, list] = {}
|
||||
for match_id, placement, units_json, augments_json in rows:
|
||||
board, augments = board_from_row(units_json, augments_json)
|
||||
for match_id, placement, level, units_json, augments_json in rows:
|
||||
board, augments = board_from_row(units_json, augments_json, aliases)
|
||||
# Das Level gehört zum Board — die Bewertung braucht es als Merkmal.
|
||||
for u in board:
|
||||
u["level"] = level
|
||||
by_match.setdefault(match_id, []).append(
|
||||
(placement, score_fn(board, augments, artifact))
|
||||
)
|
||||
@@ -64,9 +83,16 @@ def _holdout_scores(conn, artifact: dict, holdout_only: bool, score_fn) -> dict:
|
||||
|
||||
def calibrate(conn, artifact: dict, holdout_only: bool = False, score_fn=None) -> dict:
|
||||
"""Mean Spearman between board score and placement (negated: higher = better)."""
|
||||
from tft.model.score import score_board
|
||||
from tft.model.rating import board_rating
|
||||
|
||||
by_match = _holdout_scores(conn, artifact, holdout_only, score_fn or score_board)
|
||||
if score_fn is None:
|
||||
# Ohne trainiertes Modell gibt es nichts zu kalibrieren. Das alte
|
||||
# Statmodell als Rückfall ist weg — es lag bei 0.648 gegen 0.885
|
||||
# und widersprach der Kampf-Engine.
|
||||
def score_fn(board, augments, art):
|
||||
return board_rating(board, _level_of(board), art) or 0.0
|
||||
|
||||
by_match = _holdout_scores(conn, artifact, holdout_only, score_fn)
|
||||
|
||||
correlations = []
|
||||
for players in by_match.values():
|
||||
|
||||
347
backend/tft/model/carries.py
Normal file
347
backend/tft/model/carries.py
Normal file
@@ -0,0 +1,347 @@
|
||||
"""Die besten Carries — und woran ihr Vorsprung hängt.
|
||||
|
||||
Ein Carry ist hier eine Unit mit drei Items. Gemessen wird der Vorsprung
|
||||
gegenüber vergleichbaren Boards, und zwar mit drei Kontrollen:
|
||||
|
||||
* Level — auf Level 9 ist jedes Board besser als auf Level 6.
|
||||
* Item-Menge — wer mehr Items gesammelt hat, steht vorne.
|
||||
* übrige 3★ — wer eine Unit auf 3★ hat, hatte Gold und Leben übrig.
|
||||
|
||||
Die dritte Kontrolle ist der Grund, warum hier eine eigene Datei steht.
|
||||
Ohne sie schlägt jeder 3★-Wert fast jeden 2★-Wert: Boards mit 3★-Rengar
|
||||
tragen im Schnitt 1,72 weitere 3★-Units, Boards mit 2★-Rengar nur 0,66.
|
||||
Die Kluft zwischen den Medianen sinkt dadurch von +0.80 auf +0.25, und
|
||||
Kayle fällt von +0.87 auf +0.03 — bei ihr war fast alles Auswahl.
|
||||
Der Halbierungstest bleibt dabei bei +0.99.
|
||||
|
||||
Dazu die Frage "warum ist der stark". Ein Ridge-Modell auf dem
|
||||
kontrollierten Vorsprung schätzt gleichzeitig:
|
||||
|
||||
* je Unit "steht auf dem Board", "trägt drei Items", "ist 3★"
|
||||
* je Trait die aktive Stufe
|
||||
|
||||
Damit lässt sich der Vorsprung eines Carrys in drei Töpfe teilen — jeweils
|
||||
als Abweichung von dem, was auf vergleichbaren Boards üblich ist:
|
||||
|
||||
unit Ist der Champion selbst besser als ein durchschnittlicher Carry?
|
||||
traits Bringen seine Boards stärkere Trait-Stufen mit?
|
||||
rest Stehen bessere andere Units daneben?
|
||||
|
||||
"rest" ist zugleich das Maß dafür, wie sehr ein Carry an seiner Comp
|
||||
hängt. Geprüft wurde das an einer eigenen Zahl: dem Vorsprung im
|
||||
schwächsten Drittel der Begleitung. Sie war mit r=0.94 fast eine Kopie
|
||||
des Gesamtwerts und wurde deshalb wieder entfernt; was sie zusätzlich
|
||||
sagte, deckt "rest" zu r=-0.48 ab. Elder Dragon liegt gesamt bei +0.26
|
||||
und dort bei -0.16, sein "rest" ist entsprechend +0.29.
|
||||
|
||||
Geprüft: auf der zurückgehaltenen Hälfte sagt das Modell mit r=0.31
|
||||
vorher, die Koeffizienten stimmen zwischen beiden Hälften zu r=0.93, und
|
||||
die Summe der drei Töpfe trifft die gemessene Zahl zu r=0.65.
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from tft.model.rating import trait_counts, trait_tiers
|
||||
|
||||
# Schrumpfung: seltene Kombinationen werden Richtung 0 gezogen.
|
||||
PSEUDO = 30
|
||||
MAX_ITEMS = 3
|
||||
MAX_STARS = 3
|
||||
REFERENCE_STARS = 2
|
||||
MAX_BOARD_ITEMS = 12
|
||||
# Mehr als vier 3★ auf einem Board landen in einem Topf.
|
||||
MAX_OTHER_STARS = 4
|
||||
# So viele Boards braucht eine Vergleichsgruppe, damit ihr Mittel zählt.
|
||||
MIN_CELL = 30
|
||||
|
||||
# Ab dieser Trägerquote gilt eine Unit als Carry. Nicht gewählt, sondern
|
||||
# gemessen: über 63 Units mit genug Boards liegt die größte Lücke in der
|
||||
# Verteilung zwischen 51 % und 57 %.
|
||||
MIN_RATE = 0.5
|
||||
# Bis zu dieser Kostenstufe darf eine Unit auf 3★ ausgewiesen werden.
|
||||
# Darüber ist die Zahl reine Auswahl: 4-Kosten auf 3★ holen in 87 % der
|
||||
# Fälle Platz 1, 5-Kosten in 98 % — Ø-Platz 1.02.
|
||||
REROLL_MAX_COST = 3
|
||||
RIDGE = 30.0
|
||||
# Enabler: so viele Boards braucht ein (Carry, Nachbar)-Paar je Seite,
|
||||
# und so viele insgesamt, damit der Wert einer Unit zählt.
|
||||
ENABLER_MIN_PAIR = 200
|
||||
ENABLER_MIN_TOTAL = 1500
|
||||
|
||||
|
||||
def prepare(boards: list[dict], static_units: dict, static_traits: dict) -> list[dict]:
|
||||
"""Vergleichsgruppe und Trait-Stufen an jedes Board hängen."""
|
||||
for b in boards:
|
||||
stars3 = sum(1 for _, stars, _ in b["entities"] if stars == MAX_STARS)
|
||||
b["stars3"] = stars3
|
||||
counts = trait_counts([{"api_name": api, "stars": stars}
|
||||
for api, stars, _ in b["entities"]], static_units)
|
||||
b["tiers"] = trait_tiers(counts, static_traits)
|
||||
return boards
|
||||
|
||||
|
||||
def cell(board: dict, own_stars: int) -> tuple:
|
||||
"""Vergleichsgruppe aus Sicht einer Unit — ihre eigenen 3★ zählen nicht."""
|
||||
others = board["stars3"] - (1 if own_stars == MAX_STARS else 0)
|
||||
return (board["level"], board["items"], min(others, MAX_OTHER_STARS))
|
||||
|
||||
|
||||
def baseline(boards: list[dict]) -> dict:
|
||||
"""Ø-Platzierung je Vergleichsgruppe."""
|
||||
acc = defaultdict(list)
|
||||
for b in boards:
|
||||
for _, stars, _ in b["entities"]:
|
||||
acc[cell(b, stars)].append(b["place"])
|
||||
return {key: sum(v) / len(v) for key, v in acc.items() if len(v) >= MIN_CELL}
|
||||
|
||||
|
||||
def values(boards: list[dict], base: dict, static_units: dict,
|
||||
min_boards: int, min_rate: float = MIN_RATE) -> dict:
|
||||
"""api_name -> Vorsprung mit drei Items, auf der gespielten Sternstufe.
|
||||
|
||||
Enthält alle Units mit genug Boards, nicht nur die Carries — der
|
||||
Buchstabe im Reiter braucht die ganze Liste als Vergleichsmaßstab.
|
||||
"""
|
||||
seen, full = defaultdict(int), defaultdict(int)
|
||||
edges = defaultdict(list)
|
||||
for b in boards:
|
||||
for api, stars, items in b["entities"]:
|
||||
seen[api] += 1
|
||||
key = cell(b, stars)
|
||||
if items == MAX_ITEMS and key in base:
|
||||
full[api] += 1
|
||||
edges[(api, stars)].append(base[key] - b["place"])
|
||||
|
||||
def shrunk(key):
|
||||
v = edges.get(key)
|
||||
return sum(v) / (len(v) + PSEUDO) if v else None
|
||||
|
||||
out = {}
|
||||
for api in seen:
|
||||
two, three = shrunk((api, REFERENCE_STARS)), shrunk((api, MAX_STARS))
|
||||
stars = REFERENCE_STARS
|
||||
# Auf 2★ negativ, auf 3★ positiv: ein Reroll-Champion. Ihn auf 2★
|
||||
# auszuweisen wäre falsch — so spielt ihn niemand. Nur wechseln,
|
||||
# wenn die 3★-Zahl selbst auf genug Boards steht.
|
||||
if (two is not None and three is not None and two < 0 < three
|
||||
and static_units[api]["cost"] <= REROLL_MAX_COST
|
||||
and len(edges[(api, MAX_STARS)]) >= min_boards):
|
||||
stars = MAX_STARS
|
||||
picked = edges.get((api, stars)) or []
|
||||
if len(picked) >= min_boards:
|
||||
out[api] = {"edge": round(sum(picked) / (len(picked) + PSEUDO), 4),
|
||||
"boards": len(picked), "stars": stars,
|
||||
"rate": round(full[api] / seen[api], 3),
|
||||
"carry": full[api] / seen[api] >= min_rate}
|
||||
return out
|
||||
|
||||
|
||||
def enabler_edge(boards: list[dict], base: dict, static_units: dict,
|
||||
min_pair: int = ENABLER_MIN_PAIR,
|
||||
min_total: int = ENABLER_MIN_TOTAL) -> dict:
|
||||
"""api_name -> wie viel besser fremde Carries laufen, wenn diese Unit dabei ist.
|
||||
|
||||
Das ist die Ursache-Frage. Ein Topf "die anderen Units sind stark"
|
||||
sagt nur, dass neben einem Carry starke Units stehen — nicht, ob er
|
||||
sie stark macht oder umgekehrt. Hier wird derselbe Carry einmal mit
|
||||
und einmal ohne die Unit gemessen, über alle Carries gemittelt.
|
||||
|
||||
Halbierungstest: +0.92. Shen kommt so auf +0.41, obwohl er selbst
|
||||
kaum Carry ist — genau das ist ein Stützer.
|
||||
"""
|
||||
pairs = defaultdict(lambda: (defaultdict(list), defaultdict(list)))
|
||||
for b in boards:
|
||||
key = cell(b, REFERENCE_STARS)
|
||||
if key not in base:
|
||||
continue
|
||||
edge = base[key] - b["place"]
|
||||
on = {api for api, _, _ in b["entities"]}
|
||||
for carry, stars, items in b["entities"]:
|
||||
if items != MAX_ITEMS:
|
||||
continue
|
||||
with_u, without = pairs[(carry, stars)]
|
||||
for api in static_units:
|
||||
if api == carry:
|
||||
continue
|
||||
(with_u if api in on else without)[api].append(edge)
|
||||
|
||||
def shrunk(v):
|
||||
return sum(v) / (len(v) + PSEUDO)
|
||||
|
||||
acc = defaultdict(list)
|
||||
for with_u, without in pairs.values():
|
||||
for api, values in with_u.items():
|
||||
other = without.get(api) or []
|
||||
if len(values) >= min_pair and len(other) >= min_pair:
|
||||
acc[api].append((shrunk(values) - shrunk(other), len(values)))
|
||||
out = {}
|
||||
for api, parts in acc.items():
|
||||
n = sum(c for _, c in parts)
|
||||
if n >= min_total:
|
||||
out[api] = round(sum(d * c for d, c in parts) / n, 4)
|
||||
return out
|
||||
|
||||
|
||||
def supported(boards: list[dict], base: dict, lift: dict,
|
||||
min_boards: int) -> dict:
|
||||
"""(api, stars) -> wie stark die Nachbarn genau diesen Carry heben.
|
||||
|
||||
Summe der Stütz-Werte aller anderen Units auf dem Board, gegen das,
|
||||
was auf vergleichbaren Boards üblich ist.
|
||||
"""
|
||||
ref = defaultdict(list)
|
||||
rows = []
|
||||
for b in boards:
|
||||
key = cell(b, REFERENCE_STARS)
|
||||
if key not in base:
|
||||
continue
|
||||
total = sum(lift.get(api, 0.0) for api, _, _ in b["entities"])
|
||||
ref[key].append(total)
|
||||
rows.append((b, key, total))
|
||||
mean = {k: sum(v) / len(v) for k, v in ref.items() if len(v) >= MIN_CELL}
|
||||
acc = defaultdict(lambda: [0.0, 0])
|
||||
for b, key, total in rows:
|
||||
if key not in mean:
|
||||
continue
|
||||
for api, stars, items in b["entities"]:
|
||||
if items != MAX_ITEMS:
|
||||
continue
|
||||
e = acc[(api, stars)]
|
||||
e[0] += (total - lift.get(api, 0.0)) - mean[key]
|
||||
e[1] += 1
|
||||
return {k: round(v[0] / v[1], 3) for k, v in acc.items() if v[1] >= min_boards}
|
||||
|
||||
|
||||
def _columns(boards: list[dict], static_units: dict) -> dict:
|
||||
"""Merkmale des Modells.
|
||||
|
||||
"star2" fehlte zuerst, und das war der größte Fehler: ohne dieses
|
||||
Merkmal sind ein 1★- und ein 2★-Draven für das Modell dasselbe.
|
||||
Weil Carries auf 2★ gemessen werden, blieb genau ihr Vorteil
|
||||
unerklärt — Dravens Rest lag bei +0.38. Mit dem Merkmal sind es
|
||||
+0.03, und die Vorhersage auf der anderen Hälfte steigt von r=0.31
|
||||
auf r=0.44. Item-Merkmale wurden auch geprüft, sie bringen nur
|
||||
r=+0.03 und bleiben deshalb draußen — Items haben ihre eigene Zahl.
|
||||
"""
|
||||
keys = [("on", u) for u in sorted(static_units)]
|
||||
keys += [("carry", u) for u in sorted(static_units)]
|
||||
keys += [("star2", u) for u in sorted(static_units)]
|
||||
keys += [("star3", u) for u in sorted(static_units)]
|
||||
keys += [("trait", tk) for tk in
|
||||
sorted({(t, k) for b in boards for t, k in b["tiers"].items()})]
|
||||
return {key: i for i, key in enumerate(keys)}
|
||||
|
||||
|
||||
def _fit(boards: list[dict], base: dict, idx: dict, ridge: float):
|
||||
"""Ridge auf dem kontrollierten Vorsprung. Spalte 0 ist der Achsenabschnitt."""
|
||||
import numpy as np
|
||||
|
||||
rows = [b for b in boards if cell(b, REFERENCE_STARS) in base]
|
||||
X = np.zeros((len(rows), len(idx) + 1), dtype=np.float32)
|
||||
y = np.empty(len(rows), dtype=np.float32)
|
||||
for r, b in enumerate(rows):
|
||||
X[r, 0] = 1.0
|
||||
y[r] = base[cell(b, REFERENCE_STARS)] - b["place"]
|
||||
for api, stars, items in b["entities"]:
|
||||
X[r, 1 + idx[("on", api)]] = 1.0
|
||||
if items == MAX_ITEMS:
|
||||
X[r, 1 + idx[("carry", api)]] = 1.0
|
||||
if stars == MAX_STARS:
|
||||
X[r, 1 + idx[("star3", api)]] = 1.0
|
||||
elif stars == REFERENCE_STARS:
|
||||
X[r, 1 + idx[("star2", api)]] = 1.0
|
||||
for t, k in b["tiers"].items():
|
||||
col = idx.get(("trait", (t, k)))
|
||||
if col is not None:
|
||||
X[r, 1 + col] = 1.0
|
||||
pen = np.eye(X.shape[1], dtype=np.float32) * ridge
|
||||
pen[0, 0] = 0.0
|
||||
return np.linalg.solve(X.T @ X + pen, X.T @ y)
|
||||
|
||||
|
||||
def _weights(boards: list[dict], base: dict, static_units: dict, ridge: float):
|
||||
"""Modell einmal schätzen und den Beitrag einer Unit daraus ablesen."""
|
||||
idx = _columns(boards, static_units)
|
||||
beta = [float(x) for x in _fit(boards, base, idx, ridge)]
|
||||
|
||||
def own_of(api, stars, is_carry):
|
||||
v = beta[1 + idx[("on", api)]]
|
||||
if is_carry:
|
||||
v += beta[1 + idx[("carry", api)]]
|
||||
if stars == MAX_STARS:
|
||||
v += beta[1 + idx[("star3", api)]]
|
||||
elif stars == REFERENCE_STARS:
|
||||
v += beta[1 + idx[("star2", api)]]
|
||||
return v
|
||||
|
||||
return beta, idx, own_of
|
||||
|
||||
|
||||
def reasons(boards: list[dict], base: dict, own_of, idx: dict, beta: list,
|
||||
min_boards: int) -> dict:
|
||||
"""api_name -> {"unit", "traits", "rest"} als Abweichung vom Üblichen.
|
||||
|
||||
Rohe Koeffizienten wären nicht lesbar: jede Unit auf dem Board zieht
|
||||
den Achsenabschnitt nach unten, dann ist "rest" immer stark negativ.
|
||||
Deshalb wird jeder Topf gegen den Mittelwert seiner Vergleichsgruppe
|
||||
gerechnet.
|
||||
"""
|
||||
ref_traits, ref_units, ref_own = (defaultdict(list) for _ in range(3))
|
||||
prepared = []
|
||||
for b in boards:
|
||||
key = cell(b, REFERENCE_STARS)
|
||||
traits = sum(beta[1 + idx[("trait", (t, k))]]
|
||||
for t, k in b["tiers"].items() if ("trait", (t, k)) in idx)
|
||||
units = {api: own_of(api, stars, items == MAX_ITEMS)
|
||||
for api, stars, items in b["entities"]}
|
||||
carries = [(api, stars) for api, stars, items in b["entities"]
|
||||
if items == MAX_ITEMS]
|
||||
prepared.append((key, carries, traits, units))
|
||||
ref_traits[key].append(traits)
|
||||
ref_units[key].append(sum(units.values()))
|
||||
for api, _ in carries:
|
||||
ref_own[key].append(units[api])
|
||||
|
||||
def mean(table):
|
||||
return {k: sum(v) / len(v) for k, v in table.items() if len(v) >= MIN_CELL}
|
||||
|
||||
m_traits, m_units, m_own = mean(ref_traits), mean(ref_units), mean(ref_own)
|
||||
acc = defaultdict(lambda: [0.0, 0.0, 0.0, 0])
|
||||
for key, carries, traits, units in prepared:
|
||||
if key not in m_traits or key not in m_own:
|
||||
continue
|
||||
for api, stars in carries:
|
||||
e = acc[(api, stars)]
|
||||
e[0] += units[api] - m_own[key]
|
||||
e[1] += traits - m_traits[key]
|
||||
e[2] += (sum(units.values()) - units[api]) - (m_units[key] - m_own[key])
|
||||
e[3] += 1
|
||||
return {key: {"unit": round(v[0] / v[3], 3), "traits": round(v[1] / v[3], 3),
|
||||
"rest": round(v[2] / v[3], 3), "boards": v[3]}
|
||||
for key, v in acc.items() if v[3] >= min_boards}
|
||||
|
||||
|
||||
def build(boards: list[dict], static_units: dict, static_traits: dict,
|
||||
min_boards: int) -> dict:
|
||||
"""Carry-Werte plus Zerlegung. Leer, wenn zu wenige Boards da sind."""
|
||||
prepare(boards, static_units, static_traits)
|
||||
base = baseline(boards)
|
||||
if not base:
|
||||
return {}
|
||||
table = values(boards, base, static_units, min_boards)
|
||||
if not table:
|
||||
return {}
|
||||
beta, idx, own_of = _weights(boards, base, static_units, RIDGE)
|
||||
why = reasons(boards, base, own_of, idx, beta, min_boards)
|
||||
lift = enabler_edge(boards, base, static_units)
|
||||
prop = supported(boards, base, lift, min_boards)
|
||||
for api, entry in table.items():
|
||||
key = (api, entry["stars"])
|
||||
part = why.get(key)
|
||||
if part:
|
||||
# "rest" fällt raus: es sagt nur, dass starke Units danebenstehen,
|
||||
# nicht ob der Carry sie stark macht oder sie ihn.
|
||||
entry["why"] = {"unit": part["unit"], "traits": part["traits"],
|
||||
"supported": prop.get(key, 0.0),
|
||||
"enabler": lift.get(api, 0.0)}
|
||||
return table
|
||||
223
backend/tft/model/groups.py
Normal file
223
backend/tft/model/groups.py
Normal file
@@ -0,0 +1,223 @@
|
||||
"""Starke Champion-Gruppen aus echten Endboards.
|
||||
|
||||
Eine "Einheit" ist hier Champion + Itemzahl: ein Carry mit drei Items ist
|
||||
etwas anderes als derselbe Champion ohne. Gemessen wird der Vorsprung
|
||||
gegenüber Boards mit demselben Level und derselben Item-Menge.
|
||||
|
||||
Warum diese beiden Kontrollen:
|
||||
* Ohne Level gewinnen nur Boards, die weit gekommen sind — auf einem
|
||||
Endboard heißt "billige Units" meist "früh rausgeflogen".
|
||||
* Ohne Item-Menge gewinnt schlicht, wer viele Items gesammelt hat.
|
||||
Mit der Kontrolle bleibt die Frage: wer soll sie tragen?
|
||||
|
||||
Halbierungstest über 64.000 Boards: 0.93 bei Dreiergruppen, 0.85 bei
|
||||
Fünfern. Ein Überschuss-Maß (Gruppe minus Einzelwerte) wurde geprüft und
|
||||
verworfen — es belohnt Gruppen aus einzeln schwachen Units.
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from itertools import combinations
|
||||
|
||||
from tft.model.baseline import unit_aliases
|
||||
|
||||
# Schrumpfung: seltene Gruppen werden Richtung 0 gezogen.
|
||||
PSEUDO = 30
|
||||
MIN_BOARDS = 150
|
||||
SIZES = (1, 2, 3, 4, 5)
|
||||
TOP_N = 60
|
||||
MAX_ITEMS = 3
|
||||
MAX_STARS = 3
|
||||
# Die Sternstufe für Vergleiche zwischen Units. 2★ ist der Normalfall: alle
|
||||
# 63 Units haben dafür genug Boards, 3★ nur 40.
|
||||
REFERENCE_STARS = 2
|
||||
# Mehr als 12 Items landen in einem Topf — darüber wird es zu dünn.
|
||||
MAX_BOARD_ITEMS = 12
|
||||
|
||||
|
||||
# Riots Rollennamen zu den Begriffen, die Spieler benutzen. Reihenfolge
|
||||
# zählt: die erste passende gewinnt, "AttackCaster" vor "Caster".
|
||||
ROLE_GROUPS = (
|
||||
("Tank", "Tank"),
|
||||
("Bruiser", "Fighter"),
|
||||
("Mage", "MagicCaster"),
|
||||
("Scharfschütze", "Marksman"),
|
||||
("Assassine", "Assassin"),
|
||||
("Caster", "AttackCaster"),
|
||||
("Spezialist", "Specialist"),
|
||||
)
|
||||
|
||||
# Ab so vielen Boards trauen wir einem Item auf einer bestimmten Unit.
|
||||
# 150 waere sauberer, liesse aber billige Bruiser wie Akali ganz leer:
|
||||
# ihr haeufigstes Item kommt auf 149 Boards.
|
||||
ITEM_MIN_BOARDS = 120
|
||||
# So viele Items stehen auf einer Karte.
|
||||
TOP_ITEMS = 3
|
||||
# Diebeshandschuhe belegen alle drei Plätze und würfeln den Inhalt jede
|
||||
# Runde neu. Riot listet die gewürfelten Items daneben, als hätte sie
|
||||
# jemand dorthin gelegt — 6,6 % aller Item-Beobachtungen kommen so
|
||||
# zustande, bei Titan's Resolve sind es 16,7 %. Solche Träger zählen bei
|
||||
# den Item-Empfehlungen nicht mit. Am Namensende erkannt, weil das Präfix
|
||||
# je Set wechselt.
|
||||
THIEFS_GLOVES = "ThiefsGloves"
|
||||
|
||||
|
||||
def role_of(unit: dict) -> str | None:
|
||||
role = unit.get("role") or ""
|
||||
return next((label for label, needle in ROLE_GROUPS if needle in role), None)
|
||||
|
||||
|
||||
def boards_from_db(conn, set_number: int, static_units: dict) -> list[dict]:
|
||||
alias = unit_aliases(static_units)
|
||||
rows = conn.execute(
|
||||
"SELECT placement, level, units FROM endboards WHERE set_number = ?",
|
||||
(set_number,),
|
||||
).fetchall()
|
||||
out = []
|
||||
for place, level, units_json in rows:
|
||||
entities, carried, items = set(), [], 0
|
||||
for u in json.loads(units_json):
|
||||
api = alias.get(u["character_id"], u["character_id"])
|
||||
held = u.get("itemNames") or []
|
||||
items += len(held)
|
||||
if api not in static_units:
|
||||
continue
|
||||
stars = min(u["tier"], MAX_STARS)
|
||||
entities.add((api, stars, min(len(held), MAX_ITEMS)))
|
||||
# Die Itemzahl gehört zur Vergleichsgruppe, die Items selbst
|
||||
# sind das, was bewertet wird.
|
||||
carried.append((api, stars, min(len(held), MAX_ITEMS), set(held)))
|
||||
out.append({"place": place, "level": level,
|
||||
"items": min(items, MAX_BOARD_ITEMS),
|
||||
"entities": sorted(entities),
|
||||
"carried": carried})
|
||||
return out
|
||||
|
||||
|
||||
def baseline(boards: list[dict]) -> dict:
|
||||
"""Ø-Platzierung je (Level, Item-Menge) — der Maßstab für den Vorsprung."""
|
||||
acc = defaultdict(list)
|
||||
for b in boards:
|
||||
acc[(b["level"], b["items"])].append(b["place"])
|
||||
return {key: sum(v) / len(v) for key, v in acc.items()}
|
||||
|
||||
|
||||
def group_edge(boards: list[dict], size: int, base: dict,
|
||||
min_boards: int = MIN_BOARDS) -> dict:
|
||||
"""Gruppe -> {"edge", "boards"}. edge = Plätze besser als vergleichbare Boards."""
|
||||
acc = defaultdict(list)
|
||||
for b in boards:
|
||||
if len(b["entities"]) < size:
|
||||
continue
|
||||
edge = base[(b["level"], b["items"])] - b["place"]
|
||||
for combo in combinations(b["entities"], size):
|
||||
acc[combo].append(edge)
|
||||
return {
|
||||
"|".join(f"{api}@{stars}@{items}" for api, stars, items in key): {
|
||||
"edge": round(sum(v) / (len(v) + PSEUDO), 4), "boards": len(v)}
|
||||
for key, v in acc.items() if len(v) >= min_boards
|
||||
}
|
||||
|
||||
|
||||
def one_carry(key: str) -> bool:
|
||||
"""Genau ein Champion trägt drei Items.
|
||||
|
||||
Gruppen mit zwei vollen Carries sind fast immer Endgame-Boards. Für die
|
||||
Frage "worauf baue ich auf" taugt nur die Form, die im Spiel vorkommt:
|
||||
ein Carry, der Rest stützt."""
|
||||
return sum(1 for token in key.split("|")
|
||||
if token.endswith(f"@{MAX_ITEMS}")) == 1
|
||||
|
||||
|
||||
def unit_item_edge(boards: list[dict], craftable: set,
|
||||
min_boards: int = ITEM_MIN_BOARDS) -> dict:
|
||||
"""(Champion, Item) -> Vorsprung. Welches Item gehört auf diesen Champion?
|
||||
|
||||
Verglichen wird gegen denselben Champion auf derselben Sternstufe mit
|
||||
derselben Anzahl Items. Ohne diese Kontrolle misst man vor allem, dass
|
||||
ein Champion mit Items der Carry ist — dann sieht jedes Item gut aus.
|
||||
|
||||
Vorher lief das je Rolle statt je Champion. Das AD/AP-Label kam aus
|
||||
Riots Rollennamen und stimmte nur in 68 von 100 Fällen; zwei andere
|
||||
Quellen im Datensatz kamen auf denselben Wert. Dazu bestand eine Familie
|
||||
oft aus einem einzigen Champion mit genug Boards. Je Champion gemessen
|
||||
trifft die Empfehlung 41 % des erreichbaren Gewinns, MetaTFTs rohe
|
||||
Zahlen nur 23 %.
|
||||
|
||||
Nur normal baubare Items: zwei Komponenten, keine Spatula. Embleme,
|
||||
Artefakte und Radiants kann man nicht planen.
|
||||
"""
|
||||
per_item, per_cell = defaultdict(list), defaultdict(list)
|
||||
for b in boards:
|
||||
edge = b["_edge"]
|
||||
for api, stars, items, held in b["carried"]:
|
||||
if any(item.endswith(THIEFS_GLOVES) for item in held):
|
||||
continue
|
||||
cell = (api, stars, items)
|
||||
per_cell[cell].append(edge)
|
||||
for item in held:
|
||||
if item in craftable:
|
||||
per_item[(cell, item)].append(edge)
|
||||
|
||||
def shrunk(values):
|
||||
return sum(values) / (len(values) + PSEUDO)
|
||||
|
||||
cell_mean = {key: shrunk(v) for key, v in per_cell.items()}
|
||||
acc = defaultdict(list)
|
||||
for (cell, item), values in per_item.items():
|
||||
acc[(cell[0], item)].append((shrunk(values) - cell_mean[cell], len(values)))
|
||||
|
||||
out = {}
|
||||
for (api, item), parts in acc.items():
|
||||
total = sum(n for _, n in parts)
|
||||
if total >= min_boards:
|
||||
out[f"{api}|{item}"] = {
|
||||
"edge": round(sum(d * n for d, n in parts) / total, 4),
|
||||
"boards": total,
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def unit_edge(boards: list[dict], base: dict, min_boards: int = MIN_BOARDS,
|
||||
stars: int = REFERENCE_STARS) -> dict:
|
||||
"""api_name -> Vorsprung auf einer festen Sternstufe.
|
||||
|
||||
Ohne die Sternstufe vergleicht man Äpfel mit Birnen: eine 1-Kosten-Unit
|
||||
steht auf Level 9 fast nur noch als 3★ auf dem Board, ein 5-Kosten fast
|
||||
immer als 1★. Der Rohvergleich lässt die billige Unit dann besser
|
||||
aussehen, als sie ist.
|
||||
"""
|
||||
acc = defaultdict(list)
|
||||
for b in boards:
|
||||
edge = base[(b["level"], b["items"])] - b["place"]
|
||||
for api, unit_stars, _ in b["entities"]:
|
||||
if unit_stars == stars:
|
||||
acc[api].append(edge)
|
||||
return {api: {"edge": round(sum(v) / (len(v) + PSEUDO), 4), "boards": len(v)}
|
||||
for api, v in acc.items() if len(v) >= min_boards}
|
||||
|
||||
|
||||
def build(conn, set_number: int, static_units: dict,
|
||||
craftable: set | None = None) -> dict:
|
||||
"""Die besten Gruppen je Größe. Leer, solange zu wenige Boards da sind."""
|
||||
boards = boards_from_db(conn, set_number, static_units)
|
||||
if len(boards) < MIN_BOARDS * 20:
|
||||
return {}
|
||||
base = baseline(boards)
|
||||
out = {"boards": len(boards)}
|
||||
# Einzelne Units bleiben vollständig — der Rollen-Reiter braucht alle,
|
||||
# nicht nur die besten. Erst ab zwei wird auf einen Carry gefiltert.
|
||||
out["solo"] = group_edge(boards, 1, base, min_boards=MIN_BOARDS)
|
||||
out["units"] = unit_edge(boards, base)
|
||||
for b in boards:
|
||||
b["_edge"] = base[(b["level"], b["items"])] - b["place"]
|
||||
out["unit_items"] = unit_item_edge(boards, set(craftable or ()))
|
||||
for size in SIZES:
|
||||
if size < 2:
|
||||
continue
|
||||
table = {k: v for k, v in group_edge(boards, size, base).items()
|
||||
if one_carry(k)}
|
||||
best = sorted(table.items(), key=lambda x: -x[1]["edge"])[:TOP_N]
|
||||
if best:
|
||||
out[str(size)] = dict(best)
|
||||
return out
|
||||
@@ -9,11 +9,46 @@ from collections import defaultdict
|
||||
|
||||
MEAN_PLACEMENT = 4.5
|
||||
PSEUDO_COUNT = 30
|
||||
# Platzierungs-Delta -> Multiplikator: 1 Platz besser als der Schnitt ≈ +6 %.
|
||||
DELTA_SCALE = 0.06
|
||||
MULT_CAP = (0.8, 1.25)
|
||||
PAIR_MIN_N = 20
|
||||
PAIR_LIFT_CAP = (-0.9, 1.15)
|
||||
# Platzierungs-Delta -> Multiplikator je Unit: 1 Platz besser ≈ +50 %.
|
||||
#
|
||||
# Der Faktor ist gemessen, nicht geschätzt. Zwei unabhängige Belege:
|
||||
# * Über 1872 Endboards liegen zwischen zwei Plätzen 7,5 % Boardstärke.
|
||||
# Ein Board trägt acht Units, pro Unit sind das rund 60 %.
|
||||
# * Im Holdout bleibt die Rangkorrelation bis 0,5 unverändert (0,663 statt
|
||||
# 0,666) und fällt darüber ab; die Übereinstimmung mit der MetaTFT-Tierlist
|
||||
# steigt bis dahin von 0,17 auf 0,39.
|
||||
DELTA_SCALE = 0.5
|
||||
# Deckel fängt nur dünne Daten ab — bei 1872 Boards liegt das 1.-99. Perzentil
|
||||
# zwischen 0,50 und 1,32.
|
||||
MULT_CAP = (0.5, 1.6)
|
||||
# Ab so vielen gemeinsamen Boards trauen wir einem Paarwert.
|
||||
PAIR_MIN_N = 30
|
||||
|
||||
|
||||
def value(placements: list[int], pseudo: int = PSEUDO_COUNT) -> float:
|
||||
"""Stärke aus Platzierungen: Abstand zum Schnitt, gegen 4.5 geschrumpft.
|
||||
|
||||
Größer ist besser. Mit wenigen Boards zieht die Schrumpfung den Wert
|
||||
Richtung 0, also Richtung "unauffällig".
|
||||
"""
|
||||
total = sum(placements) + MEAN_PLACEMENT * pseudo
|
||||
return MEAN_PLACEMENT - total / (len(placements) + pseudo)
|
||||
|
||||
|
||||
def pair_surplus(pair_placements: dict, unit_values: dict) -> dict:
|
||||
"""Wie viel besser läuft ein Paar, als seine zwei Units einzeln hergeben?
|
||||
|
||||
Der rohe Paarwert steckt zu 81 % schon in den Einzelwerten — ein Paar
|
||||
sieht gut aus, sobald ein starker Carry dabei ist. Erst der Überschuss
|
||||
misst das Zusammenspiel. Halbierungstest: 0.95, also kein Rauschen.
|
||||
"""
|
||||
out = {}
|
||||
for (a, b), placements in pair_placements.items():
|
||||
if len(placements) < PAIR_MIN_N or a not in unit_values or b not in unit_values:
|
||||
continue
|
||||
surplus = value(placements) - unit_values[a] - unit_values[b]
|
||||
out[f"{a}|{b}"] = {"surplus": round(surplus, 4), "boards": len(placements)}
|
||||
return out
|
||||
|
||||
|
||||
def _multiplier(placements: list[int]) -> float:
|
||||
@@ -23,24 +58,24 @@ def _multiplier(placements: list[int]) -> float:
|
||||
return min(max(mult, MULT_CAP[0]), MULT_CAP[1])
|
||||
|
||||
|
||||
def learn(rows: list[tuple]) -> dict:
|
||||
"""rows: (placement, units_json, traits_json, augments_json)."""
|
||||
def learn(rows: list[tuple], aliases: dict | None = None) -> dict:
|
||||
"""rows: (placement, units_json, traits_json, augments_json).
|
||||
|
||||
aliases bildet die Unit-Ids der Match-API auf unsere api_names ab —
|
||||
Riot liefert 'DA_18_GnarSmall', unsere Statik kennt 'TFT18_Gnar'.
|
||||
"""
|
||||
resolve = (aliases or {}).get
|
||||
trait_placements = defaultdict(list)
|
||||
item_placements = defaultdict(list)
|
||||
augment_placements = defaultdict(list)
|
||||
unit_placements = defaultdict(list)
|
||||
unit_top4 = defaultdict(int)
|
||||
pair_top4 = defaultdict(int)
|
||||
pair_placements = defaultdict(list)
|
||||
n_boards = 0
|
||||
n_top4 = 0
|
||||
|
||||
for placement, units_json, traits_json, augments_json in rows:
|
||||
n_boards += 1
|
||||
top4 = placement <= 4
|
||||
n_top4 += top4
|
||||
|
||||
units = json.loads(units_json)
|
||||
names = sorted({u["character_id"] for u in units})
|
||||
names = sorted({resolve(u["character_id"], u["character_id"]) for u in units})
|
||||
for name in names:
|
||||
unit_placements[name].append(placement)
|
||||
for u in units:
|
||||
@@ -52,27 +87,18 @@ def learn(rows: list[tuple]) -> dict:
|
||||
for a in json.loads(augments_json):
|
||||
augment_placements[a].append(placement)
|
||||
|
||||
if top4:
|
||||
for name in names:
|
||||
unit_top4[name] += 1
|
||||
for i, a in enumerate(names):
|
||||
for b in names[i + 1 :]:
|
||||
pair_top4[(a, b)] += 1
|
||||
for i, a in enumerate(names):
|
||||
for b in names[i + 1:]:
|
||||
pair_placements[(a, b)].append(placement)
|
||||
|
||||
pairs = {}
|
||||
if n_top4:
|
||||
for (a, b), n_ab in pair_top4.items():
|
||||
if n_ab < PAIR_MIN_N:
|
||||
continue
|
||||
expected = unit_top4[a] * unit_top4[b] / n_top4
|
||||
if expected <= 0:
|
||||
continue
|
||||
lift = n_ab / expected - 1.0
|
||||
lift = min(max(lift, PAIR_LIFT_CAP[0]), PAIR_LIFT_CAP[1])
|
||||
pairs[f"{a}|{b}"] = round(lift, 4)
|
||||
unit_values = {k: value(v) for k, v in unit_placements.items()}
|
||||
pairs = pair_surplus(pair_placements, unit_values)
|
||||
|
||||
return {
|
||||
"units": {k: round(_multiplier(v), 4) for k, v in unit_placements.items()},
|
||||
# Wie viele Boards hinter jedem Wert stehen — entscheidet, ob wir uns
|
||||
# ein Tier überhaupt zutrauen.
|
||||
"unit_boards": {k: len(v) for k, v in unit_placements.items()},
|
||||
"traits": {k: round(_multiplier(v), 4) for k, v in trait_placements.items()},
|
||||
"items": {k: round(_multiplier(v), 4) for k, v in item_placements.items()},
|
||||
"augments": {k: round(_multiplier(v), 4) for k, v in augment_placements.items()},
|
||||
@@ -81,7 +107,8 @@ def learn(rows: list[tuple]) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def learn_from_db(conn, set_number: int, exclude_holdout: bool = True) -> dict:
|
||||
def learn_from_db(conn, set_number: int, exclude_holdout: bool = True,
|
||||
aliases: dict | None = None) -> dict:
|
||||
where = "WHERE set_number = ?"
|
||||
if exclude_holdout:
|
||||
where += " AND substr(match_id, -1) NOT IN ('0', '5')"
|
||||
@@ -89,4 +116,4 @@ def learn_from_db(conn, set_number: int, exclude_holdout: bool = True) -> dict:
|
||||
f"SELECT placement, units, traits, augments FROM endboards {where}",
|
||||
(set_number,),
|
||||
).fetchall()
|
||||
return learn(rows)
|
||||
return learn(rows, aliases)
|
||||
|
||||
156
backend/tft/model/pairs.py
Normal file
156
backend/tft/model/pairs.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Kleine Gruppen, die man fast immer zusammen stellt — und die stark sind.
|
||||
|
||||
Zwei Bedingungen, aus zwei Quellen:
|
||||
|
||||
Bindung Aus den Pro-Comps von MetaTFT. Stehen die Champions dort fast
|
||||
immer zusammen? Endboards taugen dafür nicht: dort steht alles
|
||||
neben allem, was gerade im Shop kam. Eine gebaute Comp zeigt
|
||||
die Absicht.
|
||||
|
||||
Stärke Aus unseren 82.000 Endboards. Laufen Boards mit dieser Gruppe
|
||||
besser als vergleichbare? Kontrolliert nach Level, Item-Menge
|
||||
und übrigen 3★-Units, wie bei den Carries.
|
||||
|
||||
Gewicht einer Bindung: wie oft beide in derselben Comp-Stufe stehen,
|
||||
geteilt durch die Nennungen des selteneren. Also "wenn der seltenere
|
||||
gespielt wird, wie oft ist der andere dabei?". Rohe Zählung würde nur
|
||||
die häufigen Champions nach oben spülen. Dazu eine Schrumpfung, sonst
|
||||
käme jedes Einzelvorkommen auf 1.00.
|
||||
|
||||
Eine Gruppe zählt nur, wenn JEDES Paar darin die Schwelle schafft — bei
|
||||
einem Dreier also alle drei. Sonst hängt ein Dritter an nur einem der
|
||||
beiden und wird trotzdem mitgezählt.
|
||||
|
||||
Dazu muss die ganze Gruppe einen Trait teilen. Ohne diese Bedingung kam
|
||||
Unsinn heraus: eine Comp hat acht bis neun Units, zwei davon stehen
|
||||
nebeneinander, ohne etwas miteinander zu tun zu haben. Sieben der zwölf
|
||||
Gruppen hatten keinen gemeinsamen Trait — darunter Cassiopeia, Lillia
|
||||
und Ornn.
|
||||
|
||||
Am Ende werden überlappende Gruppen verworfen: die stärkste gewinnt,
|
||||
alle weiteren mit denselben Champions fallen weg. Ohne das steht
|
||||
zwanzigmal dieselbe Cassiopeia-Gruppe in leichten Abwandlungen da.
|
||||
|
||||
Halbierungstest über 268 Kandidaten: +0.98.
|
||||
"""
|
||||
|
||||
from collections import Counter, defaultdict
|
||||
from itertools import combinations
|
||||
|
||||
# Ein Paar mit einer einzigen Nennung soll nicht wie eine feste Regel wirken.
|
||||
PSEUDO = 3
|
||||
# Ab dieser Bindung gilt ein Paar als "gehört zusammen".
|
||||
MIN_BOND = 0.4
|
||||
# Gruppengrößen, die wir anbieten.
|
||||
SIZES = (2, 3)
|
||||
# So viele Boards braucht eine Gruppe, damit ihre Zahl zählt.
|
||||
MIN_BOARDS = 300
|
||||
# Schrumpfung der Stärke, wie bei den Carries.
|
||||
EDGE_PSEUDO = 30
|
||||
|
||||
|
||||
def counts(groups: list[list[str]]) -> tuple[Counter, Counter]:
|
||||
"""Nennungen je Champion und je Paar."""
|
||||
seen, pair = Counter(), Counter()
|
||||
for units in groups:
|
||||
seen.update(units)
|
||||
for x, y in combinations(sorted(set(units)), 2):
|
||||
pair[(x, y)] += 1
|
||||
return seen, pair
|
||||
|
||||
|
||||
def weights(groups: list[list[str]], pseudo: int = PSEUDO) -> dict:
|
||||
"""(x, y) -> Bindung zwischen 0 und 1, x < y."""
|
||||
seen, pair = counts(groups)
|
||||
return {key: count / (min(seen[key[0]], seen[key[1]]) + pseudo)
|
||||
for key, count in pair.items()}
|
||||
|
||||
|
||||
def shared_traits(group: tuple, static_units: dict) -> list[str]:
|
||||
"""Traits, die alle in der Gruppe haben."""
|
||||
sets = [set(static_units[api].get("traits") or ()) for api in group]
|
||||
return sorted(set.intersection(*sets)) if sets else []
|
||||
|
||||
|
||||
def candidates(bond: dict, static_units: dict, min_bond: float = MIN_BOND,
|
||||
sizes: tuple = SIZES) -> list[tuple]:
|
||||
"""Gruppen mit gemeinsamem Trait, in denen jedes Paar die Schwelle schafft."""
|
||||
def w(x, y):
|
||||
return bond.get((x, y) if x < y else (y, x), 0.0)
|
||||
|
||||
strong = [key for key, value in bond.items() if value >= min_bond]
|
||||
nodes = sorted({u for key in strong for u in key} & set(static_units))
|
||||
out = []
|
||||
for size in sizes:
|
||||
for group in combinations(nodes, size):
|
||||
if (min(w(x, y) for x, y in combinations(group, 2)) >= min_bond
|
||||
and shared_traits(group, static_units)):
|
||||
out.append(group)
|
||||
return out
|
||||
|
||||
|
||||
def strength(boards: list[dict], base: dict, cell, groups: list[tuple],
|
||||
min_boards: int = MIN_BOARDS) -> dict:
|
||||
"""Gruppe -> Vorsprung gegenüber vergleichbaren Boards.
|
||||
|
||||
`cell` bestimmt die Vergleichsgruppe eines Boards; sie kommt aus dem
|
||||
Carry-Modul, damit beide Reiter denselben Maßstab benutzen.
|
||||
"""
|
||||
acc = defaultdict(list)
|
||||
for board in boards:
|
||||
key = cell(board)
|
||||
if key not in base:
|
||||
continue
|
||||
edge = base[key] - board["place"]
|
||||
present = {api for api, _, _ in board["entities"]}
|
||||
for group in groups:
|
||||
if set(group) <= present:
|
||||
acc[group].append(edge)
|
||||
return {group: {"edge": round(sum(v) / (len(v) + EDGE_PSEUDO), 4),
|
||||
"boards": len(v)}
|
||||
for group, v in acc.items() if len(v) >= min_boards}
|
||||
|
||||
|
||||
def select(scored: dict) -> list[tuple]:
|
||||
"""Stärkste zuerst, überlappende verwerfen.
|
||||
|
||||
Nur positive Gruppen — unter dem Schnitt ist keine Empfehlung.
|
||||
"""
|
||||
order = sorted(scored, key=lambda g: -scored[g]["edge"])
|
||||
used, out = set(), []
|
||||
for group in order:
|
||||
if scored[group]["edge"] <= 0 or used & set(group):
|
||||
continue
|
||||
used.update(group)
|
||||
out.append(group)
|
||||
return out
|
||||
|
||||
|
||||
def build(pro_groups: list[list[str]], boards: list[dict], base: dict, cell,
|
||||
static_units: dict, min_bond: float = MIN_BOND) -> dict:
|
||||
"""Die Gruppen für den Reiter. Leer ohne Pro-Comps oder ohne Boards."""
|
||||
if not pro_groups or not boards:
|
||||
return {}
|
||||
bond = weights(pro_groups)
|
||||
_, pair = counts(pro_groups)
|
||||
cands = candidates(bond, static_units, min_bond)
|
||||
scored = strength(boards, base, cell, cands)
|
||||
if not scored:
|
||||
return {}
|
||||
|
||||
def w(x, y):
|
||||
return bond.get((x, y) if x < y else (y, x), 0.0)
|
||||
|
||||
out = []
|
||||
for group in select(scored):
|
||||
links = [{"units": [x, y], "bond": round(w(x, y), 3),
|
||||
"comps": pair.get((x, y) if x < y else (y, x), 0)}
|
||||
for x, y in combinations(group, 2)]
|
||||
out.append({"units": list(group),
|
||||
"edge": scored[group]["edge"],
|
||||
"boards": scored[group]["boards"],
|
||||
"bond": round(min(l["bond"] for l in links), 3),
|
||||
"traits": shared_traits(group, static_units),
|
||||
"links": links})
|
||||
return {"groups": out, "comps": len(pro_groups),
|
||||
"candidates": len(scored), "min_bond": min_bond}
|
||||
148
backend/tft/model/rating.py
Normal file
148
backend/tft/model/rating.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Boardbewertung aus echten Endboards: welche Platzierung ist zu erwarten?
|
||||
|
||||
Ein Statmodell rechnet Off/Def aus HP, Schaden und Rüstung. Für
|
||||
den Kampf ist das richtig, für die Frage "wie stark ist dieses Board" nicht:
|
||||
gegen echte Platzierungen gemessen kommt es auf 0.65 Rangkorrelation — der
|
||||
nackte Boardwert (Kosten × 3^Sterne) schafft ohne jedes Modell schon 0.74.
|
||||
|
||||
Dieses Modul lernt stattdessen direkt aus Endboards. Es sagt die Platzierung
|
||||
vorher (1 bis 8, kleiner ist besser) und erreicht dabei 0.885. Die Obergrenze
|
||||
liegt bei etwa 0.95: identische Boards landen im Schnitt 0.84 Plätze
|
||||
auseinander, das ist Kampfzufall und die Frage, wer gegen wen antritt.
|
||||
|
||||
Trainiert wird in train_rating.py (braucht numpy), ausgewertet hier — der
|
||||
Baumdurchlauf ist ein paar hundert Vergleiche und läuft ohne Abhängigkeiten.
|
||||
"""
|
||||
|
||||
import math
|
||||
from collections import Counter
|
||||
|
||||
MEAN_PLACEMENT = 4.5
|
||||
MAX_ITEMS = 3
|
||||
|
||||
|
||||
def trait_counts(board: list[dict], static_units: dict) -> Counter:
|
||||
"""Trait -> Anzahl Units auf dem Board.
|
||||
|
||||
Bewusst selbst gezählt statt aus Riots num_units gelesen: nur so rechnen
|
||||
Training und Sim dieselbe Zahl. Der Unterschied kostet 0.004 Korrelation.
|
||||
"""
|
||||
counts: Counter = Counter()
|
||||
for u in board:
|
||||
unit = static_units.get(u["api_name"])
|
||||
if not unit:
|
||||
continue
|
||||
for trait in set(unit.get("traits") or []):
|
||||
counts[trait] += 1
|
||||
return counts
|
||||
|
||||
|
||||
def trait_tiers(counts: Counter, static_traits: dict) -> dict:
|
||||
"""Trait -> erreichte Breakpoint-Stufe (1-basiert), nur aktive."""
|
||||
out = {}
|
||||
for trait, n in counts.items():
|
||||
info = static_traits.get(trait)
|
||||
if not info:
|
||||
continue
|
||||
ordinal = 0
|
||||
for i, bp in enumerate(info["breakpoints"], start=1):
|
||||
if n >= bp["min_units"]:
|
||||
ordinal = i
|
||||
if ordinal:
|
||||
out[trait] = ordinal
|
||||
return out
|
||||
|
||||
|
||||
def _value(unit: dict, stars: int) -> int:
|
||||
"""Goldwert einer Unit: der klassische Boardwert aus TFT."""
|
||||
return unit["cost"] * 3 ** (stars - 1)
|
||||
|
||||
|
||||
def features(board: list[dict], level: int, artifact: dict) -> list[float]:
|
||||
"""Merkmalsvektor eines Boards. Reihenfolge ist Teil des Modells."""
|
||||
static = artifact["static"]
|
||||
units_tbl, traits_tbl = static["units"], static["traits"]
|
||||
tables = (artifact.get("rating") or {}).get("tables") or {}
|
||||
unit_q = tables.get("unit", {})
|
||||
star_q = tables.get("star", {})
|
||||
item_q = tables.get("item", {})
|
||||
unit_item_q = tables.get("unit_item", {})
|
||||
star_item_q = tables.get("star_item", {})
|
||||
trait_q = tables.get("trait", {})
|
||||
trait_count_q = tables.get("trait_count", {})
|
||||
signature_q = tables.get("signature", {})
|
||||
|
||||
known = [u for u in board if u["api_name"] in units_tbl]
|
||||
n = max(len(known), 1)
|
||||
vals = sorted((_value(units_tbl[u["api_name"]], u["stars"]) for u in known),
|
||||
reverse=True) or [0]
|
||||
costs = [units_tbl[u["api_name"]]["cost"] for u in known] or [0]
|
||||
stars = [u["stars"] for u in known]
|
||||
items = [i for u in known for i in u.get("items", [])]
|
||||
carry = max(known, key=lambda u: _value(units_tbl[u["api_name"]], u["stars"])) \
|
||||
if known else None
|
||||
|
||||
counts = trait_counts(known, units_tbl)
|
||||
tiers = trait_tiers(counts, traits_tbl)
|
||||
count_vals = list(counts.values()) or [0]
|
||||
signature = max(counts.items(), key=lambda x: x[1]) if counts else None
|
||||
|
||||
components = set(artifact.get("component_pool") or [])
|
||||
crafted = set(artifact.get("item_pool") or [])
|
||||
|
||||
q_unit = [unit_q.get(u["api_name"], 0.0) for u in known] or [0.0]
|
||||
q_star = [star_q.get(f"{u['api_name']}|{u['stars']}", 0.0) for u in known] or [0.0]
|
||||
q_si = [star_item_q.get(
|
||||
f"{u['api_name']}|{u['stars']}|{min(len(u.get('items', [])), MAX_ITEMS)}", 0.0)
|
||||
for u in known] or [0.0]
|
||||
q_trait = [trait_q.get(f"{t}|{lvl}", 0.0) for t, lvl in tiers.items()] or [0.0]
|
||||
q_tc = [trait_count_q.get(f"{t}|{c}", 0.0) for t, c in counts.items()] or [0.0]
|
||||
|
||||
return [
|
||||
math.log1p(sum(vals)), vals[0], sum(vals[:3]), sum(vals[:5]),
|
||||
len(known), level, level - len(known),
|
||||
len(items), len(set(items)), len(items) / n,
|
||||
sum(1 for i in items if i in components),
|
||||
sum(1 for i in items if i in crafted),
|
||||
sum(1 for i in items if i not in components and i not in crafted),
|
||||
sum(1 for s in stars if s >= 2), sum(1 for s in stars if s >= 3),
|
||||
sum(1 for s in stars if s >= 2) / n,
|
||||
sum(1 for u in known if len(u.get("items", [])) >= MAX_ITEMS),
|
||||
max(costs), sum(costs) / n, sum(1 for c in costs if c >= 4),
|
||||
len(tiers), sum(tiers.values()), max(tiers.values(), default=0),
|
||||
sum(1 for t in tiers.values() if t >= 3),
|
||||
max(count_vals), sum(count_vals), sum(1 for c in count_vals if c >= 4),
|
||||
sum(q_unit), max(q_unit), sum(q_unit) / n,
|
||||
sum(q_star), max(q_star), sum(q_si), max(q_si),
|
||||
sum(item_q.get(i, 0.0) for i in items),
|
||||
sum(unit_item_q.get(f"{u['api_name']}|{i}", 0.0)
|
||||
for u in known for i in u.get("items", [])),
|
||||
sum(q_trait), max(q_trait), sum(q_tc), max(q_tc),
|
||||
signature_q.get(f"{signature[0]}|{signature[1]}", 0.0) if signature else 0.0,
|
||||
len(carry.get("items", [])) if carry else 0,
|
||||
unit_q.get(carry["api_name"], 0.0) if carry else 0.0,
|
||||
star_q.get(f"{carry['api_name']}|{carry['stars']}", 0.0) if carry else 0.0,
|
||||
]
|
||||
|
||||
|
||||
def _walk(node, row: list[float]) -> float:
|
||||
while isinstance(node, list):
|
||||
column, threshold, left, right = node
|
||||
node = left if row[column] <= threshold else right
|
||||
return node
|
||||
|
||||
|
||||
def predict_placement(board: list[dict], level: int, artifact: dict) -> float | None:
|
||||
"""Erwartete Platzierung, 1 bis 8. None, wenn kein Modell im Artefakt steckt."""
|
||||
model = artifact.get("rating")
|
||||
if not model or not model.get("trees"):
|
||||
return None
|
||||
row = features(board, level, artifact)
|
||||
value = model["base"] + model["lr"] * sum(_walk(t, row) for t in model["trees"])
|
||||
return min(max(value, 1.0), 8.0)
|
||||
|
||||
|
||||
def board_rating(board: list[dict], level: int, artifact: dict) -> float | None:
|
||||
"""Wie predict_placement, nur größer = besser. Praktisch zum Sortieren."""
|
||||
place = predict_placement(board, level, artifact)
|
||||
return None if place is None else MEAN_PLACEMENT - place
|
||||
@@ -1,65 +0,0 @@
|
||||
"""The one board-scoring entry point. Sim, bots, calibration, and UI all call this.
|
||||
|
||||
Off/Def-Modell: Off = Auto- + Spell-DPS, Def = eHP — pro Unit aus exakten
|
||||
cdragon-Werten (Sterne und Items eingerechnet). Traits und Augments werden
|
||||
bewusst ignoriert (spätere Ausbaustufe); active_trait_tiers bleibt fürs UI.
|
||||
"""
|
||||
|
||||
from tft.model import baseline, statsheet
|
||||
|
||||
|
||||
def active_trait_tiers(board_units: list[dict], static_traits: dict, static_units: dict) -> dict:
|
||||
"""trait api_name -> reached breakpoint ordinal (1-based), only active traits."""
|
||||
counts: dict[str, int] = {}
|
||||
for u in board_units:
|
||||
unit = static_units.get(u["api_name"])
|
||||
if not unit:
|
||||
continue
|
||||
for trait in set(unit["traits"]):
|
||||
counts[trait] = counts.get(trait, 0) + 1
|
||||
|
||||
tiers = {}
|
||||
for trait, n in counts.items():
|
||||
info = static_traits.get(trait)
|
||||
if not info:
|
||||
continue
|
||||
ordinal = 0
|
||||
for i, bp in enumerate(info["breakpoints"], start=1):
|
||||
if n >= bp["min_units"]:
|
||||
ordinal = i
|
||||
if ordinal:
|
||||
tiers[trait] = ordinal
|
||||
return tiers
|
||||
|
||||
|
||||
def unit_profile(u: dict, artifact: dict) -> dict | None:
|
||||
"""Kampfprofil einer Board-Unit: {"off", "def", "defensive", "unit"}.
|
||||
|
||||
"defensive" steuert die Zielreihenfolge im Kampf: Frontline/Utility
|
||||
sterben zuerst, Carries zuletzt.
|
||||
"""
|
||||
unit = artifact["static"]["units"].get(u["api_name"])
|
||||
if not unit:
|
||||
return None
|
||||
role = artifact.get("roles", {}).get(u["api_name"]) or baseline.classify_role(unit)
|
||||
# role=None: der Frontline-Cast-Bonus verschlechtert die Holdout-Korrelation
|
||||
# (0.659 vs. 0.679) — die Rolle steuert nur die Zielreihenfolge im Kampf.
|
||||
stats = statsheet.unit_stats(
|
||||
unit, u["stars"], u.get("items", []), artifact["static"]["items"],
|
||||
None, None, spell_cap=artifact.get("spell_dps_cap"),
|
||||
)
|
||||
return {"off": stats["dps"], "def": stats["ehp"],
|
||||
"defensive": role in ("frontline", "utility"), "unit": u}
|
||||
|
||||
|
||||
def board_profiles(board_units: list[dict], artifact: dict) -> list[dict]:
|
||||
return [p for u in board_units if (p := unit_profile(u, artifact))]
|
||||
|
||||
|
||||
def score_board(board_units: list[dict], augments: list[str], artifact: dict) -> float:
|
||||
"""sqrt(Σoff × Σdef). augments bleibt nur für Signatur-Kompatibilität."""
|
||||
profiles = board_profiles(board_units, artifact)
|
||||
off = sum(p["off"] for p in profiles)
|
||||
dfn = sum(p["def"] for p in profiles)
|
||||
# /100: nur Anzeige-Skalierung, für Vergleiche irrelevant.
|
||||
return (off * dfn) ** 0.5 / 100
|
||||
@@ -1,186 +0,0 @@
|
||||
"""Effektive Kampfprofile aus exakten cdragon-Werten: eHP und DPS pro Unit.
|
||||
|
||||
Konstanten spiegeln set17.toml [stars] bzw. das TFT-Mana-Modell.
|
||||
Item-Wert-Konventionen laut Datenkatalog: AD = Fraction, AP = flat (Basis 100),
|
||||
AS/CritChance = Prozentzahl, Health/Armor/MagicResist/ManaRegen = flat.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
HP_STAR_MULT = 1.8
|
||||
AD_STAR_MULT = 1.5
|
||||
MANA_PER_ATTACK = 10
|
||||
FRONTLINE_MANA_PER_SEC = 10
|
||||
CAST_RATE_CAP = 1.5
|
||||
|
||||
SPELL_AUTO_CAP = 3.0 # Spell-DPS max. 3× eigene Auto-DPS (Ausreißer-Guard)
|
||||
|
||||
MAPPED_ITEM_KEYS = ("AD", "AP", "AS", "CritChance", "Health", "Armor",
|
||||
"MagicResist", "ManaRegen", "BonusDamage", "DamageAmp",
|
||||
"BonusPercentHP", "CritDamageToGive")
|
||||
|
||||
_TOKEN_RE = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|\d+")
|
||||
# Variablen mit diesen Tokens sind Mechanik-Parameter, keine Stat-Buffs.
|
||||
_SKIP_TOKENS = {"duration", "threshold", "rounds", "per", "num", "gold",
|
||||
"tooltiponly", "mana", "seconds", "delay", "range", "radius"}
|
||||
|
||||
|
||||
def _empty_acc() -> dict:
|
||||
return {"ad_pct": 0.0, "ap_flat": 0.0, "as_pct": 0.0, "hp_flat": 0.0,
|
||||
"hp_pct": 0.0, "armor_flat": 0.0, "mr_flat": 0.0,
|
||||
"crit_chance": 0.0, "crit_dmg": 0.0, "dr": 0.0, "mana_regen": 0.0,
|
||||
"damage_amp": 0.0}
|
||||
|
||||
|
||||
def _apply_item_effects(item_apis: list[str], static_items: dict, acc: dict) -> None:
|
||||
for api in item_apis:
|
||||
effects = (static_items.get(api) or {}).get("effects") or {}
|
||||
for key, val in effects.items():
|
||||
if val is None:
|
||||
continue
|
||||
if key == "AD":
|
||||
acc["ad_pct"] += val
|
||||
elif key == "AP":
|
||||
acc["ap_flat"] += val
|
||||
elif key == "AS":
|
||||
acc["as_pct"] += val / 100
|
||||
elif key == "CritChance":
|
||||
acc["crit_chance"] += val / 100
|
||||
elif key == "Health":
|
||||
acc["hp_flat"] += val
|
||||
elif key == "Armor":
|
||||
acc["armor_flat"] += val
|
||||
elif key == "MagicResist":
|
||||
acc["mr_flat"] += val
|
||||
elif key == "ManaRegen":
|
||||
acc["mana_regen"] += val
|
||||
elif key in ("BonusDamage", "DamageAmp"):
|
||||
acc["damage_amp"] += _fraction(val)
|
||||
elif key == "BonusPercentHP":
|
||||
acc["hp_pct"] += _fraction(val)
|
||||
elif key == "CritDamageToGive":
|
||||
acc["crit_dmg"] += _fraction(val)
|
||||
# alle übrigen Keys: bespoke Mechanik, bewusst ignoriert
|
||||
|
||||
|
||||
def item_is_modeled(item_info: dict) -> bool:
|
||||
effects = (item_info or {}).get("effects") or {}
|
||||
return any(effects.get(k) is not None for k in MAPPED_ITEM_KEYS)
|
||||
|
||||
|
||||
def _fraction(value: float) -> float:
|
||||
"""cdragon mischt Fraction (0.15) und Prozentzahl (15.0) — normalisieren."""
|
||||
return value if abs(value) <= 1.0 else value / 100
|
||||
|
||||
|
||||
def trait_buffs(active_tiers: dict, static_traits: dict) -> tuple[dict, set]:
|
||||
"""Erkannte Trait-Variablen -> teamweite Stat-Buffs; Rest bleibt generisch."""
|
||||
buffs = _empty_acc()
|
||||
recognized: set[str] = set()
|
||||
for trait, ordinal in active_tiers.items():
|
||||
info = static_traits.get(trait)
|
||||
if not info:
|
||||
continue
|
||||
breakpoints = info.get("breakpoints", [])
|
||||
if ordinal - 1 >= len(breakpoints):
|
||||
continue
|
||||
variables = breakpoints[ordinal - 1].get("variables") or {}
|
||||
matched = False
|
||||
for name, value in variables.items():
|
||||
if value is None or not isinstance(value, (int, float)):
|
||||
continue
|
||||
tokens = {t.lower() for t in _TOKEN_RE.findall(name)}
|
||||
if tokens & _SKIP_TOKENS or name.startswith("{"):
|
||||
continue
|
||||
f = _fraction(value)
|
||||
if "adap" in tokens:
|
||||
buffs["ad_pct"] += f
|
||||
buffs["ap_flat"] += f * 100
|
||||
elif "ad" in tokens:
|
||||
buffs["ad_pct"] += f
|
||||
elif "ap" in tokens:
|
||||
buffs["ap_flat"] += f * 100
|
||||
elif "as" in tokens or "attackspeed" in tokens:
|
||||
buffs["as_pct"] += f
|
||||
elif "armor" in tokens:
|
||||
buffs["armor_flat"] += value if abs(value) > 1 else value * 100
|
||||
elif "mr" in tokens or "magicresist" in tokens or "resist" in tokens:
|
||||
buffs["mr_flat"] += value if abs(value) > 1 else value * 100
|
||||
elif "health" in tokens or "hp" in tokens:
|
||||
buffs["hp_pct"] += f
|
||||
elif "shield" in tokens or "durability" in tokens or "dr" in tokens \
|
||||
or ("damage" in tokens and "reduction" in tokens):
|
||||
buffs["dr"] += f
|
||||
elif "damageamp" in tokens or ("damage" in tokens and "amp" in tokens) \
|
||||
or "bonusdamage" in tokens:
|
||||
buffs["damage_amp"] += f
|
||||
elif "heal" in tokens or "omnivamp" in tokens:
|
||||
buffs["dr"] += f * 0.5
|
||||
else:
|
||||
continue
|
||||
matched = True
|
||||
if matched:
|
||||
recognized.add(trait)
|
||||
return buffs, recognized
|
||||
|
||||
|
||||
def unit_stats(unit: dict, stars: int, item_apis: list[str], static_items: dict,
|
||||
team_buffs: dict | None, role: str | None,
|
||||
spell_cap: float | None = None) -> dict:
|
||||
stats = unit["stats"]
|
||||
hp = (stats.get("hp") or 0) * HP_STAR_MULT ** (stars - 1)
|
||||
ad = (stats.get("damage") or 0) * AD_STAR_MULT ** (stars - 1)
|
||||
armor = stats.get("armor") or 0
|
||||
mr = stats.get("magicResist") or 0
|
||||
aspd = stats.get("attackSpeed") or 0
|
||||
crit = stats.get("critChance") or 0.25
|
||||
crit_mult = stats.get("critMultiplier") or 1.4
|
||||
mana_gap = max((stats.get("mana") or 0) - (stats.get("initialMana") or 0), 1)
|
||||
|
||||
acc = _empty_acc()
|
||||
for key, val in (team_buffs or {}).items():
|
||||
acc[key] += val
|
||||
_apply_item_effects(item_apis, static_items, acc)
|
||||
|
||||
hp = (hp + acc["hp_flat"]) * (1 + acc["hp_pct"])
|
||||
armor += acc["armor_flat"]
|
||||
mr += acc["mr_flat"]
|
||||
ehp = hp * (1 + (armor + mr) / 200) * (1 + acc["dr"])
|
||||
|
||||
as_eff = aspd * (1 + acc["as_pct"])
|
||||
crit_c = min(crit + acc["crit_chance"], 1.0)
|
||||
crit_mult += acc["crit_dmg"]
|
||||
auto_dps = ad * (1 + acc["ad_pct"]) * as_eff * (1 + crit_c * (crit_mult - 1))
|
||||
|
||||
spell_dps = 0.0
|
||||
array = unit.get("spell_damage")
|
||||
if array:
|
||||
dmg = array[min(stars, len(array) - 1)] or 0
|
||||
scaling = unit.get("spell_scaling")
|
||||
if scaling in ("ap", "both"):
|
||||
dmg *= 1 + acc["ap_flat"] / 100
|
||||
if scaling in ("ad", "both"):
|
||||
dmg *= 1 + acc["ad_pct"]
|
||||
if unit.get("spell_on_attack"):
|
||||
# Passive: Bonus-Schaden pro Auto-Attacke, kein Mana-Zyklus.
|
||||
spell_dps = dmg * as_eff
|
||||
else:
|
||||
frontline = FRONTLINE_MANA_PER_SEC if role == "frontline" else 0
|
||||
cast_rate = min(
|
||||
(as_eff * MANA_PER_ATTACK + frontline + acc["mana_regen"]) / mana_gap,
|
||||
CAST_RATE_CAP,
|
||||
)
|
||||
spell_dps = dmg * cast_rate
|
||||
# Ausreißer-Guard: Spell-Rohwerte sind zwischen Champions nicht
|
||||
# vergleichbar (per-Hit vs. total). Relativer Deckel: max. 3× eigene
|
||||
# Auto-DPS; das Populations-Perzentil dient als Floor für AD-lose Caster.
|
||||
limit = SPELL_AUTO_CAP * auto_dps
|
||||
if spell_cap is not None:
|
||||
limit = max(limit, spell_cap * AD_STAR_MULT ** (stars - 1))
|
||||
spell_dps = min(spell_dps, limit)
|
||||
|
||||
return {
|
||||
"ehp": ehp,
|
||||
"dps": (auto_dps + spell_dps) * (1 + acc["damage_amp"]),
|
||||
"spell_dps": spell_dps,
|
||||
}
|
||||
195
backend/tft/model/train_rating.py
Normal file
195
backend/tft/model/train_rating.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""Training der Boardbewertung: gelernte Tabellen + Gradient Boosting.
|
||||
|
||||
Läuft nur beim Bauen des Artefakts und braucht dafür numpy. Die Auswertung
|
||||
in rating.py kommt ohne aus — das Ergebnis ist reines JSON.
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections import defaultdict
|
||||
|
||||
from tft.model import rating
|
||||
from tft.model.baseline import unit_aliases
|
||||
|
||||
# Schrumpfung gegen den Mittelwert. 30 für grobe Schlüssel, 20 für feine —
|
||||
# auf dem dev-Satz gemessen, der Unterschied zu 10 oder 250 liegt bei 0.002.
|
||||
PSEUDO_COARSE = 30
|
||||
PSEUDO_FINE = 20
|
||||
|
||||
TREES = 300
|
||||
DEPTH = 4
|
||||
LEARNING_RATE = 0.06
|
||||
BINS = 32
|
||||
MIN_LEAF = 40
|
||||
SUBSAMPLE = 0.7
|
||||
|
||||
|
||||
def _shrunk(placements: list[int], pseudo: int) -> float:
|
||||
"""Mittlere Platzierung, gegen 4.5 gezogen. Größer = besser."""
|
||||
total = sum(placements) + rating.MEAN_PLACEMENT * pseudo
|
||||
return rating.MEAN_PLACEMENT - total / (len(placements) + pseudo)
|
||||
|
||||
|
||||
def _table(boards: list[dict], key_fn, pseudo: int) -> dict:
|
||||
acc = defaultdict(list)
|
||||
for b in boards:
|
||||
for key in key_fn(b):
|
||||
acc[key].append(b["place"])
|
||||
return {k: round(_shrunk(v, pseudo), 5) for k, v in acc.items()}
|
||||
|
||||
|
||||
def learn_tables(boards: list[dict], static_units: dict) -> dict:
|
||||
"""Alle gelernten Nachschlagetabellen. Schlüssel sind Strings — JSON-fähig."""
|
||||
def units(b):
|
||||
return {u["api_name"] for u in b["units"]}
|
||||
|
||||
def star(b):
|
||||
return {f"{u['api_name']}|{u['stars']}" for u in b["units"]}
|
||||
|
||||
def item(b):
|
||||
return {i for u in b["units"] for i in u["items"]}
|
||||
|
||||
def unit_item(b):
|
||||
return {f"{u['api_name']}|{i}" for u in b["units"] for i in u["items"]}
|
||||
|
||||
def star_item(b):
|
||||
return {f"{u['api_name']}|{u['stars']}|{min(len(u['items']), rating.MAX_ITEMS)}"
|
||||
for u in b["units"]}
|
||||
|
||||
def trait(b):
|
||||
return {f"{t}|{lvl}" for t, lvl in b["tiers"].items()}
|
||||
|
||||
def trait_count(b):
|
||||
return {f"{t}|{c}" for t, c in b["counts"].items()}
|
||||
|
||||
def signature(b):
|
||||
if not b["counts"]:
|
||||
return set()
|
||||
name, count = max(b["counts"].items(), key=lambda x: x[1])
|
||||
return {f"{name}|{count}"}
|
||||
|
||||
return {
|
||||
"unit": _table(boards, units, PSEUDO_COARSE),
|
||||
"star": _table(boards, star, PSEUDO_COARSE),
|
||||
"item": _table(boards, item, PSEUDO_COARSE),
|
||||
"trait": _table(boards, trait, PSEUDO_COARSE),
|
||||
"unit_item": _table(boards, unit_item, PSEUDO_FINE),
|
||||
"star_item": _table(boards, star_item, PSEUDO_FINE),
|
||||
"trait_count": _table(boards, trait_count, PSEUDO_FINE),
|
||||
"signature": _table(boards, signature, PSEUDO_FINE),
|
||||
}
|
||||
|
||||
|
||||
def boards_from_db(conn, set_number: int, static_units: dict,
|
||||
exclude_holdout: bool = True) -> list[dict]:
|
||||
"""Endboards in der Form, die rating.features erwartet.
|
||||
|
||||
Der Holdout bleibt draußen — dieselben Matches wie in learn.py, damit
|
||||
`calibrate` ehrlich auf ungesehenen Boards misst.
|
||||
"""
|
||||
alias = unit_aliases(static_units)
|
||||
where = "WHERE set_number = ?"
|
||||
if exclude_holdout:
|
||||
where += " AND substr(match_id, -1) NOT IN ('0', '5')"
|
||||
rows = conn.execute(
|
||||
f"SELECT placement, level, units FROM endboards {where}",
|
||||
(set_number,),
|
||||
).fetchall()
|
||||
out = []
|
||||
for place, level, units_json in rows:
|
||||
units = [{"api_name": alias.get(u["character_id"], u["character_id"]),
|
||||
"stars": u["tier"], "items": u.get("itemNames", [])}
|
||||
for u in json.loads(units_json)]
|
||||
known = [u for u in units if u["api_name"] in static_units]
|
||||
counts = rating.trait_counts(known, static_units)
|
||||
out.append({"place": place, "level": level, "units": known,
|
||||
"counts": dict(counts), "tiers": {}})
|
||||
return out
|
||||
|
||||
|
||||
def train(conn, set_number: int, artifact: dict) -> dict:
|
||||
"""Gibt das fertige Modell zurück: Tabellen + Bäume. Leer ohne numpy."""
|
||||
try:
|
||||
import numpy as np
|
||||
except ImportError: # pragma: no cover - nur ohne numpy erreichbar
|
||||
return {}
|
||||
|
||||
static_units = artifact["static"]["units"]
|
||||
boards = boards_from_db(conn, set_number, static_units)
|
||||
if len(boards) < 500:
|
||||
return {}
|
||||
|
||||
tables = learn_tables(boards, static_units)
|
||||
for b in boards:
|
||||
b["tiers"] = rating.trait_tiers(b["counts"], artifact["static"]["traits"])
|
||||
tables = learn_tables(boards, static_units) # Trait-Stufen jetzt gefüllt
|
||||
|
||||
scored = {**artifact, "rating": {"tables": tables}}
|
||||
X = np.array([rating.features(b["units"], b["level"], scored) for b in boards])
|
||||
y = np.array([float(b["place"]) for b in boards])
|
||||
|
||||
trees, base = _fit(np, X, y)
|
||||
return {"tables": tables, "trees": trees, "base": base,
|
||||
"lr": LEARNING_RATE, "boards": len(boards)}
|
||||
|
||||
|
||||
def _fit(np, X, y):
|
||||
"""Gradient Boosting auf Histogramm-Bins; gibt JSON-fähige Bäume zurück."""
|
||||
edges = [np.quantile(X[:, j], np.linspace(0, 1, BINS + 1)[1:-1])
|
||||
for j in range(X.shape[1])]
|
||||
B = np.stack([np.searchsorted(edges[j], X[:, j]).astype(np.int16)
|
||||
for j in range(X.shape[1])], axis=1)
|
||||
rng = np.random.default_rng(1)
|
||||
base = float(y.mean())
|
||||
pred = np.full(len(y), base)
|
||||
n_sub = int(len(y) * SUBSAMPLE)
|
||||
trees = []
|
||||
|
||||
def grow(g, rows, depth):
|
||||
if depth == 0 or len(rows) < 2 * MIN_LEAF:
|
||||
return round(float(g[rows].mean()), 6)
|
||||
best = None
|
||||
parent, n = g[rows].sum(), len(rows)
|
||||
for j in range(B.shape[1]):
|
||||
col = B[rows, j]
|
||||
cnt = np.bincount(col, minlength=BINS)
|
||||
tot = np.bincount(col, weights=g[rows], minlength=BINS)
|
||||
cl, sl = np.cumsum(cnt), np.cumsum(tot)
|
||||
cr, sr = n - cl, parent - sl
|
||||
ok = (cl >= MIN_LEAF) & (cr >= MIN_LEAF)
|
||||
if not ok.any():
|
||||
continue
|
||||
gain = np.where(ok, sl ** 2 / np.maximum(cl, 1) + sr ** 2 / np.maximum(cr, 1),
|
||||
-np.inf)
|
||||
t = int(np.argmax(gain))
|
||||
if best is None or gain[t] > best[0]:
|
||||
best = (gain[t], j, t)
|
||||
if best is None:
|
||||
return round(float(g[rows].mean()), 6)
|
||||
_, j, t = best
|
||||
mask = B[rows, j] <= t
|
||||
# Schwelle als echter Wert, damit die Auswertung ohne Bins auskommt.
|
||||
threshold = float(edges[j][t]) if t < len(edges[j]) else float(X[:, j].max())
|
||||
return [j, threshold, grow(g, rows[mask], depth - 1),
|
||||
grow(g, rows[~mask], depth - 1)]
|
||||
|
||||
def apply(node, rows_all):
|
||||
out = np.zeros(len(rows_all))
|
||||
stack = [(node, np.arange(len(rows_all)))]
|
||||
while stack:
|
||||
nd, rows = stack.pop()
|
||||
if not isinstance(nd, list):
|
||||
out[rows] = nd
|
||||
continue
|
||||
j, thr, left, right = nd
|
||||
m = X[rows, j] <= thr
|
||||
stack.append((left, rows[m]))
|
||||
stack.append((right, rows[~m]))
|
||||
return out
|
||||
|
||||
for _ in range(TREES):
|
||||
g = y - pred
|
||||
rows = rng.choice(len(y), n_sub, replace=False)
|
||||
tree = grow(g, rows, DEPTH)
|
||||
trees.append(tree)
|
||||
pred += LEARNING_RATE * apply(tree, y)
|
||||
return trees, base
|
||||
134
backend/tft/sim/arena.py
Normal file
134
backend/tft/sim/arena.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Zwei Boards gegeneinander — die Brücke zwischen Spiel und Kampf-Engine.
|
||||
|
||||
`game.py` kennt nur Board-Listen. Die Engine will Kämpfer mit Position,
|
||||
Items und Trait-Boni. Diese Umrechnung steht hier, damit weder das Spiel
|
||||
noch die Engine die andere Seite kennen muss.
|
||||
"""
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
|
||||
from tft.sim import battle, hexgrid, lineup, traits
|
||||
|
||||
# Die eigene Hälfte: vier Reihen, sieben Spalten.
|
||||
BOARD_ROWS = hexgrid.HALF
|
||||
BOARD_COLS = hexgrid.COLS
|
||||
|
||||
|
||||
@dataclass
|
||||
class FightResult:
|
||||
"""Was das Spiel vom Kampf braucht: Sieger und Überlebende."""
|
||||
winner: str | None # "a" | "b" | None = Unentschieden
|
||||
frames: int
|
||||
survivors_a: list[dict]
|
||||
survivors_b: list[dict]
|
||||
|
||||
|
||||
def player_damage(stage: int, survivors: list[dict], cfg: dict) -> int:
|
||||
"""Schaden am Verlierer: Grundwert der Stage plus überlebende Units."""
|
||||
d = cfg["damage"]
|
||||
base = d["stage_base"][min(stage - 1, len(d["stage_base"]) - 1)]
|
||||
return base + d["per_surviving_unit"] * len(survivors)
|
||||
|
||||
|
||||
def from_hex(hexid: int, which: str) -> dict:
|
||||
"""Feldnummer des Spielers -> Koordinaten auf dem Kampffeld.
|
||||
|
||||
Der Spieler nummeriert seine vier Reihen von vorne nach hinten, das
|
||||
Kampffeld zählt über beide Hälften durch. Reihe 0 des Spielers ist
|
||||
also die vorderste — für Seite a Reihe 3, für Seite b Reihe 4.
|
||||
"""
|
||||
row, col = divmod(int(hexid), BOARD_COLS)
|
||||
row = min(row, BOARD_ROWS - 1)
|
||||
return {"col": col,
|
||||
"row": (BOARD_ROWS - 1 - row) if which == "a" else (BOARD_ROWS + row)}
|
||||
|
||||
|
||||
def placed(board: list[dict], which: str, units: dict) -> list[dict]:
|
||||
"""Eigene Positionen benutzen, wo es welche gibt.
|
||||
|
||||
Boards aus der Riot-API haben keine — dort greift die Standard-
|
||||
Aufstellung aus `lineup`.
|
||||
"""
|
||||
if not any(entry.get("hex") is not None for entry in board):
|
||||
return lineup.place(board, which, units)
|
||||
out, taken = [], set()
|
||||
fallback = lineup.place(board, which, units)
|
||||
for entry, spare in zip(board, fallback):
|
||||
hexid = entry.get("hex")
|
||||
cell = from_hex(hexid, which) if hexid is not None else \
|
||||
{"col": spare["col"], "row": spare["row"]}
|
||||
if (cell["col"], cell["row"]) in taken:
|
||||
cell = {"col": spare["col"], "row": spare["row"]}
|
||||
taken.add((cell["col"], cell["row"]))
|
||||
out.append({**entry, **cell})
|
||||
return out
|
||||
|
||||
|
||||
def side(board: list[dict], which: str, artifact: dict) -> list:
|
||||
"""Board -> aufgestellte Kämpfer mit Items und Trait-Boni."""
|
||||
static = artifact["static"]
|
||||
units, items, trait_data = static["units"], static["items"], static["traits"]
|
||||
fighters = []
|
||||
for entry in placed(board, which, units):
|
||||
one = battle.to_fighter(entry, which, units)
|
||||
if one is None:
|
||||
continue
|
||||
battle.apply_items(one, entry.get("items") or [], items)
|
||||
fighters.append(one)
|
||||
traits.apply(fighters, board, units, trait_data)
|
||||
return fighters
|
||||
|
||||
|
||||
def fight(board_a: list[dict], board_b: list[dict], artifact: dict,
|
||||
rng: random.Random, record: bool = False) -> tuple:
|
||||
"""(Ergebnis, Protokoll). Das Protokoll ist leer ohne `record`.
|
||||
|
||||
Leere Boards verlieren sofort — sonst liefe die Engine 30 Sekunden
|
||||
gegen niemanden.
|
||||
"""
|
||||
team_a, team_b = side(board_a, "a", artifact), side(board_b, "b", artifact)
|
||||
if not team_a or not team_b:
|
||||
winner = "a" if team_a else "b" if team_b else None
|
||||
return FightResult(winner=winner, frames=0,
|
||||
survivors_a=board_a if winner == "a" else [],
|
||||
survivors_b=board_b if winner == "b" else []), None
|
||||
res = battle.fight(team_a, team_b, rng, record=record)
|
||||
out = FightResult(winner=res.winner,
|
||||
frames=int(res.seconds * battle.TPS),
|
||||
survivors_a=res.survivors_a,
|
||||
survivors_b=res.survivors_b)
|
||||
return out, (_log(res, artifact) if record else None)
|
||||
|
||||
|
||||
def strength(board: list[dict], artifact: dict) -> dict:
|
||||
"""Angriff und Widerstand eines Boards, aus derselben Engine wie der Kampf.
|
||||
|
||||
Vorher kam diese Zahl aus einem eigenen Bewertungsmodell. Das konnte
|
||||
dem Kampf widersprechen: ein Board mit hohem "off" verlor trotzdem.
|
||||
Hier steckt genau das drin, womit auch gekämpft wird — Items und
|
||||
Trait-Boni eingerechnet.
|
||||
|
||||
Skala: Tausend Punkte, damit die Zahlen zweistellig bleiben.
|
||||
"""
|
||||
fighters = side(board, "a", artifact)
|
||||
return {
|
||||
"off": round(sum(battle.dps(f) for f in fighters) / 100.0, 1),
|
||||
"def": round(sum(battle.toughness(f) for f in fighters) / 1000.0, 1),
|
||||
}
|
||||
|
||||
|
||||
def _log(res, artifact: dict) -> dict:
|
||||
names = artifact["static"]["units"]
|
||||
return {
|
||||
"cols": hexgrid.COLS,
|
||||
"rows": hexgrid.ROWS,
|
||||
"tps": battle.TPS,
|
||||
"winner": res.winner,
|
||||
"seconds": res.seconds,
|
||||
"units": [{"slot": f.slot, "side": f.side,
|
||||
"name": names.get(f.key, {}).get("name", f.key),
|
||||
"max_hp": round(f.max_hp)}
|
||||
for f in res.fighters],
|
||||
"events": res.events,
|
||||
}
|
||||
450
backend/tft/sim/battle.py
Normal file
450
backend/tft/sim/battle.py
Normal file
@@ -0,0 +1,450 @@
|
||||
"""Kampf auf dem Hex-Feld, Tick für Tick.
|
||||
|
||||
Der alte `combat.py` rechnete mit einer Gesamt-Offense, die die Gegner
|
||||
nacheinander abarbeitet. Das ignoriert Reichweite, Position und
|
||||
Fähigkeiten — für die Frage "wie stark ist diese Unit" also unbrauchbar.
|
||||
|
||||
Hier laufen die Units einzeln: sie suchen ein Ziel, gehen darauf zu,
|
||||
schlagen zu, sammeln Mana und zaubern. Alles deterministisch bei
|
||||
gleichem Seed.
|
||||
|
||||
Was aus den Statikdaten kommt und damit belegt ist:
|
||||
hp, armor, magicResist, damage (je Sternstufe), attackSpeed, range,
|
||||
mana, initialMana, critChance, critMultiplier,
|
||||
spell_damage (je Sternstufe), spell_damage_type, spell_scaling.
|
||||
|
||||
Was NICHT in den Daten steht und hier als Annahme gesetzt ist — jeweils
|
||||
als Konstante mit Namen, damit man sie prüfen und drehen kann:
|
||||
* Mana pro Angriff und pro erlittenem Schaden
|
||||
* Schadensminderung aus Rüstung
|
||||
* Zielwahl und Bewegung
|
||||
Diese Annahmen sind der Grund, warum die Engine gegen echte Boards
|
||||
geprüft werden muss, nicht gegen ihr eigenes Ergebnis.
|
||||
|
||||
Fähigkeiten wirken hier generisch: beim Zaubern Schaden in Höhe von
|
||||
`spell_damage` auf das aktuelle Ziel, gemindert nach Schadensart. Die
|
||||
63 Einzelwirkungen (Flächen, Buffs, Schilde) fehlen noch.
|
||||
"""
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from tft.sim import hexgrid as H
|
||||
|
||||
# Auflösung der Simulation. 30 Ticks je Sekunde reichen: der schnellste
|
||||
# Angriff im Set liegt bei rund 5 pro Sekunde.
|
||||
TPS = 30
|
||||
MAX_SECONDS = 30.0
|
||||
|
||||
# --- Annahmen, nicht aus den Daten belegt ---
|
||||
# Rüstung und Magieresistenz mindern nach derselben Formel.
|
||||
MITIGATION_BASE = 100.0
|
||||
# Mana je Angriff.
|
||||
MANA_PER_ATTACK = 10.0
|
||||
# Anteil des erlittenen Schadens, der als Mana gutgeschrieben wird.
|
||||
MANA_PER_DAMAGE = 0.01
|
||||
MANA_PER_HIT_CAP = 42.5
|
||||
# Units ohne Mana-Kosten zaubern nie.
|
||||
MIN_SPELL_MANA = 1.0
|
||||
# Grundwert der Fähigkeitsstärke. Items geben Punkte darauf, 55 AP heißt
|
||||
# also Faktor 1.55 auf den Fähigkeitsschaden.
|
||||
BASE_AP = 100.0
|
||||
# Mana-Regeneration wirkt je Sekunde.
|
||||
MANA_REGEN_PER_SECOND = 1.0
|
||||
|
||||
|
||||
# Was die Item-Effekte bedeuten. Aus den Statikdaten abgelesen, nicht
|
||||
# geraten: AD und DamageAmp sind Anteile, AP und AS Punkte, Health,
|
||||
# Armor und MagicResist flache Werte.
|
||||
ITEM_FRACTIONS = ("AD", "DamageAmp")
|
||||
ITEM_POINTS = ("AP", "AS", "CritChance", "ManaRegen")
|
||||
ITEM_FLAT = {"Health": "max_hp", "Armor": "armor", "MagicResist": "magic_resist"}
|
||||
# AD und Angriffstempo addieren sich, statt sich zu multiplizieren.
|
||||
ADDITIVE_ITEMS = True
|
||||
|
||||
# Flächenschaden: manche Fähigkeiten nennen in ihren Variablen einen
|
||||
# Radius in Feldern. Der Name ist je Champion anders, das Muster nicht —
|
||||
# "HexRadius", "AoEHexRange", "DamageHexRange". Fehlt so eine Variable,
|
||||
# trifft die Fähigkeit nur das Ziel.
|
||||
import re as _re
|
||||
AOE_RADIUS_RE = _re.compile(r"(hexradius|hexrange|aoehex|hexrangeaoe)", _re.I)
|
||||
AOE_COUNT_RE = _re.compile(r"(numenemieshit|numsecondarytargets|numenemiestofireat)",
|
||||
_re.I)
|
||||
# Größer als das halbe Feld ergibt keinen Sinn.
|
||||
MAX_AOE_RADIUS = 4
|
||||
# Der Fähigkeitsschaden aus den Daten ist der Gesamtschaden eines Zaubers.
|
||||
# Trifft er mehrere, wird er geteilt. Volle Wucht auf jedes Ziel war
|
||||
# gemessen deutlich schlechter: 68,2 % gegen 74,3 % ohne Fläche.
|
||||
SPLIT_AREA_DAMAGE = True
|
||||
# Ziel behalten oder in jedem Tick das nächste nehmen? Gemessen wird das
|
||||
# unten in der Kampfschleife; die Konstante steht hier, damit man beide
|
||||
# Varianten vergleichen kann.
|
||||
KEEP_TARGET = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class Fighter:
|
||||
"""Eine Unit im Kampf. Nur Zahlen — kein Bezug zurück ins Artefakt."""
|
||||
key: str
|
||||
side: str
|
||||
col: int
|
||||
row: int
|
||||
hp: float
|
||||
max_hp: float
|
||||
armor: float
|
||||
magic_resist: float
|
||||
attack_damage: float
|
||||
attack_speed: float
|
||||
attack_range: int
|
||||
crit_chance: float
|
||||
crit_multiplier: float
|
||||
mana: float
|
||||
max_mana: float
|
||||
spell_damage: float
|
||||
spell_magic: bool
|
||||
spell_radius: int = 0
|
||||
spell_targets: int = 1
|
||||
unit: dict = field(default_factory=dict)
|
||||
base_attack_damage: float = 0.0
|
||||
target: object = None
|
||||
slot: int = -1
|
||||
logged_death: bool = False
|
||||
ability_power: float = BASE_AP
|
||||
damage_amp: float = 0.0
|
||||
mana_regen: float = 0.0
|
||||
durability: float = 0.0
|
||||
cooldown: float = 0.0
|
||||
casts: int = 0
|
||||
damage_done: float = 0.0
|
||||
|
||||
@property
|
||||
def alive(self) -> bool:
|
||||
return self.hp > 0
|
||||
|
||||
@property
|
||||
def pos(self) -> tuple:
|
||||
return (self.col, self.row)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BattleResult:
|
||||
winner: str | None
|
||||
seconds: float
|
||||
survivors_a: list[dict]
|
||||
survivors_b: list[dict]
|
||||
fighters: list[Fighter]
|
||||
# Ereignisse für die Anzeige. Leer, wenn niemand mitschreibt.
|
||||
events: list[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
def dps(one: Fighter) -> float:
|
||||
"""Schaden je Sekunde, wie ihn die Engine austeilt.
|
||||
|
||||
Kritische Treffer gehen als Erwartungswert ein, Schadensverstärkung
|
||||
multiplikativ. Fähigkeiten fehlen — sie hängen am Mana-Fluss und
|
||||
lassen sich nicht als Dauerwert angeben.
|
||||
"""
|
||||
crit = 1.0 + one.crit_chance * (one.crit_multiplier - 1.0)
|
||||
return one.attack_damage * one.attack_speed * crit * (1.0 + one.damage_amp)
|
||||
|
||||
|
||||
def toughness(one: Fighter) -> float:
|
||||
"""Effektives Leben: wie viel roher Schaden nötig ist.
|
||||
|
||||
Rüstung und Magieresistenz werden gemittelt, weil beide Schadensarten
|
||||
vorkommen. Härte aus Traits wirkt darüber hinaus.
|
||||
"""
|
||||
resist = (one.armor + one.magic_resist) / 2.0
|
||||
factor = (MITIGATION_BASE + max(resist, 0.0)) / MITIGATION_BASE
|
||||
return one.max_hp * factor / max(1.0 - one.durability, 0.05)
|
||||
|
||||
|
||||
def mitigate(damage: float, resist: float) -> float:
|
||||
"""Schadensminderung. 100 Rüstung halbiert den Schaden."""
|
||||
return damage * MITIGATION_BASE / (MITIGATION_BASE + max(resist, 0.0))
|
||||
|
||||
|
||||
def _stat(stats: dict, key: str, default: float = 0.0) -> float:
|
||||
value = stats.get(key)
|
||||
return float(value) if value is not None else default
|
||||
|
||||
|
||||
def spell_shape(unit: dict, stars: int) -> tuple:
|
||||
"""(Radius, Zusatzziele) aus den Fähigkeits-Variablen.
|
||||
|
||||
Die Namen sind je Champion anders, das Muster nicht. Ohne Treffer
|
||||
bleibt es beim Einzelziel.
|
||||
"""
|
||||
radius = 0
|
||||
extra = 1
|
||||
for name, values in (unit.get("ability_vars") or {}).items():
|
||||
if not isinstance(values, list):
|
||||
continue
|
||||
value = _by_star(values, stars)
|
||||
if AOE_RADIUS_RE.search(name):
|
||||
radius = max(radius, min(int(value), MAX_AOE_RADIUS))
|
||||
elif AOE_COUNT_RE.search(name):
|
||||
extra = max(extra, int(value))
|
||||
return radius, extra
|
||||
|
||||
|
||||
def _by_star(values, stars: int, fallback: float = 0.0) -> float:
|
||||
"""Wert für eine Sternstufe. Riots Listen sind 1-basiert mit Leerplatz 0."""
|
||||
if not values:
|
||||
return fallback
|
||||
index = min(stars, len(values) - 1)
|
||||
return float(values[index] or fallback)
|
||||
|
||||
|
||||
# Leben je Sternstufe. Steht nicht in den Statikdaten — dort gibt es nur
|
||||
# `damageByStar`. 1.8 ist der bekannte Wert aus dem Spiel.
|
||||
#
|
||||
# Höhere Werte heben die Trefferquote gegen echte Boards deutlich: 3.0
|
||||
# kommt auf 85 % statt 76 %. Das ist aber kein besserer Kampf, sondern
|
||||
# eine Abkürzung. Auf Paaren mit GLEICHER Sternverteilung ändert der
|
||||
# Faktor fast nichts (65 % bei 1.5, 68 % bei 3.0, Fehlerbalken ±3).
|
||||
# Der Gewinn kommt allein daher, dass Boards mit mehr 3★-Units besser
|
||||
# platzieren — das würde die Engine nur nachplappern.
|
||||
STAR_HP = 1.8
|
||||
|
||||
|
||||
def to_fighter(entry: dict, side: str, static_units: dict) -> Fighter | None:
|
||||
"""Board-Eintrag -> Kämpfer. None, wenn die Unit unbekannt ist.
|
||||
|
||||
entry: {"api_name", "stars", "col", "row"}. Fehlt die Position, wird
|
||||
sie vom Aufrufer gesetzt.
|
||||
"""
|
||||
unit = static_units.get(entry.get("api_name"))
|
||||
if not unit:
|
||||
return None
|
||||
stats = unit.get("stats") or {}
|
||||
stars = max(1, min(int(entry.get("stars", 1)), 3))
|
||||
hp = _stat(stats, "hp") * (STAR_HP ** (stars - 1))
|
||||
damage = _by_star(stats.get("damageByStar"), stars, _stat(stats, "damage"))
|
||||
return Fighter(
|
||||
key=entry["api_name"],
|
||||
side=side,
|
||||
col=int(entry.get("col", 0)),
|
||||
row=int(entry.get("row", 0)),
|
||||
hp=hp,
|
||||
max_hp=hp,
|
||||
armor=_stat(stats, "armor"),
|
||||
magic_resist=_stat(stats, "magicResist"),
|
||||
attack_damage=damage,
|
||||
attack_speed=_stat(stats, "attackSpeed", 0.6),
|
||||
attack_range=int(_stat(stats, "range", 1)),
|
||||
crit_chance=_stat(stats, "critChance", 0.25),
|
||||
crit_multiplier=_stat(stats, "critMultiplier", 1.4),
|
||||
mana=_stat(stats, "initialMana"),
|
||||
max_mana=_stat(stats, "mana"),
|
||||
spell_damage=_by_star(unit.get("spell_damage"), stars),
|
||||
spell_magic=(unit.get("spell_damage_type") or "magic") != "physical",
|
||||
spell_radius=spell_shape(unit, stars)[0],
|
||||
spell_targets=spell_shape(unit, stars)[1],
|
||||
base_attack_damage=damage,
|
||||
unit=entry,
|
||||
)
|
||||
|
||||
|
||||
def apply_items(one: Fighter, items: list[str], static_items: dict,
|
||||
additive: bool = ADDITIVE_ITEMS) -> None:
|
||||
"""Item-Effekte auf die Werte eines Kämpfers rechnen.
|
||||
|
||||
Mehrfach getragene Items zählen mehrfach — genau wie im Spiel.
|
||||
|
||||
AD und Angriffstempo werden erst aufsummiert und dann einmal
|
||||
angewandt. Multiplikativ gestapelt machten drei AD-Items aus 100 %
|
||||
Angriffsschaden 320 statt 245 Prozent; damit gewann jedes Board mit
|
||||
einem vollen Carry, und die Trefferquote fiel um zwei Punkte.
|
||||
"""
|
||||
add_ad = add_as = 0.0
|
||||
for key in items:
|
||||
effects = (static_items.get(key) or {}).get("effects") or {}
|
||||
for name, value in effects.items():
|
||||
if name == "AD":
|
||||
add_ad += value
|
||||
if not additive:
|
||||
one.attack_damage *= 1.0 + value
|
||||
elif name == "AS":
|
||||
add_as += value / 100.0
|
||||
if not additive:
|
||||
one.attack_speed *= 1.0 + value / 100.0
|
||||
elif name == "AP":
|
||||
one.ability_power += value
|
||||
elif name == "CritChance":
|
||||
one.crit_chance += value / 100.0
|
||||
elif name == "DamageAmp":
|
||||
one.damage_amp += value
|
||||
elif name == "ManaRegen":
|
||||
one.mana_regen += value
|
||||
elif name in ITEM_FLAT:
|
||||
field_name = ITEM_FLAT[name]
|
||||
setattr(one, field_name, getattr(one, field_name) + value)
|
||||
if additive:
|
||||
one.attack_damage *= 1.0 + add_ad
|
||||
one.attack_speed *= 1.0 + add_as
|
||||
one.hp = one.max_hp
|
||||
|
||||
|
||||
def _nearest(one: Fighter, foes: list[Fighter]) -> Fighter | None:
|
||||
"""Nächster lebender Gegner. Gleichstand entscheidet die Feldnummer.
|
||||
|
||||
Ohne den festen Tiebreak hinge der Kampf an der Listenreihenfolge.
|
||||
"""
|
||||
best, best_key = None, None
|
||||
for foe in foes:
|
||||
if not foe.alive:
|
||||
continue
|
||||
key = (H.distance(one.pos, foe.pos), foe.row, foe.col)
|
||||
if best_key is None or key < best_key:
|
||||
best, best_key = foe, key
|
||||
return best
|
||||
|
||||
|
||||
def _step_towards(one: Fighter, target: Fighter, taken: set) -> None:
|
||||
"""Ein Feld näher. Belegte Felder werden umgangen."""
|
||||
now = H.distance(one.pos, target.pos)
|
||||
best, best_key = None, None
|
||||
for cell in H.neighbours(one.col, one.row):
|
||||
if cell in taken:
|
||||
continue
|
||||
key = (H.distance(cell, target.pos), cell[1], cell[0])
|
||||
if key[0] < now and (best_key is None or key < best_key):
|
||||
best, best_key = cell, key
|
||||
if best:
|
||||
taken.discard(one.pos)
|
||||
taken.add(best)
|
||||
one.col, one.row = best
|
||||
|
||||
|
||||
def _log(events: list, tick: int, kind: str, actor: Fighter,
|
||||
target: Fighter, everyone: list) -> None:
|
||||
"""Ein Ereignis festhalten. Tote Ziele bekommen einen eigenen Eintrag."""
|
||||
events.append({"t": tick, "e": kind, "u": actor.slot, "to": target.slot,
|
||||
"hp": round(max(target.hp, 0))})
|
||||
for one in everyone:
|
||||
if not one.alive and not getattr(one, "logged_death", False):
|
||||
one.logged_death = True
|
||||
events.append({"t": tick, "e": "die", "u": one.slot})
|
||||
|
||||
|
||||
def _gain_mana(one: Fighter, amount: float) -> None:
|
||||
if one.max_mana >= MIN_SPELL_MANA:
|
||||
one.mana = min(one.mana + amount, one.max_mana)
|
||||
|
||||
|
||||
def _hit(attacker: Fighter, target: Fighter, rng: random.Random) -> None:
|
||||
raw = attacker.attack_damage * (1.0 + attacker.damage_amp)
|
||||
if rng.random() < attacker.crit_chance:
|
||||
raw *= attacker.crit_multiplier
|
||||
dealt = mitigate(raw, target.armor) * (1.0 - target.durability)
|
||||
target.hp -= dealt
|
||||
attacker.damage_done += dealt
|
||||
_gain_mana(attacker, MANA_PER_ATTACK)
|
||||
_gain_mana(target, min(raw * MANA_PER_DAMAGE, MANA_PER_HIT_CAP))
|
||||
|
||||
|
||||
def _cast(caster: Fighter, target: Fighter, foes: list[Fighter]) -> None:
|
||||
"""Fähigkeit auf das Ziel und, wenn sie eine Fläche hat, auf dessen Umfeld."""
|
||||
# AP-Fähigkeiten skalieren mit der Fähigkeitsstärke, AD-Fähigkeiten
|
||||
# mit dem Angriffsschaden. `spell_damage` gilt für die nackte Unit,
|
||||
# ein Carry mit drei Items schlägt also entsprechend härter zu.
|
||||
if caster.spell_magic:
|
||||
scale = caster.ability_power / BASE_AP
|
||||
else:
|
||||
base = caster.base_attack_damage or 1.0
|
||||
scale = caster.attack_damage / base
|
||||
raw = caster.spell_damage * scale * (1.0 + caster.damage_amp)
|
||||
|
||||
hit = [target]
|
||||
if caster.spell_radius or caster.spell_targets > 1:
|
||||
others = [f for f in foes
|
||||
if f.alive and f is not target
|
||||
and H.distance(target.pos, f.pos) <= max(caster.spell_radius, 1)]
|
||||
others.sort(key=lambda f: (H.distance(target.pos, f.pos), f.row, f.col))
|
||||
hit += others[:max(caster.spell_targets - 1, len(others)
|
||||
if caster.spell_radius else 0)]
|
||||
|
||||
share = raw / len(hit) if SPLIT_AREA_DAMAGE else raw
|
||||
for one in hit:
|
||||
resist = one.magic_resist if caster.spell_magic else one.armor
|
||||
dealt = mitigate(share, resist) * (1.0 - one.durability)
|
||||
one.hp -= dealt
|
||||
caster.damage_done += dealt
|
||||
caster.casts += 1
|
||||
caster.mana = 0.0
|
||||
|
||||
|
||||
def fight(team_a: list[Fighter], team_b: list[Fighter], rng: random.Random,
|
||||
max_seconds: float = MAX_SECONDS, record: bool = False) -> BattleResult:
|
||||
"""Beide Seiten ticken gleichzeitig. Gleicher Seed, gleicher Ausgang.
|
||||
|
||||
Mit `record` wird ein Ereignis-Protokoll mitgeschrieben: wer sich wohin
|
||||
bewegt, wer wen trifft, wer stirbt. Das Frontend spielt es ab. Ohne
|
||||
den Schalter kostet es nichts — die Auswertung läuft millionenfach.
|
||||
"""
|
||||
everyone = team_a + team_b
|
||||
taken = {f.pos for f in everyone}
|
||||
dt = 1.0 / TPS
|
||||
ticks = int(max_seconds * TPS)
|
||||
elapsed = 0.0
|
||||
events: list[dict] = []
|
||||
if record:
|
||||
for i, one in enumerate(everyone):
|
||||
one.slot = i
|
||||
events.append({"t": 0, "e": "spawn", "u": i, "side": one.side,
|
||||
"name": one.key, "col": one.col, "row": one.row,
|
||||
"hp": round(one.hp)})
|
||||
|
||||
for tick in range(ticks):
|
||||
if not any(f.alive for f in team_a) or not any(f.alive for f in team_b):
|
||||
break
|
||||
elapsed += dt
|
||||
for one in everyone:
|
||||
if not one.alive:
|
||||
continue
|
||||
foes = team_b if one.side == "a" else team_a
|
||||
# Ziel behalten, solange es lebt und in Reichweite ist. Sonst
|
||||
# neu suchen. Nur "solange es lebt" war zu wenig: Units liefen
|
||||
# dann einem weit entfernten Ziel hinterher, statt den Gegner
|
||||
# vor der Nase zu schlagen — die Trefferquote fiel um zehn Punkte.
|
||||
target = one.target
|
||||
if (not KEEP_TARGET or target is None or not target.alive
|
||||
or H.distance(one.pos, target.pos) > one.attack_range):
|
||||
target = one.target = _nearest(one, foes)
|
||||
if target is None:
|
||||
continue
|
||||
if H.distance(one.pos, target.pos) > one.attack_range:
|
||||
before = one.pos
|
||||
_step_towards(one, target, taken)
|
||||
if record and one.pos != before:
|
||||
events.append({"t": tick, "e": "move", "u": one.slot,
|
||||
"col": one.col, "row": one.row})
|
||||
continue
|
||||
# Zaubern hat Vorrang vor dem nächsten Angriff.
|
||||
if one.max_mana >= MIN_SPELL_MANA and one.mana >= one.max_mana:
|
||||
_cast(one, target, foes)
|
||||
if record:
|
||||
_log(events, tick, "cast", one, target, everyone)
|
||||
continue
|
||||
_gain_mana(one, one.mana_regen * MANA_REGEN_PER_SECOND * dt)
|
||||
one.cooldown -= dt
|
||||
if one.cooldown <= 0.0:
|
||||
_hit(one, target, rng)
|
||||
if record:
|
||||
_log(events, tick, "hit", one, target, everyone)
|
||||
one.cooldown = 1.0 / max(one.attack_speed, 0.1)
|
||||
|
||||
alive_a = [f for f in team_a if f.alive]
|
||||
alive_b = [f for f in team_b if f.alive]
|
||||
if alive_a and alive_b:
|
||||
# Zeit abgelaufen: mehr verbleibendes Leben gewinnt.
|
||||
rest_a = sum(f.hp for f in alive_a)
|
||||
rest_b = sum(f.hp for f in alive_b)
|
||||
winner = "a" if rest_a > rest_b else "b" if rest_b > rest_a else None
|
||||
else:
|
||||
winner = "a" if alive_a else "b" if alive_b else None
|
||||
return BattleResult(winner=winner, seconds=round(elapsed, 3),
|
||||
survivors_a=[f.unit for f in alive_a],
|
||||
survivors_b=[f.unit for f in alive_b],
|
||||
fighters=everyone, events=events)
|
||||
@@ -1,107 +0,0 @@
|
||||
"""Deterministischer Kampf: Gesamt-Offense arbeitet die Gegner-Units nacheinander ab.
|
||||
|
||||
Zielreihenfolge pro Seite: erst defensive Units (seeded zufällig gemischt),
|
||||
dann offensive. Tote Units tragen keine Offense mehr — die Gesamt-Offense
|
||||
einer Seite ist die Suffix-Summe ab dem aktuellen Ziel.
|
||||
|
||||
Ereignisgesteuert statt Frame-Schleife: zwischen zwei Todesereignissen sind
|
||||
beide Offensen konstant, die Zeit bis zum nächsten Kill ist exakt def/off.
|
||||
Das ist der Grenzwert der 60-FPS-Rechnung (Überschuss-Schaden trägt verlustfrei
|
||||
über); die Frames dienen nur als Raster für Dauer und Zeitlimit.
|
||||
"""
|
||||
|
||||
import math
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
|
||||
FPS = 60
|
||||
EPS = 1e-9
|
||||
|
||||
|
||||
@dataclass
|
||||
class FightResult:
|
||||
winner: str | None # "a" | "b" | None = Unentschieden
|
||||
frames: int
|
||||
survivors_a: list[dict]
|
||||
survivors_b: list[dict]
|
||||
|
||||
|
||||
def _queue(team: list[dict], rng: random.Random) -> list[dict]:
|
||||
"""Sterbereihenfolge: defensive Units zuerst, innerhalb der Gruppe zufällig."""
|
||||
defensive = [p for p in team if p["defensive"]]
|
||||
offensive = [p for p in team if not p["defensive"]]
|
||||
rng.shuffle(defensive)
|
||||
rng.shuffle(offensive)
|
||||
return defensive + offensive
|
||||
|
||||
|
||||
def _suffix_off(queue: list[dict]) -> list[float]:
|
||||
suffix = [0.0] * (len(queue) + 1)
|
||||
for i in range(len(queue) - 1, -1, -1):
|
||||
suffix[i] = suffix[i + 1] + queue[i]["off"]
|
||||
return suffix
|
||||
|
||||
|
||||
def fight(team_a: list[dict], team_b: list[dict], rng: random.Random,
|
||||
cfg: dict) -> FightResult:
|
||||
"""team_a/team_b: Profile aus score.board_profiles(). Beide Seiten ticken
|
||||
gleichzeitig; same seed -> same outcome."""
|
||||
qa, qb = _queue(team_a, rng), _queue(team_b, rng)
|
||||
off_a, off_b = _suffix_off(qa), _suffix_off(qb)
|
||||
limit = cfg["combat"]["max_frames"] / FPS
|
||||
|
||||
pa = pb = 0 # Zeiger auf die aktuell beschossene eigene Unit
|
||||
ra = qa[0]["def"] if qa else 0.0 # deren Rest-Defense
|
||||
rb = qb[0]["def"] if qb else 0.0
|
||||
elapsed = 0.0
|
||||
|
||||
while pa < len(qa) and pb < len(qb):
|
||||
oa, ob = off_a[pa], off_b[pb]
|
||||
ta = rb / oa if oa > EPS else math.inf # Zeit bis A das B-Ziel killt
|
||||
tb = ra / ob if ob > EPS else math.inf
|
||||
dt = min(ta, tb)
|
||||
if math.isinf(dt) or elapsed + dt >= limit:
|
||||
dt = limit - elapsed
|
||||
ra -= ob * dt
|
||||
rb -= oa * dt
|
||||
elapsed = limit
|
||||
break
|
||||
elapsed += dt
|
||||
a_kills = ta <= tb + EPS
|
||||
b_kills = tb <= ta + EPS
|
||||
if a_kills:
|
||||
pb += 1
|
||||
rb = qb[pb]["def"] if pb < len(qb) else 0.0
|
||||
else:
|
||||
rb -= oa * dt
|
||||
if b_kills:
|
||||
pa += 1
|
||||
ra = qa[pa]["def"] if pa < len(qa) else 0.0
|
||||
else:
|
||||
ra -= ob * dt
|
||||
|
||||
a_alive, b_alive = pa < len(qa), pb < len(qb)
|
||||
if a_alive and b_alive:
|
||||
# Zeitlimit erreicht: mehr verbleibende Gesamt-Defense gewinnt.
|
||||
rest_a = ra + sum(p["def"] for p in qa[pa + 1:])
|
||||
rest_b = rb + sum(p["def"] for p in qb[pb + 1:])
|
||||
winner = "a" if rest_a > rest_b + EPS else "b" if rest_b > rest_a + EPS else None
|
||||
elif a_alive:
|
||||
winner = "a"
|
||||
elif b_alive:
|
||||
winner = "b"
|
||||
else:
|
||||
winner = None
|
||||
|
||||
return FightResult(
|
||||
winner=winner,
|
||||
frames=math.ceil(elapsed * FPS),
|
||||
survivors_a=[p["unit"] for p in qa[pa:]],
|
||||
survivors_b=[p["unit"] for p in qb[pb:]],
|
||||
)
|
||||
|
||||
|
||||
def player_damage(stage: int, survivors: list[dict], cfg: dict) -> int:
|
||||
d = cfg["damage"]
|
||||
base = d["stage_base"][min(stage - 1, len(d["stage_base"]) - 1)]
|
||||
return base + d["per_surviving_unit"] * len(survivors)
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import random
|
||||
|
||||
from tft.model.score import board_profiles
|
||||
from tft.sim import combat, economy, player, policy
|
||||
from tft.sim import arena, economy, player, policy
|
||||
from tft.sim.player import BENCH_SIZE, MAX_ITEMS_PER_UNIT, InvalidAction, PlayerState # noqa: F401
|
||||
from tft.sim.pool import Pool
|
||||
from tft.sim.rounds import schedule
|
||||
@@ -11,6 +10,22 @@ from tft.sim.rounds import schedule
|
||||
BOT_NAMES = ["Aatrox", "Belle", "Cyrus", "Dana", "Eiko", "Finn", "Gwen"]
|
||||
|
||||
|
||||
def _flip(log: dict) -> dict:
|
||||
"""Seiten tauschen, damit der Spieler in der Anzeige immer "a" ist.
|
||||
|
||||
Die Reihen müssen mitgespiegelt werden. Nur die Beschriftung zu
|
||||
tauschen würde die eigenen Units auf der Gegnerhälfte zeichnen.
|
||||
"""
|
||||
swap = {"a": "b", "b": "a"}
|
||||
last = log["rows"] - 1
|
||||
events = [{**e, "row": last - e["row"]} if "row" in e else e
|
||||
for e in log["events"]]
|
||||
return {**log,
|
||||
"winner": swap.get(log["winner"]) if log["winner"] else None,
|
||||
"units": [{**u, "side": swap[u["side"]]} for u in log["units"]],
|
||||
"events": events}
|
||||
|
||||
|
||||
def pair_players(alive: list[PlayerState], rng: random.Random):
|
||||
"""PvP-Paarung: kein Wiederholungsgegner bei >2 Lebenden; ungerade -> Ghost."""
|
||||
order = list(alive)
|
||||
@@ -31,7 +46,12 @@ class Game:
|
||||
def __init__(self, artifact: dict, cfg: dict, seed: int | None = None):
|
||||
self.artifact = artifact
|
||||
self.cfg = cfg
|
||||
# Der Seed bleibt gemerkt: nur so lässt sich ein Kampf später
|
||||
# mit demselben Ausgang nachspielen.
|
||||
self.seed = seed
|
||||
self.rng = random.Random(seed)
|
||||
# Der letzte Kampf des Spielers, als Protokoll für die Anzeige.
|
||||
self.last_battle = None
|
||||
self.pool = Pool(artifact["static"]["units"], cfg["pool"]["sizes"])
|
||||
self.rounds = schedule(cfg)
|
||||
self.idx = 0
|
||||
@@ -70,8 +90,8 @@ class Game:
|
||||
def over(self) -> bool:
|
||||
return self.player.placement is not None
|
||||
|
||||
def player_score(self) -> float:
|
||||
return player.score(self.player, self.artifact)
|
||||
def player_strength(self) -> dict:
|
||||
return player.strength(self.player, self.artifact)
|
||||
|
||||
def _alive(self) -> list[PlayerState]:
|
||||
return [p for p in self.players if p.alive]
|
||||
@@ -132,7 +152,10 @@ class Game:
|
||||
player.buy_xp(self.player, self.cfg)
|
||||
|
||||
def move(self, where: str, idx: int) -> None:
|
||||
player.move(self.player, where, idx)
|
||||
player.move(self.player, where, idx, self.artifact)
|
||||
|
||||
def place(self, where: str, idx: int, hexid: int) -> None:
|
||||
player.place(self.player, where, idx, hexid)
|
||||
|
||||
def equip(self, item_idx: int, board_idx: int) -> None:
|
||||
player.equip(self.player, item_idx, board_idx, self.artifact)
|
||||
@@ -241,14 +264,15 @@ class Game:
|
||||
results[b.name] = a_wins is False
|
||||
if odd is not None:
|
||||
# Ghost-Kampf: Klon-Board, Schaden nur beim echten Spieler.
|
||||
res = combat.fight(board_profiles(odd.board, self.artifact),
|
||||
board_profiles(ghost_src.board, self.artifact),
|
||||
self.rng, self.cfg)
|
||||
res, log = arena.fight(odd.board, ghost_src.board, self.artifact,
|
||||
self.rng, record=odd is self.player)
|
||||
if log:
|
||||
self.last_battle = log
|
||||
won = res.winner == "a"
|
||||
results[odd.name] = won
|
||||
if not won:
|
||||
survivors = res.survivors_b if res.winner == "b" else []
|
||||
dmg = combat.player_damage(rnd["stage"], survivors, self.cfg)
|
||||
dmg = arena.player_damage(rnd["stage"], survivors, self.cfg)
|
||||
odd.hp -= dmg
|
||||
if odd is self.player:
|
||||
self.log.append(
|
||||
@@ -258,12 +282,15 @@ class Game:
|
||||
return results
|
||||
|
||||
def _fight(self, rnd: dict, a: PlayerState, b: PlayerState) -> bool | None:
|
||||
res = combat.fight(board_profiles(a.board, self.artifact),
|
||||
board_profiles(b.board, self.artifact),
|
||||
self.rng, self.cfg)
|
||||
mine = a is self.player or b is self.player
|
||||
res, log = arena.fight(a.board, b.board, self.artifact, self.rng,
|
||||
record=mine)
|
||||
if log:
|
||||
# Aus Sicht des Spielers protokollieren: "a" ist immer er.
|
||||
self.last_battle = log if a is self.player else _flip(log)
|
||||
if res.winner is None:
|
||||
# Unentschieden: beide nehmen den Basis-Schaden der Stage.
|
||||
dmg = combat.player_damage(rnd["stage"], [], self.cfg)
|
||||
dmg = arena.player_damage(rnd["stage"], [], self.cfg)
|
||||
a.hp -= dmg
|
||||
b.hp -= dmg
|
||||
if a is self.player or b is self.player:
|
||||
@@ -273,7 +300,7 @@ class Game:
|
||||
a_wins = res.winner == "a"
|
||||
winner, loser = (a, b) if a_wins else (b, a)
|
||||
survivors = res.survivors_a if a_wins else res.survivors_b
|
||||
dmg = combat.player_damage(rnd["stage"], survivors, self.cfg)
|
||||
dmg = arena.player_damage(rnd["stage"], survivors, self.cfg)
|
||||
loser.hp -= dmg
|
||||
if a is self.player or b is self.player:
|
||||
if winner is self.player:
|
||||
|
||||
88
backend/tft/sim/hexgrid.py
Normal file
88
backend/tft/sim/hexgrid.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Das Spielfeld: 7 Spalten, 8 Reihen, versetzte Sechsecke.
|
||||
|
||||
TFT stellt die Reihen versetzt an. Reihe 0 und 2 beginnen bündig, Reihe 1
|
||||
und 3 sind eine halbe Zelle nach rechts geschoben. Für Entfernungen taugt
|
||||
das direkt nicht — zwei Felder mit demselben Spaltenabstand liegen je nach
|
||||
Reihe unterschiedlich weit auseinander.
|
||||
|
||||
Deshalb wird intern in Würfelkoordinaten gerechnet. Dort ist die Distanz
|
||||
die halbe Summe der Beträge, und die sechs Nachbarn sind sechs feste
|
||||
Richtungen. Umgerechnet wird an genau einer Stelle, damit sich der Fehler
|
||||
nicht durch den Kampf zieht.
|
||||
|
||||
Reihen 0-3 gehören dem Angreifer, 4-7 dem Verteidiger.
|
||||
"""
|
||||
|
||||
COLS = 7
|
||||
ROWS = 8
|
||||
HALF = ROWS // 2
|
||||
|
||||
# Die sechs Nachbarn in Würfelkoordinaten.
|
||||
DIRECTIONS = ((1, -1, 0), (1, 0, -1), (0, 1, -1),
|
||||
(-1, 1, 0), (-1, 0, 1), (0, -1, 1))
|
||||
|
||||
|
||||
def valid(col: int, row: int) -> bool:
|
||||
return 0 <= col < COLS and 0 <= row < ROWS
|
||||
|
||||
|
||||
def to_cube(col: int, row: int) -> tuple:
|
||||
"""Versetzte Reihen -> Würfelkoordinaten ("odd-r"-Anordnung)."""
|
||||
x = col - (row - (row & 1)) // 2
|
||||
z = row
|
||||
return (x, -x - z, z)
|
||||
|
||||
|
||||
def _raw_distance(a: tuple, b: tuple) -> int:
|
||||
ax, ay, az = to_cube(*a)
|
||||
bx, by, bz = to_cube(*b)
|
||||
return (abs(ax - bx) + abs(ay - by) + abs(az - bz)) // 2
|
||||
|
||||
|
||||
# Das Feld hat 56 Zellen, also 3.136 Paare. Einmal vorrechnen spart im
|
||||
# Kampf über 100.000 Umrechnungen je Duell.
|
||||
_DISTANCE = {(a, b): _raw_distance(a, b)
|
||||
for a in ((c, r) for r in range(ROWS) for c in range(COLS))
|
||||
for b in ((c, r) for r in range(ROWS) for c in range(COLS))}
|
||||
|
||||
|
||||
def distance(a: tuple, b: tuple) -> int:
|
||||
"""Entfernung in Feldern zwischen zwei (Spalte, Reihe)-Paaren."""
|
||||
found = _DISTANCE.get((a, b))
|
||||
return found if found is not None else _raw_distance(a, b)
|
||||
|
||||
|
||||
_NEIGHBOURS: dict = {}
|
||||
|
||||
|
||||
def neighbours(col: int, row: int) -> list[tuple]:
|
||||
"""Die begehbaren Nachbarfelder, immer in derselben Reihenfolge.
|
||||
|
||||
Die feste Reihenfolge ist wichtig: ohne sie hinge der Ausgang eines
|
||||
Kampfes davon ab, wie ein Dictionary gerade sortiert ist.
|
||||
"""
|
||||
cached = _NEIGHBOURS.get((col, row))
|
||||
if cached is not None:
|
||||
return cached
|
||||
x, y, z = to_cube(col, row)
|
||||
out = []
|
||||
for dx, dy, dz in DIRECTIONS:
|
||||
nx, nz = x + dx, z + dz
|
||||
ncol = nx + (nz - (nz & 1)) // 2
|
||||
if valid(ncol, nz):
|
||||
out.append((ncol, nz))
|
||||
_NEIGHBOURS[(col, row)] = out
|
||||
return out
|
||||
|
||||
|
||||
def mirror(col: int, row: int) -> tuple:
|
||||
"""Ein Feld auf die andere Hälfte spiegeln.
|
||||
|
||||
Im Spiel steht der Gegner gespiegelt gegenüber; nur so heißt "vorne"
|
||||
für beide Seiten dasselbe.
|
||||
"""
|
||||
return (COLS - 1 - col, ROWS - 1 - row)
|
||||
|
||||
|
||||
def own_half(row: int, side: str) -> bool:
|
||||
return row < HALF if side == "a" else row >= HALF
|
||||
49
backend/tft/sim/lineup.py
Normal file
49
backend/tft/sim/lineup.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Aufstellung: Board-Einträge auf Felder setzen.
|
||||
|
||||
Die Endboards aus der Riot-API tragen keine Positionen. Für den Kampf
|
||||
braucht es aber welche. Deshalb eine feste Regel: Nahkämpfer nach vorne,
|
||||
Fernkämpfer nach hinten, innerhalb einer Reihe von der Mitte nach außen.
|
||||
|
||||
Die Regel ist eine Annahme, keine Messung — echte Spieler stellen anders
|
||||
auf. Sie ist nur deterministisch und plausibel, damit derselbe Board
|
||||
immer dieselbe Aufstellung ergibt.
|
||||
"""
|
||||
|
||||
from tft.sim import hexgrid as H
|
||||
|
||||
# Reihe 0 ist die vorderste der eigenen Hälfte.
|
||||
MELEE_RANGE = 1
|
||||
# Von der Mitte nach außen füllen — so steht der Carry geschützt.
|
||||
COLUMN_ORDER = (3, 2, 4, 1, 5, 0, 6)
|
||||
|
||||
|
||||
def _rows(side: str) -> list[int]:
|
||||
"""Reihen der eigenen Hälfte, vorderste zuerst."""
|
||||
if side == "a":
|
||||
return [H.HALF - 1, H.HALF - 2, H.HALF - 3, H.HALF - 4]
|
||||
return [H.HALF, H.HALF + 1, H.HALF + 2, H.HALF + 3]
|
||||
|
||||
|
||||
def place(board: list[dict], side: str, static_units: dict) -> list[dict]:
|
||||
"""Board-Einträge mit col/row versehen. Reihenfolge bleibt erhalten.
|
||||
|
||||
Nahkämpfer bekommen die vorderen Reihen, Fernkämpfer die hinteren.
|
||||
Innerhalb einer Reihe wird von der Mitte nach außen gefüllt.
|
||||
"""
|
||||
def reach(entry):
|
||||
stats = (static_units.get(entry.get("api_name")) or {}).get("stats") or {}
|
||||
return int(stats.get("range") or MELEE_RANGE)
|
||||
|
||||
front = [e for e in board if reach(e) <= MELEE_RANGE]
|
||||
back = [e for e in board if reach(e) > MELEE_RANGE]
|
||||
rows = _rows(side)
|
||||
cells = [(col, row) for row in rows for col in COLUMN_ORDER]
|
||||
|
||||
out = []
|
||||
# Nahkämpfer von vorne, Fernkämpfer von hinten — sie treffen sich
|
||||
# in der Mitte, wenn das Board voll ist.
|
||||
for entry, cell in zip(front, cells):
|
||||
out.append({**entry, "col": cell[0], "row": cell[1]})
|
||||
for entry, cell in zip(back, reversed(cells[len(front):])):
|
||||
out.append({**entry, "col": cell[0], "row": cell[1]})
|
||||
return out
|
||||
@@ -3,13 +3,17 @@
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from tft.model.score import score_board
|
||||
# Die vier eigenen Reihen mal sieben Spalten. Das Kampffeld in
|
||||
# `hexgrid` hat acht Reihen — vier je Seite.
|
||||
BOARD_ROWS = 4
|
||||
BOARD_COLS = 7
|
||||
from tft.sim import economy
|
||||
from tft.sim.pool import Pool
|
||||
from tft.sim.shop import roll
|
||||
|
||||
BENCH_SIZE = 9
|
||||
MAX_ITEMS_PER_UNIT = 3
|
||||
BOARD_HEXES = BOARD_ROWS * BOARD_COLS
|
||||
|
||||
|
||||
class InvalidAction(Exception):
|
||||
@@ -52,8 +56,11 @@ def new_player(name: str, archetype: str, cfg: dict) -> PlayerState:
|
||||
)
|
||||
|
||||
|
||||
def score(p: PlayerState, artifact: dict) -> float:
|
||||
return score_board(p.board, p.augments, artifact)
|
||||
def strength(p: PlayerState, artifact: dict) -> dict:
|
||||
"""Offensiv-/Defensivstärke des Boards, 1.0 = Ø-Champion auf 1★."""
|
||||
from tft.sim import arena
|
||||
|
||||
return arena.strength(p.board, artifact)
|
||||
|
||||
|
||||
def unit_worth(artifact: dict, u: dict) -> float:
|
||||
@@ -70,11 +77,14 @@ def merge(p: PlayerState, api_name: str, stars: int) -> None:
|
||||
if u["api_name"] == api_name and u["stars"] == stars]
|
||||
if len(copies) < 3:
|
||||
return
|
||||
keep, *rest = copies
|
||||
# Genau 3 Kopien verschmelzen — überzählige bleiben als eigene Unit stehen.
|
||||
keep, *rest = copies[:3]
|
||||
keep["stars"] += 1
|
||||
for u in rest:
|
||||
keep["items"].extend(u["items"])
|
||||
(p.board if u in p.board else p.bench).remove(u)
|
||||
# Mehr als 3 Items: Überschuss zurück in die Leiste statt verfallen lassen.
|
||||
p.items.extend(keep["items"][MAX_ITEMS_PER_UNIT:])
|
||||
keep["items"] = keep["items"][:MAX_ITEMS_PER_UNIT]
|
||||
stars += 1
|
||||
|
||||
@@ -131,7 +141,20 @@ def buy_xp(p: PlayerState, cfg: dict) -> None:
|
||||
p.level, p.xp = economy.apply_xp(p.level, p.xp, xp["buy_amount"], cfg)
|
||||
|
||||
|
||||
def move(p: PlayerState, where: str, idx: int) -> None:
|
||||
def is_ranged(artifact: dict, u: dict) -> bool:
|
||||
unit = artifact["static"]["units"].get(u["api_name"]) or {}
|
||||
return (unit.get("stats") or {}).get("range", 1) > 1
|
||||
|
||||
|
||||
def free_hex(p: PlayerState, back_first: bool = False) -> int | None:
|
||||
"""Nächster freier Hex. Fernkämpfer fangen hinten an, Nahkämpfer vorn."""
|
||||
rows = reversed(range(BOARD_ROWS)) if back_first else range(BOARD_ROWS)
|
||||
taken = {u.get("hex") for u in p.board}
|
||||
order = [r * BOARD_COLS + c for r in rows for c in range(BOARD_COLS)]
|
||||
return next((h for h in order if h not in taken), None)
|
||||
|
||||
|
||||
def move(p: PlayerState, where: str, idx: int, artifact: dict | None = None) -> None:
|
||||
src, dst = (p.bench, p.board) if where == "bench" else (p.board, p.bench)
|
||||
if not (0 <= idx < len(src)):
|
||||
raise InvalidAction("no unit there")
|
||||
@@ -139,7 +162,45 @@ def move(p: PlayerState, where: str, idx: int) -> None:
|
||||
raise InvalidAction("board full for your level")
|
||||
if dst is p.bench and len(p.bench) >= BENCH_SIZE:
|
||||
raise InvalidAction("bench full")
|
||||
dst.append(src.pop(idx))
|
||||
unit = src.pop(idx)
|
||||
if dst is p.board:
|
||||
unit["hex"] = free_hex(p, artifact is not None and is_ranged(artifact, unit))
|
||||
else:
|
||||
unit["hex"] = None
|
||||
dst.append(unit)
|
||||
|
||||
|
||||
def place(p: PlayerState, where: str, idx: int, hexid: int) -> None:
|
||||
"""Unit auf ein Hex ziehen — von der Bank oder innerhalb des Kampffelds."""
|
||||
if not (0 <= hexid < BOARD_HEXES):
|
||||
raise InvalidAction("no such hex")
|
||||
# Eine Bedingung für beide Zweige — sonst prüft der eine gegen das Board
|
||||
# und der andere greift auf die Bank zu.
|
||||
from_bench = where == "bench"
|
||||
src = p.bench if from_bench else p.board
|
||||
if not (0 <= idx < len(src)):
|
||||
raise InvalidAction("no unit there")
|
||||
unit = src[idx]
|
||||
occupant = next((u for u in p.board if u.get("hex") == hexid), None)
|
||||
if occupant is unit:
|
||||
return
|
||||
|
||||
if not from_bench:
|
||||
# Tausch innerhalb des Felds: der Belegte übernimmt das alte Hex.
|
||||
if occupant is not None:
|
||||
occupant["hex"] = unit["hex"]
|
||||
unit["hex"] = hexid
|
||||
return
|
||||
|
||||
if occupant is None and len(p.board) >= p.level:
|
||||
raise InvalidAction("board full for your level")
|
||||
p.bench.pop(idx)
|
||||
if occupant is not None:
|
||||
p.board.remove(occupant)
|
||||
occupant["hex"] = None
|
||||
p.bench.append(occupant)
|
||||
unit["hex"] = hexid
|
||||
p.board.append(unit)
|
||||
|
||||
|
||||
def equip(p: PlayerState, item_idx: int, board_idx: int, artifact: dict | None = None) -> None:
|
||||
@@ -200,7 +261,9 @@ def fill_board(p: PlayerState, artifact: dict, swap: bool = True) -> None:
|
||||
swap=False für den Menschen: seine Aufstellung wird nie überstimmt."""
|
||||
p.bench.sort(key=lambda u: -unit_worth(artifact, u))
|
||||
while len(p.board) < p.level and p.bench:
|
||||
p.board.append(p.bench.pop(0))
|
||||
unit = p.bench.pop(0)
|
||||
unit["hex"] = free_hex(p, is_ranged(artifact, unit))
|
||||
p.board.append(unit)
|
||||
if not swap:
|
||||
return
|
||||
for i, u in enumerate(p.board):
|
||||
@@ -209,5 +272,10 @@ def fill_board(p: PlayerState, artifact: dict, swap: bool = True) -> None:
|
||||
best = max(p.bench, key=lambda b: unit_worth(artifact, b))
|
||||
if unit_worth(artifact, best) > unit_worth(artifact, u):
|
||||
p.bench.remove(best)
|
||||
u["hex"] = None
|
||||
p.bench.append(u)
|
||||
# Neu einsortieren statt das Hex zu erben — sonst landet ein
|
||||
# Fernkämpfer in der Front, nur weil dort vorher jemand stand.
|
||||
best["hex"] = None
|
||||
p.board[i] = best
|
||||
best["hex"] = free_hex(p, is_ranged(artifact, best))
|
||||
|
||||
@@ -8,7 +8,10 @@ class Pool:
|
||||
for api, u in static_units.items()
|
||||
if 1 <= u["cost"] <= len(sizes)
|
||||
}
|
||||
self.cost_of = {api: u["cost"] for api, u in static_units.items()}
|
||||
# Nur Units, die auch im Pool liegen — sonst KeyError in units_of_cost,
|
||||
# sobald ein Set Kostenstufen außerhalb von sizes mitbringt.
|
||||
self.cost_of = {api: u["cost"] for api, u in static_units.items()
|
||||
if api in self.copies}
|
||||
|
||||
def available(self, api_name: str) -> int:
|
||||
return self.copies.get(api_name, 0)
|
||||
|
||||
158
backend/tft/sim/traits.py
Normal file
158
backend/tft/sim/traits.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""Trait-Boni auf die Kämpfer rechnen.
|
||||
|
||||
Die Statikdaten geben je Trait und Stufe einen Satz Variablen mit freien
|
||||
Namen — `DefenderDefenseGain`, `HunterAD`, `BrawlerHealthPercent`. Es
|
||||
gibt kein Schema dazu. Deshalb hier eine Tabelle, die Namensteile auf
|
||||
Werte abbildet, und zwei Regeln, die aus den Daten abgelesen sind:
|
||||
|
||||
* Enthält der Name "Team", gilt der Bonus für das ganze Board.
|
||||
Sonst nur für die Units mit diesem Trait.
|
||||
* Werte unter 1 sind Anteile, Werte darüber flache Zahlen. Das ist
|
||||
nicht schön, aber belegt: `Blossom.ADAP` läuft von 0.12 bis 1.0,
|
||||
`Fae.ADAP` steht bei 5 und 8. Derselbe Name, zwei Skalen.
|
||||
|
||||
Dreizehn der 36 Traits haben gar keine Variablen — Beschwörer, Wandler
|
||||
und ähnliche Verhaltens-Traits. Die fehlen hier und sind unten gelistet,
|
||||
damit klar ist, was die Engine noch nicht kann.
|
||||
"""
|
||||
|
||||
from collections import Counter
|
||||
|
||||
# Namensteil -> Wirkung. Reihenfolge zählt, der erste Treffer gewinnt.
|
||||
# "ADAP" muss vor "AD" stehen, sonst greift die kürzere Regel.
|
||||
RULES = (
|
||||
("ADAP", ("attack_damage_pct", "ability_power_pct")),
|
||||
("HunterAD", ("attack_damage_pct",)),
|
||||
("CapstoneAD", ("attack_damage_pct",)),
|
||||
("CapstoneAP", ("ability_power_pct",)),
|
||||
("CapstoneAspd", ("attack_speed_pct",)),
|
||||
("CapstoneArmor", ("armor_flat",)),
|
||||
("CapstoneMR", ("magic_resist_flat",)),
|
||||
("CapstoneHealth", ("health_flat",)),
|
||||
("DefenseGain", ("armor_flat", "magic_resist_flat")),
|
||||
("Durability", ("durability",)),
|
||||
("HealthPercent", ("health_pct",)),
|
||||
("HealthIncrease", ("health_pct",)),
|
||||
("BonusHealth", ("health_flat",)),
|
||||
("MaxHealthShield", ("shield_pct",)),
|
||||
("Health", ("health_flat",)),
|
||||
("AttackSpeedIncrease", ("attack_speed_pct",)),
|
||||
("AttackSpeed", ("attack_speed_pct",)),
|
||||
("ASPerAttack", ("attack_speed_pct",)),
|
||||
("AbilityPower", ("ability_power_pct",)),
|
||||
("AP", ("ability_power_pct",)),
|
||||
("BonusDamagePercent", ("damage_amp",)),
|
||||
("CritChance", ("crit_chance",)),
|
||||
("ManaRegen", ("mana_regen",)),
|
||||
("ManaBonus", ("mana_flat",)),
|
||||
("Mana", ("mana_flat",)),
|
||||
)
|
||||
|
||||
# Ab hier gilt ein Wert als flache Zahl statt als Anteil.
|
||||
FRACTION_LIMIT = 1.0
|
||||
# Diese Wirkungen sind immer Anteile, auch bei großen Zahlen.
|
||||
ALWAYS_FRACTION = ("durability", "damage_amp", "shield_pct", "crit_chance")
|
||||
|
||||
|
||||
def effects_of(variables: dict) -> dict:
|
||||
"""Variablen einer Trait-Stufe -> Wirkungen. Unbekannte fallen weg."""
|
||||
out = Counter()
|
||||
for name, value in (variables or {}).items():
|
||||
if not isinstance(value, (int, float)):
|
||||
continue
|
||||
target = next((fields for part, fields in RULES if part in name), None)
|
||||
if not target:
|
||||
continue
|
||||
for field in target:
|
||||
amount = float(value)
|
||||
if field.endswith("_pct") and amount > FRACTION_LIMIT:
|
||||
amount /= 100.0
|
||||
out[field] += amount
|
||||
return dict(out)
|
||||
|
||||
|
||||
def counts(board: list[dict], static_units: dict) -> Counter:
|
||||
"""Trait -> Zahl der Träger auf dem Board.
|
||||
|
||||
Gezählt werden Champions, nicht Kopien: zwei Exemplare desselben
|
||||
Champions bringen den Trait nur einmal. Das UI muss dieselbe Zählung
|
||||
benutzen, sonst zeigt es Stufen an, die im Kampf nicht wirken.
|
||||
"""
|
||||
out = Counter()
|
||||
seen = set()
|
||||
for entry in board:
|
||||
api = entry.get("api_name")
|
||||
if api in seen or api not in static_units:
|
||||
continue
|
||||
seen.add(api)
|
||||
out.update(static_units[api].get("traits") or ())
|
||||
return out
|
||||
|
||||
|
||||
def active(board: list[dict], static_units: dict, static_traits: dict) -> dict:
|
||||
"""Trait -> erreichte Stufe und ihre Variablen."""
|
||||
counts_ = counts(board, static_units)
|
||||
|
||||
out = {}
|
||||
for trait, count in counts_.items():
|
||||
info = static_traits.get(trait)
|
||||
if not info:
|
||||
continue
|
||||
reached = None
|
||||
for ordinal, step in enumerate(info.get("breakpoints") or [], start=1):
|
||||
if count >= step.get("min_units", 0):
|
||||
# "step" ist die erreichte Stufe, 1-basiert — das UI zeigt
|
||||
# sie an, und die Zählung muss zum Kampf passen.
|
||||
reached = {**step, "step": ordinal, "units": count}
|
||||
if reached:
|
||||
out[trait] = reached
|
||||
return out
|
||||
|
||||
|
||||
def apply(fighters: list, board: list[dict], static_units: dict,
|
||||
static_traits: dict) -> dict:
|
||||
"""Boni auf die Kämpfer rechnen. Gibt die aktiven Traits zurück.
|
||||
|
||||
Trägt der Name "Team", gilt der Bonus für alle. Sonst nur für die
|
||||
Units, die den Trait selbst haben.
|
||||
"""
|
||||
tiers = active(board, static_units, static_traits)
|
||||
for trait, step in tiers.items():
|
||||
variables = step.get("variables") or {}
|
||||
team_wide = {k: v for k, v in variables.items() if "Team" in k}
|
||||
own_only = {k: v for k, v in variables.items() if "Team" not in k}
|
||||
for scope, group in ((team_wide, fighters),
|
||||
(own_only, [f for f in fighters
|
||||
if trait in (static_units.get(f.key) or {})
|
||||
.get("traits", ())])):
|
||||
gains = effects_of(scope)
|
||||
if not gains:
|
||||
continue
|
||||
for one in group:
|
||||
_grant(one, gains)
|
||||
return tiers
|
||||
|
||||
|
||||
def _grant(one, gains: dict) -> None:
|
||||
one.attack_damage *= 1.0 + gains.get("attack_damage_pct", 0.0)
|
||||
one.attack_speed *= 1.0 + gains.get("attack_speed_pct", 0.0)
|
||||
one.ability_power += gains.get("ability_power_pct", 0.0) * 100.0
|
||||
one.armor += gains.get("armor_flat", 0.0)
|
||||
one.magic_resist += gains.get("magic_resist_flat", 0.0)
|
||||
one.crit_chance += gains.get("crit_chance", 0.0)
|
||||
one.mana_regen += gains.get("mana_regen", 0.0)
|
||||
one.max_mana = max(1.0, one.max_mana - gains.get("mana_flat", 0.0))
|
||||
one.damage_amp += gains.get("damage_amp", 0.0)
|
||||
# Härte wirkt wie ein Abschlag auf allen erlittenen Schaden.
|
||||
one.durability = min(0.9, getattr(one, "durability", 0.0)
|
||||
+ gains.get("durability", 0.0))
|
||||
bonus = (gains.get("health_flat", 0.0)
|
||||
+ one.max_hp * gains.get("health_pct", 0.0))
|
||||
one.max_hp += bonus
|
||||
one.hp = one.max_hp
|
||||
# Schilde zählen hier als zusätzliches Leben — die Engine kennt noch
|
||||
# keinen eigenen Schild-Zustand.
|
||||
shield = gains.get("shield_pct", 0.0)
|
||||
if shield:
|
||||
one.max_hp *= 1.0 + shield
|
||||
one.hp = one.max_hp
|
||||
@@ -6,6 +6,7 @@ from datetime import date
|
||||
import requests
|
||||
|
||||
from tft import paths
|
||||
from tft.staticdata import icons, metatft
|
||||
from tft.staticdata.parse import parse
|
||||
|
||||
CDRAGON = "https://raw.communitydragon.org/latest"
|
||||
@@ -13,19 +14,8 @@ METADATA_URL = f"{CDRAGON}/content-metadata.json"
|
||||
TFT_DATA_URL = f"{CDRAGON}/cdragon/tft/en_us.json"
|
||||
|
||||
|
||||
def fetch_static(set_override: int | None = None) -> dict:
|
||||
meta = requests.get(METADATA_URL, timeout=30)
|
||||
meta.raise_for_status()
|
||||
version = meta.json()["version"]
|
||||
patch = ".".join(version.split(".")[:2])
|
||||
|
||||
resp = requests.get(TFT_DATA_URL, timeout=120)
|
||||
resp.raise_for_status()
|
||||
raw = resp.json()
|
||||
|
||||
parsed = parse(raw, set_override=set_override)
|
||||
def _write(parsed: dict, raw: dict, patch: str, version: str | None) -> dict:
|
||||
set_number = parsed["meta"]["set"]
|
||||
|
||||
out_dir = paths.static_dir(set_number, patch)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -43,13 +33,54 @@ def fetch_static(set_override: int | None = None) -> dict:
|
||||
return parsed
|
||||
|
||||
|
||||
def fetch_metatft(set_number: int) -> dict:
|
||||
"""Set-Daten von MetaTFT holen — die Quelle, die ein neues Set zuerst hat."""
|
||||
resp = requests.get(metatft.SOURCE_URL.format(set=set_number), timeout=120)
|
||||
resp.raise_for_status()
|
||||
raw = resp.json()
|
||||
|
||||
parsed = metatft.parse(raw)
|
||||
if parsed["meta"]["set"] != set_number:
|
||||
raise SystemExit(
|
||||
f"metatft returned set {parsed['meta']['set']}, asked for {set_number}"
|
||||
)
|
||||
found = icons.resolve(parsed, set_number)
|
||||
print(" icons:", ", ".join(f"{k} {v}/{len(parsed[k])}" for k, v in found.items()))
|
||||
return _write(parsed, raw, parsed["meta"]["source_patch"] or "unknown", None)
|
||||
|
||||
|
||||
def fetch_static(set_override: int | None = None) -> dict:
|
||||
meta = requests.get(METADATA_URL, timeout=30)
|
||||
meta.raise_for_status()
|
||||
version = meta.json()["version"]
|
||||
patch = ".".join(version.split(".")[:2])
|
||||
|
||||
resp = requests.get(TFT_DATA_URL, timeout=120)
|
||||
resp.raise_for_status()
|
||||
raw = resp.json()
|
||||
|
||||
parsed = parse(raw, set_override=set_override)
|
||||
return _write(parsed, raw, patch, version)
|
||||
|
||||
|
||||
def load_static() -> dict:
|
||||
"""Load the most recently fetched static data."""
|
||||
from pathlib import Path
|
||||
|
||||
pointer = json.loads(paths.latest_static_pointer().read_text())
|
||||
static_dir = Path(pointer["dir"])
|
||||
return {
|
||||
static = {
|
||||
name: json.loads((static_dir / f"{name}.json").read_text())
|
||||
for name in ("units", "traits", "items", "augments", "meta")
|
||||
}
|
||||
# Optional: aus einer gespeicherten Tierlist importierte Meta-Werte.
|
||||
tiers = static_dir / "tierlist.json"
|
||||
static["tierlist"] = json.loads(tiers.read_text()) if tiers.exists() else {}
|
||||
return static
|
||||
|
||||
|
||||
def static_dir_of_latest():
|
||||
"""Verzeichnis der zuletzt geholten Statikdaten."""
|
||||
from pathlib import Path
|
||||
|
||||
return Path(json.loads(paths.latest_static_pointer().read_text())["dir"])
|
||||
|
||||
157
backend/tft/staticdata/icons.py
Normal file
157
backend/tft/staticdata/icons.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""Icon-URLs für MetaTFT-Tabellen aus den cdragon-Assets nachtragen.
|
||||
|
||||
MetaTFT liefert nur Asset-Namen ("T_18_Ahri_Square"), keine Pfade. Die Bilder
|
||||
selbst liegen aber bei cdragon — nur eben unter Dateinamen, die je Champion
|
||||
eine andere Skin-Nummer tragen. Deshalb listen wir die Verzeichnisse auf
|
||||
statt Pfade zu raten: falsch geratene URLs liefern 404 und damit stumme
|
||||
Leerkacheln im UI.
|
||||
"""
|
||||
|
||||
import re
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import requests
|
||||
|
||||
from tft.staticdata.parse import CDRAGON_GAME
|
||||
|
||||
LISTING = "https://raw.communitydragon.org/json/latest/game/{path}/"
|
||||
TRAIT_ICONS = "assets/ux/traiticons"
|
||||
ITEM_ICON_DIRS = [
|
||||
"assets/maps/tft/icons/items/hexcore",
|
||||
"assets/maps/tft/icons/augments/hexcore",
|
||||
*(f"assets/maps/particles/tft/item_icons/{d}" for d in
|
||||
("standard", "ornn_items", "radiant", "shadow", "consumables",
|
||||
"augments", "pairs")),
|
||||
]
|
||||
CHARACTER_IMAGES = "assets/characters/{character}/skins/base/images"
|
||||
|
||||
# Item-Icons kommen von Riots Data Dragon statt von cdragon. Grund: cdragon
|
||||
# kennt die Set-18-Items gar nicht (Striker's Flail, Protector's Vow und
|
||||
# Steadfast Heart fehlen im ganzen Baum), und die Namenssuche traf ersatzweise
|
||||
# falsche Bilder aus alten Sets. Data Dragon liefert sie unter genau dem
|
||||
# apiName, den auch die Riot-API benutzt — kein Raten mehr.
|
||||
DDRAGON_VERSIONS = "https://ddragon.leagueoflegends.com/api/versions.json"
|
||||
DDRAGON_ITEMS = "https://ddragon.leagueoflegends.com/cdn/{v}/data/en_US/tft-item.json"
|
||||
DDRAGON_IMAGE = "https://ddragon.leagueoflegends.com/cdn/{v}/img/tft-item/{file}"
|
||||
|
||||
|
||||
def ddragon_items() -> dict:
|
||||
"""api_name -> Bild-URL. Leer, wenn Data Dragon nicht erreichbar ist."""
|
||||
try:
|
||||
version = requests.get(DDRAGON_VERSIONS, timeout=30).json()[0]
|
||||
data = requests.get(DDRAGON_ITEMS.format(v=version), timeout=30).json()
|
||||
except (requests.RequestException, ValueError, IndexError, KeyError):
|
||||
return {}
|
||||
return {key.rsplit("/", 1)[-1]:
|
||||
DDRAGON_IMAGE.format(v=version, file=entry["image"]["full"])
|
||||
for key, entry in data.get("data", {}).items()}
|
||||
|
||||
|
||||
def listing(path: str) -> list[str]:
|
||||
"""Dateinamen eines cdragon-Verzeichnisses; leer, wenn es das nicht gibt."""
|
||||
try:
|
||||
resp = requests.get(LISTING.format(path=path), timeout=30)
|
||||
resp.raise_for_status()
|
||||
return [e["name"] for e in resp.json() if e.get("type") == "file"]
|
||||
except (requests.RequestException, ValueError):
|
||||
return []
|
||||
|
||||
|
||||
def index(paths: list[str]) -> dict:
|
||||
"""Dateiname ohne Endung (kleingeschrieben) -> vollständige URL."""
|
||||
out = {}
|
||||
for path in paths:
|
||||
for name in listing(path):
|
||||
out.setdefault(name.rsplit(".", 1)[0].lower(), f"{CDRAGON_GAME}/{path}/{name}")
|
||||
return out
|
||||
|
||||
|
||||
def _slug(text: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]", "", (text or "").lower())
|
||||
|
||||
|
||||
def _key(text: str | None) -> str:
|
||||
"""Asset-Name -> Index-Schlüssel: kleingeschrieben, Endung weg."""
|
||||
return (text or "").lower().rsplit(".", 1)[0]
|
||||
|
||||
|
||||
def _item_keys(api_name: str, asset: str | None) -> list[str]:
|
||||
"""Kandidaten-Dateinamen; Items überleben Sets unter wechselnden Präfixen."""
|
||||
base = re.sub(r"^(DA|TFT\d*)_", "", api_name)
|
||||
base = re.sub(r"^(Component|Artifact|Item)_", "", base)
|
||||
asset = re.sub(r"^T_(Item_)?", "", asset or "")
|
||||
stem = re.sub(r"^Artifact_", "", asset)
|
||||
return [k.lower() for k in (
|
||||
api_name, f"tft_item_{base}", f"tft_item_{asset}", f"tft_item_{stem}",
|
||||
f"tft_item_artifact_{stem}", base, asset, stem,
|
||||
) if k]
|
||||
|
||||
|
||||
ROMAN = {1: "i", 2: "ii", 3: "iii"}
|
||||
|
||||
|
||||
def _augment_keys(asset: str | None, tier: int | None) -> list[str]:
|
||||
"""Augment-Icons heißen '<slug>_<stufe römisch>.png'."""
|
||||
stem = re.sub(r"_AugmentMasks$", "", re.sub(r"^T_AugmentIcon_", "", asset or ""))
|
||||
slug = _slug(stem)
|
||||
return [k for k in (f"{slug}_{ROMAN.get(tier, '')}", slug, _key(stem),
|
||||
*(f"{slug}_{r}" for r in ROMAN.values())) if k]
|
||||
|
||||
|
||||
def resolve(tables: dict, set_number: int) -> dict:
|
||||
"""Icons in-place eintragen. Gibt zurück, wie viele je Tabelle gefunden wurden."""
|
||||
trait_icons = index([TRAIT_ICONS])
|
||||
prefix = f"trait_icon_{set_number}_"
|
||||
for trait in tables["traits"].values():
|
||||
# Der Asset-Name aus MetaTFT trifft am genauesten; das Set-Suffix
|
||||
# ("..._TFT_Set18") trägt der Dateiname nicht.
|
||||
asset = re.sub(r"_tft_set\d+$", "", _key(trait.pop("icon_asset", None)))
|
||||
trait["icon"] = next(
|
||||
(trait_icons[k] for k in
|
||||
(asset, prefix + _slug(trait["name"]), prefix + _slug(trait["api_name"]))
|
||||
if k in trait_icons), None)
|
||||
|
||||
item_icons = index(ITEM_ICON_DIRS)
|
||||
ddragon = ddragon_items()
|
||||
for table in ("items", "augments"):
|
||||
for entry in tables[table].values():
|
||||
asset = entry.pop("icon_asset", None)
|
||||
keys = (_augment_keys(asset, entry.get("tier")) if table == "augments"
|
||||
else _item_keys(entry["api_name"], asset))
|
||||
entry["icon"] = (
|
||||
ddragon.get(entry["api_name"]) if table == "items" else None
|
||||
) or next((item_icons[k] for k in keys if k in item_icons), None)
|
||||
|
||||
units = list(tables["units"].values())
|
||||
with ThreadPoolExecutor(8) as pool:
|
||||
found = list(pool.map(_unit_files, units))
|
||||
for unit, files in zip(units, found):
|
||||
unit.pop("icon_asset", None)
|
||||
# splash_tile ist der quadratische Zuschnitt (Bank, Board, Liste),
|
||||
# splash_centered die breite Szene für die Shop-Karte. Die
|
||||
# Monster-Units haben nur ein TeamPlanner-Splash.
|
||||
unit["icon"] = _pick(files, "_splash_tile_") or _pick(files, "teamplannersplash")
|
||||
unit["splash"] = _pick(files, "_splash_centered_") or unit["icon"]
|
||||
|
||||
return {
|
||||
"traits": sum(1 for t in tables["traits"].values() if t["icon"]),
|
||||
"items": sum(1 for i in tables["items"].values() if i["icon"]),
|
||||
"augments": sum(1 for a in tables["augments"].values() if a["icon"]),
|
||||
"units": sum(1 for u in tables["units"].values() if u["icon"]),
|
||||
}
|
||||
|
||||
|
||||
def _unit_files(unit: dict) -> tuple[str, list[str]]:
|
||||
character = (unit.get("icon_asset") or unit["api_name"]).lower()
|
||||
for name in (character, re.sub(r"_base$", "", character)):
|
||||
path = CHARACTER_IMAGES.format(character=name)
|
||||
files = listing(path)
|
||||
if files:
|
||||
return path, files
|
||||
return path, []
|
||||
|
||||
|
||||
def _pick(files: tuple[str, list[str]], marker: str) -> str | None:
|
||||
path, names = files
|
||||
name = next((n for n in sorted(names) if marker in n), None)
|
||||
return f"{CDRAGON_GAME}/{path}/{name}" if name else None
|
||||
298
backend/tft/staticdata/metatft.py
Normal file
298
backend/tft/staticdata/metatft.py
Normal file
@@ -0,0 +1,298 @@
|
||||
"""Parse MetaTFT's set lookup into the same tables as the cdragon parser.
|
||||
|
||||
Community Dragon veröffentlicht die Champions eines neuen Sets erst mit dem
|
||||
Live-Patch — für Set 18 stehen dort nur zwei Units. MetaTFT baut dieselben
|
||||
Spieldateien schon ab PBE aus und stellt sie öffentlich bereit.
|
||||
|
||||
Ausgabe-Shape ist identisch mit parse.parse(), damit Artefakt, Sim und UI
|
||||
unverändert bleiben. Unterschiede zur cdragon-Quelle:
|
||||
|
||||
* Tooltips nutzen <TFTCurveTable row="X" column="N"/> statt @Platzhalter@.
|
||||
* Werte je Sternstufe stehen als dünn besetzte [stufe, wert]-Paare in
|
||||
curveValues — fehlende Stufen erben den vorherigen Wert.
|
||||
* Items tragen ihre Stats in statLine, nicht in effects.
|
||||
* Icons sind Asset-Namen ohne Pfad; die füllt fetch.resolve_icons nach.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
SOURCE_URL = "https://data.metatft.com/lookups/TFTSet{set}_latest_en_us.json"
|
||||
TABLES_URL = "https://data.metatft.com/lookups/{channel}_TFTSet{set}_tables.json"
|
||||
|
||||
# <TFTCurveTable row="BrawlerHealthPercent" column="2" format="percent"/>
|
||||
CURVE_RE = re.compile(r"<TFTCurveTable\s+([^/>]*?)/>", re.I)
|
||||
ATTR_RE = re.compile(r"<TFTAttribute\s+([^/>]*?)/>", re.I)
|
||||
XML_ATTRS_RE = re.compile(r"(\w+)=\"([^\"]*)\"")
|
||||
TAG_RE = re.compile(r"<[^>]+>")
|
||||
|
||||
MAX_STARS = 3
|
||||
|
||||
# statLine-Icon -> Effekt-Key, den statsheet._apply_item_effects kennt.
|
||||
ICON_TO_EFFECT = {
|
||||
"ad": "AD", "ap": "AP", "as": "AS", "health": "Health", "armor": "Armor",
|
||||
"mr": "MagicResist", "manaregen": "ManaRegen", "critchance": "CritChance",
|
||||
"critdmg": "CritDamageToGive", "damageamp": "DamageAmp",
|
||||
}
|
||||
# Zielkonvention je Key (siehe statsheet-Docstring): Fraction, Prozentzahl
|
||||
# oder flach. MetaTFT liefert alles als Fraction, sobald ein format gesetzt ist.
|
||||
EFFECT_AS_PERCENT_NUMBER = {"AP", "AS", "CritChance"}
|
||||
|
||||
AUGMENT_TIERS = {"silver": 1, "gold": 2, "prismatic": 3}
|
||||
|
||||
|
||||
def xml_attrs(fragment: str) -> dict:
|
||||
return {k.lower(): v for k, v in XML_ATTRS_RE.findall(fragment)}
|
||||
|
||||
|
||||
def curve_at(curve: list, column: int) -> float | None:
|
||||
"""Wert einer Kurve in Spalte N. Kurven sind Treppen: fehlende Spalten
|
||||
erben den letzten kleineren Eintrag."""
|
||||
value = None
|
||||
for col, val in curve or []:
|
||||
if col <= column:
|
||||
value = val
|
||||
else:
|
||||
break
|
||||
return value
|
||||
|
||||
|
||||
def star_array(curve: list) -> list:
|
||||
"""Kurve -> [0, 1★, 2★, 3★] in cdragon-Konvention (Index 1..3 = Sterne)."""
|
||||
return [0] + [curve_at(curve, star) for star in range(1, MAX_STARS + 1)]
|
||||
|
||||
|
||||
PERCENT_FORMATS = ("percent", "percentminusone", "invertedpercent")
|
||||
|
||||
|
||||
def as_fraction(value: float, fmt: str | None) -> float:
|
||||
"""Rohwert -> Anteil, gesteuert vom format-Attribut des Tooltips.
|
||||
|
||||
percent 0.25 -> 0.25 (Anteil)
|
||||
percentMinusOne 1.10 -> 0.10 (Faktor, z.B. Angriffstempo)
|
||||
invertedPercent 0.96 -> 0.04 (Resthärte, z.B. Durability)
|
||||
ohne format 175 -> 175 (flacher Wert)
|
||||
"""
|
||||
fmt = (fmt or "").lower()
|
||||
if fmt == "percentminusone":
|
||||
return value - 1
|
||||
if fmt == "invertedpercent":
|
||||
return 1 - value
|
||||
return value
|
||||
|
||||
|
||||
def render_desc(desc: str | None, curves: dict, attribute_values: dict | None = None) -> str:
|
||||
"""Tooltip-Tags durch ihre Werte ersetzen, Markup entfernen."""
|
||||
|
||||
def curve_repl(m: re.Match) -> str:
|
||||
at = xml_attrs(m.group(1))
|
||||
value = curve_at(curves.get(at.get("row")), int(at.get("column") or 1))
|
||||
if value is None:
|
||||
return "?"
|
||||
fmt = (at.get("format") or "").lower()
|
||||
if fmt in PERCENT_FORMATS:
|
||||
value = as_fraction(value, fmt) * 100
|
||||
return str(int(value)) if float(value).is_integer() else str(round(value, 1))
|
||||
|
||||
def attr_repl(m: re.Match) -> str:
|
||||
at = xml_attrs(m.group(1))
|
||||
values = (attribute_values or {}).get(at.get("attributeid"))
|
||||
return "/".join(str(int(v)) for v in values[:MAX_STARS]) if values else "?"
|
||||
|
||||
text = CURVE_RE.sub(curve_repl, desc or "")
|
||||
text = ATTR_RE.sub(attr_repl, text)
|
||||
return re.sub(r"\s+", " ", TAG_RE.sub(" ", text)).strip()
|
||||
|
||||
|
||||
DAMAGE_ATTR_RE = re.compile(r"DamageCalc\d*$", re.I)
|
||||
# "Attacks deal <tag>" / "Each attack deals <tag>": Schaden pro Auto-Attacke
|
||||
# statt pro Cast — der Mana-Zyklus gilt dafür nicht.
|
||||
ON_ATTACK_RE = re.compile(r"attacks?\s+(?:also\s+)?deals?|per attack|each attack", re.I)
|
||||
|
||||
|
||||
def spell_tags(ability: dict) -> list[tuple[dict, list, int]]:
|
||||
"""Alle Schadens-Tags des Tooltips als (Attribute, Werte, Position).
|
||||
|
||||
Nur Tags, deren attributeID wirklich in attributeValues steht und auf
|
||||
DamageCalc endet. Der erste Tag im Text ist oft ein Mana- oder
|
||||
Stat-Wert — bei Lux stünde sonst 3 statt 330 im Modell.
|
||||
"""
|
||||
values = ability.get("attributeValues") or {}
|
||||
out = []
|
||||
for match in ATTR_RE.finditer(ability.get("desc") or ""):
|
||||
at = xml_attrs(match.group(1))
|
||||
name = at.get("attributeid") or ""
|
||||
array = values.get(name)
|
||||
if array and DAMAGE_ATTR_RE.search(name):
|
||||
out.append((at, array, match.start()))
|
||||
return out
|
||||
|
||||
|
||||
def resolve_spell(unit: dict) -> dict:
|
||||
"""Spell-Schaden je Stern aus attributeValues.
|
||||
|
||||
Eine Fähigkeit nennt oft mehrere Schadenszahlen (Haupttreffer, Splash,
|
||||
Sonderfall). Wir nehmen die größte auf 1★ — das ist der Treffer, der die
|
||||
Fähigkeit ausmacht. icon= sagt, ob sie mit AD, AP oder beidem skaliert.
|
||||
"""
|
||||
ability = unit.get("ability") or {}
|
||||
tags = spell_tags(ability)
|
||||
if not tags:
|
||||
return {"spell_damage": None, "spell_damage_type": None,
|
||||
"spell_scaling": None, "spell_on_attack": False}
|
||||
|
||||
at, array, pos = max(tags, key=lambda t: t[1][0])
|
||||
icons = (at.get("icon") or "").lower()
|
||||
has_ad, has_ap = "icon.ad" in icons, "icon.ap" in icons
|
||||
style = (at.get("style") or "").lower()
|
||||
context = (ability.get("desc") or "")[max(0, pos - 70):pos]
|
||||
|
||||
return {
|
||||
# MetaTFT zählt ab Index 0 = 1★; cdragon ab Index 1.
|
||||
"spell_damage": [0] + list(array[:MAX_STARS]),
|
||||
"spell_damage_type": ("physical" if "physical" in style else
|
||||
"true" if "true" in style else "magic"),
|
||||
"spell_scaling": ("both" if has_ad and has_ap else
|
||||
"ad" if has_ad else "ap" if has_ap else None),
|
||||
"spell_on_attack": bool(ON_ATTACK_RE.search(context)),
|
||||
}
|
||||
|
||||
|
||||
def ability_vars(unit: dict) -> dict:
|
||||
"""Beschreibende Kurven der Fähigkeit als Werte je Sternstufe.
|
||||
|
||||
Nur die im Tooltip referenzierten Zeilen — der Rest sind ungenutzte
|
||||
Reste aus der Kurventabelle.
|
||||
"""
|
||||
ability = unit.get("ability") or {}
|
||||
curves = unit.get("curveValues") or {}
|
||||
rows = {xml_attrs(m.group(1)).get("row")
|
||||
for m in CURVE_RE.finditer(ability.get("desc") or "")}
|
||||
return {row: star_array(curves[row]) for row in sorted(rows) if row in curves}
|
||||
|
||||
|
||||
def trait_variables(effect: dict, curves: dict, ordinal: int) -> dict:
|
||||
"""Kurvenwerte einer Trait-Stufe, benannt nach ihrer Kurvenzeile.
|
||||
|
||||
statsheet.trait_buffs erkennt Stats am Namen, daher bleibt der
|
||||
Zeilenname (z.B. BrawlerHealthPercent) erhalten.
|
||||
"""
|
||||
out = {}
|
||||
for m in CURVE_RE.finditer(effect.get("desc") or ""):
|
||||
at = xml_attrs(m.group(1))
|
||||
row = at.get("row")
|
||||
value = curve_at(curves.get(row), int(at.get("column") or ordinal))
|
||||
if value is not None:
|
||||
out[row] = as_fraction(value, at.get("format"))
|
||||
return out
|
||||
|
||||
|
||||
def item_effects(item: dict) -> dict:
|
||||
"""statLine -> Effekt-Dict in der Werte-Konvention von statsheet."""
|
||||
curves = item.get("curveValues") or {}
|
||||
out = {}
|
||||
for m in CURVE_RE.finditer(item.get("statLine") or ""):
|
||||
at = xml_attrs(m.group(1))
|
||||
key = ICON_TO_EFFECT.get((at.get("icon") or "").split(".")[-1].lower())
|
||||
value = curve_at(curves.get(at.get("row")), 1)
|
||||
if key is None or value is None:
|
||||
continue
|
||||
value = as_fraction(value, at.get("format"))
|
||||
out[key] = out.get(key, 0) + (value * 100 if key in EFFECT_AS_PERCENT_NUMBER
|
||||
else value)
|
||||
return out
|
||||
|
||||
|
||||
def parse(raw: dict) -> dict:
|
||||
"""MetaTFT-Lookup -> {meta, units, traits, items, augments}."""
|
||||
set_number = int(re.sub(r"\D", "", raw["_metadata"]["set"]))
|
||||
roles = raw.get("roles") or {}
|
||||
|
||||
units, incomplete = {}, []
|
||||
for u in raw["units"]:
|
||||
# shopUnit trennt kaufbare Champions von PvE-Monstern, Summons und
|
||||
# Verwandlungsformen (TFT18_EliseSpider) sauberer als "hat Traits".
|
||||
if not u.get("shopUnit") or not u.get("traits"):
|
||||
continue
|
||||
if not (u.get("stats") or {}).get("hp"):
|
||||
# Lücke im PBE-Dump. Ohne HP wäre die eHP 0 und die Unit wertlos —
|
||||
# lieber weglassen und melden als still verzerren.
|
||||
incomplete.append(f"{u['name']} (hp)")
|
||||
continue
|
||||
if not (u.get("stats") or {}).get("damage"):
|
||||
# Auto-Angriff fehlt, Fähigkeit und Verteidigung zählen weiter.
|
||||
# Nicht auslassen: die Units werden gespielt, nur ihr Auto-DPS ist 0.
|
||||
incomplete.append(f"{u['name']} (damage)")
|
||||
units[u["apiName"]] = {
|
||||
"api_name": u["apiName"],
|
||||
"name": u["name"],
|
||||
"cost": u["cost"],
|
||||
"traits": list(u["traitApiNames"]),
|
||||
# "DA_Role_MagicCaster" -> "MagicCaster"; baseline.classify_role
|
||||
# liest daraus dieselben Rollen wie aus cdragons "ADFighter".
|
||||
"role": re.sub(r"^\w+_Role_", "", u.get("role") or "")
|
||||
or roles.get(u.get("role")),
|
||||
# Die Match-API nennt Units beim Asset-Namen ("DA_18_GnarSmall"),
|
||||
# nicht beim apiName. Ohne diese Liste findet learn.py nichts wieder.
|
||||
"asset_names": list(u.get("assetNames") or []),
|
||||
"stats": dict(u["stats"]),
|
||||
"ability_name": (u.get("ability") or {}).get("name"),
|
||||
"ability_vars": ability_vars(u),
|
||||
"icon": None,
|
||||
"splash": None,
|
||||
"icon_asset": u.get("characterName") or u["apiName"],
|
||||
**resolve_spell(u),
|
||||
}
|
||||
|
||||
traits = {}
|
||||
for t in raw["traits"]:
|
||||
curves = t.get("curveValues") or {}
|
||||
traits[t["apiName"]] = {
|
||||
"api_name": t["apiName"],
|
||||
"name": t["name"],
|
||||
"breakpoints": [
|
||||
{"min_units": e["minUnits"], "style": e.get("style") or 1,
|
||||
"variables": trait_variables(e, curves, i)}
|
||||
for i, e in enumerate(t.get("effects") or [], start=1)
|
||||
],
|
||||
"icon": None,
|
||||
"icon_asset": t.get("icon"),
|
||||
}
|
||||
|
||||
known_traits = set(traits)
|
||||
for u in units.values():
|
||||
u["traits"] = [t for t in u["traits"] if t in known_traits]
|
||||
|
||||
items, augments = {}, {}
|
||||
for i in raw["items"]:
|
||||
items[i["apiName"]] = {
|
||||
"api_name": i["apiName"],
|
||||
"name": i["name"],
|
||||
"composition": list(i.get("composition") or []),
|
||||
"effects": item_effects(i),
|
||||
"icon": None,
|
||||
# Asset-Name ohne Pfad; icons.resolve macht daraus eine URL.
|
||||
"icon_asset": i.get("icon"),
|
||||
}
|
||||
for a in raw["augments"]:
|
||||
curves = a.get("curveValues") or {}
|
||||
augments[a["apiName"]] = {
|
||||
"api_name": a["apiName"],
|
||||
"name": a["name"],
|
||||
"composition": [],
|
||||
# Kurvenzeilen sind hier die Effekt-Werte (GoldInstantBasic: 20).
|
||||
"effects": {row: curve_at(curve, 1) for row, curve in curves.items()},
|
||||
"tier": AUGMENT_TIERS.get((a.get("rarity") or "").lower()),
|
||||
"desc": render_desc(a.get("desc"), curves, a.get("attributeValues")),
|
||||
"icon": None,
|
||||
"icon_asset": a.get("icon"),
|
||||
}
|
||||
|
||||
return {
|
||||
"meta": {"set": set_number, "set_name": raw["_metadata"]["setName"],
|
||||
"source": "metatft", "source_patch": raw["_metadata"].get("patch"),
|
||||
"incomplete_units": incomplete},
|
||||
"units": units,
|
||||
"traits": traits,
|
||||
"items": items,
|
||||
"augments": augments,
|
||||
}
|
||||
@@ -106,11 +106,12 @@ def resolve_spell(ability: dict) -> dict:
|
||||
|
||||
|
||||
def icon_url(asset_path: str) -> str:
|
||||
p = asset_path.lower()
|
||||
for ext in (".tex", ".dds"):
|
||||
if p.endswith(ext):
|
||||
p = p[: -len(ext)] + ".png"
|
||||
return f"{CDRAGON_GAME}/{p}"
|
||||
"""cdragon serviert alles kleingeschrieben als PNG und ohne Set-Suffix:
|
||||
'..._Splash_Tile_27.TFT_Set17.tex' -> '..._splash_tile_27.png'."""
|
||||
path = asset_path.lower().replace("\\", "/")
|
||||
head, _, name = path.rpartition("/")
|
||||
name = f"{name.split('.')[0]}.png"
|
||||
return f"{CDRAGON_GAME}/{head}/{name}" if head else f"{CDRAGON_GAME}/{name}"
|
||||
|
||||
|
||||
def detect_set(raw: dict) -> int:
|
||||
@@ -138,7 +139,15 @@ def parse(raw: dict, set_override: int | None = None) -> dict:
|
||||
"role": c.get("role"),
|
||||
"stats": c["stats"],
|
||||
"ability_name": c["ability"]["name"],
|
||||
"icon": icon_url(c["squareIcon"]),
|
||||
# Rohe Fähigkeits-Variablen (Wert je Sternstufe, Index 1..3).
|
||||
# Welche davon wie ein Dauer-Stat wirken, entscheidet statsheet.
|
||||
"ability_vars": {v["name"]: v["value"]
|
||||
for v in c["ability"].get("variables") or []
|
||||
if isinstance(v.get("value"), list)},
|
||||
# tileIcon = HUD-Quadrat für Units, Bank und Spielerliste.
|
||||
# squareIcon = Splash-Kachel, füllt die Shop-Karte wie im Spiel.
|
||||
"icon": icon_url(c["tileIcon"]),
|
||||
"splash": icon_url(c["squareIcon"]),
|
||||
**resolve_spell(c["ability"]),
|
||||
}
|
||||
|
||||
|
||||
67
backend/tft/staticdata/procomps.py
Normal file
67
backend/tft/staticdata/procomps.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Pro-Comps von MetaTFT holen.
|
||||
|
||||
Die Liste stammt von einem Spieler, nicht aus Statistik — sie sagt, was
|
||||
zusammen gespielt wird, nicht wie es ausgeht. Genau dafür brauchen wir
|
||||
sie: welche Champions gehören zusammen?
|
||||
|
||||
Der Endpunkt liefert ein Dokument mit Comps und ihren Stufen (Level 4
|
||||
bis 9). Jede Stufe zählt einzeln, das ist mehr Signal als nur das
|
||||
Endboard.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import requests
|
||||
|
||||
from tft.staticdata.fetch import static_dir_of_latest
|
||||
|
||||
URL = "https://api-hc.metatft.com/tft-stat-api/pro-comps"
|
||||
CACHE = "procomps.json"
|
||||
MIN_UNITS = 4
|
||||
|
||||
|
||||
def fetch() -> dict:
|
||||
"""Rohdokument holen und neben den Statikdaten ablegen.
|
||||
|
||||
Bei Netzfehler kommt der zuletzt gespeicherte Stand zurück — der
|
||||
Artefakt-Bau soll daran nicht scheitern.
|
||||
"""
|
||||
path = static_dir_of_latest() / CACHE
|
||||
try:
|
||||
doc = requests.get(URL, timeout=60).json()
|
||||
except (requests.RequestException, ValueError):
|
||||
return json.loads(path.read_text()) if path.exists() else {}
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(doc))
|
||||
return doc
|
||||
|
||||
|
||||
def comps(doc: dict, static_units: dict) -> list[dict]:
|
||||
"""Jede Comp-Stufe als {"tier", "title", "units"}.
|
||||
|
||||
Die Namen im Dokument sind Riots Asset-Namen, unsere Schlüssel nicht.
|
||||
Deshalb dieselbe Alias-Tabelle wie bei den Endboards.
|
||||
"""
|
||||
alias = {}
|
||||
for key, unit in static_units.items():
|
||||
for name in (unit.get("asset_names") or []) + [key]:
|
||||
alias[name] = key
|
||||
|
||||
tier_of = {entry["data"]["$ref"].rsplit("/", 1)[-1]: entry.get("tier")
|
||||
for entry in (doc.get("content") or {}).get("comps") or []}
|
||||
|
||||
out = []
|
||||
for ref in doc.get("refs") or []:
|
||||
content = ref.get("content") or {}
|
||||
tier = tier_of.get(ref.get("content_id"))
|
||||
title = (ref.get("metadata") or {}).get("title", "")
|
||||
stages = [content.get("unit_positions") or {}]
|
||||
stages += [v.get("unit_positions") or {}
|
||||
for v in (content.get("variations") or [])
|
||||
if v.get("type") == "comp"]
|
||||
for positions in stages:
|
||||
units = sorted({alias[slot["apiName"]] for slot in positions.values()
|
||||
if slot.get("apiName") in alias})
|
||||
if len(units) >= MIN_UNITS:
|
||||
out.append({"tier": tier, "title": title, "units": units})
|
||||
return out
|
||||
68
backend/tft/staticdata/tierlist.py
Normal file
68
backend/tft/staticdata/tierlist.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Meta-Stärke der Units aus einer gespeicherten MetaTFT-Tierlist.
|
||||
|
||||
Off/Def sagen, was eine Unit auf dem Papier kann. Die Tierlist sagt, was sie
|
||||
in echten Spielen bringt: die durchschnittliche Platzierung aller Boards, auf
|
||||
denen sie am Ende stand.
|
||||
|
||||
Die Seite rendert clientseitig, deshalb die im Browser gespeicherte HTML.
|
||||
Zeilen mit winziger Stichprobe (Skin-Varianten, Verwandlungsformen) fliegen
|
||||
raus — bei 3 Spielen ist eine 1.20er Platzierung Rauschen.
|
||||
"""
|
||||
|
||||
import html
|
||||
import re
|
||||
|
||||
ROW_RE = re.compile(r'class="TableItemRow"')
|
||||
TAG_RE = re.compile(r"<[^>]+>")
|
||||
NUM_RE = re.compile(r"^\d[\d,]*\.?\d*%?$")
|
||||
# "Lux (Blossom)" ist dieselbe Unit mit anderem Trait, keine eigene Zeile wert.
|
||||
VARIANT_RE = re.compile(r"\s*\(.*\)$")
|
||||
|
||||
MIN_GAMES = 1000
|
||||
# Platzierung 1 ist das Beste, 8 das Schlechteste — die Skala ist invertiert.
|
||||
WORST_PLACE = 8.0
|
||||
|
||||
|
||||
def _cells(fragment: str) -> list[str]:
|
||||
lines = html.unescape(TAG_RE.sub("\n", fragment)).splitlines()
|
||||
return [x.strip() for x in lines if x.strip() and not x.startswith("class=")]
|
||||
|
||||
|
||||
def parse(page: str) -> dict:
|
||||
"""HTML -> {Unit-Name: {"avg_place", "win_rate", "games", "pick_rate"}}."""
|
||||
starts = [m.start() for m in ROW_RE.finditer(page)] + [len(page)]
|
||||
rows = {}
|
||||
for start, end in zip(starts, starts[1:]):
|
||||
cells = _cells(page[start:end])
|
||||
if not cells or cells[0] == "Unit":
|
||||
continue
|
||||
nums = [c for c in cells if NUM_RE.match(c)]
|
||||
if len(nums) < 4:
|
||||
continue
|
||||
games = int(nums[2].replace(",", ""))
|
||||
name = VARIANT_RE.sub("", cells[0])
|
||||
if games < MIN_GAMES or games <= rows.get(name, {}).get("games", 0):
|
||||
continue
|
||||
rows[name] = {
|
||||
"avg_place": float(nums[0]),
|
||||
"win_rate": float(nums[1].rstrip("%")) / 100,
|
||||
"games": games,
|
||||
"pick_rate": float(nums[3].rstrip("%")) / 100,
|
||||
}
|
||||
return rows
|
||||
|
||||
|
||||
def meta_strength(rows: dict, static_units: dict) -> dict:
|
||||
"""api_name -> Meta-Stärke, 1.0 = durchschnittliche Unit des Sets.
|
||||
|
||||
Gerechnet wird auf dem Abstand zum letzten Platz, nicht auf der
|
||||
Platzierung selbst: 2.6 statt 3.6 ist ein Drittel mehr Vorsprung,
|
||||
3.6 durch 2.6 wäre nur eine willkürliche Verhältniszahl.
|
||||
"""
|
||||
by_name = {u["name"]: api for api, u in static_units.items() if u.get("name")}
|
||||
edge = {by_name[n]: WORST_PLACE - r["avg_place"]
|
||||
for n, r in rows.items() if n in by_name}
|
||||
if not edge:
|
||||
return {}
|
||||
mean = sum(edge.values()) / len(edge)
|
||||
return {api: round(value / mean, 3) for api, value in edge.items()}
|
||||
75
backend/uv.lock
generated
75
backend/uv.lock
generated
@@ -195,6 +195,79 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
version = "2.5.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/80/db0b4559e57ec36362bedbb05530a87fafbcb6067708c946967a41d449e7/numpy-2.5.2.tar.gz", hash = "sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860", size = 20773161, upload-time = "2026-08-09T13:48:27.962Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/72/dccb0aaf40972777283303919f613964227266d0c13adebb79ac124f1c3e/numpy-2.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:14e373cfc6387177e8409dac3c7159be8eb05cd77096cd7c950268b86f62831c", size = 16891693, upload-time = "2026-08-09T13:44:51.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/2e/b5aee50a1f74ac815cf8331812cb8251e29024025de462e0c047641c614c/numpy-2.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbd96c833ecc8cc069ce518078fc8c60cb9cbfb0fea5b7a803ad65035596d03", size = 11903109, upload-time = "2026-08-09T13:44:55.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/f4/29e78102a80601cf034d4e9767022cffeca2c3b4c926e1754572ca95593d/numpy-2.5.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:6e8172ddfcf5cf74b811d372b570b83c60bd2de87a6fbfbebdadb4a9bd9c6cbb", size = 5350202, upload-time = "2026-08-09T13:44:58.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/4b/dcd3b7eadaf4035d2c7a4289d232523a6964f602598ef7674e4bd7291f93/numpy-2.5.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:65f188481f1669e26f62b701e8205d19e460fa4a9b52a1414ba382330e4a3414", size = 6687736, upload-time = "2026-08-09T13:45:00.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/21/4947e0e9d6c9fc2e2ff15b8949049ee44f63adb9cacc729ab8793f97e712/numpy-2.5.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ee9c4eeb8454b3660a8b53493563c3e121c2fc94fbd72b848ef814ed7b676a9", size = 15612696, upload-time = "2026-08-09T13:45:04.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/5f/62d28cf019460c7f1394105b4d49d9911a9c444cb77ab0bd95a204c5a6de/numpy-2.5.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3cdec01fa790a186d430433fdd4d4ffb70eed6f0eeb4bf05c8dbe2dce0a9bcb8", size = 16722264, upload-time = "2026-08-09T13:45:07.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/25/3f0be4c1b9fdf5dd5e708a6806978564d7c46a055c000496309ff2a2f8af/numpy-2.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7999d4ddb0c4025018373fd787510d46e04c769467af22869707b3c1cfd459ab", size = 16974396, upload-time = "2026-08-09T13:45:11.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/72/6262cbdeeb45da9d971e40715f579d791603ba8ec0b5e2db1ac55454421d/numpy-2.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c1f017dc0875c9209d219f97feceb7d54c2661bb243deb4114478e1295808af7", size = 18476044, upload-time = "2026-08-09T13:45:14.869Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/33/29208b8b075bde62d26a81d14b358c42b0f69b6cabd98d4ff97f37f22b05/numpy-2.5.2-cp312-cp312-win32.whl", hash = "sha256:d6a48072864e3324e194a8fbb3c657bcc5b5c869dbc64c9537b1d5c862572c0a", size = 6072817, upload-time = "2026-08-09T13:45:17.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/b9/87fea2769fe1c47c1b5b01d8310772c9d1a85d485de7cf386ef7a3332b02/numpy-2.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:28ac63476ec7651484215ee7fa15a1f78b57c14621f01e392afe17b9a1390ce4", size = 12464674, upload-time = "2026-08-09T13:45:20.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/52/032b97e00461ab0809bbe4c588b035620e5a14b8cdee47ecddefc7b17d33/numpy-2.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:27650bb0e7140fa3d37b9923b4803645e0b125d190f326eecfd3f4dad8e8ade1", size = 10397131, upload-time = "2026-08-09T13:45:23.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/d2/6b24738a0ef4557d189b150046cd07823c50e4273e8aebd651222e24306f/numpy-2.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8e4cb9a754c8a0c62eaa88273a5fba3391f4a610d1dee893c0755da31c083f15", size = 16886595, upload-time = "2026-08-09T13:45:27.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/60/f2d208d366f263f39c6e69ed309290717aab41078b6d04c9be2a84fa2a07/numpy-2.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:52c808f96484f5571a5cc863775ce50247c17dfb3b0361f8ed6b4b0456f80080", size = 11896845, upload-time = "2026-08-09T13:45:31.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/79/81e0bf24f4d020a2b1d5cd297a9f60c3f24eeb116f9bba5870443f7b6a4a/numpy-2.5.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:29d81e97f668489cba8ebfd796b9bdd453525d35dd9e162e2daec94bf3fc7740", size = 5343880, upload-time = "2026-08-09T13:45:34.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/cc/e3141cf06d1a8a2c7e107543fe1269c1d1af760d4d683c0794a4ee1127c2/numpy-2.5.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afb3f0632d6b2e3ba04dbce8d1e48d321b369138b73830b5ca371a0e8d479d56", size = 6682264, upload-time = "2026-08-09T13:45:36.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/f1/2a64a307d92c5d98f5255a4014eb43bb6103ee477087b61ecae44a3aa9b9/numpy-2.5.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0aadf13b60048d501e05fa699efaf7734e2494f3498a4c2a5521d822640324f3", size = 15609566, upload-time = "2026-08-09T13:45:39.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/44/59a1eb68e773c4098d107ef34a0dbdeca501d72ffcfbff9a7707343921ce/numpy-2.5.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29b86ff8a6cc556b47ec6b64b194815cc80e6bf5eedcc6cddfd65318cb0b4eee", size = 16709995, upload-time = "2026-08-09T13:45:43.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/4c/3e54d4ddbc359a1295f8b633e8106bcd4d7d4a206e82df051bdfb3058755/numpy-2.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6950c4b7dd562453090548ba7f5da7e59f57f85663f15d5dcc60e249192f7e59", size = 16972511, upload-time = "2026-08-09T13:45:47.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/9f/02e371638ebf19b66d46231e4be52999e87f32d1961b113bc45656608b22/numpy-2.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9727f472d2f3888053b8a75ab0cb94745a9de224bb5846dbadc0092101bc71d", size = 18465609, upload-time = "2026-08-09T13:45:50.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/ae/ad6645abc7a3510fe48e8ea1ab4598166f500057ef4ebf38bfad4f1577de/numpy-2.5.2-cp313-cp313-win32.whl", hash = "sha256:4f9744f9fbdcea0bc552e8f19e1f141f811a3f9bc2be2cc6e86d982cab23e3f4", size = 6070204, upload-time = "2026-08-09T13:45:54.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/20/f3489f86d81ea460b2bcdceaed094142ca6579f6be0ec527b781d39afe68/numpy-2.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:85aaccb24182c25df891ad0ec333585967e115269d5f1b17f2c9ae005bc96657", size = 12460532, upload-time = "2026-08-09T13:45:57.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/21/35b31dde1b283b79de828b80f876afd8c94e28fe1e9c375f89e261cc4c0d/numpy-2.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:bd68ece1553d2023c09a4226d9e41c586ad2d20594d1a456186c33513d2cb3f2", size = 10396725, upload-time = "2026-08-09T13:46:00.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/f8/c3b222bf075b50afd8e949a07a15c4b312a4a84bd8102a332bcd953cbbb4/numpy-2.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d787cf769c3baeb5f6235e778edb52c08dfa923789b5958f28e6450f96107cb1", size = 16885180, upload-time = "2026-08-09T13:46:03.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/e1/2c1d4b1987795a92b5bbf7c24fe249ab96aa2573ab0d7604802c189d7b86/numpy-2.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:24b9dc2e3d84aa58523798805194e23e736f3f6ce2d1a5b92583ae734e6dbda8", size = 11907878, upload-time = "2026-08-09T13:46:07.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/ee/d08226fc858044355983a6e5b94f08ff6f3969e0a2b160a4a89f0ddb3445/numpy-2.5.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:9e9413326d726c2545bfa65d2c0876871e8d8386e77f992c1d426e180bbd4323", size = 5354922, upload-time = "2026-08-09T13:46:10.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/f0/6d3d933056440ebbc5e6bad92065fc6c26a48a84a36b1208580e94eea76c/numpy-2.5.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:60e902ac295855348a5ca2ea4c89108989a9f5fddfad3dfc0a8f36b10358567e", size = 6679168, upload-time = "2026-08-09T13:46:12.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/3b/ecd49dd90033cceb2704d88ca905d4d7d89b0e8c739608754ffd325fa820/numpy-2.5.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e500dc868e9313530ce12ba470fe50ff3afe3d62993ed6eff652dacd555b65", size = 15624501, upload-time = "2026-08-09T13:46:15.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/99/461bd36dbdfac6c1c53efa370bd55a83227542d0d118f1677dbf1a3dacd5/numpy-2.5.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318b9a4c845dbea06708a29c84ee429cc3065048db34cdb799047643492050ee", size = 16713701, upload-time = "2026-08-09T13:46:18.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/9c/2b251df9e8a5d647b62b0cbc1b90a91850c1cf4859ecb532fd0b4eacff6c/numpy-2.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34c319e2963be042673fb46570501b2f06c41924e17e3563d58646b4380dfb68", size = 16986065, upload-time = "2026-08-09T13:46:23.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/25/20de43f53ff1390534a124475055a19f01fe10c920a0fd11b8e18d6d6052/numpy-2.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f06571a052127dc1b4e8b83029b4d1b20daa2b64a31cdd181fc6bc774e9000eb", size = 18470031, upload-time = "2026-08-09T13:46:27.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/5e/0c577ca308d6da5eb79b546ba10bbe5b60148192194e2da060913b1de4f1/numpy-2.5.2-cp314-cp314-win32.whl", hash = "sha256:2cc779226e476d1e1f08c74068c419e60f41a9e0e069c92f6671d31d5c985e98", size = 6121028, upload-time = "2026-08-09T13:46:30.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/5c/7bcbd5b11f94199073320410cddcbb80cee62415bfeb540874b265c2d922/numpy-2.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:7587f53dfbd5edc0f7b87c6217b4c6d2d1f2ef9c3da70bc1315e7db5f8d7ec9d", size = 12597627, upload-time = "2026-08-09T13:46:32.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/bc/4d0b06fba0da90ccc75af62823cb9dcedb6c9ea0cffa058cb2c9ee773a77/numpy-2.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:3e4c367352d3747784248a227fbec218e193b56f7e6692e3b64fc805478ecfdf", size = 10680414, upload-time = "2026-08-09T13:46:36.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/17/f429aac9dc08833a0d0f188eba38c532a751b1a1f2ca6018a37b455cb321/numpy-2.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b879fb674276e331513fb136b78dbc6bd3c848309e0d841cfd63be3896c4cfc1", size = 12026967, upload-time = "2026-08-09T13:46:39.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/9f/d0849de96a2a4ceaa16662f18ee13eaa9c0aa418269fdc8c4857c56b11da/numpy-2.5.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:fd0d703772bba096843785bd38371e31bb4a0c1151497ad5739d182114a73f7f", size = 5473874, upload-time = "2026-08-09T13:46:42.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/3c/8df216d4a4a5422a3de045301cf7df8ea47286d76f5cb7160b0128ac26b7/numpy-2.5.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:3a2f061cebd9e3d23bdcfaaded5e2293a4c6a5b60fa42df85d410a725ce621bf", size = 6789276, upload-time = "2026-08-09T13:46:44.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/3a/20d7e9891c4ddfadd6ff8d95bf4b29f353d8e1770553de2099880551dfb9/numpy-2.5.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6df895598c0edcb41030126c89e0f353b07d93238116143b7405e937359736c4", size = 15659154, upload-time = "2026-08-09T13:46:47.538Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/d6/f3aa3d2688bf501b858835c6bd087ae9b51a56ae6fca8e2b0990abd177af/numpy-2.5.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ab3d4a901f844ea836c3e80bf463c6a27d7f3c14e8e292fcf28d348b25b9bce", size = 16748909, upload-time = "2026-08-09T13:46:51.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/8f/1c5cae8d2baf86ab802ae97a00be55bc7e21ebc11b12bbc33376c5f05342/numpy-2.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cebc2d6dbb605a7703d59751dea4bd6b0ab127a5a4338a6f432df1936fef8b26", size = 17027685, upload-time = "2026-08-09T13:46:55.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/27/71d3467404aedc1c24ce79610f91b52b0b0f466c43a701aa56fc75c145ab/numpy-2.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eaca7ff36f0f52e2111ec71f169d8fd3e889e7ddc0d2592e0d703fd8d3ce8fac", size = 18501181, upload-time = "2026-08-09T13:46:59.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/2f/42921d27c40aea7e077f4a423ae509fd9220b028cd787bafefd8ab2b3a5f/numpy-2.5.2-cp314-cp314t-win32.whl", hash = "sha256:ddf47472af2e4280d79bac82304f5e80150211f1b9e614b760061d5fdfbb6eba", size = 6271085, upload-time = "2026-08-09T13:47:01.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/e6/bad5f5d56de9b1971bac959963dda276d35c40f1854475005434bbe08692/numpy-2.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:44ef9675d908e65f9953063837c3277730f3f4437615a4cdab67b366cabaf884", size = 12787971, upload-time = "2026-08-09T13:47:04.963Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/05/f608795cb34391acd67e38d94a3c36abd8d8576293a3a80727d7595c372c/numpy-2.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:eaa088384c46f519dacb93b7ec483a6d6b19a4a2085ae4f25ab9b1c43d387d1e", size = 10750306, upload-time = "2026-08-09T13:47:07.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/c6/28de0191c5f82b7d42a0a51390ba98587048aa93a39fafb05bdbe6e8d00c/numpy-2.5.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:078f9b027b478c9379b9677babbf0f8b8f1ecfada27636d7b9a93990c638739f", size = 16885274, upload-time = "2026-08-09T13:47:11.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/d1/973ca116000d244897e468ea1aff30b589e5022e3c8744b71706fe33bd57/numpy-2.5.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:50a68f4bacd8a2b33d8da3d2269d0d78500f86ea582e4786dc10f5ef2c2c6842", size = 11907846, upload-time = "2026-08-09T13:47:15.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/d9/8c4b3937ef204cb2fd88d389ccd0f265a2ffb11f35a01d2064cf46714bd6/numpy-2.5.2-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:e79aba74ffaf5f78a050d777c184cddf8fdffabab38acf5f3ef1fecbc17895d6", size = 5354892, upload-time = "2026-08-09T13:47:18.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/9b/b6ee65ea2999fdb7023935e108e6fb776ee4082aa15f159acfa857e578c8/numpy-2.5.2-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:9a0731745a72a184490a582fb4af2533512bd071ace67785b5fdffc0ae58dce8", size = 6679309, upload-time = "2026-08-09T13:47:20.456Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/f3/acb18d8b137a393c8e7803a8c994c9e64bde3930692a69d826993113a159/numpy-2.5.2-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ec954036759bcee3aa484f8603bd9c14f3e776293b85578b8734c2d72777c69", size = 15625850, upload-time = "2026-08-09T13:47:24.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/bf/a8e9bb0db815a0e265b5744ebedd3af0bd5faad8604e5b50a1cd012f3c91/numpy-2.5.2-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc649493697006bc90614a5f0bbc8cb3cb1866715c474e473694968d7e6b99ab", size = 16713664, upload-time = "2026-08-09T13:47:27.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/6e913736b3dd6582344af32418b5fb9dab34282e8a8174ae1d54ceb0fc13/numpy-2.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cf7de32f486e4ac9e2d93b810f9e9ac72a728dd46a32a0bb403222f27f653514", size = 16986749, upload-time = "2026-08-09T13:47:31.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/09/7d3b23eff5c7428ef6c01e6f7052bb60d504c4d33e317b36b8959c24ad97/numpy-2.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2ffa7bacab3e2ee1b19ed31766bb60bb380b68c23f051e199c5cc598afd68710", size = 18470495, upload-time = "2026-08-09T13:47:35.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/a4/68a321d825374f6eb677ffe8ef8c6b9a328304e6fd2e39d9530822776607/numpy-2.5.2-cp315-cp315-win32.whl", hash = "sha256:6b588cc8f902d6bff201c19fd00c43ab8545671e3554d014e12e14139e5e8617", size = 6120696, upload-time = "2026-08-09T13:47:38.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/23/deafbb1700f79fae9cd1e91220f133d124cc267de1b584da3fbf6db2f6cd/numpy-2.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:07d4e89f3a9ab0a9ba24264ccdb642b3dd951b2281e8883a5481a4aa79cc31a7", size = 12597324, upload-time = "2026-08-09T13:47:41.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/cd/3272ba105e3bbbdaeb11357eda31e7a6825ffe159e8171665660299a948f/numpy-2.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:a610dc7e3c52edd39c2bc2375ff9c3fd59cb3ad00e4472d36f83bc1457145788", size = 10680466, upload-time = "2026-08-09T13:47:44.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/0e/58370637b1bb70a5c9ce2b43f4b521ccb224e36ccb76a6596b17ae4b447c/numpy-2.5.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:40f4d451aed46a8046a1aae41c4e55fb3612273df9c502480135e1501576a34b", size = 16993947, upload-time = "2026-08-09T13:47:48.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/93/2abcb807712b289d6d60fe4cf30532f98974a8396d885650f3ba5a13026e/numpy-2.5.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c081cbe16ba1ab53078e5ff29013621e33c509eedab055775d956427712c236e", size = 12025331, upload-time = "2026-08-09T13:47:52.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/3a/2898e003a5fbaf87e76c039b4ee1f5eb390471b4ffe74887c1f34c4e791e/numpy-2.5.2-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:0090ccdd57ec2703e9b49d0bf554767370581c1dd0a6b2bb2b2d9def317d042a", size = 5472336, upload-time = "2026-08-09T13:47:55.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/a5/23f69d07c544597b29758b31b55c27dc9d541012a2c1496189fef702aec2/numpy-2.5.2-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:6a9bb119fb8dd21ba30b3f0e555b7e2b081bd9883af21ec9c1c633d161cda3a8", size = 6788387, upload-time = "2026-08-09T13:47:58.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/ea/c0dbdbcf22f43782510a3e492dd3da73c6112b69cac8929d16d127536fc4/numpy-2.5.2-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a839318485284a6fb31be4f8f2c91c8f2cb22f4543c4a8903f12b0671ffe07cc", size = 15667096, upload-time = "2026-08-09T13:48:01.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/5e/29c73c31748cdb0f7566642125ba17fd5b56780cddf891b085dab27e4466/numpy-2.5.2-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba0a474801b8dc67b66bf465548abc90e82b44d2611b5770f33008dcabffe8ec", size = 16751730, upload-time = "2026-08-09T13:48:05.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/95/02501e8454796bb58dadf7a99d3181e0b464bf264e1003039572f9779fac/numpy-2.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0a4035ae1129ff8777f08bfbd44f1e5d8e9c049ce0c2dd78fc0d92c13e7251c0", size = 17038686, upload-time = "2026-08-09T13:48:09.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/b5/53a681d91b5c82687067d8ea5035e02d917b5509d6f334cb06484a954714/numpy-2.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:77843ca236b777e67f8d6b3660ea116e499612703a0ecd7093f316201eb9d8e2", size = 18507727, upload-time = "2026-08-09T13:48:13.744Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/06/6e11443f7b64ee376c860506091103bf68f92d2cab9e8d96d4501babf07c/numpy-2.5.2-cp315-cp315t-win32.whl", hash = "sha256:7354826bc6f8f69402e9b7fe28d15fcd34feebd74f856f111585c5b0c9fb0251", size = 6269775, upload-time = "2026-08-09T13:48:17.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/18/195d6b86cd72dbbc501edfa778005fa6b87afd34c153e46028cd3a0938f4/numpy-2.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:e5651f3f87add730ee6608d915009e19c911fba0cb000c7e3ea994b7d768eb12", size = 12782559, upload-time = "2026-08-09T13:48:21.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/07/458c344f0f0c178f4481dad5cca790626ffe4c34eabf9467069d06ee4999/numpy-2.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:5f8e00be2ec6f45f4e8a41a527f68d44a7d96fee92a650e4d8b1326f77f61e6e", size = 10748103, upload-time = "2026-08-09T13:48:24.21Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.2"
|
||||
@@ -362,6 +435,7 @@ version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "numpy" },
|
||||
{ name = "requests" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
@@ -375,6 +449,7 @@ dev = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "numpy" },
|
||||
{ name = "requests" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user