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

View File

@@ -1,7 +1,12 @@
# TFT-Simulator
Trainings-Tool: TFT-Runden schnell simulieren, 1 Spieler vs. 7 Bots.
Kampf = Vergleich von Boardstärke-Scores (Regel-Baseline + gelernte Gewichte aus Endboard-Matchdaten).
Kampf = deterministische DPS-Simulation (`sim/combat.py`) auf Unit-Profilen aus
exakten Community-Dragon-Werten (`model/statsheet.py`: eHP und DPS je Unit).
`model/score.py` liefert daraus die angezeigte Boardstärke und die Kalibrierung.
Hinweis: die aus Matchdaten gelernten Gewichte (`model/learn.py`) landen im
Artifact, werden vom Scorer aber derzeit **nicht** gelesen.
## Setup

View File

@@ -7,6 +7,8 @@ dependencies = [
"fastapi",
"uvicorn",
"requests",
# nur fürs Training der Boardbewertung; die Auswertung läuft ohne
"numpy",
]
[dependency-groups]

View File

@@ -58,7 +58,8 @@ def test_state_shape(client):
"opponents", "items", "over", "log", "shop_odds", "shop_locked"):
assert key in state
assert sum(state["shop_odds"]) == 100
assert state["player"]["score"] == 0.0
assert state["player"]["off"] == 0.0
assert state["player"]["def"] == 0.0
assert all(o["board"] is not None for o in state["opponents"])
@@ -76,3 +77,84 @@ def test_upgrade_hint():
assert upgrade_hint(game, "U1_0") == 3
game.bench = [{"api_name": "U1_0", "stars": 3, "items": []}]
assert upgrade_hint(game, "U1_0") == 0
@pytest.fixture
def group_client(monkeypatch):
static = make_static()
static["meta"]["patch"] = "test"
art = artifact_mod.build(static)
a, b = list(art["static"]["units"])[:2]
# Schlüssel sind "api@itemzahl" — die Karte zeigt die Items mit an.
# Schlüssel sind "api@sterne@itemzahl". Zwei Gruppen teilen sich
# denselben Carry — nur die bessere darf durchkommen.
art["groups"] = {
"2": {f"{a}@2@3|{b}@2@0": {"edge": 1.2, "boards": 400},
f"{a}@2@3|{b}@1@1": {"edge": 0.9, "boards": 700},
f"{a}@1@0|{b}@2@3": {"edge": 0.4, "boards": 900}},
"boards": 5000,
}
cfg = load_constants(17)
monkeypatch.setattr(app_mod, "_load_game_deps", lambda: (art, cfg))
return TestClient(app_mod.app)
def test_groups_endpoint(group_client):
"""Der Gruppen-Reiter braucht Name, Kosten und Icon je Unit."""
r = group_client.get("/api/groups?size=2&limit=3")
assert r.status_code == 200
body = r.json()
assert body["size"] == 2 and body["boards"] == 5000
assert 0 < len(body["groups"]) <= 3
first = body["groups"][0]
assert len(first["units"]) == 2
assert all({"name", "cost", "icon", "splash", "items", "stars", "traits", "tier"}
<= set(u) for u in first["units"])
assert [u["items"] for u in first["units"]] == [3, 0]
assert [u["stars"] for u in first["units"]] == [2, 2]
# Je Carry nur eine Gruppe: a@3 kommt einmal vor, dann b@3.
carries = [g["units"][0]["name"] for g in body["groups"]]
assert len(carries) == len(set(carries))
# Nach Vorsprung sortiert, absteigend.
edges = [g["edge"] for g in body["groups"]]
assert edges == sorted(edges, reverse=True)
assert group_client.get("/api/groups?size=2&limit=1").json()["groups"] == [first]
def test_groups_without_data(client):
# Ohne trainiertes Artefakt gibt es nichts zu zeigen — 404 statt leerer Liste.
assert client.get("/api/groups?size=2").status_code == 404
def test_groups_unknown_size(group_client):
assert group_client.get("/api/groups?size=7").status_code == 404
def test_roles_endpoint(monkeypatch):
"""Beste Unit je Rolle und Kostenstufe, mit den Items dieses Champions."""
static = make_static()
static["meta"]["patch"] = "test"
art = artifact_mod.build(static)
api = next(a for a, u in art["static"]["units"].items() if "Tank" in (u.get("role") or ""))
item = next(iter(art["static"]["items"]))
art["groups"] = {"solo": {f"{api}@2@3": {"edge": 0.5, "boards": 300}},
"unit_items": {f"{api}|{item}": {"edge": 0.3, "boards": 400},
f"{api}|schlecht": {"edge": -0.1, "boards": 400}},
"boards": 9000}
art["unit_tiers"] = {api: {"tier": "S", "strength": 0.4, "boards": 1100}}
monkeypatch.setattr(app_mod, "_load_game_deps", lambda: (art, load_constants(17)))
body = TestClient(app_mod.app).get("/api/roles").json()
tank = next(r for r in body["roles"] if r["label"] == "Tank")
entry = next(c for c in tank["costs"] if c["unit"])
# Buchstabe und Zahl kommen aus derselben Quelle.
assert entry["unit"]["tier"] == "S"
assert entry["unit"]["edge"] == 0.4
# Items haengen am Champion, Negatives ist keine Empfehlung.
assert [i["edge"] for i in entry["unit"]["items"]] == [0.3]
assert {"name", "cost", "traits", "splash"} <= set(entry["unit"])
# Kostenstufen ohne Daten bleiben leer statt zu raten.
assert any(c["unit"] is None for c in tank["costs"])
def test_roles_without_data(client):
assert client.get("/api/roles").status_code == 404

103
backend/tests/test_arena.py Normal file
View File

@@ -0,0 +1,103 @@
"""Arena: Boards auf das Kampffeld, eigene Positionen, leere Seiten."""
import random
from tft.sim.arena import BOARD_COLS
from tft.sim import arena, hexgrid
STATS = {"hp": 800, "armor": 20, "magicResist": 20, "damage": 50,
"damageByStar": [0, 50, 100, 200], "attackSpeed": 1.0, "range": 1,
"mana": 0, "initialMana": 0, "critChance": 0.0, "critMultiplier": 1.4}
ARTIFACT = {"static": {
"units": {"A": {"traits": [], "stats": STATS},
"B": {"traits": [], "stats": {**STATS, "range": 4}}},
"items": {"HP": {"effects": {"Health": 500}}},
"traits": {},
}}
def test_front_row_of_both_sides_faces_the_other():
"""Reihe 0 des Spielers ist die vorderste — sonst laufen alle aneinander vorbei."""
a = arena.from_hex(3, "a")
b = arena.from_hex(3, "b")
assert hexgrid.distance((a["col"], a["row"]), (b["col"], b["row"])) == 1
def test_back_row_is_furthest_away():
a = arena.from_hex(3 + 3 * BOARD_COLS, "a")
b = arena.from_hex(3 + 3 * BOARD_COLS, "b")
assert hexgrid.distance((a["col"], a["row"]), (b["col"], b["row"])) == 7
def test_own_positions_are_used():
board = [{"api_name": "A", "stars": 1, "hex": 3}]
out = arena.placed(board, "a", ARTIFACT["static"]["units"])
assert (out[0]["col"], out[0]["row"]) == (3, 3)
def test_boards_without_positions_fall_back_to_the_lineup():
"""Endboards aus der Riot-API tragen keine Felder."""
board = [{"api_name": "A", "stars": 1}, {"api_name": "B", "stars": 1}]
out = arena.placed(board, "a", ARTIFACT["static"]["units"])
assert all("col" in e and "row" in e for e in out)
# Nahkämpfer vorne, Fernkämpfer hinten.
front = next(e for e in out if e["api_name"] == "A")
back = next(e for e in out if e["api_name"] == "B")
assert front["row"] > back["row"]
def test_two_units_never_share_a_cell():
board = [{"api_name": "A", "stars": 1, "hex": 3},
{"api_name": "A", "stars": 1, "hex": 3}]
out = arena.placed(board, "a", ARTIFACT["static"]["units"])
assert len({(e["col"], e["row"]) for e in out}) == 2
def test_an_empty_board_loses_at_once():
board = [{"api_name": "A", "stars": 1, "hex": 0}]
res, log = arena.fight([], board, ARTIFACT, random.Random(1))
assert res.winner == "b" and res.frames == 0 and log is None
res, _ = arena.fight(board, [], ARTIFACT, random.Random(1))
assert res.winner == "a"
assert arena.fight([], [], ARTIFACT, random.Random(1))[0].winner is None
def test_items_reach_the_fighters():
board = [{"api_name": "A", "stars": 1, "hex": 0, "items": ["HP"]}]
plain = arena.side([{"api_name": "A", "stars": 1, "hex": 0}], "a", ARTIFACT)
armed = arena.side(board, "a", ARTIFACT)
assert armed[0].max_hp == plain[0].max_hp + 500
def test_the_bigger_board_wins():
big = [{"api_name": "A", "stars": 1, "hex": h} for h in range(4)]
small = [{"api_name": "A", "stars": 1, "hex": 0}]
assert arena.fight(big, small, ARTIFACT, random.Random(1))[0].winner == "a"
def test_replay_returns_a_playable_log():
"""Feldmaße, Kämpfer und Ereignisse — mehr braucht die Anzeige nicht."""
a = [{"api_name": "A", "stars": 1, "hex": h} for h in range(3)]
b = [{"api_name": "A", "stars": 1, "hex": h} for h in range(2)]
_, log = arena.fight(a, b, ARTIFACT, random.Random(1), record=True)
assert log["cols"] == hexgrid.COLS and log["rows"] == hexgrid.ROWS
assert len(log["units"]) == 5
assert {u["slot"] for u in log["units"]} == set(range(5))
kinds = {e["e"] for e in log["events"]}
assert "spawn" in kinds and "hit" in kinds
assert log["winner"] == "a"
def test_replay_is_none_without_a_fight():
_, log = arena.fight([], [{"api_name": "A", "stars": 1, "hex": 0}],
ARTIFACT, random.Random(1), record=True)
assert log is None
def test_replay_matches_the_fight():
"""Sonst zeigt die Anzeige etwas anderes als das Spiel gewertet hat."""
a = [{"api_name": "A", "stars": 1, "hex": h} for h in range(3)]
b = [{"api_name": "B", "stars": 1, "hex": h} for h in range(3)]
res, log = arena.fight(a, b, ARTIFACT, random.Random(3), record=True)
plain, _ = arena.fight(a, b, ARTIFACT, random.Random(3))
assert log["winner"] == res.winner == plain.winner

View File

@@ -0,0 +1,206 @@
"""Kampf: Minderung, Reichweite, Bewegung, Zaubern, Determinismus."""
import random
from tft.sim import battle as B
from tft.sim import hexgrid as H
UNITS = {
"NAH": {"stats": {"hp": 1000, "armor": 0, "magicResist": 0, "damage": 100,
"damageByStar": [0, 100, 200, 400], "attackSpeed": 1.0,
"range": 1, "mana": 0, "initialMana": 0,
"critChance": 0.0, "critMultiplier": 1.4}},
"FERN": {"stats": {"hp": 500, "armor": 0, "magicResist": 0, "damage": 50,
"damageByStar": [0, 50, 100, 200], "attackSpeed": 1.0,
"range": 4, "mana": 0, "initialMana": 0,
"critChance": 0.0, "critMultiplier": 1.4}},
"PANZER": {"stats": {"hp": 1000, "armor": 100, "magicResist": 100,
"damage": 10, "damageByStar": [0, 10, 20, 40],
"attackSpeed": 1.0, "range": 1, "mana": 0,
"initialMana": 0, "critChance": 0.0,
"critMultiplier": 1.4}},
"MAGIER": {"stats": {"hp": 600, "armor": 0, "magicResist": 0, "damage": 10,
"damageByStar": [0, 10, 20, 40], "attackSpeed": 1.0,
"range": 4, "mana": 30, "initialMana": 0,
"critChance": 0.0, "critMultiplier": 1.4},
"spell_damage": [0, 300, 600, 1200],
"spell_damage_type": "magic"},
}
def make(key, side, col, row, stars=1):
f = B.to_fighter({"api_name": key, "stars": stars, "col": col, "row": row},
side, UNITS)
assert f is not None
return f
def test_mitigation_halves_at_hundred_armor():
assert B.mitigate(100, 0) == 100
assert B.mitigate(100, 100) == 50
assert B.mitigate(100, 300) == 25
def test_stars_raise_health_and_damage():
one, two = make("NAH", "a", 0, 0, 1), make("NAH", "a", 0, 0, 2)
assert two.max_hp > one.max_hp
assert two.attack_damage == 2 * one.attack_damage
def test_the_stronger_side_wins():
a = [make("NAH", "a", i, 3) for i in range(3)]
b = [make("NAH", "b", i, 4) for i in range(1)]
assert B.fight(a, b, random.Random(1)).winner == "a"
def test_armour_buys_time():
"""Gleiches Leben, aber einer mindert — der hält doppelt so lange durch."""
def survives(defender):
a = [make(defender, "a", 3, 3)]
b = [make("NAH", "b", 3, 4)]
return B.fight(a, b, random.Random(1), max_seconds=120).seconds
assert survives("PANZER") > 1.8 * survives("NAH")
def test_ranged_units_close_the_gap_and_shoot_first():
"""Der Fernkämpfer steht außer Reichweite und muss trotzdem treffen."""
a = [make("FERN", "a", 3, 0)]
b = [make("NAH", "b", 3, 7)]
res = B.fight(a, b, random.Random(1), max_seconds=60)
# Beide bewegen sich; der Fernkämpfer trifft aus vier Feldern.
assert res.fighters[0].damage_done > 0
def test_units_move_towards_the_enemy():
one = make("NAH", "a", 3, 0)
foe = make("NAH", "b", 3, 7)
before = H.distance(one.pos, foe.pos)
B._step_towards(one, foe, {one.pos, foe.pos})
assert H.distance(one.pos, foe.pos) == before - 1
def test_occupied_cells_are_not_entered():
one = make("NAH", "a", 3, 3)
foe = make("NAH", "b", 3, 4)
taken = {one.pos, foe.pos} | set(H.neighbours(3, 3))
B._step_towards(one, foe, taken)
assert one.pos == (3, 3)
def test_mana_fills_from_attacks_and_then_casts():
caster = make("MAGIER", "a", 3, 3)
foe = make("NAH", "b", 3, 4)
assert caster.mana == 0
B.fight([caster], [foe], random.Random(1), max_seconds=10)
assert caster.casts >= 1
def test_units_without_mana_never_cast():
"""NAH hat keine Mana-Kosten — sonst zauberte er im ersten Tick."""
one = make("NAH", "a", 3, 3)
foe = make("PANZER", "b", 3, 4)
B.fight([one], [foe], random.Random(1), max_seconds=10)
assert one.casts == 0
def test_same_seed_same_outcome():
def run(seed):
a = [make("NAH", "a", i, 3) for i in range(3)]
b = [make("FERN", "b", i, 4) for i in range(3)]
res = B.fight(a, b, random.Random(seed))
return res.winner, res.seconds, len(res.survivors_a)
assert run(7) == run(7)
def test_a_fight_always_ends():
"""Zwei Panzer ohne Schaden dürfen nicht ewig laufen."""
a = [make("PANZER", "a", 3, 3)]
b = [make("PANZER", "b", 3, 4)]
res = B.fight(a, b, random.Random(1), max_seconds=5)
assert res.seconds <= 5
def test_unknown_units_are_skipped():
assert B.to_fighter({"api_name": "GIBTS_NICHT"}, "a", UNITS) is None
AOE = {"stats": {"hp": 600, "armor": 0, "magicResist": 0, "damage": 10,
"damageByStar": [0, 10, 20, 40], "attackSpeed": 1.0,
"range": 4, "mana": 30, "initialMana": 30,
"critChance": 0.0, "critMultiplier": 1.4},
"spell_damage": [0, 300, 600, 1200], "spell_damage_type": "magic",
"ability_vars": {"AoEHexRange": [0, 2, 2, 2]}}
def test_area_spells_hit_the_neighbours():
"""Radius aus den Fähigkeits-Variablen: alle im Umkreis nehmen Schaden."""
UNITS["FLAECHE"] = AOE
try:
caster = make("FLAECHE", "a", 3, 3)
near = [make("NAH", "b", c, 4) for c in (2, 3, 4)]
B.fight([caster], near, random.Random(1), max_seconds=1)
assert all(f.hp < f.max_hp for f in near)
finally:
del UNITS["FLAECHE"]
def test_single_target_spells_stay_single():
caster = make("MAGIER", "a", 3, 3)
near = [make("NAH", "b", c, 4) for c in (2, 3, 4)]
B.fight([caster], near, random.Random(1), max_seconds=1)
assert sum(1 for f in near if f.hp < f.max_hp) == 1
def test_spell_shape_reads_radius_and_count():
assert B.spell_shape(AOE, 2) == (2, 1)
assert B.spell_shape({"ability_vars": {"NumEnemiesHit": [0, 4, 4, 4]}}, 2) \
== (0, 4)
assert B.spell_shape({}, 2) == (0, 1)
def test_physical_spells_scale_with_attack_damage():
"""Ein Carry mit AD-Items soll auch härter zaubern."""
UNITS["AD_ZAUBER"] = {
"stats": {**UNITS["MAGIER"]["stats"], "initialMana": 30},
"spell_damage": [0, 300, 600, 1200], "spell_damage_type": "physical"}
ITEMS = {"AD": {"effects": {"AD": 1.0}}}
try:
plain = make("AD_ZAUBER", "a", 3, 3)
armed = make("AD_ZAUBER", "a", 3, 3)
B.apply_items(armed, ["AD"], ITEMS)
foes = [make("PANZER", "b", 3, 4), make("PANZER", "b", 3, 4)]
B.fight([plain], [foes[0]], random.Random(1), max_seconds=0.5)
B.fight([armed], [foes[1]], random.Random(1), max_seconds=0.5)
assert armed.damage_done > 1.5 * plain.damage_done
finally:
del UNITS["AD_ZAUBER"]
def test_the_log_records_spawn_hit_and_death():
a = [make("NAH", "a", 3, 3)]
b = [make("FERN", "b", 3, 4)]
res = B.fight(a, b, random.Random(1), max_seconds=30, record=True)
kinds = {e["e"] for e in res.events}
assert {"spawn", "hit", "die"} <= kinds
# Zwei Kämpfer, zwei Spawns, und jeder Eintrag trägt einen Tick.
assert sum(1 for e in res.events if e["e"] == "spawn") == 2
assert all("t" in e for e in res.events)
def test_the_log_stays_off_by_default():
"""Die Auswertung läuft millionenfach — dort darf nichts mitgeschrieben werden."""
a = [make("NAH", "a", 3, 3)]
b = [make("NAH", "b", 3, 4)]
assert B.fight(a, b, random.Random(1), max_seconds=5).events == []
def test_recording_does_not_change_the_outcome():
def run(record):
a = [make("NAH", "a", i, 3) for i in range(3)]
b = [make("FERN", "b", i, 4) for i in range(3)]
res = B.fight(a, b, random.Random(5), record=record)
return res.winner, res.seconds
assert run(False) == run(True)

View File

@@ -0,0 +1,117 @@
"""Carries: Kontrollen, Zielstufe, Zerlegung."""
from tft.model import carries
UNITS = {a: {"cost": 3, "traits": ["T"]} for a in ("A", "B", "CARRY", "TANK")}
UNITS["TEUER"] = {"cost": 5, "traits": ["T"]}
TRAITS = {"T": {"breakpoints": [{"min_units": 1}, {"min_units": 3}]}}
def board(place, level, items_total, *ents):
"""ents: (api, sterne, itemzahl)"""
return {"place": place, "level": level, "items": items_total,
"entities": list(ents)}
def prepared(boards):
return carries.prepare(boards, UNITS, TRAITS)
def test_other_three_stars_are_part_of_the_comparison_group():
"""Wer eine Unit auf 3★ hat, hatte Gold übrig — das Board ist deshalb gut.
Die eigenen 3★ zählen dabei nicht mit, sonst würde man den Effekt
misst, den man erklären will.
"""
b = prepared([board(1, 9, 3, ("A", 3, 3), ("B", 3, 0))])[0]
assert carries.cell(b, 3) == (9, 3, 1) # eigene 3★ abgezogen
assert carries.cell(b, 2) == (9, 3, 2) # aus Sicht einer 2★-Unit beide
def test_reroll_carries_are_shown_on_three_stars():
"""Auf 2★ negativ, auf 3★ positiv: so spielt man diesen Champion."""
boards = prepared([board(6, 9, 3, ("A", 2, 3)) for _ in range(60)]
+ [board(1, 9, 3, ("A", 3, 3)) for _ in range(60)])
base = carries.baseline(boards)
out = carries.values(boards, base, UNITS, min_boards=10)
assert out["A"]["stars"] == 3 and out["A"]["edge"] > 0
def test_expensive_units_are_never_shown_on_three_stars():
"""Ein 5-Kosten auf 3★ heißt: das Spiel war schon gewonnen.
In den Daten holen 5-Kosten auf 3★ in 98 % der Fälle Platz 1.
"""
boards = prepared([board(6, 9, 3, ("TEUER", 2, 3)) for _ in range(60)]
+ [board(1, 9, 3, ("TEUER", 3, 3)) for _ in range(60)])
base = carries.baseline(boards)
out = carries.values(boards, base, UNITS, min_boards=10)
assert out["TEUER"]["stars"] == 2
def test_units_below_the_rate_are_kept_but_not_carries():
"""Ein Tank ohne Items bleibt in der Tabelle — er ist der Maßstab.
Der Buchstabe im Reiter vergleicht mit allen Units, nicht nur mit
den Carries.
"""
boards = prepared([board(1, 9, 3, ("CARRY", 2, 3), ("TANK", 2, 0))
for _ in range(60)]
+ [board(4, 9, 3, ("CARRY", 2, 3), ("TANK", 2, 3))
for _ in range(20)])
base = carries.baseline(boards)
out = carries.values(boards, base, UNITS, min_boards=10)
assert out["CARRY"]["carry"] and not out["TANK"]["carry"]
def test_rare_units_are_dropped():
boards = prepared([board(1, 9, 3, ("A", 2, 3)) for _ in range(9)])
base = carries.baseline(boards)
assert carries.values(boards, base, UNITS, min_boards=10) == {}
def test_reasons_separate_unit_from_the_rest():
"""Zwei Carries, gleiche Traits, aber einer steht neben besseren Units.
Der Unterschied muss im Topf "rest" landen, nicht in "unit".
"""
boards = prepared(
[board(2, 9, 3, ("A", 2, 3), ("TANK", 2, 0)) for _ in range(400)]
+ [board(2, 9, 3, ("B", 2, 3), ("CARRY", 2, 0)) for _ in range(400)]
+ [board(7, 9, 3, ("TANK", 2, 3)) for _ in range(400)]
+ [board(1, 9, 3, ("CARRY", 2, 3)) for _ in range(400)])
base = carries.baseline(boards)
beta, idx, own_of = carries._weights(boards, base, UNITS, carries.RIDGE)
why = carries.reasons(boards, base, own_of, idx, beta, min_boards=100)
a, b = why[("A", 2)], why[("B", 2)]
# B steht neben CARRY (gut), A neben TANK (schlecht).
assert b["rest"] > a["rest"]
def test_build_is_empty_without_data():
assert carries.build([], UNITS, TRAITS, min_boards=10) == {}
def test_enabler_measures_the_same_carry_with_and_without():
"""Stützt die Unit fremde Carries — oder steht sie nur daneben?
CARRY läuft mit B besser als ohne. B trägt selbst nie Items, kann den
Vorsprung also nicht selbst erklären.
"""
boards = prepared(
[board(2, 9, 3, ("CARRY", 2, 3), ("B", 2, 0)) for _ in range(400)]
+ [board(6, 9, 3, ("CARRY", 2, 3), ("TANK", 2, 0)) for _ in range(400)])
base = carries.baseline(boards)
lift = carries.enabler_edge(boards, base, UNITS, min_pair=100, min_total=100)
assert lift["B"] > 0 > lift["TANK"]
def test_supported_is_relative_to_comparable_boards():
"""Ein Carry mit starken Stützern liegt über dem Üblichen."""
boards = prepared(
[board(2, 9, 3, ("A", 2, 3), ("B", 2, 0)) for _ in range(400)]
+ [board(6, 9, 3, ("CARRY", 2, 3), ("TANK", 2, 0)) for _ in range(400)])
base = carries.baseline(boards)
lift = {"B": 0.4, "TANK": -0.4}
prop = carries.supported(boards, base, lift, min_boards=100)
assert prop[("A", 2)] > prop[("CARRY", 2)]

View File

@@ -1,92 +0,0 @@
import random
from tft.sim import combat
def prof(off, dfn, defensive=True, name="u"):
return {"off": off, "def": dfn, "defensive": defensive,
"unit": {"api_name": name, "stars": 1, "items": []}}
CFG = {
"combat": {"max_frames": 1800},
"damage": {"stage_base": [0, 2, 5, 8, 10, 12, 17, 17],
"per_surviving_unit": 1, "player_hp": 100},
}
def test_kill_time_matches_dps_at_60fps():
# Def 10 gegen Gesamt-Off 5 -> 2 s = 120 Frames.
a = [prof(5, 1000)]
b = [prof(0, 10)]
res = combat.fight(a, b, random.Random(1), CFG)
assert res.winner == "a"
assert res.frames == 120
assert res.survivors_b == []
def test_overflow_carries_to_next_unit():
# Zwei Units à 10 Def gegen Off 5 -> exakt 4 s, kein Schaden verpufft.
a = [prof(5, 1000)]
b = [prof(0, 10), prof(0, 10)]
res = combat.fight(a, b, random.Random(1), CFG)
assert res.winner == "a"
assert res.frames == 240
def test_dead_units_stop_contributing_offense():
# B killt As Front nach 2 s; danach kämpft A nur noch mit halber Offense.
a = [prof(6, 12, defensive=True), prof(6, 1000, defensive=False)]
b = [prof(6, 60)]
res = combat.fight(a, b, random.Random(1), CFG)
assert res.winner == "a"
assert res.frames == 480 # 2 s + 36/6 s statt 60/12 s bei konstanter Offense
assert len(res.survivors_a) == 1
def test_queue_targets_defensive_units_first():
team = [prof(1, 1, defensive=False, name="carry1"),
prof(1, 1, defensive=True, name="tank1"),
prof(1, 1, defensive=False, name="carry2"),
prof(1, 1, defensive=True, name="tank2")]
q = combat._queue(team, random.Random(5))
assert [p["defensive"] for p in q] == [True, True, False, False]
def test_simultaneous_last_kill_is_draw():
a = [prof(5, 10)]
b = [prof(5, 10)]
res = combat.fight(a, b, random.Random(1), CFG)
assert res.winner is None
assert res.survivors_a == [] and res.survivors_b == []
def test_empty_boards():
res = combat.fight([], [], random.Random(1), CFG)
assert res.winner is None and res.frames == 0
res = combat.fight([prof(1, 1)], [], random.Random(1), CFG)
assert res.winner == "a"
def test_stall_hits_time_limit_more_defense_wins():
a = [prof(0, 100)]
b = [prof(0, 50)]
res = combat.fight(a, b, random.Random(1), CFG)
assert res.winner == "a"
assert res.frames == CFG["combat"]["max_frames"]
def test_same_seed_same_result():
team_a = [prof(7, 300), prof(12, 150, defensive=False), prof(5, 400)]
team_b = [prof(9, 250), prof(10, 200, defensive=False)]
r1 = combat.fight(team_a, team_b, random.Random(42), CFG)
r2 = combat.fight(team_a, team_b, random.Random(42), CFG)
assert (r1.winner, r1.frames) == (r2.winner, r2.frames)
assert [p["api_name"] for p in r1.survivors_a] == \
[p["api_name"] for p in r2.survivors_a]
def test_player_damage_counts_exact_survivors():
survivors = [{"api_name": f"u{i}"} for i in range(4)]
assert combat.player_damage(3, survivors, CFG) == 5 + 4
assert combat.player_damage(1, [], CFG) == 0

View File

@@ -0,0 +1,257 @@
"""Ein ganzes Spiel durchspielen und dabei die Regeln prüfen.
Die Einzeltests decken jede Funktion für sich ab. Hier geht es um das
Zusammenspiel über 30 Runden: bleibt Gold nicht-negativ, verschwinden
keine Units, endet das Spiel, stimmt der Pool.
"""
import random
import pytest
from tft.constants.loader import load_constants
from tft.model import artifact as artifact_mod
from tft.sim import player as sim_player
from tft.sim.game import Game
SET = 18
@pytest.fixture(scope="module")
def deps():
try:
return artifact_mod.load(SET), load_constants(SET)
except (FileNotFoundError, KeyError):
pytest.skip("kein Artefakt für Set 18 gebaut")
def act(game):
"""Wie ein einfacher Spieler handeln: Level kaufen, Shop leeren, aufstellen.
Ohne das Level-Kaufen bleibt die Bank nach wenigen Runden voll und das
Spiel prüft nichts mehr — Gold staut sich, das Board wächst nicht.
"""
while game.player.gold >= 12:
try:
game.buy_xp()
except Exception:
break
for slot in range(len(game.player.shop or [])):
try:
game.buy(slot)
except Exception:
pass
for _ in range(len(game.player.bench)):
try:
game.move("bench", 0)
except Exception:
break
# Volle Bank blockiert jeden weiteren Kauf.
while len(game.player.bench) >= sim_player.BENCH_SIZE:
game.sell("bench", 0)
def play(deps, seed, steps=60):
artifact, cfg = deps
game = Game(artifact, cfg, seed=seed)
seen = []
for _ in range(steps):
if game.over:
break
act(game)
seen.append({
"round": game.round["label"],
"hp": game.player.hp,
"gold": game.player.gold,
"level": game.player.level,
"board": len(game.player.board),
"bench": len(game.player.bench),
})
game.step()
return game, seen
def test_a_game_runs_to_the_end(deps):
game, seen = play(deps, seed=1)
assert len(seen) > 5
assert game.over or game.round["label"]
def test_gold_never_goes_negative(deps):
_, seen = play(deps, seed=2)
assert all(s["gold"] >= 0 for s in seen)
def test_health_only_falls(deps):
_, seen = play(deps, seed=3)
hps = [s["hp"] for s in seen]
assert hps == sorted(hps, reverse=True)
def test_the_board_never_exceeds_the_level(deps):
"""Mehr Units als Level wäre ein Regelbruch."""
artifact, cfg = deps
game = Game(artifact, cfg, seed=4)
for _ in range(40):
if game.over:
break
act(game)
assert len(game.player.board) <= game.player.level
game.step()
def test_the_bench_has_a_limit(deps):
artifact, cfg = deps
game = Game(artifact, cfg, seed=5)
for _ in range(30):
if game.over:
break
act(game)
assert len(game.player.bench) <= sim_player.BENCH_SIZE
game.step()
def test_every_unit_sits_on_its_own_hex(deps):
artifact, cfg = deps
game = Game(artifact, cfg, seed=6)
for _ in range(40):
if game.over:
break
act(game)
hexes = [u.get("hex") for u in game.player.board]
assert len(hexes) == len(set(hexes)), "zwei Units auf demselben Feld"
assert all(h is not None for h in hexes)
game.step()
def test_the_same_seed_gives_the_same_game(deps):
"""Ohne das lässt sich kein Kampf nachspielen und nichts vergleichen."""
_, first = play(deps, seed=42, steps=20)
_, again = play(deps, seed=42, steps=20)
assert first == again
def test_players_drop_out_over_time(deps):
artifact, cfg = deps
game = Game(artifact, cfg, seed=7)
for _ in range(60):
if game.over:
break
game.step()
alive = [p for p in game.players if p.hp > 0]
assert len(alive) < len(game.players)
def test_fights_produce_a_result(deps):
"""Jede PvP-Runde muss einen Sieger oder ein Unentschieden liefern."""
artifact, cfg = deps
game = Game(artifact, cfg, seed=8)
seen = 0
for _ in range(30):
if game.over:
break
before = [p.hp for p in game.players]
game.step()
after = [p.hp for p in game.players]
if before != after:
seen += 1
assert seen > 0, "in 30 Runden hat niemand Leben verloren"
def test_the_pool_stays_complete(deps):
"""Jede Kopie eines Champions ist entweder im Pool oder auf einem Board.
Verschwinden welche, sind Käufe oder Verkäufe kaputt. Auftauchen
dürfen auch keine, sonst wäre der Pool unendlich.
"""
from collections import Counter
artifact, cfg = deps
game = Game(artifact, cfg, seed=9)
sizes = cfg["pool"]["sizes"]
units = artifact["static"]["units"]
start = {api: sizes[u["cost"] - 1] for api, u in units.items()
if 1 <= u["cost"] <= len(sizes)}
def total():
held = Counter()
for p in game.players:
for u in p.board + p.bench:
held[u["api_name"]] += 3 ** (u["stars"] - 1)
return {api: game.pool.available(api) + held[api] for api in start}
for _ in range(30):
if game.over:
break
act(game)
wrong = {api: (start[api], now) for api, now in total().items()
if now != start[api]}
assert not wrong, f"Kopien verschwunden oder erfunden: {list(wrong)[:3]}"
game.step()
def test_a_full_bench_blocks_buying(deps):
"""Sonst kauft man Units ins Nichts."""
artifact, cfg = deps
game = Game(artifact, cfg, seed=10)
for _ in range(40):
if game.over:
break
if len(game.player.bench) >= sim_player.BENCH_SIZE and game.player.shop:
before = len(game.player.bench)
with pytest.raises(Exception):
game.buy(0)
assert len(game.player.bench) == before
return
act_without_selling(game)
game.step()
pytest.skip("Bank wurde in 40 Runden nie voll")
def act_without_selling(game):
for slot in range(len(game.player.shop or [])):
try:
game.buy(slot)
except Exception:
pass
def test_the_players_fight_is_recorded(deps):
"""Ohne Protokoll zeigt die Anzeige nichts oder den falschen Kampf."""
artifact, cfg = deps
game = Game(artifact, cfg, seed=11)
for _ in range(20):
if game.over:
break
act(game)
game.step()
if game.last_battle:
log = game.last_battle
assert log["units"] and log["events"]
# Der Spieler steht in der Anzeige immer auf Seite "a".
mine = [u for u in log["units"] if u["side"] == "a"]
assert len(mine) == len(game.player.board) or game.over
return
pytest.skip("in 20 Runden kam kein Kampf mit Board zustande")
def test_the_player_is_always_side_a(deps):
"""Sonst zeichnet die Anzeige die eigenen Units beim Gegner."""
artifact, cfg = deps
game = Game(artifact, cfg, seed=12)
seen = 0
for _ in range(30):
if game.over:
break
act(game)
game.step()
log = game.last_battle
if not log:
continue
seen += 1
rows = [e["row"] for e in log["events"]
if e["e"] == "spawn"
and next(u["side"] for u in log["units"] if u["slot"] == e["u"]) == "a"]
# Eigene Units starten in der eigenen Hälfte, also Reihe 0 bis 3.
assert all(r < log["rows"] // 2 for r in rows), rows
assert seen > 0

View File

@@ -3,7 +3,7 @@ import pytest
from tft.constants.loader import load_constants
from tft.model import artifact as artifact_mod
from tft.sim.autoplay import play_afk, play_econ
from tft.sim.game import Game, InvalidAction
from tft.sim.game import MAX_ITEMS_PER_UNIT, Game, InvalidAction
from tests.test_sim import make_static
@@ -229,7 +229,20 @@ def test_natural_level_progression(art, cfg):
assert checks["3-2"] == (5, 0)
def test_no_augments_are_offered(art, cfg):
"""Augments sind per Config abgeschaltet — weder Spieler noch Bots bekommen welche."""
game = Game(art, cfg, seed=2)
for _ in range(24):
assert not game.player.augment_offer
assert all(not p.augments for p in game.players)
if game.over:
break
game.step()
def test_augment_offer_single_tier(art, cfg):
# Die Mechanik bleibt geprüft, auch wenn sie ausgeliefert deaktiviert ist.
cfg = {**cfg, "rounds": {**cfg["rounds"], "augment_rounds": ["2-1"]}}
game = Game(art, cfg, seed=9)
mixed = {f"AUG_T{t}_{i}": {"tier": t} for t in (1, 2, 3) for i in range(4)}
game.artifact = {**art, "static": {**art["static"], "augments": mixed},
@@ -255,6 +268,89 @@ def test_equip_crafts_completed_item(art, cfg):
assert game.board[0]["items"] == ["COMPLETED", "C_A"]
def test_merge_keeps_surplus_items_and_extra_copy(art, cfg):
"""Verschmelzen darf weder Items vernichten noch die 4. Kopie schlucken."""
from tft.sim import player
game = Game(art, cfg, seed=1)
game.items = []
game.board = [{"api_name": "U1_0", "stars": 1, "items": ["I_A", "I_B"]},
{"api_name": "U1_0", "stars": 1, "items": ["I_C", "I_D"]}]
game.bench = [{"api_name": "U1_0", "stars": 1, "items": ["I_E", "I_F"]},
{"api_name": "U1_0", "stars": 1, "items": []}]
player.merge(game.player, "U1_0", 1)
on_units = [i for u in game.board + game.bench for i in u["items"]]
assert sorted(on_units + game.items) == ["I_A", "I_B", "I_C", "I_D", "I_E", "I_F"]
assert len(on_units) == MAX_ITEMS_PER_UNIT
# 3 Kopien verschmelzen, die 4. bleibt als eigene 1★-Unit stehen.
stars = sorted(u["stars"] for u in game.board + game.bench)
assert stars == [1, 2]
def test_place_on_hex(art, cfg):
from tft.sim.player import BOARD_HEXES
game = Game(art, cfg, seed=1)
game.level = 3
game.bench = [{"api_name": "U1_0", "stars": 1, "items": [], "hex": None},
{"api_name": "U2_0", "stars": 1, "items": [], "hex": None}]
game.board = []
game.place("bench", 0, 12)
assert [(u["api_name"], u["hex"]) for u in game.board] == [("U1_0", 12)]
# Auf ein belegtes Hex ziehen -> der Belegte wandert auf die Bank.
game.place("bench", 0, 12)
assert [(u["api_name"], u["hex"]) for u in game.board] == [("U2_0", 12)]
assert game.bench[0]["api_name"] == "U1_0" and game.bench[0]["hex"] is None
# Innerhalb des Felds verschieben.
game.place("board", 0, 5)
assert game.board[0]["hex"] == 5
with pytest.raises(InvalidAction):
game.place("board", 0, BOARD_HEXES)
def test_ranged_units_are_seated_in_the_back():
"""Automatisches Aufstellen: Nahkämpfer nach vorn, Fernkämpfer nach hinten."""
from tft.sim.player import (BOARD_COLS, BOARD_ROWS, PlayerState, fill_board)
ranged_art = {"static": {"units": {
"MELEE": {"cost": 1, "stats": {"range": 1}},
"RANGED": {"cost": 1, "stats": {"range": 4}},
}}}
p = PlayerState(name="t", archetype="x", hp=100, gold=0, level=4)
p.bench = [{"api_name": "RANGED", "stars": 1, "items": []},
{"api_name": "MELEE", "stars": 1, "items": []}]
fill_board(p, ranged_art)
rows = {u["api_name"]: u["hex"] // BOARD_COLS for u in p.board}
assert rows["MELEE"] == 0
assert rows["RANGED"] == BOARD_ROWS - 1
def test_place_swaps_within_board(art, cfg):
game = Game(art, cfg, seed=1)
game.level = 3
game.board = [{"api_name": "U1_0", "stars": 1, "items": [], "hex": 0},
{"api_name": "U2_0", "stars": 1, "items": [], "hex": 9}]
game.bench = []
game.place("board", 0, 9)
assert {u["api_name"]: u["hex"] for u in game.board} == {"U1_0": 9, "U2_0": 0}
def test_autofill_assigns_unique_hexes(art, cfg):
game = Game(art, cfg, seed=4)
for _ in range(6):
game.step()
for p in game.players:
hexes = [u["hex"] for u in p.board]
assert all(h is not None for h in hexes)
assert len(set(hexes)) == len(hexes)
assert all(u.get("hex") is None for u in p.bench)
def test_loot_drops_components(art, cfg):
comp_art = {**art, "component_pool": ["C_A", "C_B"]}
game = Game(comp_art, cfg, seed=2)

View File

@@ -0,0 +1,197 @@
"""Gruppen: Kontrolle nach Level und Item-Menge, Schwellen, Schlüsselformat."""
from tft.model import groups
def board(place, level, *units):
"""units: (api_name, itemzahl) — Sterne sind hier immer 2."""
return {"place": place, "level": level,
"items": sum(n for _, n in units),
"entities": sorted((api, 2, n) for api, n in units)}
def test_baseline_splits_by_level_and_items():
boards = [board(1, 9, ("A", 3)), board(3, 9, ("B", 3)),
board(7, 5, ("A", 0)), board(8, 5, ("B", 0))]
base = groups.baseline(boards)
assert base[(9, 3)] == 2.0
assert base[(5, 0)] == 7.5
def test_edge_measures_against_comparable_boards():
"""Zwei Boards auf Level 9 mit gleichen Items: nur der Unterschied zählt."""
boards = ([board(1, 9, ("A", 3), ("B", 3)) for _ in range(40)]
+ [board(7, 9, ("C", 3), ("D", 3)) for _ in range(40)])
base = groups.baseline(boards)
table = groups.group_edge(boards, 2, base, min_boards=10)
assert table["A@2@3|B@2@3"]["edge"] > 0
assert table["C@2@3|D@2@3"]["edge"] < 0
assert table["A@2@3|B@2@3"]["boards"] == 40
def test_item_count_is_part_of_the_key():
"""Derselbe Champion mit und ohne Items ist nicht dieselbe Einheit.
Beide Boards tragen sechs Items — nur anders verteilt. Genau dafür ist
die Kontrolle da: die Frage ist, wer die Items tragen soll.
"""
boards = ([board(1, 9, ("A", 3), ("B", 3), ("C", 0)) for _ in range(40)]
+ [board(8, 9, ("A", 0), ("B", 0), ("C", 3), ("D", 3)) for _ in range(40)])
base = groups.baseline(boards)
table = groups.group_edge(boards, 2, base, min_boards=10)
assert "A@2@3|B@2@3" in table and "A@2@0|B@2@0" in table
assert table["A@2@3|B@2@3"]["edge"] > table["A@2@0|B@2@0"]["edge"]
def test_rare_groups_are_dropped():
boards = [board(1, 9, ("A", 3), ("B", 3)) for _ in range(9)]
base = groups.baseline(boards)
assert groups.group_edge(boards, 2, base, min_boards=10) == {}
def test_groups_smaller_than_size_are_skipped():
boards = [board(1, 9, ("A", 3)) for _ in range(40)]
base = groups.baseline(boards)
assert groups.group_edge(boards, 2, base, min_boards=10) == {}
def test_build_needs_enough_boards():
import json
class Conn:
"""Minimaler sqlite-Ersatz: execute(...).fetchall()."""
def __init__(self, n):
row = (1, 9, json.dumps([{"character_id": "A", "tier": 2,
"itemNames": []}]))
self.rows = [row] * n
def execute(self, *_):
return self
def fetchall(self):
return self.rows
static = {"A": {"cost": 1, "traits": []}}
assert groups.build(Conn(10), 18, static) == {}
out = groups.build(Conn(groups.MIN_BOARDS * 20), 18, static)
assert out["boards"] == groups.MIN_BOARDS * 20
def test_only_groups_with_exactly_one_carry():
"""Ein Champion trägt drei Items, der Rest stützt."""
assert groups.one_carry("A@2@3|B@2@0")
assert groups.one_carry("A@2@3|B@1@1|C@3@2")
assert not groups.one_carry("A@2@3|B@2@3") # zwei Carries
assert not groups.one_carry("A@2@1|B@2@0") # gar keiner
def test_build_keeps_only_one_carry_groups():
import json
class Conn:
def __init__(self, rows):
self.rows = rows
def execute(self, *_):
return self
def fetchall(self):
return self.rows
def raw(place, *pairs):
units = [{"character_id": api, "tier": 1, "itemNames": ["x"] * n}
for api, n in pairs]
return (place, 9, json.dumps(units))
static = {a: {"cost": 1, "traits": []} for a in "ABCD"}
rows = ([raw(1, ("A", 3), ("B", 0))] * (groups.MIN_BOARDS * 12)
+ [raw(2, ("C", 3), ("D", 3))] * (groups.MIN_BOARDS * 12))
out = groups.build(Conn(rows), 18, static)
assert "C@1@3|D@1@3" not in out["2"]
assert "A@1@3|B@1@0" in out["2"]
def carrier(place, *pairs):
"""pairs: (api, sterne, [items]) — ein Board mit Trägern."""
return {"place": place, "level": 9, "items": 3, "_edge": 4.5 - place,
"carried": [(api, st, min(len(items), 3), set(items))
for api, st, items in pairs]}
def test_only_craftable_items_are_ranked():
"""Embleme, Artefakte und Radiant-Varianten kann man nicht planen."""
boards = [carrier(1, ("T", 2, ["I_NORMAL", "I_EMBLEM"]))] * 200
table = groups.unit_item_edge(boards, {"I_NORMAL"}, min_boards=10)
assert set(table) == {"T|I_NORMAL"}
def test_item_value_is_measured_against_the_same_champion():
"""Ein gutes Board macht nicht jedes Item darauf gut.
Beide Items liegen auf derselben Unit, aber nur eines auf den guten
Boards. Das andere darf davon nicht profitieren.
"""
boards = ([carrier(1, ("T", 2, ["GOOD"])) for _ in range(60)]
+ [carrier(7, ("T", 2, ["BAD"])) for _ in range(60)])
t = groups.unit_item_edge(boards, {"GOOD", "BAD"}, min_boards=10)
assert t["T|GOOD"]["edge"] > 0 > t["T|BAD"]["edge"]
def test_stars_and_item_count_are_the_comparison_group():
"""Ein Item darf nicht gut aussehen, nur weil sein Träger der Carry ist.
Der Champion steht mit drei Items auf guten Boards, mit einem auf
schlechten. Beide Items liegen nur auf je einer der beiden Gruppen —
ohne die Kontrolle gewänne allein das Item der Drei-Item-Boards.
"""
boards = ([carrier(2, ("T", 2, ["A", "X", "Y"])) for _ in range(80)]
+ [carrier(7, ("T", 2, ["B"])) for _ in range(80)])
t = groups.unit_item_edge(boards, {"A", "B"}, min_boards=10)
assert abs(t["T|A"]["edge"]) < 0.05 and abs(t["T|B"]["edge"]) < 0.05
def test_items_are_kept_apart_per_champion():
"""Dieselbe Rolle, anderer Champion: die Werte dürfen nicht verschmelzen."""
boards = ([carrier(1, ("M", 2, ["AD_ITEM"])) for _ in range(60)]
+ [carrier(1, ("N", 2, ["AP_ITEM"])) for _ in range(60)])
t = groups.unit_item_edge(boards, {"AD_ITEM", "AP_ITEM"}, min_boards=10)
assert set(t) == {"M|AD_ITEM", "N|AP_ITEM"}
def test_role_of_maps_riot_names():
assert groups.role_of({"role": "MagicTank"}) == "Tank"
assert groups.role_of({"role": "AttackFighter"}) == "Bruiser"
assert groups.role_of({"role": "MagicCaster"}) == "Mage"
assert groups.role_of({"role": "AttackMarksman"}) == "Scharfschütze"
assert groups.role_of({"role": ""}) is None
def test_unit_edge_uses_one_star_level():
"""Ohne feste Sternstufe gewinnt die billige Unit, die nur als 3★ überlebt."""
boards = ([{"place": 1, "level": 9, "items": 0,
"entities": [("CHEAP", 3, 0)]} for _ in range(60)]
+ [{"place": 5, "level": 9, "items": 0,
"entities": [("CHEAP", 2, 0), ("RICH", 2, 0)]} for _ in range(60)])
base = groups.baseline(boards)
two = groups.unit_edge(boards, base, min_boards=10, stars=2)
three = groups.unit_edge(boards, base, min_boards=10, stars=3)
# Auf 2★ zählen nur die schlechten Boards, auf 3★ nur die guten.
assert set(two) == {"CHEAP", "RICH"}
assert set(three) == {"CHEAP"}
assert three["CHEAP"]["edge"] > two["CHEAP"]["edge"]
def test_thiefs_gloves_carriers_are_excluded():
"""Die Handschuhe würfeln ihren Inhalt — das hat niemand so gebaut.
Riot listet die gewürfelten Items daneben, als wären sie gelegt worden.
Hier stehen sie nur auf den guten Boards; ohne den Ausschluss würden
sie dadurch gut aussehen.
"""
boards = ([carrier(1, ("T", 2, ["DA_ThiefsGloves", "ZUFALL", "X"]))
for _ in range(200)]
+ [carrier(6, ("T", 2, ["ECHT", "X", "Y"])) for _ in range(200)])
t = groups.unit_item_edge(boards, {"ZUFALL", "ECHT", "X"}, min_boards=10)
assert "T|ZUFALL" not in t
assert "T|ECHT" in t

View File

@@ -0,0 +1,46 @@
"""Das Spielfeld: Entfernungen und Nachbarn."""
from tft.sim import hexgrid as H
def test_neighbours_are_all_one_step_away():
"""Wenn das nicht gilt, bewegen sich Units in Sprüngen."""
for col in range(H.COLS):
for row in range(H.ROWS):
for n in H.neighbours(col, row):
assert H.distance((col, row), n) == 1
def test_offset_rows_are_not_a_grid():
"""Versetzte Reihen: gleicher Spaltenabstand, andere Entfernung.
Genau dafür ist die Umrechnung da — mit rohen Spalten wäre beides 1.
"""
assert H.distance((2, 0), (1, 1)) == 1
assert H.distance((2, 0), (3, 1)) == 2
def test_distance_along_a_row_counts_columns():
assert H.distance((0, 0), (6, 0)) == 6
assert H.distance((3, 4), (3, 4)) == 0
def test_neighbour_count_is_six_in_the_middle():
assert len(H.neighbours(3, 3)) == 6
# Am Rand fallen Felder weg.
assert len(H.neighbours(0, 0)) < 6
def test_neighbour_order_is_stable():
"""Sonst hängt der Ausgang eines Kampfes an der Iterationsreihenfolge."""
assert H.neighbours(3, 3) == H.neighbours(3, 3)
def test_mirror_swaps_the_halves():
assert H.mirror(0, 0) == (H.COLS - 1, H.ROWS - 1)
assert H.mirror(*H.mirror(2, 5)) == (2, 5)
def test_halves_are_split_evenly():
assert H.own_half(0, "a") and not H.own_half(4, "a")
assert H.own_half(7, "b") and not H.own_half(3, "b")

View File

@@ -1,6 +1,9 @@
import json
from tft.model.learn import learn
from tft.model.learn import MEAN_PLACEMENT, learn
# Bester Fall: alle Boards auf Platz 1, also 3.5 Plätze über dem Schnitt.
MEAN_PLACEMENT_DELTA = MEAN_PLACEMENT - 1
def board_row(placement, unit="TFT17_A", item="TFT_Item_X", trait_tier=1):
@@ -18,9 +21,19 @@ def test_strong_item_gets_multiplier_above_1():
def test_shrinkage_with_thin_data():
"""Drei Boards dürfen den Multiplikator kaum bewegen.
Der Vergleich läuft gegen DELTA_SCALE, nicht gegen eine feste Zahl:
3 gegen 30 Pseudo-Boards lassen höchstens ein Zehntel des vollen
Effekts durch, egal wie stark die Skala eingestellt ist.
"""
from tft.model.learn import DELTA_SCALE
rows = [board_row(1, item="TFT_Item_Rare") for _ in range(3)]
learned = learn(rows)
assert abs(learned["items"]["TFT_Item_Rare"] - 1.0) < 0.06
thin = learn(rows)["items"]["TFT_Item_Rare"]
full = learn([board_row(1, item="TFT_Item_Rare") for _ in range(2000)])
assert abs(thin - 1.0) < 0.1 * MEAN_PLACEMENT_DELTA * DELTA_SCALE
assert full["items"]["TFT_Item_Rare"] > thin
def test_trait_keys_match_score_format():
@@ -29,20 +42,38 @@ def test_trait_keys_match_score_format():
assert "TFT17_T@3" in learned["traits"]
def test_pair_lift_for_cooccurring_units():
def pair_row(placement):
units = [
{"character_id": "TFT17_A", "tier": 2, "itemNames": []},
{"character_id": "TFT17_B", "tier": 2, "itemNames": []},
]
def test_pair_surplus_finds_real_synergy():
"""Zwei Units, die einzeln mittelmäßig sind, aber zusammen gewinnen."""
def row(placement, *ids):
units = [{"character_id": i, "tier": 1, "itemNames": []} for i in ids]
return (placement, json.dumps(units), json.dumps([]), json.dumps([]))
# A und B stehen immer zusammen in Top-4-Boards; C ist überall.
rows = [pair_row(1) for _ in range(100)]
solo = [{"character_id": "TFT17_C", "tier": 1, "itemNames": []}]
rows += [(1, json.dumps(solo), json.dumps([]), json.dumps([])) for _ in range(100)]
learned = learn(rows)
assert learned["pairs"]["TFT17_A|TFT17_B"] > 0.5
rows = []
# A und B einzeln mit Füllern: Mittelfeld.
rows += [row(4, "A", "X") for _ in range(120)]
rows += [row(5, "A", "Y") for _ in range(120)]
rows += [row(4, "B", "X") for _ in range(120)]
rows += [row(5, "B", "Y") for _ in range(120)]
# Zusammen: Platz 1.
rows += [row(1, "A", "B") for _ in range(120)]
pairs = learn(rows)["pairs"]
assert pairs["A|B"]["surplus"] > pairs["A|X"]["surplus"]
assert pairs["A|B"]["surplus"] > 0
def test_pair_surplus_cannot_separate_units_that_never_split():
"""Bekannte Grenze: stehen zwei Units immer zusammen, zählt der
Einzelwert beide Male dieselbe Stärke — der Überschuss kippt ins Minus.
In echten Daten fällt das kaum ins Gewicht: nur 9 von 1784 Paaren
hängen zu über 50 % aneinander, der Median liegt bei 2 %.
"""
def row(placement):
units = [{"character_id": i, "tier": 1, "itemNames": []} for i in ("A", "B")]
return (placement, json.dumps(units), json.dumps([]), json.dumps([]))
pairs = learn([row(1) for _ in range(120)])["pairs"]
assert pairs["A|B"]["surplus"] < 0
def test_empty_rows():
@@ -86,3 +117,62 @@ def test_augment_spec_builder():
junk = build_spec({"desc": "", "effects": {}}, units)
assert junk["offerable"] is False
def test_conditional_clause_does_not_leak_into_atoms():
"""Nur der unbedingte Satz zählt — effects["Gold"] ist der bedingte Nachschlag."""
from tft.model.augments import build_spec
units = {}
survivor = build_spec(
{"desc": "Gain 4 gold. After 3 players are eliminated, gain 100 more gold.",
"effects": {"Gold": 100.0, "InitialGold": 4.0, "PlayersDead": 3.0}}, units)
assert survivor["atoms"] == [{"kind": "gold_now", "amount": 4}]
avenge = build_spec(
{"desc": "After 3 allies die, your team gains 28% Attack Damage.",
"effects": {"AD": 28.0}}, units)
assert avenge["atoms"] == []
# Wiederkehrend, aber unbedingt: bleibt erhalten.
each_round = build_spec(
{"desc": "Your team gains 2% Attack Damage per level. "
"Gain 2 XP each round for the rest of the game.", "effects": {}}, units)
assert each_round["atoms"] == [{"kind": "xp_per_round", "amount": 2}]
def test_pair_surplus_isolates_the_synergy():
"""Ein Paar zählt nur, was es über seine zwei Units hinaus bringt.
A ist stark, B schwach. Zusammen laufen sie genau so, wie die Summe
erwarten lässt — also kein Überschuss.
"""
from tft.model.learn import pair_surplus, value
a_only = [1] * 100
b_only = [8] * 100
together = [1, 8] * 50
units = {"A": value(a_only + together[::2]), "B": value(b_only + together[1::2])}
pairs = pair_surplus({("A", "B"): together}, units)
assert abs(pairs["A|B"]["surplus"]) < 0.5
assert pairs["A|B"]["boards"] == 100
def test_pair_surplus_needs_enough_boards():
from tft.model.learn import PAIR_MIN_N, pair_surplus, value
units = {"A": 0.0, "B": 0.0}
thin = pair_surplus({("A", "B"): [1] * (PAIR_MIN_N - 1)}, units)
thick = pair_surplus({("A", "B"): [1] * PAIR_MIN_N}, units)
assert thin == {}
assert "A|B" in thick
# Unbekannte Units fliegen raus statt still als 0 zu zählen.
assert pair_surplus({("A", "X"): [1] * PAIR_MIN_N}, units) == {}
def test_value_shrinks_towards_average():
from tft.model.learn import value
assert value([1] * 3) < value([1] * 300) # wenige Boards -> naher an 0
assert value([8] * 300) < 0 < value([1] * 300)
assert abs(value([])) < 1e-9

View File

@@ -0,0 +1,203 @@
"""Der MetaTFT-Parser muss dieselben Tabellen liefern wie der cdragon-Parser.
Die Fixtures sind gekürzte, aber echte Ausschnitte aus
data.metatft.com/lookups/TFTSet18_latest_en_us.json.
"""
import pytest
from tft.staticdata import metatft
@pytest.fixture
def raw():
return {
"_metadata": {"set": "TFTSet18", "setName": "Enchanted Wilds", "patch": "pbe"},
"roles": {"DA_Role_AttackCaster": "Attack Caster"},
"units": [
{
"apiName": "TFT18_Ashe", "characterName": "TFT18_Ashe", "name": "Ashe",
"cost": 5, "shopUnit": True, "role": "DA_Role_AttackCaster",
"traits": ["Blossom", "Hunter"],
"traitApiNames": ["DA_18_Blossom", "DA_18_Hunter"],
"stats": {"hp": 900, "damage": 75, "armor": 45, "magicResist": 45,
"attackSpeed": 0.8, "mana": 80, "initialMana": 20,
"critChance": 0.25, "critMultiplier": 1.4, "range": 5},
"curveValues": {"AbilityShield": [[1, 60], [2, 90], [3, 200], [4, 310]],
"ChillPercent": [[1, 20], [4, 20]]},
"ability": {
"name": "Spirit Rift",
# Wie im Original: erst ein Mana-Tag, dann zwei Schadenszahlen.
"desc": ('Costs <TFTAttribute attributeID="TFTCalculationAttributes.ManaCalc1"'
' style="colorStat" icon="icon.AP"/> mana. Deals'
' <TFTAttribute attributeID="TFTCalculationAttributes.PhysicalDamageCalc2"'
' style="colorPhysical" icon="icon.AD"/> then'
' <TFTAttribute attributeID="TFTCalculationAttributes.PhysicalDamageCalc1"'
' style="colorPhysical" icon="icon.AD"/> physical damage,'
' shields for <TFTCurveTable row="AbilityShield"/>, slowing by'
' <TFTCurveTable row="ChillPercent"/>%.'),
"variables": [],
"attributeValues": {
"TFTCalculationAttributes.ManaCalc1": [3, 3, 100, 100],
"TFTCalculationAttributes.PhysicalDamageCalc1": [440, 660, 1000, 1000],
"TFTCalculationAttributes.PhysicalDamageCalc2": [7, 11, 220, 220],
},
},
},
{ # Verwandlungsform: kein shopUnit, gehört nicht in den Pool.
"apiName": "TFT18_EliseSpider", "name": "Elise", "cost": 2,
"shopUnit": False, "traits": ["Coven"], "traitApiNames": ["DA_18_Coven"],
"stats": {"hp": 700}, "ability": {},
},
{ # Lücke im PBE-Dump: ohne HP wäre die eHP 0.
"apiName": "TFT18_Ivern", "name": "Ivern", "cost": 5, "shopUnit": True,
"traits": ["Greenfather"], "traitApiNames": ["DA_18_Greenfather"],
"stats": {"damage": 60, "armor": 55}, "ability": {},
},
],
"traits": [
{"apiName": "DA_18_Brawler", "name": "Brawler", "type": "class",
"icon": "T_TraitIcon_18_Brawler",
"effects": [
{"minUnits": 2, "style": 1,
"desc": '(2) <TFTCurveTable row="BrawlerHealthPercent" column="1"'
' format="percent"/>'},
{"minUnits": 4, "style": 3,
"desc": '(4) <TFTCurveTable row="BrawlerHealthPercent" column="2"'
' format="percent"/>'},
],
"curveValues": {"BrawlerHealthPercent": [[0, 0], [1, 0.25], [2, 0.4]]}},
{"apiName": "DA_18_Blossom", "name": "Blossom", "type": "origin",
"icon": "T_TraitIcon_18_Blossom", "effects": [], "curveValues": {}},
{"apiName": "DA_18_Hunter", "name": "Hunter", "type": "class",
"icon": "T_TraitIcon_18_Hunter", "effects": [], "curveValues": {}},
],
"items": [
{"apiName": "DA_Component_RecurveBow", "name": "Recurve Bow", "composition": [],
"icon": "T_RecurveBow",
"statLine": '<TFTCurveTable row="AS" icon="icon.AS" format="percentMinusOne"/>',
"curveValues": {"AS": [[1, 1.1]]}},
{"apiName": "DA_Component_NeedlesslyLargeRod", "name": "Rod", "composition": [],
"icon": "T_Rod",
"statLine": '<TFTCurveTable row="AP" icon="icon.AP" format="percent"/>',
"curveValues": {"AP": [[1, 0.1]]}},
{"apiName": "DA_Component_GiantsBelt", "name": "Belt", "composition": [],
"icon": "T_Belt",
"statLine": '<TFTCurveTable row="Health" icon="icon.Health"/>',
"curveValues": {"Health": [[1, 150]]}},
],
"augments": [
{"apiName": "DA_AdvancedLoan", "name": "Advanced Loan", "rarity": "Gold",
"icon": "T_AugmentIcon_AdvancedLoan",
"desc": 'Gain <TFTCurveTable row="GoldInstantBasic"/> Gold.',
"curveValues": {"GoldInstantBasic": [[1, 20]]}},
],
}
def test_meta_and_unit_filter(raw):
tables = metatft.parse(raw)
assert tables["meta"]["set"] == 18
assert tables["meta"]["set_name"] == "Enchanted Wilds"
# EliseSpider ist kein Shop-Unit, Ivern hat keine HP.
assert list(tables["units"]) == ["TFT18_Ashe"]
assert tables["meta"]["incomplete_units"] == ["Ivern (hp)"]
def test_missing_attack_damage_is_reported_but_kept(raw):
"""Ohne AD ist der Auto-DPS 0 — die Unit wird trotzdem gespielt.
Anders als bei fehlender HP fliegt sie deshalb nicht aus dem Pool.
"""
del raw["units"][0]["stats"]["damage"]
tables = metatft.parse(raw)
assert "TFT18_Ashe" in tables["units"]
assert "Ashe (damage)" in tables["meta"]["incomplete_units"]
def test_unit_matches_cdragon_shape(raw):
unit = metatft.parse(raw)["units"]["TFT18_Ashe"]
assert unit["traits"] == ["DA_18_Blossom", "DA_18_Hunter"]
assert unit["role"] == "AttackCaster"
assert unit["stats"]["hp"] == 900
# Der erste Tag im Text ist die Manazahl 3 — der Schaden ist 440.
# cdragon zählt Sterne ab Index 1; MetaTFT ab 0.
assert unit["spell_damage"] == [0, 440, 660, 1000]
assert unit["spell_damage_type"] == "physical"
assert unit["spell_scaling"] == "ad"
# Kurven sind Treppen: Stufe 4 fehlt, Stufe 1 gilt weiter.
assert unit["ability_vars"]["AbilityShield"] == [0, 60, 90, 200]
assert unit["ability_vars"]["ChillPercent"] == [0, 20, 20, 20]
def test_trait_breakpoints_carry_their_curve_values(raw):
brawler = metatft.parse(raw)["traits"]["DA_18_Brawler"]
assert [b["min_units"] for b in brawler["breakpoints"]] == [2, 4]
assert brawler["breakpoints"][0]["variables"] == {"BrawlerHealthPercent": 0.25}
assert brawler["breakpoints"][1]["variables"] == {"BrawlerHealthPercent": 0.4}
def test_unknown_traits_are_dropped(raw):
# Greenfather steht in keiner Trait-Tabelle; ein Verweis darauf würde
# active_trait_tiers durchlaufen und im UI als Geist-Trait auftauchen.
raw["units"][0]["traitApiNames"].append("DA_18_DoesNotExist")
assert metatft.parse(raw)["units"]["TFT18_Ashe"]["traits"] == [
"DA_18_Blossom", "DA_18_Hunter"]
def test_item_effects_follow_statsheet_conventions(raw):
items = metatft.parse(raw)["items"]
# AS und AP sind Prozentzahlen, Health ist flach — wie in Set 17.
assert items["DA_Component_RecurveBow"]["effects"] == {"AS": pytest.approx(10.0)}
assert items["DA_Component_NeedlesslyLargeRod"]["effects"] == {"AP": pytest.approx(10.0)}
assert items["DA_Component_GiantsBelt"]["effects"] == {"Health": 150}
def test_augment_tier_and_rendered_desc(raw):
aug = metatft.parse(raw)["augments"]["DA_AdvancedLoan"]
assert aug["tier"] == 2 # Gold
assert aug["desc"] == "Gain 20 Gold."
assert aug["effects"] == {"GoldInstantBasic": 20}
def test_curve_steps_hold_the_last_value():
curve = [[1, 5], [3, 9]]
assert [metatft.curve_at(curve, i) for i in (1, 2, 3, 4)] == [5, 5, 9, 9]
assert metatft.curve_at([], 1) is None
@pytest.mark.parametrize("value,fmt,want", [
(0.25, "percent", 0.25),
(1.10, "percentMinusOne", pytest.approx(0.10)),
# Durability wird als Resthärte angegeben: 0.96 = 4% weniger Schaden.
(0.96, "invertedPercent", pytest.approx(0.04)),
(175, None, 175),
])
def test_format_drives_the_conversion(value, fmt, want):
assert metatft.as_fraction(value, fmt) == want
def test_spell_picks_the_biggest_damage_not_the_first_tag(raw):
"""Lux nennt im Tooltip zuerst 3 Mana, dann 330 Schaden.
Wer den ersten <TFTAttribute> nimmt, modelliert die Manazahl als
Schaden — das betraf 33 von 63 Units.
"""
unit = metatft.parse(raw)["units"]["TFT18_Ashe"]
assert unit["spell_damage"][1] == 440
assert unit["spell_scaling"] == "ad"
assert unit["spell_damage_type"] == "physical"
def test_on_attack_damage_is_flagged(raw):
ability = raw["units"][0]["ability"]
ability["desc"] = ('Attacks deal <TFTAttribute'
' attributeID="TFTCalculationAttributes.PhysicalDamageCalc1"'
' style="colorPhysical" icon="icon.AD"/> bonus damage.')
assert metatft.parse(raw)["units"]["TFT18_Ashe"]["spell_on_attack"] is True
def test_asset_names_are_kept_for_the_match_api(raw):
# Riot nennt Units beim Asset-Namen; ohne die Liste findet learn.py nichts.
raw["units"][0]["assetNames"] = ["DA_18_Ashe"]
assert metatft.parse(raw)["units"]["TFT18_Ashe"]["asset_names"] == ["DA_18_Ashe"]

113
backend/tests/test_pairs.py Normal file
View File

@@ -0,0 +1,113 @@
"""Gruppen: Bindung aus den Pro-Comps, Stärke aus den Boards."""
from tft.model import pairs
from tft.staticdata import procomps
UNITS = {a: {"cost": 1, "traits": ["T"], "asset_names": [f"DA_{a}"]}
for a in "ABCDEF"}
def board(place, *units):
return {"place": place, "cell": (9, 3),
"entities": [(u, 2, 0) for u in units]}
def cell(b):
return b["cell"]
def test_bond_asks_about_the_rarer_unit():
"""Nicht "wie oft zusammen", sondern "wie oft zusammen, wenn möglich".
B steht nie ohne A. C steht halb so oft bei A. Rohe Zählung sähe C
vorne, weil C öfter vorkommt.
"""
groups = [["A", "B", "X", "Y"]] * 3 + [["A", "C", "X", "Y"]] * 2 \
+ [["C", "D", "X", "Y"]] * 2
w = pairs.weights(groups, pseudo=0)
assert w[("A", "B")] == 1.0
assert w[("A", "C")] == 0.5
def test_pseudo_counts_hold_back_single_sightings():
"""Ein Paar mit einer Nennung darf nicht wie eine Regel aussehen."""
groups = [["A", "B", "C", "D"]]
assert pairs.weights(groups, pseudo=0)[("A", "B")] == 1.0
assert pairs.weights(groups, pseudo=3)[("A", "B")] == 0.25
def test_every_pair_in_a_group_must_clear_the_threshold():
"""Ein Dritter darf nicht an nur einem der beiden hängen.
C bindet stark an A, aber gar nicht an B. Also kein Trio.
"""
bond = {("A", "B"): 0.9, ("A", "C"): 0.9, ("B", "C"): 0.1}
out = pairs.candidates(bond, UNITS, min_bond=0.4)
assert ("A", "B") in out and ("A", "C") in out
assert ("A", "B", "C") not in out
def test_a_group_needs_a_shared_trait():
"""Ohne das kommt Unsinn heraus: eine Comp hat acht bis neun Units,
zwei davon stehen nebeneinander, ohne etwas miteinander zu tun.
"""
units = {"A": {"traits": ["T"]}, "B": {"traits": ["T"]},
"C": {"traits": ["U"]}}
bond = {("A", "B"): 0.9, ("A", "C"): 0.9, ("B", "C"): 0.9}
out = pairs.candidates(bond, units, min_bond=0.4)
assert ("A", "B") in out
assert ("A", "C") not in out and ("A", "B", "C") not in out
def test_strength_is_measured_against_comparable_boards():
boards = [board(1, "A", "B", "C") for _ in range(400)] \
+ [board(7, "D", "E", "F") for _ in range(400)]
out = pairs.strength(boards, {(9, 3): 4.0}, cell,
[("A", "B"), ("D", "E")], min_boards=10)
assert out[("A", "B")]["edge"] > 0 > out[("D", "E")]["edge"]
assert out[("A", "B")]["boards"] == 400
def test_rare_groups_are_dropped():
boards = [board(1, "A", "B") for _ in range(5)]
assert pairs.strength(boards, {(9, 3): 4.0}, cell, [("A", "B")],
min_boards=10) == {}
def test_select_drops_overlapping_and_negative_groups():
"""Sonst steht dieselbe Gruppe zwanzigmal leicht abgewandelt da."""
scored = {("A", "B", "C"): {"edge": 0.5}, ("A", "B"): {"edge": 0.4},
("D", "E"): {"edge": 0.2}, ("A", "F"): {"edge": -0.1}}
assert pairs.select(scored) == [("A", "B", "C"), ("D", "E")]
def test_build_is_empty_without_input():
assert pairs.build([], [board(1, "A", "B")], {}, cell, UNITS) == {}
assert pairs.build([["A", "B", "C", "D"]], [], {}, cell, UNITS) == {}
def test_comps_reads_every_stage():
"""Level 4 bis 9 sind eigene Stufen — jede zählt als Beobachtung."""
doc = {
"content": {"comps": [{"data": {"$ref": "#/comp/x"}, "tier": "S"}]},
"refs": [{
"content_id": "x",
"metadata": {"title": "Test"},
"content": {
"unit_positions": {str(i): {"apiName": f"DA_{a}"}
for i, a in enumerate("ABCD")},
"variations": [
{"type": "comp",
"unit_positions": {str(i): {"apiName": f"DA_{a}"}
for i, a in enumerate("ABCDE")}},
{"type": "tierlist"},
# zu klein, fliegt raus
{"type": "comp",
"unit_positions": {"0": {"apiName": "DA_A"}}},
],
},
}],
}
out = procomps.comps(doc, UNITS)
assert [len(c["units"]) for c in out] == [4, 5]
assert all(c["tier"] == "S" for c in out)

View File

@@ -13,7 +13,8 @@ RAW = {
"role": "Caster",
"stats": {"hp": 600, "armor": 25, "magicResist": 25, "damage": 40,
"attackSpeed": 0.7, "mana": 60, "initialMana": 10},
"squareIcon": "ASSETS/x.TFT_Set17.tex",
"squareIcon": "ASSETS/x_splash_tile.TFT_Set17.tex",
"tileIcon": "ASSETS/HUD/x_Square.TFT_Set17.tex",
"ability": {
"name": "Blast",
"desc": "Deal <magicDamage>@ModifiedDamage@ (%i:scaleAP%)</magicDamage> magic damage.",
@@ -92,6 +93,23 @@ def test_resolve_spell_adaptive_and_fallback():
assert resolve_spell(utility)["spell_damage"] is None
def test_icon_url_drops_set_suffix():
"""cdragon serviert ohne Set-Suffix — mit Suffix gab es stumme 404er."""
from tft.staticdata.parse import icon_url
assert icon_url("ASSETS/Characters/TFT17_Illaoi/HUD/TFT17_Illaoi_Square.TFT_Set17.tex") == (
"https://raw.communitydragon.org/latest/game"
"/assets/characters/tft17_illaoi/hud/tft17_illaoi_square.png"
)
assert icon_url("ASSETS/UX/TraitIcons/Trait_Icon_17_Doomer.TFT_Set17.tex").endswith(
"/assets/ux/traiticons/trait_icon_17_doomer.png"
)
# Ohne Suffix bleibt der Name unverändert.
assert icon_url("ASSETS/Maps/TFT/Icons/Items/TFT_Item_BFSword.tex").endswith(
"/tft_item_bfsword.png"
)
def test_augment_tier_from_icon():
from tft.staticdata.parse import augment_tier
assert augment_tier("ASSETS/x/GodAugmentEkko_II.TFT_Set17.tex") == 2

View File

@@ -0,0 +1,79 @@
"""Boardbewertung: Merkmale, Baumdurchlauf, Grenzfälle."""
import pytest
from tft.model import rating
STATIC = {
"units": {
"U_TANK": {"cost": 1, "traits": ["T_BRAWL"], "name": "Tank"},
"U_CARRY": {"cost": 4, "traits": ["T_BRAWL", "T_SHOT"], "name": "Carry"},
"U_FIVE": {"cost": 5, "traits": ["T_SHOT"], "name": "Five"},
},
"traits": {
"T_BRAWL": {"breakpoints": [{"min_units": 2}, {"min_units": 4}]},
"T_SHOT": {"breakpoints": [{"min_units": 2}]},
},
}
def board(*specs):
return [{"api_name": a, "stars": s, "items": list(i)} for a, s, i in specs]
@pytest.fixture
def art():
return {"static": STATIC, "component_pool": ["C_SWORD"], "item_pool": ["I_BLADE"],
"rating": {"tables": {}}}
def test_trait_counts_and_tiers():
b = board(("U_TANK", 1, []), ("U_CARRY", 1, []), ("U_FIVE", 1, []))
counts = rating.trait_counts(b, STATIC["units"])
assert counts == {"T_BRAWL": 2, "T_SHOT": 2}
assert rating.trait_tiers(counts, STATIC["traits"]) == {"T_BRAWL": 1, "T_SHOT": 1}
# Eine Unit unter dem Breakpoint aktiviert nichts.
single = rating.trait_counts(board(("U_TANK", 1, [])), STATIC["units"])
assert rating.trait_tiers(single, STATIC["traits"]) == {}
def test_features_are_stable_in_length(art):
empty = rating.features([], 1, art)
full = rating.features(board(("U_CARRY", 3, ["I_BLADE", "C_SWORD"])), 9, art)
assert len(empty) == len(full)
assert all(isinstance(x, (int, float)) for x in empty + full)
def test_unknown_units_are_ignored(art):
known = rating.features(board(("U_TANK", 1, [])), 5, art)
mixed = rating.features(board(("U_TANK", 1, []), ("NOPE", 3, [])), 5, art)
assert known == mixed
def test_no_model_means_no_prediction(art):
assert rating.predict_placement(board(("U_TANK", 1, [])), 5, art) is None
assert rating.board_rating(board(("U_TANK", 1, [])), 5, art) is None
def test_tree_walk_and_clamping(art):
# Ein Baum, der nur auf die Unit-Zahl schaut (Merkmal 4).
art["rating"] = {"tables": {}, "base": 4.0, "lr": 1.0,
"trees": [[4, 1.5, 2.0, -2.0]]}
few = rating.predict_placement(board(("U_TANK", 1, [])), 3, art)
many = rating.predict_placement(
board(("U_TANK", 1, []), ("U_CARRY", 1, []), ("U_FIVE", 1, [])), 9, art)
assert few == 6.0 and many == 2.0
assert rating.board_rating(board(("U_TANK", 1, [])), 3, art) == pytest.approx(-1.5)
# Ausreißer werden auf 1 bis 8 gestutzt — es gibt keinen Platz 0.
art["rating"]["trees"] = [[4, 1.5, 99.0, -99.0]]
assert rating.predict_placement(board(("U_TANK", 1, [])), 3, art) == 8.0
assert rating.predict_placement(
board(("U_TANK", 1, []), ("U_CARRY", 1, [])), 9, art) == 1.0
def test_learned_tables_reach_the_features(art):
art["rating"]["tables"] = {"unit": {"U_CARRY": 1.5}}
plain = rating.features(board(("U_TANK", 1, [])), 5, art)
good = rating.features(board(("U_CARRY", 1, [])), 5, art)
assert good != plain

View File

@@ -1,146 +0,0 @@
import pytest
from tft.model.baseline import classify_role, unit_value
from tft.model.calibrate import spearman
from tft.model.score import active_trait_tiers, board_profiles, score_board
STATIC = {
"units": {
"TFT17_A": {
"cost": 1,
"traits": ["TFT17_Tank"],
"role": "Tank",
"stats": {"hp": 650, "armor": 40, "magicResist": 40, "damage": 50,
"attackSpeed": 0.6, "mana": 60, "initialMana": 0, "range": 1},
},
"TFT17_B": {
"cost": 4,
"traits": ["TFT17_Tank"],
"role": "Marksman",
"stats": {"hp": 700, "armor": 25, "magicResist": 25, "damage": 75,
"attackSpeed": 0.75, "mana": 100, "initialMana": 20, "range": 4},
},
},
"traits": {
"TFT17_Tank": {
"breakpoints": [{"min_units": 2, "style": 1}, {"min_units": 4, "style": 3}],
},
},
}
ARTIFACT = {
"meta": {"set": 17},
"static": {
**STATIC,
"items": {"TFT_Item_InfinityEdge": {"effects": {"AD": 0.35, "CritChance": 35.0}}},
"augments": {},
},
"roles": {},
}
def test_unit_value():
assert unit_value(1, 1) == 1
assert unit_value(4, 2) == 12
assert unit_value(3, 3) == 27
def test_classify_role_from_cdragon_role():
assert classify_role(STATIC["units"]["TFT17_A"]) == "frontline"
assert classify_role(STATIC["units"]["TFT17_B"]) == "ad_carry"
def test_trait_activation():
board = [{"api_name": "TFT17_A", "stars": 1, "items": []},
{"api_name": "TFT17_B", "stars": 1, "items": []}]
tiers = active_trait_tiers(board, STATIC["traits"], STATIC["units"])
assert tiers == {"TFT17_Tank": 1}
assert active_trait_tiers(board[:1], STATIC["traits"], STATIC["units"]) == {}
def test_score_ordering():
weak = [{"api_name": "TFT17_A", "stars": 1, "items": []}]
strong = [{"api_name": "TFT17_B", "stars": 2, "items": []}]
assert score_board(strong, [], ARTIFACT) > score_board(weak, [], ARTIFACT)
def test_items_increase_score():
bare = [{"api_name": "TFT17_B", "stars": 1, "items": []}]
with_item = [{"api_name": "TFT17_B", "stars": 1, "items": ["TFT_Item_InfinityEdge"]}]
assert score_board(with_item, [], ARTIFACT) > score_board(bare, [], ARTIFACT)
def test_augments_ignored():
board = [{"api_name": "TFT17_B", "stars": 1, "items": []}]
assert score_board(board, ["TFT17_Augment_X"], ARTIFACT) == \
score_board(board, [], ARTIFACT)
def test_board_profiles_roles_and_unknown_units():
board = [{"api_name": "TFT17_A", "stars": 1, "items": []},
{"api_name": "TFT17_B", "stars": 1, "items": []},
{"api_name": "UNKNOWN", "stars": 1, "items": []}]
profiles = board_profiles(board, ARTIFACT)
assert len(profiles) == 2 # unbekannte Units fallen raus
tank, carry = profiles
assert tank["defensive"] and not carry["defensive"]
assert tank["def"] > carry["def"]
assert carry["off"] > tank["off"]
def test_spearman():
assert spearman([1, 2, 3, 4], [10, 20, 30, 40]) == pytest.approx(1.0)
assert spearman([1, 2, 3, 4], [40, 30, 20, 10]) == pytest.approx(-1.0)
assert spearman([1, 2, 3, 4], [10, 10, 10, 10]) == 0.0
def test_craftable_item_pool():
from tft.model.artifact import craftable_items
items = {
"TFT_Item_InfinityEdge": {"composition": ["TFT_Item_BFSword", "TFT_Item_SparringGloves"]},
"TFT_Item_BFSword": {"composition": []},
"TFT_Item_Emblem": {"composition": ["TFT_Item_Spatula", "TFT_Item_BFSword"]},
"TFT17_Item_Weird": {"composition": ["TFT_Item_BFSword", "TFT_Item_BFSword"]},
"TFT_Item_Radiant": {"composition": None},
}
assert craftable_items(items) == ["TFT_Item_InfinityEdge"]
def _built_artifact():
from tft.model import artifact as artifact_mod
static = {
"meta": {"set": 17, "patch": "test"},
"units": {
"TANK": {"cost": 2, "traits": ["T_AD"], "role": "Tank",
"stats": {"hp": 900, "armor": 50, "magicResist": 50, "damage": 45,
"attackSpeed": 0.6, "mana": 60, "initialMana": 0},
"spell_damage": None, "spell_scaling": None},
"CARRY": {"cost": 2, "traits": ["T_AD"], "role": "Marksman",
"stats": {"hp": 600, "armor": 20, "magicResist": 20, "damage": 70,
"attackSpeed": 0.8, "mana": 50, "initialMana": 0},
"spell_damage": [0, 250, 375, 560, 0, 0, 0], "spell_scaling": "ad"},
},
"traits": {"T_AD": {"breakpoints": [
{"min_units": 2, "style": 1, "variables": {"BonusAP": 20.0}}]}},
"items": {"SWORD": {"effects": {"AD": 0.35}}},
"augments": {},
}
return artifact_mod.build(static)
def test_balanced_beats_lopsided():
art = _built_artifact()
balanced = [{"api_name": "TANK", "stars": 1, "items": []},
{"api_name": "CARRY", "stars": 1, "items": []}]
tanks = [{"api_name": "TANK", "stars": 1, "items": []} for _ in range(2)]
assert score_board(balanced, [], art) > score_board(tanks, [], art)
def test_items_and_stars_raise_strength():
art = _built_artifact()
base = [{"api_name": "CARRY", "stars": 1, "items": []}]
starred = [{"api_name": "CARRY", "stars": 2, "items": []}]
equipped = [{"api_name": "CARRY", "stars": 1, "items": ["SWORD"]}]
assert score_board(starred, [], art) > score_board(base, [], art)
assert score_board(equipped, [], art) > score_board(base, [], art)

View File

@@ -1,84 +0,0 @@
import pytest
from tft.model import statsheet
def unit(spell=None, scaling="ap", **overrides):
stats = {"hp": 700, "armor": 30, "magicResist": 30, "damage": 50,
"attackSpeed": 0.7, "mana": 60, "initialMana": 10,
"critChance": 0.25, "critMultiplier": 1.4}
stats.update(overrides)
return {"stats": stats, "spell_damage": spell, "spell_scaling": scaling}
ITEMS = {
"sword": {"effects": {"AD": 0.35, "CritChance": 35.0}},
"vest": {"effects": {"Armor": 20.0}},
"rod": {"effects": {"AP": 10.0}},
"unmodeled": {"effects": {"BurnPercent": 1.0}},
}
def test_star_scaling():
one = statsheet.unit_stats(unit(), 1, [], ITEMS, None, None)
two = statsheet.unit_stats(unit(), 2, [], ITEMS, None, None)
assert two["ehp"] == pytest.approx(one["ehp"] * 1.8)
assert two["dps"] == pytest.approx(one["dps"] * 1.5)
def test_spell_damage_uses_star_index_and_ap():
spell = [0, 200, 300, 450, 0, 0, 0]
bare = statsheet.unit_stats(unit(spell=spell), 1, [], ITEMS, None, None)
with_rod = statsheet.unit_stats(unit(spell=spell), 1, ["rod"], ITEMS, None, None)
no_spell = statsheet.unit_stats(unit(), 1, [], ITEMS, None, None)
assert bare["dps"] > no_spell["dps"]
assert with_rod["dps"] > bare["dps"] # +10 AP skaliert den Spell
def test_items_change_profile():
base = statsheet.unit_stats(unit(), 1, [], ITEMS, None, None)
sword = statsheet.unit_stats(unit(), 1, ["sword"], ITEMS, None, None)
vest = statsheet.unit_stats(unit(), 1, ["vest"], ITEMS, None, None)
assert sword["dps"] > base["dps"]
assert vest["ehp"] > base["ehp"]
assert statsheet.item_is_modeled(ITEMS["sword"])
assert not statsheet.item_is_modeled(ITEMS["unmodeled"])
def test_on_attack_spell_scales_with_attack_speed():
# Pro-Attacke-Passive: DPS = Schaden × AS, unabhängig vom Mana-Zyklus.
spell = [0, 50, 75, 110, 0, 0, 0]
u = unit(spell=spell)
u["spell_on_attack"] = True
on_attack = statsheet.unit_stats(u, 1, [], ITEMS, None, None)
assert on_attack["spell_dps"] == pytest.approx(50 * 0.7)
cast = statsheet.unit_stats(unit(spell=spell), 1, [], ITEMS, None, None)
assert on_attack["spell_dps"] > cast["spell_dps"]
def test_frontline_casts_more():
spell = [0, 300, 450, 700, 0, 0, 0]
tank = statsheet.unit_stats(unit(spell=spell), 1, [], ITEMS, None, "frontline")
carry = statsheet.unit_stats(unit(spell=spell), 1, [], ITEMS, None, "ad_carry")
assert tank["dps"] > carry["dps"]
def test_trait_buffs_keyword_mapping():
traits = {
"T_AD": {"breakpoints": [{"min_units": 2, "style": 1,
"variables": {"BonusAD": 0.15}}]},
"T_Mech": {"breakpoints": [{"min_units": 2, "style": 1,
"variables": {"Wolf_Gold": 1.0}}]},
"T_Skip": {"breakpoints": [{"min_units": 2, "style": 1,
"variables": {"StunDuration": 1.5}}]},
}
buffs, recognized = statsheet.trait_buffs(
{"T_AD": 1, "T_Mech": 1, "T_Skip": 1}, traits
)
assert recognized == {"T_AD"}
assert buffs["ad_pct"] == pytest.approx(0.15)
def test_fraction_normalization():
assert statsheet._fraction(0.15) == 0.15
assert statsheet._fraction(15.0) == 0.15

View File

@@ -0,0 +1,42 @@
"""Tierlist-Parser: Rauschzeilen raus, Meta-Stärke auf 1.0 zentriert."""
from tft.staticdata import tierlist
PAGE = """
<div class="TableItemRow">Unit</div><div>Tier</div><div>Avg Place</div>
<div class="TableItemRow"><a>Lux (Blossom)</a><span>S</span><span>1.20</span>
<span>80.0%</span><span>5</span><span>0.0%</span></div>
<div class="TableItemRow"><a>Lux</a><span>S</span><span>2.62</span>
<span>33.6%</span><span>134,219</span><span>29.3%</span></div>
<div class="TableItemRow"><a>Shen</a><span>B</span><span>4.59</span>
<span>10.0%</span><span>139,563</span><span>30.4%</span></div>
<div class="TableItemRow"><a>Elise</a><span>B</span><span>4.55</span>
<span>9.1%</span><span>11</span><span>0.0%</span></div>
<div class="TableItemRow"><a>Elise</a><span>B</span><span>4.46</span>
<span>10.3%</span><span>67,541</span><span>14.7%</span></div>
"""
def test_small_samples_and_variants_are_dropped():
rows = tierlist.parse(PAGE)
# "Lux (Blossom)" mit 5 Spielen ist Rauschen, die Elise-Zeile mit 11 auch.
assert sorted(rows) == ["Elise", "Lux", "Shen"]
assert rows["Lux"]["avg_place"] == 2.62
assert rows["Lux"]["games"] == 134219
assert rows["Elise"]["avg_place"] == 4.46 # die größere Stichprobe gewinnt
def test_meta_strength_centres_on_one():
rows = tierlist.parse(PAGE)
units = {"TFT18_Lux": {"name": "Lux"}, "TFT18_Shen": {"name": "Shen"},
"TFT18_Elise": {"name": "Elise"}, "TFT18_Ivern": {"name": "Ivern"}}
meta = tierlist.meta_strength(rows, units)
# Ivern steht in keiner Zeile und bekommt keinen Wert.
assert set(meta) == {"TFT18_Lux", "TFT18_Shen", "TFT18_Elise"}
assert abs(sum(meta.values()) / len(meta) - 1.0) < 0.001
assert meta["TFT18_Lux"] > 1 > meta["TFT18_Shen"]
def test_no_rows_means_no_meta():
assert tierlist.parse("<html>nichts</html>") == {}
assert tierlist.meta_strength({}, {"X": {"name": "X"}}) == {}

View File

@@ -0,0 +1,98 @@
"""Trait-Boni: Zählung, Stufen, Reichweite des Bonus."""
from tft.sim import battle, traits
UNITS = {
"A": {"traits": ["PANZER"], "stats": {"hp": 1000, "armor": 20, "damage": 50,
"attackSpeed": 1.0, "range": 1}},
"B": {"traits": ["PANZER"], "stats": {"hp": 1000, "armor": 20, "damage": 50,
"attackSpeed": 1.0, "range": 1}},
"C": {"traits": ["ANDERE"], "stats": {"hp": 1000, "armor": 20, "damage": 50,
"attackSpeed": 1.0, "range": 1}},
}
TRAITS = {
"PANZER": {"breakpoints": [
{"min_units": 2, "variables": {"DefenderDefenseGain": 25}},
{"min_units": 3, "variables": {"DefenderDefenseGain": 60}},
]},
"ANDERE": {"breakpoints": [
{"min_units": 1, "variables": {"TeamManaRegen": 2}},
]},
}
def board(*keys):
return [{"api_name": k, "stars": 1} for k in keys]
def fighters(*keys):
return [battle.to_fighter({"api_name": k, "stars": 1, "col": 0, "row": 0},
"a", UNITS) for k in keys]
def test_a_trait_needs_enough_units():
assert traits.active(board("A"), UNITS, TRAITS) == {}
assert "PANZER" in traits.active(board("A", "B"), UNITS, TRAITS)
def test_copies_of_the_same_champion_count_once():
"""Zwei Kopien von A sind ein Trait-Träger, nicht zwei."""
assert traits.active(board("A", "A"), UNITS, TRAITS) == {}
def test_the_highest_reached_step_wins():
step = traits.active(board("A", "B", "C"), UNITS, TRAITS)["PANZER"]
assert step["variables"]["DefenderDefenseGain"] == 25
UNITS["C"]["traits"].append("PANZER")
try:
step = traits.active(board("A", "B", "C"), UNITS, TRAITS)["PANZER"]
assert step["variables"]["DefenderDefenseGain"] == 60
finally:
UNITS["C"]["traits"].remove("PANZER")
def test_the_bonus_only_reaches_its_own_units():
fa, fb, fc = fighters("A", "B", "C")
traits.apply([fa, fb, fc], board("A", "B", "C"), UNITS, TRAITS)
assert fa.armor == 45 and fb.armor == 45 # 20 + 25
assert fc.armor == 20
def test_team_bonuses_reach_everyone():
"""Steht "Team" im Namen, gilt der Bonus für das ganze Board."""
fa, fb, fc = fighters("A", "B", "C")
traits.apply([fa, fb, fc], board("A", "B", "C"), UNITS, TRAITS)
assert fa.mana_regen == 2 and fc.mana_regen == 2
def test_percent_and_flat_are_told_apart():
"""Derselbe Name trägt beide Skalen — unter 1 Anteil, darüber flach."""
assert traits.effects_of({"ADAP": 0.3})["attack_damage_pct"] == 0.3
assert traits.effects_of({"ADAP": 30})["attack_damage_pct"] == 0.3
def test_unknown_variables_are_ignored():
assert traits.effects_of({"OverrunCombats": 3, "RengarGold": 5}) == {}
def test_durability_reduces_incoming_damage():
gains = traits.effects_of({"JuggernautDurability": 0.2})
assert gains["durability"] == 0.2
def test_the_panel_and_the_fight_count_the_same():
"""Zwei Kopien desselben Champions sind ein Trait-Träger.
Zählt das UI anders als der Kampf, zeigt es Stufen an, die im
Gefecht nicht wirken.
"""
board = [{"api_name": "A", "stars": 1}, {"api_name": "A", "stars": 2},
{"api_name": "B", "stars": 1}]
assert traits.counts(board, UNITS)["PANZER"] == 2
step = traits.active(board, UNITS, TRAITS)["PANZER"]
assert step["units"] == 2 and step["step"] == 1
def test_unknown_units_do_not_count():
board = [{"api_name": "GIBTS_NICHT", "stars": 1}]
assert traits.counts(board, UNITS) == {}

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

@@ -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", []):

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View 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

View 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,
}

View File

@@ -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"]),
}

View 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

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

@@ -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" },
]

View File

@@ -1,53 +1,109 @@
<script setup>
import { ref, watch } from 'vue'
import GroupPanel from './components/GroupPanel.vue'
import RolePanel from './components/RolePanel.vue'
import CarryPanel from './components/CarryPanel.vue'
import PairPanel from './components/PairPanel.vue'
// Reiter neben dem Spiel — reine Statistik, kein Spielzustand.
// Der aktive Reiter steht in der URL, damit ein Reload dort bleibt.
const TABS = ['spiel', 'gruppen', 'rollen', 'carries', 'paare']
const fromHash = () => TABS.find((t) => t === location.hash.slice(1)) || 'spiel'
const tab = ref(fromHash())
watch(tab, (t) => {
history.replaceState(null, '', t === 'spiel' ? location.pathname : `#${t}`)
})
// Vor und Zurück im Browser sollen den Reiter mitnehmen.
window.addEventListener('hashchange', () => { tab.value = fromHash() })
import { computed } from 'vue'
import { store } from './store.js'
import TraitPanel from './components/TraitPanel.vue'
import OpponentList from './components/OpponentList.vue'
import Shop from './components/Shop.vue'
import UnitChip from './components/UnitChip.vue'
import HexGrid from './components/HexGrid.vue'
import BattleView from './components/BattleView.vue'
const s = computed(() => store.state)
const kindLabel = { pvp: 'PvP', pve: 'PvE', carousel: 'Carousel' }
const kindIcon = { pvp: '⚔️', pve: '🐺', carousel: '🎠' }
function dropOnBench() {
const d = store.dragging
store.dragging = null
if (d?.where === 'board') store.move('board', d.idx)
}
// Ab Platz 5 verliert man HP — das ist die Linie, die zählt.
const placeClass = (p) => (p <= 2 ? 'good' : p <= 4 ? 'mid' : 'bad')
const standings = computed(() => {
const p = s.value.player
const rows = [
{
name: 'Du', is_player: true, hp: Math.max(p.hp, 0), alive: p.hp > 0,
score: p.score, level: p.level, gold: p.gold, archetype: '',
off: p.off, def: p.def, level: p.level, gold: p.gold, archetype: '',
placement: s.value.placement, board: [],
},
...s.value.opponents,
]
const total = (r) => (r.off ?? 0) + (r.def ?? 0)
return rows.sort((a, b) => {
if (a.alive !== b.alive) return a.alive ? -1 : 1
if (!a.alive) return (a.placement ?? 9) - (b.placement ?? 9)
return (b.score ?? 0) - (a.score ?? 0)
return total(b) - total(a)
})
})
</script>
<template>
<div v-if="!s" class="splash">
<nav class="toptabs">
<button :class="{ on: tab === 'spiel' }" @click="tab = 'spiel'">Spiel</button>
<button :class="{ on: tab === 'gruppen' }" @click="tab = 'gruppen'">Gruppen</button>
<button :class="{ on: tab === 'rollen' }" @click="tab = 'rollen'">Rollen</button>
<button :class="{ on: tab === 'carries' }" @click="tab = 'carries'">Carries</button>
<button :class="{ on: tab === 'paare' }" @click="tab = 'paare'">Paare</button>
</nav>
<GroupPanel v-if="tab === 'gruppen'" />
<RolePanel v-else-if="tab === 'rollen'" />
<CarryPanel v-else-if="tab === 'carries'" />
<PairPanel v-else-if="tab === 'paare'" />
<div v-else-if="!s" class="splash">
<h1>TFT-Simulator</h1>
<button class="big" @click="store.start()">Neues Spiel</button>
<div v-if="store.error" class="error">{{ store.error }}</div>
</div>
<div v-else class="layout">
<header>
<span class="round">Runde {{ s.round.label }} · {{ kindLabel[s.round.kind] }}</span>
<span class="stagebar">
<span class="stagehex">⬢</span>
<span class="stagelabel">{{ s.round.label }}</span>
<span class="tracker">
<span
v-for="r in s.stage_rounds"
:key="r.label"
class="tick"
:class="{ done: r.done, current: r.current, augment: r.augment }"
:title="r.label"
:class="[r.kind, { done: r.done, current: r.current, augment: r.augment }]"
:title="`${r.label} · ${kindLabel[r.kind]}`"
>{{ kindIcon[r.kind] }}</span>
</span>
<span class="stat">❤️ {{ s.player.hp }}</span>
<span class="score">Boardstärke {{ s.player.score }}</span>
<span class="sep" />
<span class="strengths" title="Boardstärke — 1.0 = Ø-Champion auf 1★">
<span class="off">⚔ {{ s.player.off }}</span>
<span class="def">🛡 {{ s.player.def }}</span>
</span>
<span
v-if="s.player.expected_place"
class="place"
:class="placeClass(s.player.expected_place)"
title="Platz, den dein Board jetzt machen würde — Vergleich mit den sieben Gegnern"
>🏁 {{ s.player.expected_place }}</span>
</span>
<span class="hpbadge">❤️ {{ s.player.hp }}</span>
<button class="fight" :disabled="s.over" @click="store.next()">
{{ s.round.kind === 'pvp' ? 'Kampf' : 'Weiter' }} ▶
</button>
@@ -78,46 +134,44 @@ const standings = computed(() => {
</div>
<main>
<div class="itemrail">
<div class="railtitle" :title="store.selectedItem !== null ? 'Ziel-Unit anklicken' : 'Items'">🎒</div>
<img
v-for="(it, i) in s.items"
:key="i"
:src="it.icon"
:alt="it.name"
:title="it.name"
:class="{ selected: store.selectedItem === i }"
@click="store.selectedItem = store.selectedItem === i ? null : i"
/>
</div>
<TraitPanel :traits="s.traits" />
<section class="center">
<div class="sidebar">
<TraitPanel :traits="s.traits" :items="s.items" :bench-size="s.bench_size" />
<div class="zone log">
<div v-for="(line, i) in [...s.log].reverse()" :key="i">{{ line }}</div>
</div>
<div class="zone">
<h3>Board ({{ s.board.length }}/{{ s.player.level }})</h3>
<div class="unitrow">
<UnitChip
v-for="(u, i) in s.board"
:key="'b' + i"
:unit="u"
sellable
@click="store.selectedItem !== null ? store.equip(i) : store.move('board', i)"
@sell="store.sell('board', i)"
</div>
<section class="center">
<div class="zone field">
<h3>
Kampffeld ({{ s.board.length }}/{{ s.player.level }})
<button class="replaybtn" @click="store.loadReplay()">Kampf zeigen</button>
<button v-if="store.replay" class="replaybtn"
@click="store.replay = null">schließen</button>
</h3>
<BattleView v-if="store.replay" :log="store.replay" />
<HexGrid
v-else
:units="s.board"
:rows="s.board_grid.rows"
:cols="s.board_grid.cols"
/>
</div>
</div>
<div class="zone">
<h3>Bank ({{ s.bench.length }}/9)</h3>
<div
class="zone"
@dragover.prevent
@drop.prevent="dropOnBench()"
>
<h3>Bank ({{ s.bench.length }}/{{ s.bench_size }}) · Rechtsklick verkauft</h3>
<div class="unitrow">
<UnitChip
v-for="(u, i) in s.bench"
:key="'n' + i"
:unit="u"
sellable
:size="92"
draggable="true"
@dragstart="store.dragging = { where: 'bench', idx: i }"
@dragend="store.dragging = null"
@click="store.move('bench', i)"
@sell="store.sell('bench', i)"
/>
@@ -145,31 +199,64 @@ const standings = computed(() => {
.layout {
height: 100vh;
display: flex; flex-direction: column;
padding: 10px; gap: 8px;
max-width: 1400px; margin: 0 auto;
padding: 12px 18px; gap: 10px;
}
header {
display: flex; align-items: center; gap: 16px;
background: #161b29; border-radius: 8px; padding: 8px 14px;
/* Kopfleiste mittig wie im Spiel; HP und Weiter hängen an den Rändern. */
header { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; }
header > .stagebar { grid-column: 2; justify-self: center; }
header > .hpbadge { grid-column: 1; justify-self: start; }
header > .fight { grid-column: 3; justify-self: end; }
/* Kopfleiste wie im Spiel: eine schmale dunkle Pille, Runde links,
Rundensymbole in der Mitte, rechts statt Timer die Boardstärke. */
.stagebar {
display: flex; align-items: center; gap: 9px;
background: #0e1119; border: 1px solid #2a3145;
border-radius: 6px; padding: 3px 12px;
}
.round { font-weight: 700; }
.tracker { display: flex; gap: 3px; }
.stagehex { color: #8fe3d4; font-size: 13px; }
.stagelabel { font-weight: 800; font-size: 14px; letter-spacing: 0.5px; }
.tracker { display: flex; gap: 2px; }
.tick {
font-size: 13px;
opacity: 0.35;
filter: grayscale(0.7);
padding: 1px 3px;
border-radius: 4px;
font-size: 13px; line-height: 1;
opacity: 0.3; filter: grayscale(0.8);
padding: 2px 3px; border-radius: 3px;
}
.tick.done { opacity: 0.15; }
.tick.current {
opacity: 1; filter: none;
background: #2a3145;
outline: 1px solid #56638a;
/* Gespielte Runden bekommen ein rotes Kreuz, wie im Spiel. */
.tick { position: relative; }
.tick.done { opacity: 0.55; filter: grayscale(1) brightness(0.65); }
.tick.done::after {
content: '✕';
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
color: #d9534f; font-size: 13px; font-weight: 800;
}
.tick.current { opacity: 1; filter: none; background: #23304a; outline: 1px solid #5f7bb0; }
.tick.augment { outline: 1px solid #ffd75e88; }
.sep { width: 1px; align-self: stretch; background: #2a3145; margin: 0 2px; }
.strengths { display: flex; gap: 11px; font-weight: 800; font-size: 13px; }
.off { color: #e5a531; }
.def { color: #7de3d4; }
.toptabs {
display: flex; gap: 4px; padding: 6px 10px 0;
border-bottom: 1px solid #1e2534;
}
.toptabs button {
background: transparent; border: none; border-bottom: 2px solid transparent;
padding: 6px 16px; font-weight: 700; color: #8a92a8; border-radius: 0;
}
.toptabs button.on { color: #dfe5f0; border-bottom-color: #4a90d9; }
.place { font-weight: 800; margin-left: 10px; }
.place.good { color: #7ee38a; }
.place.mid { color: #dfe5f0; }
.place.bad { color: #e58686; }
.hpbadge {
background: #0e1119; border: 1px solid #2a3145;
border-radius: 6px; padding: 3px 12px;
font-weight: 700; color: #ff8d8d;
}
.tick.augment { outline: 1px solid #ffd75e66; }
.stat { color: #c6cddd; }
.score { margin-left: auto; font-weight: 700; color: #7de3d4; }
.fight { background: #2b4a33; border-color: #4caf6e; font-weight: 700; }
.error {
@@ -199,27 +286,34 @@ header {
main {
flex: 1; min-height: 0;
display: grid; grid-template-columns: 46px 210px 1fr 250px; gap: 10px;
display: grid;
grid-template-columns: clamp(230px, 16vw, 320px) 1fr clamp(230px, 17vw, 330px);
gap: 12px;
overflow: hidden;
}
main > * { overflow-y: auto; }
.itemrail {
display: flex; flex-direction: column; gap: 5px; align-items: center;
background: #12141d; border-radius: 8px; padding: 6px 4px;
}
.railtitle { font-size: 15px; opacity: 0.7; }
.itemrail img {
width: 34px; height: 34px; border-radius: 6px;
border: 2px solid #3a4460; cursor: pointer;
}
.itemrail img.selected { border-color: #7de3d4; }
.center { display: flex; flex-direction: column; gap: 10px; }
/* overflow: das Log scrollt selbst, die Spalte darf keinen zweiten Balken zeigen. */
.center { display: flex; flex-direction: column; gap: 10px; overflow: hidden; }
.zone h3 { font-size: 12px; text-transform: uppercase; color: #8a92a8; margin-bottom: 5px; }
.zone h3, .center .unitrow { justify-content: center; text-align: center; }
.unitrow { display: flex; flex-wrap: wrap; gap: 6px; min-height: 46px; }
/* Log in die Seitenspalte: die Mitte gehört komplett dem Kampffeld. */
.sidebar { display: flex; flex-direction: column; gap: 10px; min-height: 0; }
.log {
flex: 1; min-height: 0; overflow-y: auto;
color: #7d86a0; font-size: 12px;
}
.zone.field {
/* Feld gibt Höhe ab, damit die Bank-Kacheln groß sein können. */
flex: 1; min-height: 0; max-height: 50vh;
display: flex; flex-direction: column;
}
.zone.field > :last-child { flex: 1; min-height: 0; }
.unitrow { justify-content: center; }
.replaybtn {
background: #141927; border: 1px solid #2a3145; border-radius: 5px;
padding: 1px 8px; margin-left: 8px; font-size: 11px; font-weight: 700;
color: #dfe4ee;
}
</style>

View File

@@ -10,5 +10,12 @@ async function request(method, url, body) {
}
export const newGame = () => request('POST', '/api/game')
export const fetchGroups = (size, limit) =>
request('GET', `/api/groups?size=${size}&limit=${limit}`)
export const fetchRoles = () => request('GET', '/api/roles')
export const fetchCarries = () => request('GET', '/api/carries')
export const fetchPairs = () => request('GET', '/api/pairs')
export const fetchReplay = (gameId) =>
request('POST', `/api/game/${gameId}/replay`)
export const doAction = (gameId, action) =>
request('POST', `/api/game/${gameId}/action`, action)

View File

@@ -0,0 +1,220 @@
<script setup>
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
const props = defineProps({ log: { type: Object, default: null } })
// Sechsecke mit Spitze oben. R ist der Radius bis zur Ecke.
// Waagerechter Abstand: √3·R. Senkrechter Abstand: 1.5·R. Ungerade
// Reihen sind eine halbe Zellbreite nach rechts versetzt.
const R = 26
const COL_STEP = Math.sqrt(3) * R
const ROW_STEP = 1.5 * R
const PAD = 14
const canvas = ref(null)
const playing = ref(false)
const tick = ref(0)
const speed = ref(1)
let raf = null
let last = 0
// Zustand je Kämpfer, aus dem Protokoll aufgebaut.
const state = new Map()
// Gezeichnet wird gespiegelt: der Spieler steht unten, wie im Spiel.
// Die Reihe 0 der Engine ist die hinterste eigene, sie landet ganz unten.
function center(col, row) {
const flipped = (props.log?.rows ?? 8) - 1 - row
const shift = flipped % 2 === 1 ? COL_STEP / 2 : 0
return { x: PAD + shift + col * COL_STEP + COL_STEP / 2,
y: PAD + flipped * ROW_STEP + R }
}
// Setzt nur den Kampfzustand zurück, nicht die Zeit. Die Zeit hier mit
// zurückzusetzen war der Fehler: `step` ruft `reset` in jedem Bild auf,
// die Uhr stand dadurch dauerhaft auf null.
function reset() {
state.clear()
if (!props.log) return
for (const u of props.log.units) {
state.set(u.slot, { ...u, col: 0, row: 0, hp: u.max_hp, alive: true, flash: 0 })
}
// Startfelder stehen in den spawn-Ereignissen.
for (const e of props.log.events) {
if (e.e !== 'spawn') break
const s = state.get(e.u)
if (s) Object.assign(s, { col: e.col, row: e.row, hp: e.hp })
}
}
function applyUpTo(target) {
for (const e of props.log.events) {
if (e.t > target) break
const s = state.get(e.u)
if (!s) continue
if (e.e === 'move') { s.col = e.col; s.row = e.row }
else if (e.e === 'die') s.alive = false
else if (e.e === 'hit' || e.e === 'cast') {
const foe = state.get(e.to)
if (foe) { foe.hp = e.hp; foe.flash = e.t }
s.attacking = e.t
s.attackTarget = e.to
}
}
}
function hexPath(ctx, x, y) {
ctx.beginPath()
for (let i = 0; i < 6; i++) {
const a = (Math.PI / 180) * (60 * i - 30)
const px = x + R * Math.cos(a)
const py = y + R * Math.sin(a)
i ? ctx.lineTo(px, py) : ctx.moveTo(px, py)
}
ctx.closePath()
}
function draw() {
const el = canvas.value
if (!el || !props.log) return
const ctx = el.getContext('2d')
ctx.clearRect(0, 0, el.width, el.height)
for (let r = 0; r < props.log.rows; r++) {
for (let c = 0; c < props.log.cols; c++) {
const { x, y } = center(c, r)
hexPath(ctx, x, y)
ctx.fillStyle = r < props.log.rows / 2 ? '#18241c' : '#241820'
ctx.fill()
ctx.strokeStyle = '#2a3145'
ctx.stroke()
}
}
for (const s of state.values()) {
if (!s.alive) continue
const { x, y } = center(s.col, s.row)
// Angriffslinie zum Ziel, solange der Schlag frisch ist.
if (s.attacking !== undefined && tick.value - s.attacking < 3) {
const foe = state.get(s.attackTarget)
if (foe && foe.alive) {
const t = center(foe.col, foe.row)
ctx.strokeStyle = s.side === 'a' ? '#7ee38a88' : '#e5868688'
ctx.beginPath()
ctx.moveTo(x, y)
ctx.lineTo(t.x, t.y)
ctx.stroke()
}
}
const hurt = tick.value - (s.flash ?? -99) < 3
ctx.beginPath()
ctx.arc(x, y, R * 0.55, 0, Math.PI * 2)
ctx.fillStyle = hurt ? '#ffffff' : s.side === 'a' ? '#4caf6e' : '#b153d8'
ctx.fill()
// Lebensbalken
const share = Math.max(0, Math.min(1, s.hp / s.max_hp))
ctx.fillStyle = '#0d1017'
ctx.fillRect(x - R * 0.6, y - R * 0.85, R * 1.2, 4)
ctx.fillStyle = s.side === 'a' ? '#7ee38a' : '#e58686'
ctx.fillRect(x - R * 0.6, y - R * 0.85, R * 1.2 * share, 4)
ctx.fillStyle = '#dfe4ee'
ctx.font = '9px sans-serif'
ctx.textAlign = 'center'
ctx.fillText(s.name.slice(0, 9), x, y + R * 0.95)
}
}
function lastTick() {
const events = props.log?.events
return events?.length ? events[events.length - 1].t : 0
}
function step(now) {
if (!playing.value) return
const dt = last ? (now - last) / 1000 : 0
last = now
tick.value += dt * props.log.tps * speed.value
if (tick.value >= lastTick()) {
tick.value = lastTick()
playing.value = false
}
reset()
applyUpTo(tick.value)
draw()
if (playing.value) raf = requestAnimationFrame(step)
}
function play() {
if (!props.log) return
if (tick.value >= lastTick()) tick.value = 0
playing.value = true
last = 0
raf = requestAnimationFrame(step)
}
function scrub(value) {
playing.value = false
tick.value = Number(value)
reset()
applyUpTo(tick.value)
draw()
}
// Erst nach dem Einhängen zeichnen — vorher gibt es kein Canvas.
async function show() {
playing.value = false
tick.value = 0
reset()
await nextTick()
draw()
if (lastTick() > 0) play()
}
watch(() => props.log, show)
onMounted(show)
onUnmounted(() => raf && cancelAnimationFrame(raf))
</script>
<template>
<div v-if="log" class="battle">
<div class="bar">
<button @click="playing ? (playing = false) : play()">
{{ playing ? '⏸' : '▶' }}
</button>
<input
type="range"
min="0"
:max="lastTick()"
:value="tick"
@input="scrub($event.target.value)"
/>
<span class="clock">{{ (tick / log.tps).toFixed(1) }}s</span>
<button v-for="s in [1, 2, 4]" :key="s" :class="{ on: speed === s }"
@click="speed = s">{{ s }}×</button>
<span v-if="log.winner" class="winner" :class="log.winner">
{{ log.winner === 'a' ? 'Sieg' : 'Niederlage' }}
</span>
</div>
<canvas
ref="canvas"
:width="2 * PAD + (log.cols + 0.5) * COL_STEP"
:height="2 * PAD + (log.rows - 1) * ROW_STEP + 2 * R"
/>
</div>
</template>
<style scoped>
.battle { padding: 8px 0; }
.bar { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.bar input[type='range'] { flex: 1; max-width: 420px; }
button {
background: #141927; border: 1px solid #2a3145; border-radius: 5px;
padding: 2px 10px; color: #dfe4ee; font-weight: 700;
}
button.on { border-color: #4a90d9; color: #9ccfff; background: #1b2a44; }
.clock { color: #8a92a8; font-size: 12px; width: 42px; }
.winner { font-weight: 800; }
.winner.a { color: #7ee38a; }
.winner.b { color: #e58686; }
canvas { background: #0d1017; border-radius: 6px; }
</style>

View File

@@ -0,0 +1,134 @@
<script setup>
import { onMounted, ref } from 'vue'
import { fetchCarries } from '../api.js'
const data = ref(null)
const error = ref(null)
const costColors = ['#8a8f9d', '#4caf6e', '#4a90d9', '#b153d8', '#e5a531']
const costWash = [
'linear-gradient(0deg, #2b303c 0%, #2b303c 22%, transparent 60%)',
'linear-gradient(0deg, #17402c 0%, #17402c 22%, transparent 60%)',
'linear-gradient(0deg, #16324f 0%, #16324f 22%, transparent 60%)',
'linear-gradient(0deg, #40185c 0%, #40185c 22%, transparent 60%)',
'linear-gradient(0deg, #5c3f10 0%, #5c3f10 22%, transparent 60%)',
]
const sign = (n) => (n >= 0 ? '+' : '') + n.toFixed(2)
onMounted(async () => {
try {
data.value = await fetchCarries()
} catch (e) {
error.value = e.message
}
})
</script>
<template>
<div class="carries">
<h2>Beste Carries</h2>
<div v-if="error" class="error">{{ error }}</div>
<p v-else-if="!data" class="loading">lädt…</p>
<div v-else class="grid">
<div v-for="c in data.carries" :key="c.name" class="slot">
<div
class="card"
:title="`${c.name} · ${c.cost} Gold`
+ (c.tier ? ` · Tier ${c.tier}` : '')
+ ` · ${c.boards} Boards`"
>
<div class="art" :style="{ backgroundImage: `url(${c.splash || c.icon})` }" />
<div class="wash" :style="{ backgroundImage: costWash[c.cost - 1] }" />
<div class="traitlist">
<span v-for="t in c.traits" :key="t.name" class="traitpill">
<img v-if="t.icon" :src="t.icon" :alt="t.name" />{{ t.name }}
</span>
</div>
<span v-if="c.tier" class="tier" :class="c.tier">{{ c.tier }}</span>
<span class="edge" :class="{ neg: c.edge < 0 }">{{ sign(c.edge) }}</span>
<div class="items">
<span
v-for="it in c.items"
:key="it.name"
class="item"
:title="`${it.name} — ${sign(it.edge)} Plätze, ${it.boards} Boards`"
>
<img v-if="it.icon" :src="it.icon" :alt="it.name" />
</span>
</div>
<div
class="cardbottom"
:style="{ background: `linear-gradient(180deg, ${costColors[c.cost - 1]}66,`
+ ` ${costColors[c.cost - 1]}bb)` }"
>
<span class="cname">
{{ c.name }}
<span class="stars">{{ '★'.repeat(c.stars) }}</span>
</span>
<span class="ccoin">🪙 {{ c.cost }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.carries { padding: 14px 18px 40px; }
h2 { margin: 0 0 10px; font-size: 18px; }
.loading { color: #8a92a8; font-size: 12px; }
.error { color: #e58686; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 8px;
}
.card {
position: relative; width: 100%; aspect-ratio: 1.7;
border-radius: 5px; background: #0d1017; overflow: hidden;
}
.art { position: absolute; inset: 0; background-size: cover; background-position: center 25%; }
.wash { position: absolute; inset: 0; }
.traitlist { position: absolute; top: 5px; left: 5px; display: flex; flex-direction: column; gap: 2px; }
.traitpill {
display: inline-flex; align-items: center; gap: 4px;
background: rgba(6, 8, 14, 0.62); border-radius: 3px; padding: 1px 6px 1px 3px;
font-size: 10px; width: fit-content; text-shadow: 0 1px 2px #000;
}
.traitpill img { width: 12px; height: 12px; filter: brightness(0) invert(1); }
.tier {
position: absolute; top: 4px; right: 4px;
background: rgba(4, 6, 11, 0.78); border-radius: 4px; padding: 1px 8px;
font-size: 20px; font-weight: 900; letter-spacing: 0.5px; line-height: 1.15;
}
.tier.S { color: #ffb347; }
.tier.A { color: #b98cff; }
.tier.B { color: #6ea8ff; }
.tier.C { color: #9aa5b8; }
.tier.D { color: #d97a7a; }
.edge {
position: absolute; top: 32px; right: 4px;
background: rgba(4, 6, 11, 0.78); border-radius: 3px; padding: 0 4px;
font-size: 11px; font-weight: 800; color: #7ee38a;
}
.edge.neg { color: #e58686; }
.items { position: absolute; left: 5px; bottom: 24px; display: flex; gap: 3px; }
.item img {
display: block; width: 30px; height: 30px; border-radius: 4px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
}
.cardbottom {
position: absolute; left: 0; right: 0; bottom: 0;
display: flex; justify-content: space-between; align-items: center;
padding: 3px 7px; font-size: 12px;
}
.cname { font-weight: 700; text-shadow: 0 1px 3px #000; }
.stars { color: #ffd75e; font-size: 10px; letter-spacing: -1px; }
.ccoin { color: #ffd75e; font-weight: 700; }
</style>

View File

@@ -0,0 +1,156 @@
<script setup>
import { onMounted, ref, watch } from 'vue'
import { fetchGroups } from '../api.js'
const size = ref(3)
const data = ref(null)
const error = ref(null)
const costColors = ['#8a8f9d', '#4caf6e', '#4a90d9', '#b153d8', '#e5a531']
// Kartenfläche wird von der Kostenstufe eingefärbt, wie im Shop.
const costWash = [
'linear-gradient(0deg, #2b303c 0%, #2b303c 22%, transparent 60%)',
'linear-gradient(0deg, #17402c 0%, #17402c 22%, transparent 60%)',
'linear-gradient(0deg, #16324f 0%, #16324f 22%, transparent 60%)',
'linear-gradient(0deg, #40185c 0%, #40185c 22%, transparent 60%)',
'linear-gradient(0deg, #5c3f10 0%, #5c3f10 22%, transparent 60%)',
]
async function load() {
error.value = null
data.value = null
try {
data.value = await fetchGroups(size.value, 5)
} catch (e) {
error.value = e.message
}
}
onMounted(load)
watch(size, load)
</script>
<template>
<div class="groups">
<header>
<h2>Starke Gruppen</h2>
<span class="tabs">
<button
v-for="n in [2, 3, 4, 5]"
:key="n"
:class="{ on: size === n }"
@click="size = n"
>{{ n }}er</button>
</span>
</header>
<div v-if="error" class="error">{{ error }}</div>
<p v-else-if="!data" class="hint">lädt…</p>
<template v-else>
<div v-for="(g, i) in data.groups" :key="i" class="row">
<div class="head">
<span class="rank">{{ i + 1 }}</span>
<span class="edge">+{{ g.edge.toFixed(2) }}</span>
<span class="boards">{{ g.boards.toLocaleString('de-DE') }} Boards</span>
</div>
<div class="cards">
<div
v-for="u in g.units"
:key="u.name"
class="card"
:title="`${u.name} · ${u.cost} Gold · ${u.items} Items`
+ (u.tier ? ` · Tier ${u.tier}` : '')"
>
<div class="art" :style="{ backgroundImage: `url(${u.splash || u.icon})` }" />
<div class="wash" :style="{ backgroundImage: costWash[u.cost - 1] }" />
<div class="traitlist">
<span v-for="t in u.traits" :key="t.name" class="traitpill">
<img v-if="t.icon" :src="t.icon" :alt="t.name" />{{ t.name }}
</span>
</div>
<div class="vals">
<span v-if="u.tier" class="tier" :class="u.tier">{{ u.tier }}</span>
</div>
<div class="items" :title="`${u.items} Items`">
<span v-for="n in 3" :key="n" :class="{ on: n <= u.items }" />
</div>
<div
class="cardbottom"
:style="{ background: `linear-gradient(180deg, ${costColors[u.cost - 1]}66,`
+ ` ${costColors[u.cost - 1]}bb)` }"
>
<span class="cname">{{ u.name }}</span>
<span class="ccoin">🪙 {{ u.cost }}</span>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.groups { padding: 14px 18px 40px; max-width: 1180px; margin: 0 auto; }
header { display: flex; align-items: baseline; gap: 16px; }
h2 { margin: 0; font-size: 18px; }
.tabs button {
background: #141927; border: 1px solid #2a3145; border-radius: 5px;
padding: 3px 12px; margin-left: 4px; font-weight: 700;
}
.tabs button.on { border-color: #4a90d9; color: #9ccfff; background: #1b2a44; }
.hint { color: #8a92a8; font-size: 12px; margin: 8px 0 14px; max-width: 760px; }
.error { color: #e58686; }
.row { margin-bottom: 14px; }
.head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 4px; }
.rank { color: #8a92a8; font-size: 12px; width: 20px; }
.edge { color: #7ee38a; font-weight: 800; font-size: 15px; }
.boards { color: #8a92a8; font-size: 11px; }
/* Karten wie im Shop: 1.7:1, Splash füllt die Fläche. */
.cards { display: flex; gap: 6px; flex-wrap: wrap; }
.card {
position: relative; width: 168px; aspect-ratio: 1.7;
border-radius: 5px; background: #0d1017; overflow: hidden;
}
.art { position: absolute; inset: 0; background-size: cover; background-position: center 25%; }
.wash { position: absolute; inset: 0; }
.traitlist { position: absolute; top: 5px; left: 5px; display: flex; flex-direction: column; gap: 2px; }
.traitpill {
display: inline-flex; align-items: center; gap: 4px;
background: rgba(6, 8, 14, 0.62); border-radius: 3px; padding: 1px 6px 1px 3px;
font-size: 10px; width: fit-content; text-shadow: 0 1px 2px #000;
}
.traitpill img { width: 12px; height: 12px; filter: brightness(0) invert(1); }
.vals {
position: absolute; top: 4px; right: 4px;
display: flex; flex-direction: column; align-items: flex-end; gap: 2px;
font-size: 11px; font-weight: 800;
}
.tier {
background: rgba(4, 6, 11, 0.78); border-radius: 3px; padding: 0 4px;
font-weight: 900; letter-spacing: 0.5px;
}
.tier.S { color: #ffb347; }
.tier.A { color: #b98cff; }
.tier.B { color: #6ea8ff; }
.tier.C { color: #9aa5b8; }
.tier.D { color: #d97a7a; }
/* Drei Punkte = drei Item-Plätze, gefüllte zeigen die Belegung. */
.items { position: absolute; bottom: 24px; left: 6px; display: flex; gap: 3px; }
.items span {
width: 9px; height: 9px; border-radius: 2px;
background: rgba(4, 6, 11, 0.6); border: 1px solid #4a5468;
}
.items span.on { background: #ffd75e; border-color: #ffd75e; }
.cardbottom {
position: absolute; left: 0; right: 0; bottom: 0;
display: flex; justify-content: space-between; align-items: center;
padding: 3px 7px; font-size: 12px;
}
.cname { font-weight: 700; text-shadow: 0 1px 3px #000; }
.ccoin { color: #ffd75e; font-weight: 700; }
</style>

View File

@@ -0,0 +1,191 @@
<script setup>
import { computed } from 'vue'
import { store } from '../store.js'
const props = defineProps({
units: { type: Array, required: true },
rows: { type: Number, default: 4 },
cols: { type: Number, default: 7 },
})
const fmt = (n) => Number(n).toFixed(1)
const tip = (u) => `Tier ${u.tier} — Stärke ${fmt(u.strength)}`
+ ' (1.0 = Schnitt der eigenen Kostenstufe, aus echten Spielen gelernt)'
const costColors = ['#8a8f9d', '#4caf6e', '#4a90d9', '#b153d8', '#e5a531']
// hex-Id -> { unit, idx }, damit jedes Feld seine Unit direkt kennt.
const byHex = computed(() => {
const map = {}
props.units.forEach((u, idx) => {
if (u.hex !== null && u.hex !== undefined) map[u.hex] = { unit: u, idx }
})
return map
})
const grid = computed(() =>
Array.from({ length: props.rows }, (_, r) =>
Array.from({ length: props.cols }, (_, c) => r * props.cols + c)))
function onDrop(hex) {
const d = store.dragging
store.dragging = null
if (d) store.place(d.where, d.idx, hex)
}
function onClick(hex) {
const cell = byHex.value[hex]
if (!cell) return
if (store.selectedItem !== null) store.equip(cell.idx)
else store.move('board', cell.idx)
}
</script>
<template>
<div class="gridwrap">
<div class="grid">
<div v-for="(row, r) in grid" :key="r" class="hexrow" :class="{ shifted: r % 2 === 1 }">
<div
v-for="hex in row"
:key="hex"
class="hex"
:class="{ filled: byHex[hex], droppable: store.dragging }"
:style="byHex[hex] ? { '--cost': costColors[byHex[hex].unit.cost - 1] } : null"
:title="byHex[hex] ? byHex[hex].unit.name : ''"
:draggable="!!byHex[hex]"
@dragstart="byHex[hex] && (store.dragging = { where: 'board', idx: byHex[hex].idx })"
@dragend="store.dragging = null"
@dragover.prevent
@drop.prevent="onDrop(hex)"
@click="onClick(hex)"
>
<template v-if="byHex[hex]">
<img class="art" :src="byHex[hex].unit.icon" :alt="byHex[hex].unit.name" />
<span class="stars">{{ '★'.repeat(byHex[hex].unit.stars) }}</span>
<span v-if="byHex[hex].unit.items.length" class="items">
<img
v-for="(it, i) in byHex[hex].unit.items"
:key="i"
:src="it.icon"
:alt="it.name"
:title="it.name"
/>
</span>
<span
v-if="byHex[hex].unit.tier"
class="meta tier"
:class="byHex[hex].unit.tier"
:title="tip(byHex[hex].unit)"
>{{ byHex[hex].unit.tier }}</span>
<span class="vals">
<span class="off">{{ fmt(byHex[hex].unit.off) }}</span>
<span class="def">{{ fmt(byHex[hex].unit.def) }}</span>
</span>
</template>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* Geometrie am Original vermessen (MetaTFT Team Builder):
Hex 91.9 x 106.1 -> regelmäßiges Sechseck mit Spitze oben (h = w * 2/√3).
Spalten-Pitch 1.1 * w, Reihen-Pitch 0.826 * h — die Reihen greifen ineinander. */
.gridwrap {
/* Größen-Container: das Grid darf Breite UND Höhe des Platzes ausmessen. */
container-type: size;
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
}
.grid {
/* Gesamtmaße in Hex-Breiten: 8.2 breit (Versatz + 6 Pitches + 1 Hex),
4.05 hoch (3 Reihen-Pitches + 1 Hex). Die engere Achse gewinnt. */
--hex-w: min(calc(100cqw / 8.2), calc(100cqh / 4.05));
--hex-h: calc(var(--hex-w) * 1.1545);
--hex-shape: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
display: flex; flex-direction: column;
width: fit-content;
}
.hexrow { display: flex; gap: calc(var(--hex-w) * 0.1); }
.hexrow + .hexrow { margin-top: calc(var(--hex-h) * -0.174); }
.hexrow.shifted { margin-left: calc(var(--hex-w) * 0.55); }
.hex {
position: relative;
width: var(--hex-w);
height: var(--hex-h);
background: #232a3d;
/* clip-path begrenzt auch die Trefferfläche — die Boxen dürfen überlappen. */
clip-path: var(--hex-shape);
transition: background 0.12s;
}
.hex.droppable { background: #2f3a55; }
.hex.filled { background: var(--cost); cursor: pointer; }
.hex.filled:hover { filter: brightness(1.25); }
.art {
position: absolute;
inset: 2px;
width: calc(100% - 4px);
height: calc(100% - 4px);
object-fit: cover;
clip-path: var(--hex-shape);
pointer-events: none;
}
.stars {
position: absolute;
top: 12%; left: 0; right: 0;
text-align: center;
font-size: calc(var(--hex-w) * 0.17);
letter-spacing: 1px;
color: #ffd75e;
text-shadow: 0 0 3px #000;
pointer-events: none;
}
.items {
position: absolute;
bottom: 36%; left: 0; right: 0;
display: flex; justify-content: center; gap: 2px;
pointer-events: none;
}
.items img {
width: calc(var(--hex-w) * 0.2);
height: calc(var(--hex-w) * 0.2);
border-radius: 3px;
}
.vals {
position: absolute;
/* Auf 18% Höhe ist das Hex noch ~80% breit; weiter unten läuft es
spitz zu und clip-path schneidet die Zahlen ab. */
bottom: 18%; left: 13%; right: 13%;
display: flex; justify-content: space-between;
font-size: calc(var(--hex-w) * 0.16);
font-weight: 800;
pointer-events: none;
}
/* Dunkle Pille dahinter — auf hellen Portraits waren die Zahlen sonst weg. */
.off, .def {
background: rgba(4, 6, 11, 0.8);
border-radius: 3px;
padding: 0 3px;
}
.off { color: #e5a531; }
.def { color: #7de3d4; }
/* Meta sitzt zwischen Sternen und Portraitmitte, wo das Hex breit ist. */
.meta {
position: absolute;
top: 30%; left: 0; right: 0;
text-align: center;
font-size: calc(var(--hex-w) * 0.15);
font-weight: 800;
text-shadow: 0 0 4px #000, 0 1px 2px #000;
pointer-events: none;
}
/* Tier-Farben wie im Spiel gewohnt: S golden, D grau-rot. */
.tier { font-weight: 900; letter-spacing: 0.5px; }
.tier.S { color: #ffb347; }
.tier.A { color: #b98cff; }
.tier.B { color: #6ea8ff; }
.tier.C { color: #9aa5b8; }
.tier.D { color: #d97a7a; }
</style>

View File

@@ -2,31 +2,41 @@
import { ref } from 'vue'
defineProps({ players: { type: Array, required: true } })
const expanded = ref(null)
// Ring um das Portrait färbt sich mit dem Leben, wie im Spiel.
function ring(hp) {
if (hp > 60) return '#4caf6e'
if (hp > 30) return '#e5a531'
return '#d9534f'
}
</script>
<template>
<aside class="opponents">
<h3>Spieler</h3>
<aside class="players">
<div
v-for="p in players"
:key="p.name"
class="opp"
class="row"
:class="{ dead: !p.alive, me: p.is_player }"
@click="!p.is_player && (expanded = expanded === p.name ? null : p.name)"
>
<div class="row">
<span class="oname">{{ p.name }}</span>
<span v-if="p.archetype" class="arch">{{ p.archetype }}</span>
<span v-if="p.alive && p.level" class="lvl">L{{ p.level }}</span>
<span v-if="p.alive && p.gold !== undefined" class="goldval">🪙{{ p.gold }}</span>
<span v-if="p.alive" class="strength" title="Boardstärke">⚡ {{ p.score }}</span>
<span v-if="p.alive" class="hp">{{ p.hp }}</span>
<span v-else class="placement">#{{ p.placement }}</span>
</div>
<div v-if="p.alive" class="hpbar"><div :style="{ width: p.hp + '%' }" /></div>
<span class="emblem" :style="{ borderColor: ring(p.hp) }">{{ p.name[0] }}</span>
<span class="mid">
<span class="name">{{ p.name }}</span>
<span v-if="p.alive" class="stats">
<span v-if="p.level" class="lvl">L{{ p.level }}</span>
<span class="off">⚔ {{ p.off }}</span>
<span class="def">🛡 {{ p.def }}</span>
</span>
<span v-else class="stats">ausgeschieden</span>
</span>
<span class="hp" :style="{ color: ring(p.hp) }">{{ p.alive ? p.hp : '#' + p.placement }}</span>
<span class="portrait" :style="{ borderColor: ring(p.hp) }">
<img v-if="p.carry" :src="p.carry" :alt="p.name" />
</span>
<span class="hpbar"><span :style="{ width: p.hp + '%', background: ring(p.hp) }" /></span>
<div v-if="expanded === p.name && p.alive && p.board.length" class="scout">
<div>Level {{ p.level }}</div>
<div class="units">
<img
v-for="(u, i) in p.board"
:key="i"
@@ -36,28 +46,57 @@ const expanded = ref(null)
/>
</div>
</div>
</div>
</aside>
</template>
<style scoped>
.opponents { display: flex; flex-direction: column; gap: 6px; }
h3 { font-size: 12px; text-transform: uppercase; color: #8a92a8; margin-bottom: 2px; }
.opp { background: #161b29; border-radius: 6px; padding: 6px 8px; cursor: pointer; }
.opp.me { border: 1px solid #7de3d4; cursor: default; }
.opp.dead { opacity: 0.4; }
.row { display: flex; align-items: center; gap: 6px; }
.oname { flex: 1; font-weight: 600; }
.me .oname { color: #7de3d4; }
.arch { color: #7d86a0; font-size: 11px; }
.lvl { color: #8a92a8; font-size: 11px; font-weight: 700; }
.goldval { color: #ffd75e; font-size: 11px; }
.strength { color: #e5a531; font-size: 12px; font-weight: 600; }
.hp { font-weight: 700; color: #7de37d; }
.placement { color: #8a92a8; }
.hpbar { height: 4px; background: #232a3d; border-radius: 2px; margin-top: 4px; }
.hpbar div { height: 100%; background: #4caf6e; border-radius: 2px; }
.scout { margin-top: 6px; font-size: 12px; color: #aab2c8; }
.units { display: flex; flex-wrap: wrap; gap: 3px; margin-top: 4px; }
.units img { width: 26px; height: 26px; border-radius: 4px; object-fit: cover; }
.players { display: flex; flex-direction: column; gap: 5px; }
.row {
position: relative;
display: grid;
grid-template-columns: 28px 1fr auto 42px;
align-items: center;
gap: 7px;
padding: 5px 6px 8px;
background: linear-gradient(90deg, #10131c, #1b2130);
border-radius: 6px;
cursor: pointer;
overflow: hidden;
}
.row.me { background: linear-gradient(90deg, #12232a, #1b3540); outline: 1px solid #7de3d4; cursor: default; }
.row.dead { opacity: 0.42; }
.emblem {
width: 26px; height: 26px;
border-radius: 50%;
border: 2px solid;
background: #0a0c12;
display: flex; align-items: center; justify-content: center;
font-weight: 800; font-size: 12px; color: #c6cddd;
}
.hp { font-size: 17px; font-weight: 800; text-align: right; }
.mid { display: flex; flex-direction: column; min-width: 0; line-height: 1.25; }
.name { font-weight: 700; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.me .name { color: #7de3d4; }
.stats { display: flex; gap: 7px; font-size: 11px; color: #8a92a8; }
.lvl { font-weight: 700; }
.off { color: #e5a531; }
.def { color: #7de3d4; }
.portrait {
width: 40px; height: 40px;
border-radius: 50%;
border: 2px solid;
background: #0a0c12;
overflow: hidden;
}
.portrait img { width: 100%; height: 100%; object-fit: cover; }
.hpbar { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: #232a3d; }
.hpbar span { display: block; height: 100%; }
.scout { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 3px; margin-top: 4px; }
.scout img { width: 26px; height: 26px; border-radius: 4px; object-fit: cover; }
</style>

View File

@@ -0,0 +1,140 @@
<script setup>
import { onMounted, ref } from 'vue'
import { fetchPairs } from '../api.js'
const data = ref(null)
const error = ref(null)
const costColors = ['#8a8f9d', '#4caf6e', '#4a90d9', '#b153d8', '#e5a531']
const costWash = [
'linear-gradient(0deg, #2b303c 0%, #2b303c 22%, transparent 60%)',
'linear-gradient(0deg, #17402c 0%, #17402c 22%, transparent 60%)',
'linear-gradient(0deg, #16324f 0%, #16324f 22%, transparent 60%)',
'linear-gradient(0deg, #40185c 0%, #40185c 22%, transparent 60%)',
'linear-gradient(0deg, #5c3f10 0%, #5c3f10 22%, transparent 60%)',
]
onMounted(async () => {
try {
data.value = await fetchPairs()
} catch (e) {
error.value = e.message
}
})
</script>
<template>
<div class="pairs">
<h2>Gruppen von Champions</h2>
<div v-if="error" class="error">{{ error }}</div>
<p v-else-if="!data" class="loading">lädt…</p>
<div v-else class="grid">
<div v-for="(g, i) in data.groups" :key="i" class="pair">
<div class="head">
<span
class="weight"
title="Plätze besser als vergleichbare Boards — gleiches Level,
gleiche Item-Menge, gleich viele 3★-Units"
>+{{ g.edge.toFixed(2) }}</span>
<span
class="src"
:title="g.links.map((l) => `${l.comps}× zusammen in den Pro-Comps`)
.join(' · ')"
>Bindung {{ g.bond.toFixed(2) }}</span>
<span
v-for="t in g.traits"
:key="t.name"
class="shared"
title="dieser Trait verbindet die Gruppe"
>
<img v-if="t.icon" :src="t.icon" :alt="t.name" />{{ t.name }}
</span>
<span class="src">{{ g.boards.toLocaleString('de-DE') }} Boards</span>
</div>
<div class="cards">
<div
v-for="u in g.units"
:key="u.name"
class="card"
:title="`${u.name} · ${u.cost} Gold` + (u.tier ? ` · Tier ${u.tier}` : '')"
>
<div class="art" :style="{ backgroundImage: `url(${u.splash || u.icon})` }" />
<div class="wash" :style="{ backgroundImage: costWash[u.cost - 1] }" />
<div class="traitlist">
<span v-for="t in u.traits" :key="t.name" class="traitpill">
<img v-if="t.icon" :src="t.icon" :alt="t.name" />{{ t.name }}
</span>
</div>
<span v-if="u.tier" class="tier" :class="u.tier">{{ u.tier }}</span>
<div
class="cardbottom"
:style="{ background: `linear-gradient(180deg, ${costColors[u.cost - 1]}66,`
+ ` ${costColors[u.cost - 1]}bb)` }"
>
<span class="cname">{{ u.name }}</span>
<span class="ccoin">🪙 {{ u.cost }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.pairs { padding: 14px 18px 40px; }
h2 { margin: 0 0 10px; font-size: 18px; }
.loading { color: #8a92a8; font-size: 12px; }
.error { color: #e58686; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
gap: 12px;
}
.head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 3px; }
.weight { color: #7ee38a; font-weight: 800; font-size: 15px; }
.src { color: #8a92a8; font-size: 11px; }
/* Der Trait, der die Gruppe zusammenhält. */
.shared {
display: inline-flex; align-items: center; gap: 4px;
background: #1b2a44; border: 1px solid #2f4a72; border-radius: 4px;
padding: 0 6px 0 3px; font-size: 11px; color: #9ccfff;
}
.shared img { width: 13px; height: 13px; filter: brightness(0) invert(1); }
.cards { display: flex; gap: 5px; }
.card {
position: relative; flex: 1; aspect-ratio: 1.7;
border-radius: 5px; background: #0d1017; overflow: hidden;
}
.art { position: absolute; inset: 0; background-size: cover; background-position: center 25%; }
.wash { position: absolute; inset: 0; }
.traitlist { position: absolute; top: 5px; left: 5px; display: flex; flex-direction: column; gap: 2px; }
.traitpill {
display: inline-flex; align-items: center; gap: 4px;
background: rgba(6, 8, 14, 0.62); border-radius: 3px; padding: 1px 6px 1px 3px;
font-size: 10px; width: fit-content; text-shadow: 0 1px 2px #000;
}
.traitpill img { width: 12px; height: 12px; filter: brightness(0) invert(1); }
.tier {
position: absolute; top: 4px; right: 4px;
background: rgba(4, 6, 11, 0.78); border-radius: 4px; padding: 1px 8px;
font-size: 20px; font-weight: 900; letter-spacing: 0.5px; line-height: 1.15;
}
.tier.S { color: #ffb347; }
.tier.A { color: #b98cff; }
.tier.B { color: #6ea8ff; }
.tier.C { color: #9aa5b8; }
.tier.D { color: #d97a7a; }
.cardbottom {
position: absolute; left: 0; right: 0; bottom: 0;
display: flex; justify-content: space-between; align-items: center;
padding: 3px 7px; font-size: 12px;
}
.cname { font-weight: 700; text-shadow: 0 1px 3px #000; }
.ccoin { color: #ffd75e; font-weight: 700; }
</style>

View File

@@ -0,0 +1,159 @@
<script setup>
import { onMounted, ref } from 'vue'
import { fetchRoles } from '../api.js'
const data = ref(null)
const error = ref(null)
const costColors = ['#8a8f9d', '#4caf6e', '#4a90d9', '#b153d8', '#e5a531']
const costWash = [
'linear-gradient(0deg, #2b303c 0%, #2b303c 22%, transparent 60%)',
'linear-gradient(0deg, #17402c 0%, #17402c 22%, transparent 60%)',
'linear-gradient(0deg, #16324f 0%, #16324f 22%, transparent 60%)',
'linear-gradient(0deg, #40185c 0%, #40185c 22%, transparent 60%)',
'linear-gradient(0deg, #5c3f10 0%, #5c3f10 22%, transparent 60%)',
]
const sign = (n) => (n >= 0 ? '+' : '') + n.toFixed(2)
onMounted(async () => {
try {
data.value = await fetchRoles()
} catch (e) {
error.value = e.message
}
})
</script>
<template>
<div class="roles">
<h2>Beste Unit je Rolle</h2>
<div v-if="error" class="error">{{ error }}</div>
<p v-else-if="!data" class="hint">lädt…</p>
<template v-else>
<div class="grid">
<div v-for="role in data.roles" :key="role.label" class="role">
<h3>{{ role.label }}</h3>
<div class="slot" v-for="e in role.costs.filter((c) => c.unit)" :key="e.cost">
<template v-if="e.unit">
<div
class="card"
:title="`${e.unit.name} · ${e.unit.cost} Gold`
+ (e.unit.tier ? ` · Tier ${e.unit.tier}` : '')
+ ` · ${e.unit.boards} Boards`"
>
<div class="art"
:style="{ backgroundImage: `url(${e.unit.splash || e.unit.icon})` }" />
<div class="wash" :style="{ backgroundImage: costWash[e.unit.cost - 1] }" />
<div class="traitlist">
<span v-for="t in e.unit.traits" :key="t.name" class="traitpill">
<img v-if="t.icon" :src="t.icon" :alt="t.name" />{{ t.name }}
</span>
</div>
<span v-if="e.unit.tier" class="tier" :class="e.unit.tier">
{{ e.unit.tier }}
</span>
<span class="edge" :class="{ neg: e.unit.edge < 0 }">
{{ sign(e.unit.edge) }}
</span>
<div class="items">
<span
v-for="it in e.unit.items"
:key="it.name"
class="item"
:title="`${it.name} — ${sign(it.edge)} Plätze, ${it.boards} Boards`"
>
<img v-if="it.icon" :src="it.icon" :alt="it.name" />
</span>
</div>
<div
class="cardbottom"
:style="{ background: `linear-gradient(180deg,`
+ ` ${costColors[e.unit.cost - 1]}66,`
+ ` ${costColors[e.unit.cost - 1]}bb)` }"
>
<span class="cname">
{{ e.unit.name }}
<span class="stars">{{ '★'.repeat(e.unit.stars) }}</span>
</span>
<span class="ccoin">🪙 {{ e.unit.cost }}</span>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.roles { padding: 14px 18px 40px; }
h2 { margin: 0 0 10px; font-size: 18px; }
h3 { margin: 0 0 6px; font-size: 14px; color: #c6cddd; }
.hint { color: #8a92a8; font-size: 12px; margin: 6px 0 16px; }
.error { color: #e58686; }
/* Eine Spalte je Rolle, die Kostenstufen stapeln sich darunter. */
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 14px;
align-items: start;
}
.role { min-width: 0; }
.slot { margin-bottom: 6px; }
/* Die drei Items gehoeren zum Champion und sitzen auf seinem Bild,
unten links nebeneinander — das beste zuerst. */
.items {
position: absolute; left: 5px; bottom: 24px;
display: flex; gap: 3px;
}
.item img {
display: block; width: 30px; height: 30px; border-radius: 4px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
}
.card {
position: relative; width: 100%; aspect-ratio: 1.7;
border-radius: 5px; background: #0d1017; overflow: hidden;
}
.art { position: absolute; inset: 0; background-size: cover; background-position: center 25%; }
.wash { position: absolute; inset: 0; }
.traitlist { position: absolute; top: 5px; left: 5px; display: flex; flex-direction: column; gap: 2px; }
.traitpill {
display: inline-flex; align-items: center; gap: 4px;
background: rgba(6, 8, 14, 0.62); border-radius: 3px; padding: 1px 6px 1px 3px;
font-size: 10px; width: fit-content; text-shadow: 0 1px 2px #000;
}
.traitpill img { width: 12px; height: 12px; filter: brightness(0) invert(1); }
.tier {
position: absolute; top: 4px; right: 4px;
background: rgba(4, 6, 11, 0.78); border-radius: 4px; padding: 1px 8px;
font-size: 20px; font-weight: 900; letter-spacing: 0.5px; line-height: 1.15;
}
.tier.S { color: #ffb347; }
.tier.A { color: #b98cff; }
.tier.B { color: #6ea8ff; }
.tier.C { color: #9aa5b8; }
.tier.D { color: #d97a7a; }
/* Der Wert sitzt jetzt auf der Karte — in der Spalte ist darunter kein Platz. */
.edge {
position: absolute; top: 32px; right: 4px;
background: rgba(4, 6, 11, 0.78); border-radius: 3px; padding: 0 4px;
font-size: 11px; font-weight: 800; color: #7ee38a;
}
.edge.neg { color: #e58686; }
.cardbottom {
position: absolute; left: 0; right: 0; bottom: 0;
display: flex; justify-content: space-between; align-items: center;
padding: 3px 7px; font-size: 12px;
}
.cname { font-weight: 700; text-shadow: 0 1px 3px #000; }
.stars { color: #ffd75e; font-size: 10px; letter-spacing: -1px; }
.ccoin { color: #ffd75e; font-weight: 700; }
</style>

View File

@@ -1,9 +1,25 @@
<script setup>
import { computed } from 'vue'
import { store } from '../store.js'
const s = computed(() => store.state)
const costColors = ['#8a8f9d', '#4caf6e', '#4a90d9', '#b153d8', '#e5a531']
// Kartenfläche wird von der Kostenstufe eingefärbt, wie im Spiel.
const costWash = [
'linear-gradient(0deg, #2b303c 0%, #2b303c 22%, transparent 60%)',
'linear-gradient(0deg, #17402c 0%, #17402c 22%, transparent 60%)',
'linear-gradient(0deg, #16324f 0%, #16324f 22%, transparent 60%)',
'linear-gradient(0deg, #40185c 0%, #40185c 22%, transparent 60%)',
'linear-gradient(0deg, #5c3f10 0%, #5c3f10 22%, transparent 60%)',
]
const fmt = (n) => Number(n).toFixed(1)
// Tier aus echten Endboards, Rang innerhalb der Kostenstufe.
const tip = (c) => `Tier ${c.tier} — Stärke ${fmt(c.strength)}`
+ ' (1.0 = Schnitt der eigenen Kostenstufe, aus echten Spielen gelernt)'
function owned(api) {
return [...store.state.board, ...store.state.bench].some((u) => u.api_name === api)
return [...s.value.board, ...s.value.bench].some((u) => u.api_name === api)
}
</script>
@@ -11,83 +27,95 @@ function owned(api) {
<div class="shopwrap">
<div class="topbar">
<div class="tleft">
<span class="lvl">Lvl. {{ store.state.player.level }}</span>
<span v-if="store.state.player.xp_needed !== null" class="xp">
{{ store.state.player.xp }}/{{ store.state.player.xp_needed }}
<span class="lvl">Lvl. {{ s.player.level }}</span>
<span v-if="s.player.xp_needed !== null" class="xp">
{{ s.player.xp }}/{{ s.player.xp_needed }}
</span>
<span class="odds">
<span
v-for="(p, i) in store.state.shop_odds"
:key="i"
:style="{ color: costColors[i] }"
>● {{ p }}%</span>
<span v-for="(p, i) in s.shop_odds" :key="i" class="odd">
<span class="dot" :style="{ background: costColors[i] }" />{{ p }}%
</span>
</span>
</div>
<div class="tcenter">
<span class="pill gold">🪙 {{ store.state.player.gold }}</span>
<span class="pill gold">🪙 {{ s.player.gold }}</span>
<span
v-if="store.state.player.streak !== 0"
v-if="s.player.streak !== 0"
class="pill streak"
:class="store.state.player.streak > 0 ? 'hot' : 'cold'"
>
<span class="flame">🔥</span> {{ Math.abs(store.state.player.streak) }}
</span>
:class="s.player.streak > 0 ? 'hot' : 'cold'"
:title="s.player.streak > 0 ? 'Siegesserie' : 'Niederlagenserie'"
><span class="flame">🔥</span> {{ Math.abs(s.player.streak) }}</span>
</div>
<div class="tright">
<button
class="lock"
:class="{ locked: store.state.shop_locked }"
:title="store.state.shop_locked ? 'Shop entsperren' : 'Shop sperren'"
:class="{ locked: s.shop_locked }"
:title="s.shop_locked ? 'Shop entsperren' : 'Shop sperren'"
@click="store.act({ action: 'lock' })"
>
{{ store.state.shop_locked ? '🔒' : '🔓' }}
</button>
>{{ s.shop_locked ? '🔒' : '🔓' }}</button>
</div>
</div>
<div class="shoprow">
<div class="controls">
<button
class="ctrl"
:disabled="store.state.player.xp_needed === null || store.state.player.gold < store.state.xp_cost"
class="ctrl xp"
:disabled="s.player.xp_needed === null || s.player.gold < s.xp_cost"
@click="store.buyXp()"
>
<span class="ctrl-label">XP kaufen</span>
<span class="ctrl-cost">🪙 {{ store.state.xp_cost }} ⌃</span>
<span class="cbody"><span class="clabel">Buy XP</span>
<span class="ccost">🪙 {{ s.xp_cost }}</span></span>
<span class="cicon">▲</span>
</button>
<button
class="ctrl"
:disabled="store.state.player.gold < store.state.reroll_cost"
class="ctrl reroll"
:disabled="s.player.gold < s.reroll_cost"
@click="store.reroll()"
>
<span class="ctrl-label">Refresh</span>
<span class="ctrl-cost">🪙 {{ store.state.reroll_cost }} ⟳</span>
<span class="cbody"><span class="clabel">Reroll</span>
<span class="ccost">🪙 {{ s.reroll_cost }}</span></span>
<span class="cicon">↻</span>
</button>
</div>
<div class="cards">
<div
v-for="(s, i) in store.state.shop"
v-for="(c, i) in s.shop"
:key="i"
class="card"
:class="{ empty: !s, unaffordable: s && s.cost > store.state.player.gold, upgrade: s && s.upgrade }"
:style="s ? { borderColor: s.upgrade ? '#ffd75e' : costColors[s.cost - 1] } : {}"
@click="s && store.buy(i)"
:class="{ empty: !c, unaffordable: c && c.cost > s.player.gold }"
@click="c && store.buy(i)"
>
<template v-if="s">
<div class="art" :style="{ backgroundImage: `url(${s.icon})` }" />
<div v-if="owned(s.api_name)" class="shine" />
<div v-if="s.upgrade" class="starhint">
<span v-for="n in s.upgrade" :key="n">★</span>
<template v-if="c">
<div class="art" :style="{ backgroundImage: `url(${c.splash || c.icon})` }" />
<div class="wash" :style="{ backgroundImage: costWash[c.cost - 1] }" />
<div v-if="owned(c.api_name)" class="shine" />
<div v-if="c.upgrade" class="starhint">
<span v-for="n in c.upgrade" :key="n">★</span>
</div>
<div class="vals">
<span class="off">⚔ {{ fmt(c.off) }}</span>
<span class="def">🛡 {{ fmt(c.def) }}</span>
<!-- Nur der Buchstabe: die Zahl gilt je Kostenstufe und
wäre zwischen zwei Karten unterschiedlicher Kosten irreführend. -->
<span v-if="c.tier" class="meta tier" :class="c.tier" :title="tip(c)">
{{ c.tier }}
</span>
</div>
<div class="traitlist">
<span v-for="t in s.traits" :key="t.name" class="traitpill">
<span v-for="t in c.traits" :key="t.name" class="traitpill">
<img v-if="t.icon" :src="t.icon" :alt="t.name" />{{ t.name }}
</span>
</div>
<div class="cardbottom">
<span class="cname">{{ s.name }}</span>
<span class="ccost">🪙 {{ s.cost }}</span>
<div
class="cardbottom"
:style="{ background: `linear-gradient(180deg, ${costColors[c.cost - 1]}66,`
+ ` ${costColors[c.cost - 1]}bb)` }"
>
<span class="cname">{{ c.name }}</span>
<span class="ccoin">🪙 {{ c.cost }}</span>
</div>
</template>
</div>
@@ -97,113 +125,144 @@ function owned(api) {
</template>
<style scoped>
.shopwrap { display: flex; flex-direction: column; gap: 6px; }
/* Shopleiste als kompaktes, zentriertes Panel — nicht über die volle Breite.
Maße aus der Vorlage: Karte 132 x 78 (Verhältnis 1.7), Knopfspalte so breit
wie eine Karte. */
.shopwrap {
display: flex; flex-direction: column; gap: 5px;
width: fit-content; max-width: 100%; margin: 0 auto;
background: linear-gradient(180deg, #12161f, #0b0e15);
border: 1px solid #2a3145;
border-radius: 8px;
padding: 6px 8px 8px;
}
.topbar { display: flex; align-items: center; gap: 10px; }
.tleft { flex: 1; display: flex; align-items: baseline; gap: 8px; }
.tcenter { display: flex; gap: 10px; }
.tcenter { display: flex; gap: 8px; }
.tright { flex: 1; display: flex; justify-content: flex-end; }
.lvl { font-weight: 800; font-size: 15px; }
.lvl { font-weight: 800; font-size: 16px; }
.xp { color: #8a92a8; font-size: 12px; }
.odds { display: flex; gap: 8px; font-size: 12px; font-weight: 600; margin-left: 8px; }
.odds { display: flex; gap: 10px; font-size: 12px; font-weight: 600; margin-left: 10px; color: #c6cddd; }
.odd { display: inline-flex; align-items: center; gap: 4px; }
.dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
.pill {
background: #161b29;
border: 1px solid #3a4460;
border-radius: 14px;
padding: 3px 14px;
font-weight: 700;
background: #10131c; border: 1px solid #3a4460; border-radius: 14px;
padding: 2px 16px; font-weight: 700;
}
.pill.gold { color: #ffd75e; }
.streak.hot { border-color: #b3543a; }
.streak.cold { border-color: #4a7fd9; }
.streak.cold .flame { filter: hue-rotate(190deg) saturate(2); }
.streak.hot { border-color: #b3543a; color: #ffb08a; }
/* Verlustserie: dieselbe Flamme, nur blau eingefärbt. */
.streak.cold { border-color: #4a7fd9; color: #a8c6ff; }
.streak.cold .flame { filter: hue-rotate(190deg) saturate(1.8); }
.shoprow { display: flex; gap: 8px; align-items: stretch; }
.shoprow {
--card-w: clamp(140px, 12vw, 210px);
display: flex; gap: 6px; align-items: stretch;
}
.controls { display: flex; flex-direction: column; gap: 6px; width: 120px; }
.controls {
display: flex; flex-direction: column; gap: 5px;
width: var(--card-w); flex: none;
}
.ctrl {
flex: 1;
display: flex; flex-direction: column;
align-items: flex-start; justify-content: center; gap: 2px;
background: linear-gradient(180deg, #1d2438, #141927);
border: 1px solid #4a4f30;
display: flex; align-items: center; justify-content: space-between;
padding: 6px 10px;
border: 1px solid #3d4457;
border-radius: 5px;
}
.ctrl:hover:not(:disabled) { border-color: #8a8340; }
.ctrl-label { font-weight: 700; font-size: 13px; }
.ctrl-cost { font-size: 11px; color: #c9b96a; }
.ctrl .cbody { display: flex; flex-direction: column; align-items: flex-start; gap: 1px; }
.clabel { font-weight: 700; font-size: 14px; }
.ccost { font-size: 11px; color: #c9b96a; }
/* Der farbige Block rechts im Knopf entspricht dem Pfeil-Feld im Spiel. */
.cicon {
font-size: 18px; line-height: 1;
padding: 5px 8px; border-radius: 4px;
}
.ctrl.xp { background: linear-gradient(180deg, #1b2a44, #131a28); }
.ctrl.xp:hover:not(:disabled) { border-color: #4a90d9; }
.ctrl.xp .cicon { background: #26456f; color: #9ccfff; }
.ctrl.reroll { background: linear-gradient(180deg, #2d2716, #1a1710); }
.ctrl.reroll:hover:not(:disabled) { border-color: #b79a3f; }
.ctrl.reroll .cicon { background: #5c4a12; color: #f0d675; }
.cards { display: flex; gap: 6px; flex: 1; }
.cards { display: flex; gap: 5px; }
/* Maße aus der Vorlage: 132 x 78, der Splash füllt die Karte. */
.card {
flex: 1;
flex: none;
position: relative;
border: 2px solid #2a3145;
border-radius: 8px;
background: #12141d;
height: 130px;
border-radius: 5px;
background: #0d1017;
width: var(--card-w);
aspect-ratio: 1.7;
cursor: pointer;
overflow: hidden;
}
.card:hover:not(.empty) { filter: brightness(1.15); }
.card.empty { cursor: default; }
.card.unaffordable { opacity: 0.5; }
.card.upgrade { box-shadow: 0 0 10px #ffd75e88; }
.card:hover:not(.empty) { filter: brightness(1.18); }
.card.empty { cursor: default; background: #0a0c12; border: 1px solid #1e2534; box-shadow: inset 0 0 18px #000; }
.card.unaffordable { opacity: 0.55; }
.art { position: absolute; inset: 0; background-size: cover; background-position: center 20%; }
.art {
position: absolute; inset: 0;
background-size: cover; background-position: center 25%;
}
.wash { position: absolute; inset: 0; }
.vals {
position: absolute; top: 4px; right: 4px;
display: flex; flex-direction: column; align-items: flex-end; gap: 2px;
font-size: 12px; font-weight: 800;
}
.off, .def, .meta {
background: rgba(4, 6, 11, 0.78);
border-radius: 3px;
padding: 0 4px;
}
.off { color: #e5a531; }
.def { color: #7de3d4; }
/* Tier-Farben wie im Spiel gewohnt: S golden, D grau-rot. */
.tier { font-weight: 900; letter-spacing: 0.5px; }
.tier.S { color: #ffb347; }
.tier.A { color: #b98cff; }
.tier.B { color: #6ea8ff; }
.tier.C { color: #9aa5b8; }
.tier.D { color: #d97a7a; }
.shine { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
.shine::after {
content: '';
position: absolute;
top: 0; bottom: 0; width: 45%;
position: absolute; top: 0; bottom: 0; width: 45%;
background: linear-gradient(105deg, transparent, rgba(255, 255, 255, 0.16), transparent);
animation: sweep 3s ease-in-out infinite;
}
@keyframes sweep {
0% { left: -50%; }
55% { left: 110%; }
100% { left: 110%; }
}
@keyframes sweep { 0% { left: -50%; } 55% { left: 110%; } 100% { left: 110%; } }
.starhint {
position: absolute;
top: 3px; left: 0; right: 0;
text-align: center;
color: #ffd75e;
text-shadow: 0 0 4px #000;
font-size: 15px;
letter-spacing: 2px;
position: absolute; top: 2px; left: 0; right: 0;
text-align: center; color: #ffd75e; text-shadow: 0 0 4px #000;
font-size: 15px; letter-spacing: 2px;
}
.traitlist {
position: absolute;
top: 4px; left: 4px;
display: flex; flex-direction: column; gap: 3px;
}
.traitlist { position: absolute; top: 5px; left: 5px; display: flex; flex-direction: column; gap: 2px; }
.traitpill {
display: inline-flex; align-items: center; gap: 4px;
background: rgba(8, 10, 18, 0.72);
border-radius: 9px;
padding: 1px 7px 1px 3px;
font-size: 10px;
width: fit-content;
background: rgba(6, 8, 14, 0.62);
border-radius: 3px; padding: 1px 6px 1px 3px;
font-size: 11px; width: fit-content;
text-shadow: 0 1px 2px #000;
}
.traitpill img { width: 13px; height: 13px; filter: brightness(0) invert(1); }
.cardbottom {
position: absolute;
left: 0; right: 0; bottom: 0;
position: absolute; left: 0; right: 0; bottom: 0;
display: flex; justify-content: space-between; align-items: center;
padding: 14px 7px 4px;
background: linear-gradient(transparent, rgba(5, 7, 12, 0.92) 55%);
padding: 4px 7px;
font-size: 12px;
}
.cname { font-weight: 700; }
.ccost { color: #ffd75e; font-weight: 700; }
.cname { font-weight: 700; text-shadow: 0 1px 3px #000; }
.ccoin { color: #ffd75e; font-weight: 700; }
.lock {
font-size: 15px;
padding: 3px 10px;
background: #141927;
}
.lock { font-size: 15px; padding: 2px 10px; background: #141927; }
.lock.locked { border-color: #ffd75e; background: #2a2618; }
</style>

View File

@@ -1,50 +1,127 @@
<script setup>
defineProps({ traits: { type: Array, required: true } })
const tierColors = ['#4a4f5d', '#a8703d', '#b9bec9', '#e8c35a', '#7de3d4']
import { computed } from 'vue'
import { store } from '../store.js'
const props = defineProps({
traits: { type: Array, required: true },
items: { type: Array, required: true },
benchSize: { type: Number, default: 9 },
})
// Riot-Stile: 1 Bronze, 3 Silber, 4 Chromatisch, 5 Gold, 6 Prismatisch.
const STYLE = {
1: { bg: '#cd7f32', edge: '#e8a463' },
3: { bg: '#c3cedb', edge: '#e6eef7' },
4: { bg: '#3fc9b4', edge: '#8ff0e2' },
5: { bg: '#f0c94d', edge: '#ffe28a' },
6: { bg: '#dcd0ff', edge: '#f2ecff' },
}
const active = computed(() => props.traits.filter((t) => t.tier > 0))
const inactive = computed(() => props.traits.filter((t) => t.tier === 0))
const style = (t) => STYLE[t.style] || STYLE[1]
// Item-Leiste: immer volle Höhe der Bank, leere Plätze bleiben als Kästchen.
const slots = computed(() => {
const s = props.items.slice(0, props.benchSize)
while (s.length < props.benchSize) s.push(null)
return s
})
</script>
<template>
<aside class="traits">
<h3>Traits</h3>
<div v-if="!traits.length" class="empty">Keine Units auf dem Board</div>
<aside class="panel">
<div class="itemrail">
<div
v-for="t in traits"
:key="t.api_name"
class="trait"
:class="{ active: t.tier > 0 }"
v-for="(it, i) in slots"
:key="i"
class="slot"
:class="{ selected: it && store.selectedItem === i, empty: !it }"
:title="it ? it.name : ''"
@click="it && (store.selectedItem = store.selectedItem === i ? null : i)"
>
<img v-if="it" :src="it.icon" :alt="it.name" />
</div>
</div>
<div class="traits">
<div v-for="t in active" :key="t.api_name" class="row on">
<span
class="hex"
:style="{ background: style(t).bg, borderColor: style(t).edge }"
>
<span class="badge" :style="{ background: tierColors[Math.min(t.tier, 4)] }">
<img v-if="t.icon" :src="t.icon" :alt="t.name" />
</span>
<span class="count">{{ t.count }}</span>
<span class="count" :style="{ color: style(t).edge }">{{ t.count }}</span>
<span class="body">
<span class="tname">{{ t.name }}</span>
<span class="bp">{{ t.breakpoints.join(' › ') }}</span>
<span class="pips">
<template v-for="(bp, i) in t.breakpoints" :key="i">
<span v-if="i" class="sep">›</span>
<span
class="pip"
:class="{ hit: i + 1 === t.tier }"
:style="i + 1 === t.tier ? { color: style(t).edge } : null"
>{{ bp.units }}</span>
</template>
</span>
</span>
</div>
<div v-for="t in inactive" :key="t.api_name" class="row off">
<span class="hex dim">
<img v-if="t.icon" :src="t.icon" :alt="t.name" />
</span>
<span class="body">
<span class="tname">{{ t.name }}</span>
<span class="pips">{{ t.count }} / {{ t.breakpoints[0]?.units ?? '–' }}</span>
</span>
</div>
<div v-if="!traits.length" class="hint">Keine Units auf dem Board</div>
</div>
</aside>
</template>
<style scoped>
.traits { display: flex; flex-direction: column; gap: 4px; }
h3 { font-size: 12px; text-transform: uppercase; color: #8a92a8; margin-bottom: 4px; }
.empty { color: #5a6178; font-size: 12px; }
.trait {
display: flex;
align-items: center;
gap: 6px;
background: #161b29;
border-radius: 6px;
padding: 4px 6px;
opacity: 0.55;
.panel { display: flex; gap: 5px; min-height: 0; }
.itemrail { display: flex; flex-direction: column; gap: 3px; flex: none; }
.slot {
width: 30px; height: 30px;
border-radius: 4px;
background: #0d1017;
border: 1px solid #232a3d;
}
.trait.active { opacity: 1; }
.badge {
width: 24px; height: 24px;
border-radius: 50%;
.slot:not(.empty) { cursor: pointer; border-color: #3a4460; }
.slot.selected { border-color: #7de3d4; box-shadow: 0 0 6px #7de3d488; }
.slot img { width: 100%; height: 100%; border-radius: 3px; }
.traits { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; overflow-y: auto; }
.row { display: flex; align-items: center; gap: 6px; padding: 2px 6px 2px 2px; border-radius: 4px; }
.row.on { background: linear-gradient(90deg, #2b3243, #1a1f2c 70%); }
.row.off { background: #171b26; }
.hex {
flex: none;
width: 26px; height: 29px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
border: 2px solid transparent;
}
.badge img { width: 16px; height: 16px; filter: brightness(0) invert(1); }
.count { font-weight: 700; min-width: 14px; text-align: center; }
.tname { flex: 1; font-size: 13px; }
.bp { color: #7d86a0; font-size: 11px; }
/* Aktive Hexes tragen das Icon dunkel auf der Stufenfarbe, wie im Spiel. */
.hex img { width: 17px; height: 17px; filter: brightness(0); }
.hex.dim { background: #2a2f3d; }
.hex.dim img { filter: brightness(0) invert(1); opacity: 0.42; }
.count { flex: none; font-weight: 800; font-size: 14px; width: 12px; text-align: center; }
.body { display: flex; flex-direction: column; line-height: 1.2; min-width: 0; }
.tname { font-size: 12px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.row.off .tname { color: #79808f; font-weight: 500; }
.pips { font-size: 10px; color: #6d7484; display: flex; gap: 3px; }
.pip.hit { font-weight: 800; }
.sep { opacity: 0.5; }
.hint { color: #6d7484; font-size: 12px; padding: 4px 2px; }
</style>

View File

@@ -1,50 +1,89 @@
<script setup>
defineProps({
unit: { type: Object, required: true },
sellable: { type: Boolean, default: false },
size: { type: Number, default: 72 },
})
defineEmits(['click', 'sell'])
const fmt = (n) => Number(n).toFixed(1)
const tip = (u) => `Tier ${u.tier} — Stärke ${fmt(u.strength)}`
+ ' (1.0 = Schnitt der eigenen Kostenstufe, aus echten Spielen gelernt)'
const costColors = ['#8a8f9d', '#4caf6e', '#4a90d9', '#b153d8', '#e5a531']
</script>
<template>
<div class="chip" :style="{ borderColor: costColors[unit.cost - 1] }" @click="$emit('click')">
<img v-if="unit.icon" :src="unit.icon" :alt="unit.name" class="portrait" />
<div class="info">
<div
class="tile"
:style="{ width: size + 'px', height: size + 'px',
borderColor: costColors[unit.cost - 1] }"
:title="`${unit.name} — Kampfkraft ${unit.power} · Rechtsklick verkauft`"
@click="$emit('click')"
@contextmenu.prevent="$emit('sell')"
>
<img v-if="unit.icon" :src="unit.icon" :alt="unit.name" class="art" />
<span class="stars">{{ '★'.repeat(unit.stars) }}</span>
<span class="name">{{ unit.name }}</span>
<span v-if="unit.items.length" class="items">
<img v-for="(it, i) in unit.items" :key="i" :src="it.icon" :alt="it.name" :title="it.name" />
</span>
</div>
<button v-if="sellable" class="sell" title="Verkaufen" @click.stop="$emit('sell')">✕</button>
<span v-if="unit.tier" class="meta tier" :class="unit.tier" :title="tip(unit)">
{{ unit.tier }}
</span>
<span class="vals">
<span class="off">{{ fmt(unit.off) }}</span>
<span class="def">{{ fmt(unit.def) }}</span>
</span>
</div>
</template>
<style scoped>
.chip {
display: flex;
align-items: center;
gap: 6px;
background: #1a2030;
border: 2px solid;
border-radius: 8px;
padding: 3px 6px 3px 3px;
cursor: pointer;
.tile {
position: relative;
border: 2px solid;
border-radius: 6px;
background: #0d1017;
overflow: hidden;
cursor: pointer;
}
.chip:hover { filter: brightness(1.2); }
.portrait { width: 36px; height: 36px; border-radius: 6px; object-fit: cover; }
.info { display: flex; flex-direction: column; line-height: 1.15; }
.stars { color: #ffd75e; font-size: 10px; letter-spacing: 1px; }
.name { font-size: 12px; }
.items { display: flex; gap: 2px; margin-top: 1px; }
.items img { width: 14px; height: 14px; border-radius: 3px; }
.sell {
.tile:hover { filter: brightness(1.2); }
.art { width: 100%; height: 100%; object-fit: cover; display: block; }
/* Dunkle Streifen oben und unten, damit Sterne und Werte auf jedem
Portrait lesbar bleiben — helle Icons haben sie sonst verschluckt. */
.stars {
position: absolute; top: 0; left: 0; right: 0;
padding: 1px 0 3px;
background: linear-gradient(rgba(4, 6, 11, 0.8), transparent);
text-align: center; font-size: 14px; letter-spacing: 1px;
color: #ffd75e; text-shadow: 0 0 4px #000, 0 1px 2px #000;
}
.items {
position: absolute; bottom: 20px; left: 2px;
display: flex; gap: 1px;
}
.items img { width: 15px; height: 15px; border-radius: 2px; }
.vals {
position: absolute; bottom: 0; left: 0; right: 0;
display: flex; justify-content: space-between;
padding: 3px 4px;
font-size: 12px; font-weight: 800;
}
.off, .def {
background: rgba(4, 6, 11, 0.8);
border-radius: 3px;
padding: 0 4px;
font-size: 10px;
background: #3d2330;
border-color: #6a3a50;
margin-left: 2px;
}
.off { color: #e5a531; }
.def { color: #7de3d4; }
/* Meta oben rechts — unten ist die Zeile mit Off/Def schon voll. */
.meta {
position: absolute; top: 2px; right: 2px;
background: rgba(4, 6, 11, 0.8);
border-radius: 3px; padding: 0 3px;
font-size: 11px; font-weight: 800;
}
/* Tier-Farben wie im Spiel gewohnt: S golden, D grau-rot. */
.tier { font-weight: 900; letter-spacing: 0.5px; }
.tier.S { color: #ffb347; }
.tier.A { color: #b98cff; }
.tier.B { color: #6ea8ff; }
.tier.C { color: #9aa5b8; }
.tier.D { color: #d97a7a; }
</style>

View File

@@ -1,23 +1,37 @@
import { reactive } from 'vue'
import { newGame, doAction } from './api.js'
import { newGame, doAction, fetchReplay } from './api.js'
export const store = reactive({
gameId: null,
state: null,
error: null,
selectedItem: null,
dragging: null,
// Kampf-Protokoll der letzten Runde, nur wenn es angefragt wurde.
replay: null,
async start() {
this.error = null
this.selectedItem = null
try {
const data = await newGame()
this.gameId = data.game_id
this.state = data
} catch (e) {
this.error = e.message
}
},
async act(action) {
this.error = null
// Jede Aktion ändert das Board — ein alter Kampf gehört dann weg.
// Sonst verdeckt das Protokoll das Hex-Feld und man kann nichts
// mehr aufstellen.
if (action.action !== 'lock') this.replay = null
try {
this.state = await doAction(this.gameId, action)
// Item-Indizes verschieben sich bei jeder Aktion — Auswahl verfällt.
this.selectedItem = null
} catch (e) {
this.error = e.message
setTimeout(() => { if (this.error === e.message) this.error = null }, 2500)
@@ -29,7 +43,19 @@ export const store = reactive({
reroll() { return this.act({ action: 'reroll' }) },
buyXp() { return this.act({ action: 'buy_xp' }) },
move(where, idx) { return this.act({ action: 'move', where, idx }) },
next() { return this.act({ action: 'next' }) },
place(where, idx, hex) { return this.act({ action: 'place', where, idx, hex }) },
async next() {
return this.act({ action: 'next' })
},
async loadReplay() {
try {
this.replay = await fetchReplay(this.gameId)
} catch {
// Kein Gegner oder leeres Board: dann gibt es nichts zu zeigen.
this.replay = null
}
},
pickAugment(choice) { return this.act({ action: 'pick_augment', choice }) },
equip(boardIdx) {