From f41ac76b5789bcac009f7970da761df1482979bb Mon Sep 17 00:00:00 2001 From: team3 Date: Thu, 23 Jul 2026 11:54:46 +0200 Subject: [PATCH] update --- backend/tests/test_combat.py | 92 ++++++++++++++++++ backend/tests/test_game.py | 37 ++++---- backend/tests/test_score.py | 98 +++++++------------ backend/tft/cli.py | 27 +----- backend/tft/constants/set17.toml | 7 +- backend/tft/model/artifact.py | 27 +----- backend/tft/model/baseline.py | 64 +------------ backend/tft/model/calibrate.py | 35 ------- backend/tft/model/score.py | 155 +++++++------------------------ backend/tft/model/statsheet.py | 24 ++++- backend/tft/sim/combat.py | 112 ++++++++++++++++++---- backend/tft/sim/game.py | 39 +++++--- 12 files changed, 331 insertions(+), 386 deletions(-) create mode 100644 backend/tests/test_combat.py diff --git a/backend/tests/test_combat.py b/backend/tests/test_combat.py new file mode 100644 index 0000000..405b322 --- /dev/null +++ b/backend/tests/test_combat.py @@ -0,0 +1,92 @@ +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 diff --git a/backend/tests/test_game.py b/backend/tests/test_game.py index 81b3a7e..e2c3788 100644 --- a/backend/tests/test_game.py +++ b/backend/tests/test_game.py @@ -173,17 +173,24 @@ def test_pairing(): def test_ghost_source_takes_no_damage(art, cfg): game = Game(art, cfg, seed=3) - game.bots[0].hp = 0 - game.bots[0].placement = 8 - game.bots[0].board, game.bots[0].bench = [], [] - # 7 Lebende -> jede PvP-Runde hat einen Ghost-Kampf; HP-Summe der - # Ghost-Quelle darf nur durch ihren echten Kampf sinken (kein Doppelschaden). - hp_before = {p.name: p.hp for p in game.players} - while game.round["kind"] != "pvp": - game.step() + # Auf 3 Lebende reduzieren -> genau 1 Paar + 1 Ghost-Kampf. + for b in game.bots[2:]: + b.hp, b.placement, b.board, b.bench = 0, 8, [], [] + boards = [ + (game.player, [{"api_name": "U5_0", "stars": 3, "items": []}]), + (game.bots[0], [{"api_name": "U3_0", "stars": 2, "items": []}]), + (game.bots[1], [{"api_name": "U1_0", "stars": 1, "items": []}]), + ] + for p, board in boards: + p.board, p.bench, p.gold = board, [], 0 + game.idx = next(i for i, r in enumerate(game.rounds) if r["kind"] == "pvp") + hp_before = {p.name: p.hp for p in game._alive()} game.step() - drops = sum(1 for p in game.players if p.hp < hp_before[p.name]) - assert drops <= 4 # max. 3 Paar-Verlierer + 1 Ghost-Verlierer + # Nur Paar-Verlierer + ggf. Ghost-Verlierer nehmen Schaden; die + # Ghost-Quelle darf durch ihren Klon-Kampf nicht getroffen werden. + drops = sum(1 for p in game.players if p.name in hp_before + and p.hp < hp_before[p.name]) + assert drops <= 2 def test_pve_freezes_streak_and_pays_no_win_bonus(art, cfg): @@ -279,13 +286,3 @@ def test_augment_effects_apply(art, cfg): game.pick_augment(0) assert len(game.player.bench) == bench_before + 2 assert sum(game.pool.available(a) for a in game.pool.copies) == pool_before - 2 - - -def test_team_buff_augment_raises_score(art, cfg): - from tft.model.score import score_mechanical - buffed_art = {**art, "augment_specs": { - "AUG_BUFF": {"atoms": [{"kind": "team_buff", "stat": "hp_pct", "value": 0.3}], - "offerable": True}}} - board = [{"api_name": "U2_0", "stars": 2, "items": []}] - assert score_mechanical(board, ["AUG_BUFF"], buffed_art) > \ - score_mechanical(board, [], buffed_art) diff --git a/backend/tests/test_score.py b/backend/tests/test_score.py index c596203..45a6abf 100644 --- a/backend/tests/test_score.py +++ b/backend/tests/test_score.py @@ -2,7 +2,7 @@ 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, score_board +from tft.model.score import active_trait_tiers, board_profiles, score_board STATIC = { "units": { @@ -28,8 +28,15 @@ STATIC = { }, } -ARTIFACT = {"meta": {"set": 17}, "static": {**STATIC, "items": {}, "augments": {}}, - "roles": {}, "learned": {}} +ARTIFACT = { + "meta": {"set": 17}, + "static": { + **STATIC, + "items": {"TFT_Item_InfinityEdge": {"effects": {"AD": 0.35, "CritChance": 35.0}}}, + "augments": {}, + }, + "roles": {}, +} def test_unit_value(): @@ -57,19 +64,30 @@ def test_score_ordering(): assert score_board(strong, [], ARTIFACT) > score_board(weak, [], ARTIFACT) -def test_active_trait_beats_inactive(): - pair = [{"api_name": "TFT17_A", "stars": 1, "items": []}, - {"api_name": "TFT17_B", "stars": 1, "items": []}] - solo_sum = score_board(pair[:1], [], ARTIFACT) + score_board(pair[1:], [], ARTIFACT) - assert score_board(pair, [], ARTIFACT) > solo_sum - - 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) @@ -88,39 +106,7 @@ def test_craftable_item_pool(): assert craftable_items(items) == ["TFT_Item_InfinityEdge"] -def test_stat_mults_rank_within_cost(): - from tft.model.baseline import compute_stat_mults - - def unit(hp, armor, ad, aspd): - return {"cost": 2, "traits": [], "role": "Tank", - "stats": {"hp": hp, "armor": armor, "magicResist": armor, - "damage": ad, "attackSpeed": aspd, "mana": 60, - "initialMana": 0, "range": 1}} - - units = {"tanky": unit(900, 60, 45, 0.6), "avg": unit(700, 40, 55, 0.7), - "weak": unit(550, 25, 45, 0.6)} - mults = compute_stat_mults(units) - assert mults["tanky"] > mults["avg"] > mults["weak"] - assert all(0.9 <= m <= 1.1 for m in mults.values()) - - -def test_stat_mults_identical_units_are_neutral(): - from tft.model.baseline import compute_stat_mults - from tests.test_sim import make_static - - mults = compute_stat_mults(make_static()["units"]) - assert all(m == 1.0 for m in mults.values()) - - -def test_stat_mult_raises_score_legacy(): - from tft.model.score import score_board_legacy as score_board - - art = {**ARTIFACT, "stat_mults": {"TFT17_B": 1.1}} - board = [{"api_name": "TFT17_B", "stars": 1, "items": []}] - assert score_board(board, [], art) > score_board(board, [], ARTIFACT) - - -def _mech_artifact(): +def _built_artifact(): from tft.model import artifact as artifact_mod static = { @@ -143,30 +129,18 @@ def _mech_artifact(): return artifact_mod.build(static) -def test_mechanical_balanced_beats_lopsided(): - from tft.model.score import score_mechanical - art = _mech_artifact() +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_mechanical(balanced, [], art) > score_mechanical(tanks, [], art) + assert score_board(balanced, [], art) > score_board(tanks, [], art) -def test_mechanical_items_and_stars_raise_strength(): - from tft.model.score import score_mechanical - art = _mech_artifact() +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_mechanical(starred, [], art) > score_mechanical(base, [], art) - assert score_mechanical(equipped, [], art) > score_mechanical(base, [], art) - - -def test_mechanical_recognized_trait_buffs_stats(): - from tft.model.score import score_mechanical - art = _mech_artifact() - pair = [{"api_name": "TANK", "stars": 1, "items": []}, - {"api_name": "CARRY", "stars": 1, "items": []}] - solo_sum = (score_mechanical(pair[:1], [], art) - + score_mechanical(pair[1:], [], art)) - assert score_mechanical(pair, [], art) > solo_sum + assert score_board(starred, [], art) > score_board(base, [], art) + assert score_board(equipped, [], art) > score_board(base, [], art) diff --git a/backend/tft/cli.py b/backend/tft/cli.py index 4111338..68a3c76 100644 --- a/backend/tft/cli.py +++ b/backend/tft/cli.py @@ -22,9 +22,7 @@ def main() -> None: sub.add_parser("extract", help="extract endboards from raw matches") sub.add_parser("build-artifact", help="build analysis.json from static data + endboards") - p_cal = sub.add_parser("calibrate", help="backtest score vs real placements (holdout)") - p_cal.add_argument("--compare", action="store_true", - help="A/B legacy vs. mechanical scorer + tau-Fit") + sub.add_parser("calibrate", help="backtest score vs real placements (holdout)") p_auto = sub.add_parser("autoplay", help="run scripted games headless") p_auto.add_argument("--policy", choices=["afk", "econ"], default="econ") @@ -83,28 +81,13 @@ def main() -> None: elif args.command == "calibrate": from tft import db from tft.model import artifact as artifact_mod - from tft.model.calibrate import calibrate, fit_tau - from tft.model.score import score_board_legacy, score_mechanical - from tft.staticdata.fetch import load_static + from tft.model.calibrate import calibrate conn = db.connect() - learned_art = artifact_mod.load(current_set()) - baseline_art = artifact_mod.build(load_static(), {}) - r_base = calibrate(conn, baseline_art, holdout_only=True) - r_learned = calibrate(conn, learned_art, holdout_only=True) - print(f"holdout matches: {r_learned['matches']}") - print(f"spearman baseline: {r_base['mean_spearman']:.3f}") - print(f"spearman learned: {r_learned['mean_spearman']:.3f}") - if args.compare: - r_leg = calibrate(conn, learned_art, holdout_only=True, score_fn=score_board_legacy) - r_mech = calibrate(conn, learned_art, holdout_only=True, score_fn=score_mechanical) - delta = r_mech["mean_spearman"] - r_leg["mean_spearman"] - tau = fit_tau(conn, learned_art, score_mechanical) - print(f"A/B legacy: {r_leg['mean_spearman']:.3f}") - print(f"A/B mechanical: {r_mech['mean_spearman']:.3f} (delta {delta:+.3f})") - print(f"tau-fit (mechanical): {tau['tau']} über {tau['pairs']} Paare, " - f"log-loss {tau.get('log_loss')}") + result = calibrate(conn, artifact_mod.load(current_set()), holdout_only=True) conn.close() + print(f"holdout matches: {result['matches']}") + print(f"spearman: {result['mean_spearman']:.3f}") elif args.command == "autoplay": from tft.constants.loader import load_constants diff --git a/backend/tft/constants/set17.toml b/backend/tft/constants/set17.toml index ba4c17b..17ff2c3 100644 --- a/backend/tft/constants/set17.toml +++ b/backend/tft/constants/set17.toml @@ -80,7 +80,6 @@ hp_multiplier = 1.8 ad_multiplier = 1.5 [combat] -# p_win = 1 / (1 + exp(-(score_a - score_b) / tau)). -# tau = 0.28: Log-Loss-Fit über 1064 Holdout-Platzierungspaare (mechanical Scorer). -tau = 0.28 -variance = 0.08 +# Deterministische DPS-Simulation mit 60 FPS; Zeitlimit 30 s. +# Bei Ablauf gewinnt die Seite mit mehr verbleibender Gesamt-Defense. +max_frames = 1800 diff --git a/backend/tft/model/artifact.py b/backend/tft/model/artifact.py index e0e08d2..fedd123 100644 --- a/backend/tft/model/artifact.py +++ b/backend/tft/model/artifact.py @@ -47,27 +47,12 @@ def component_pool(static_items: dict) -> list[str]: return sorted(c for c, n in counts.items() if n >= 3) -def tier_profiles(static: dict, roles: dict) -> dict: - """Mittleres Mechanik-Profil (eHP/DPS, 1★, itemlos) pro Kostenstufe.""" - from tft.model import statsheet - - by_cost: dict[int, list] = {} - for api, unit in static["units"].items(): - profile = statsheet.unit_stats( - unit, 1, [], static["items"], None, roles.get(api) - ) - by_cost.setdefault(unit["cost"], []).append(profile) - return { - str(cost): { - "ehp": sum(p["ehp"] for p in profiles) / len(profiles), - "dps": max(sum(p["dps"] for p in profiles) / len(profiles), 1.0), - } - for cost, profiles in by_cost.items() - } - - def spell_dps_cap(static: dict, roles: dict) -> float | None: - """90. Perzentil der Spell-DPS aller Units (1★, itemlos) — Ausreißer-Guard.""" + """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( @@ -107,8 +92,6 @@ def build(static: dict, learned: dict | None = None, extra_meta: dict | None = N "augments": static["augments"], }, "roles": roles, - "stat_mults": baseline.compute_stat_mults(static["units"]), - "tier_profiles": tier_profiles(static, roles), "spell_dps_cap": spell_dps_cap(static, roles), "item_pool": craftable_items(static["items"]), "component_pool": component_pool(static["items"]), diff --git a/backend/tft/model/baseline.py b/backend/tft/model/baseline.py index a4988fc..bd3d050 100644 --- a/backend/tft/model/baseline.py +++ b/backend/tft/model/baseline.py @@ -1,56 +1,10 @@ -"""Rule-based baseline: unit values, stat proxies, role classification.""" +"""Rule-based baseline: unit values and role classification.""" def unit_value(cost: int, stars: int) -> float: return cost * 3 ** (stars - 1) -def stat_proxies(stats: dict) -> dict: - # cdragon liefert für manche Units null-Werte — als 0 behandeln. - hp = stats.get("hp") or 0 - resists = ((stats.get("armor") or 0) + (stats.get("magicResist") or 0)) / 2 - ad = stats.get("damage") or 0 - aspd = stats.get("attackSpeed") or 0 - mana_gap = max((stats.get("mana") or 0) - (stats.get("initialMana") or 0), 1) - return { - "ehp": hp * (1 + resists / 100), - "auto_dps": ad * aspd, - "cast_rate": aspd * 10 / mana_gap, - } - - -STAT_MULT_RANGE = (0.9, 1.1) - - -def compute_stat_mults(static_units: dict) -> dict: - """Stat-Stärke relativ zur eigenen Kostenstufe: (eHP + Offense) / 2, gekappt. - - Offense = Maximum aus Auto-DPS und Cast-Rate (je normalisiert), damit - Caster nicht gegen Auto-Attacker abfallen. - """ - proxies = {api: stat_proxies(u["stats"]) for api, u in static_units.items()} - by_cost: dict[int, list[str]] = {} - for api, u in static_units.items(): - by_cost.setdefault(u["cost"], []).append(api) - - mults = {} - for apis in by_cost.values(): - n = len(apis) - avg = { - key: sum(proxies[a][key] for a in apis) / n or 1.0 - for key in ("ehp", "auto_dps", "cast_rate") - } - for a in apis: - ehp_norm = proxies[a]["ehp"] / avg["ehp"] - offense = max( - proxies[a]["auto_dps"] / avg["auto_dps"], - proxies[a]["cast_rate"] / avg["cast_rate"], - ) - raw = (ehp_norm + offense) / 2 - mults[a] = round(min(max(raw, STAT_MULT_RANGE[0]), STAT_MULT_RANGE[1]), 4) - return mults - - def classify_role(unit: dict) -> str: """frontline | ad_carry | ap_carry | utility, from cdragon role with stat fallback.""" role = (unit.get("role") or "").lower() @@ -64,19 +18,3 @@ def classify_role(unit: dict) -> str: return "utility" stats = unit["stats"] return "frontline" if stats["range"] <= 1 else "ad_carry" - - -# Default multipliers, replaced by learned values when the artifact has them. -DEFAULT_ITEM_MULT = 1.15 -ROLE_FIT_BONUS = 1.05 -DEFAULT_TRAIT_TIER_MULT = [1.0, 1.03, 1.07, 1.12, 1.20] # index = reached breakpoint ordinal - -# Item tags that fit a role (checked against item api_name, crude but stable). -AD_HINTS = ("Deathblade", "InfinityEdge", "GiantSlayer", "LastWhisper", "RunaansHurricane", "GuinsoosRageblade") -AP_HINTS = ("RabadonsDeathcap", "ArchangelsStaff", "JeweledGauntlet", "HextechGunblade", "NashorsTooth", "Morellonomicon") -TANK_HINTS = ("BrambleVest", "DragonsClaw", "WarmogsArmor", "GargoyleStoneplate", "Redemption", "SunfireCape") - - -def item_fits_role(item_api_name: str, role: str) -> bool: - hints = {"ad_carry": AD_HINTS, "ap_carry": AP_HINTS, "frontline": TANK_HINTS}.get(role, ()) - return any(h in item_api_name for h in hints) diff --git a/backend/tft/model/calibrate.py b/backend/tft/model/calibrate.py index c4baac5..0db9215 100644 --- a/backend/tft/model/calibrate.py +++ b/backend/tft/model/calibrate.py @@ -81,38 +81,3 @@ def calibrate(conn, artifact: dict, holdout_only: bool = False, score_fn=None) - "matches": n, "mean_spearman": sum(correlations) / n if n else 0.0, } - - -def fit_tau(conn, artifact: dict, score_fn) -> dict: - """Tau per Log-Loss über alle Platzierungspaare der Holdout-Matches fitten.""" - import math - - by_match = _holdout_scores(conn, artifact, True, score_fn) - pairs = [] - for players in by_match.values(): - if len(players) < 8: - continue - for i in range(len(players)): - for j in range(i + 1, len(players)): - (pl_a, s_a), (pl_b, s_b) = players[i], players[j] - mean = (s_a + s_b) / 2 - if mean <= 0: - continue - d = (s_a - s_b) / mean - pairs.append((d, pl_a < pl_b)) # kleinere Platzierung = besser - - if not pairs: - return {"tau": None, "pairs": 0} - - def log_loss(tau: float) -> float: - eps = 1e-9 - total = 0.0 - for d, a_wins in pairs: - p = 1 / (1 + math.exp(-d / tau)) - p = min(max(p, eps), 1 - eps) - total += -math.log(p if a_wins else 1 - p) - return total / len(pairs) - - taus = [t / 100 for t in range(2, 51, 2)] - best = min(taus, key=log_loss) - return {"tau": best, "pairs": len(pairs), "log_loss": round(log_loss(best), 4)} diff --git a/backend/tft/model/score.py b/backend/tft/model/score.py index d656aff..9b442c4 100644 --- a/backend/tft/model/score.py +++ b/backend/tft/model/score.py @@ -1,4 +1,9 @@ -"""The one board-scoring entry point. Sim, bots, calibration, and UI all call this.""" +"""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 @@ -27,132 +32,34 @@ def active_trait_tiers(board_units: list[dict], static_traits: dict, static_unit return tiers -def _apply_shared_multipliers(total: float, board_units: list[dict], augments: list[str], - learned: dict) -> float: - augment_mults = learned.get("augments", {}) - for augment in augments: - total *= augment_mults.get(augment, 1.0) +def unit_profile(u: dict, artifact: dict) -> dict | None: + """Kampfprofil einer Board-Unit: {"off", "def", "defensive", "unit"}. - pair_lifts = learned.get("pairs", {}) - names = sorted({u["api_name"] for u in board_units}) - lift_sum = sum( - pair_lifts.get(f"{a}|{b}", 0.0) - for i, a in enumerate(names) - for b in names[i + 1 :] + "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 total * (1 + min(max(lift_sum * 0.01, -0.10), 0.10)) + return {"off": stats["dps"], "def": stats["ehp"], + "defensive": role in ("frontline", "utility"), "unit": u} -def score_board_legacy(board_units: list[dict], augments: list[str], artifact: dict) -> float: - """Heuristik-Scorer: Kostenwert × Multiplikatoren.""" - static_units = artifact["static"]["units"] - static_traits = artifact["static"]["traits"] - learned = artifact.get("learned", {}) - unit_mults = learned.get("units", {}) - item_mults = learned.get("items", {}) - trait_mults = learned.get("traits", {}) - roles = artifact.get("roles", {}) - stat_mults = artifact.get("stat_mults", {}) - - total = 0.0 - for u in board_units: - unit = static_units.get(u["api_name"]) - if not unit: - continue - value = ( - baseline.unit_value(unit["cost"], u["stars"]) - * stat_mults.get(u["api_name"], 1.0) - * unit_mults.get(u["api_name"], 1.0) - ) - role = roles.get(u["api_name"]) or baseline.classify_role(unit) - for item in u.get("items", []): - mult = item_mults.get(item, baseline.DEFAULT_ITEM_MULT) - if baseline.item_fits_role(item, role): - mult *= baseline.ROLE_FIT_BONUS - value *= mult - total += value - - for trait, ordinal in active_trait_tiers(board_units, static_traits, static_units).items(): - default = baseline.DEFAULT_TRAIT_TIER_MULT[ - min(ordinal, len(baseline.DEFAULT_TRAIT_TIER_MULT) - 1) - ] - total *= trait_mults.get(f"{trait}@{ordinal}", default) - - return _apply_shared_multipliers(total, board_units, augments, learned) +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_mechanical(board_units: list[dict], augments: list[str], artifact: dict) -> float: - """Kampf-Approximation: strength = sqrt(Σ eHP × Σ DPS) aus exakten Stats.""" - static_units = artifact["static"]["units"] - static_items = artifact["static"]["items"] - static_traits = artifact["static"]["traits"] - learned = artifact.get("learned", {}) - unit_mults = learned.get("units", {}) - item_mults = learned.get("items", {}) - trait_mults = learned.get("traits", {}) - roles = artifact.get("roles", {}) - - tiers = active_trait_tiers(board_units, static_traits, static_units) - team_buffs, recognized = statsheet.trait_buffs(tiers, static_traits) - # Augment-Team-Buffs (z.B. "+35 Health für dein Team") in die Stats mischen. - for a in augments: - for atom in artifact.get("augment_specs", {}).get(a, {}).get("atoms", []): - if atom["kind"] == "team_buff": - team_buffs[atom["stat"]] += atom["value"] - spell_cap = artifact.get("spell_dps_cap") - tier_profiles = artifact.get("tier_profiles", {}) - - # Eigenschafts-Bewertung: eHP = defensiv, DPS = offensiv. Der Stufen-Maßstab - # ist der gemessene Tier-Durchschnitt; Ratio-Cap fängt Extraktionsfehler. - RATIO_CAP = (0.5, 2.0) - STAR_VALUE = 3.0 # Kopienwert pro Sternstufe (Endboard-validiert) - - total_ehp = 0.0 - total_dps = 0.0 - for u in board_units: - unit = static_units.get(u["api_name"]) - if not unit: - continue - profile = statsheet.unit_stats( - unit, u["stars"], u.get("items", []), static_items, - team_buffs, roles.get(u["api_name"]), spell_cap=spell_cap, - ) - mult = unit_mults.get(u["api_name"], 1.0) - ref = tier_profiles.get(str(unit["cost"])) - if ref: - star_ehp = statsheet.HP_STAR_MULT ** (u["stars"] - 1) - star_dps = statsheet.AD_STAR_MULT ** (u["stars"] - 1) - r_ehp = min(max(profile["ehp"] / (ref["ehp"] * star_ehp), RATIO_CAP[0]), RATIO_CAP[1]) - r_dps = min(max(profile["dps"] / (ref["dps"] * star_dps), RATIO_CAP[0]), RATIO_CAP[1]) - star = STAR_VALUE ** (u["stars"] - 1) - total_ehp += mult * ref["ehp"] * star * r_ehp - total_dps += mult * ref["dps"] * star * r_dps - else: - total_ehp += mult * profile["ehp"] - total_dps += mult * profile["dps"] - - # /100: nur Anzeige-Skalierung, für den Kampfvergleich irrelevant. - strength = (total_ehp * total_dps) ** 0.5 / 100 - - for trait, ordinal in tiers.items(): - key = f"{trait}@{ordinal}" - if trait in recognized: - # Buff steckt schon in den Stats — nur gelerntes Residuum. - strength *= trait_mults.get(key, 1.0) - else: - default = baseline.DEFAULT_TRAIT_TIER_MULT[ - min(ordinal, len(baseline.DEFAULT_TRAIT_TIER_MULT) - 1) - ] - strength *= trait_mults.get(key, default) - - for u in board_units: - for item in u.get("items", []): - modeled = statsheet.item_is_modeled(static_items.get(item)) - strength *= item_mults.get(item, 1.0 if modeled else baseline.DEFAULT_ITEM_MULT) - - return _apply_shared_multipliers(strength, board_units, augments, learned) - - -# Aktiver Scorer: mechanical (A/B 23.07.: 0.724 vs. legacy 0.695 auf 38 Holdout-Matches). -# Legacy bleibt für `calibrate --compare` erhalten. -score_board = score_mechanical +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 diff --git a/backend/tft/model/statsheet.py b/backend/tft/model/statsheet.py index f199c94..7fe13b5 100644 --- a/backend/tft/model/statsheet.py +++ b/backend/tft/model/statsheet.py @@ -13,8 +13,11 @@ 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") + "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. @@ -25,7 +28,8 @@ _SKIP_TOKENS = {"duration", "threshold", "rounds", "per", "num", "gold", 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, "dr": 0.0, "mana_regen": 0.0, "damage_amp": 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: @@ -50,6 +54,12 @@ def _apply_item_effects(item_apis: list[str], static_items: dict, acc: dict) -> 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 @@ -139,6 +149,7 @@ def unit_stats(unit: dict, stars: int, item_apis: list[str], static_items: dict, 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 @@ -156,10 +167,13 @@ def unit_stats(unit: dict, stars: int, item_apis: list[str], static_items: dict, 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: - # Ausreißer-Guard: Spell-Rohwerte sind zwischen Champions nicht - # vergleichbar (per-Hit vs. total) — Kappung am Populations-Perzentil. - spell_dps = min(spell_dps, spell_cap * AD_STAR_MULT ** (stars - 1)) + limit = max(limit, spell_cap * AD_STAR_MULT ** (stars - 1)) + spell_dps = min(spell_dps, limit) return { "ehp": ehp, diff --git a/backend/tft/sim/combat.py b/backend/tft/sim/combat.py index 5c1ae54..3436cee 100644 --- a/backend/tft/sim/combat.py +++ b/backend/tft/sim/combat.py @@ -1,27 +1,107 @@ -"""Combat = score comparison. Relative score difference -> win probability -> damage.""" +"""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 -def win_probability(score_a: float, score_b: float, cfg: dict) -> float: - mean = (score_a + score_b) / 2 - if mean <= 0: - return 0.5 - d = (score_a - score_b) / mean - return 1 / (1 + math.exp(-d / cfg["combat"]["tau"])) +@dataclass +class FightResult: + winner: str | None # "a" | "b" | None = Unentschieden + frames: int + survivors_a: list[dict] + survivors_b: list[dict] -def resolve(score_a: float, score_b: float, cfg: dict, rng: random.Random) -> bool: - """True if A wins. Adds noise on top of the probability.""" - noise = rng.gauss(0, cfg["combat"]["variance"]) - return rng.random() < min(max(win_probability(score_a, score_b, cfg) + noise, 0.02), 0.98) +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 damage(stage: int, winner_score: float, loser_score: float, winner_units: int, cfg: dict) -> int: +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)] - mean = (winner_score + loser_score) / 2 or 1 - margin = abs(winner_score - loser_score) / mean - surviving = max(1, round(winner_units * min(margin, 1.0))) - return base + d["per_surviving_unit"] * surviving + return base + d["per_surviving_unit"] * len(survivors) diff --git a/backend/tft/sim/game.py b/backend/tft/sim/game.py index 35b7db5..dbc66b2 100644 --- a/backend/tft/sim/game.py +++ b/backend/tft/sim/game.py @@ -2,6 +2,7 @@ import random +from tft.model.score import board_profiles from tft.sim import combat, economy, player, policy from tft.sim.player import BENCH_SIZE, MAX_ITEMS_PER_UNIT, InvalidAction, PlayerState # noqa: F401 from tft.sim.pool import Pool @@ -227,17 +228,19 @@ class Game: pairs, odd, ghost_src = pair_players(self._alive(), self.rng) for a, b in pairs: a_wins = self._fight(rnd, a, b) - results[a.name] = a_wins - results[b.name] = not a_wins + # Unentschieden (None) zählt für beide als nicht gewonnen. + results[a.name] = a_wins is True + results[b.name] = a_wins is False if odd is not None: # Ghost-Kampf: Klon-Board, Schaden nur beim echten Spieler. - s_odd = player.score(odd, self.artifact) - s_ghost = player.score(ghost_src, self.artifact) - won = combat.resolve(s_odd, s_ghost, self.cfg, self.rng) + res = combat.fight(board_profiles(odd.board, self.artifact), + board_profiles(ghost_src.board, self.artifact), + self.rng, self.cfg) + won = res.winner == "a" results[odd.name] = won if not won: - dmg = combat.damage(rnd["stage"], s_ghost, s_odd, - len(ghost_src.board), self.cfg) + survivors = res.survivors_b if res.winner == "b" else [] + dmg = combat.player_damage(rnd["stage"], survivors, self.cfg) odd.hp -= dmg if odd is self.player: self.log.append( @@ -246,13 +249,23 @@ class Game: self.log.append(f"{rnd['label']}: Sieg vs Ghost ({ghost_src.name})") return results - def _fight(self, rnd: dict, a: PlayerState, b: PlayerState) -> bool: - sa = player.score(a, self.artifact) - sb = player.score(b, self.artifact) - a_wins = combat.resolve(sa, sb, self.cfg, self.rng) + 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) + if res.winner is None: + # Unentschieden: beide nehmen den Basis-Schaden der Stage. + dmg = combat.player_damage(rnd["stage"], [], self.cfg) + a.hp -= dmg + b.hp -= dmg + if a is self.player or b is self.player: + other = b if a is self.player else a + self.log.append(f"{rnd['label']}: Unentschieden vs {other.name} (-{dmg} HP)") + return None + a_wins = res.winner == "a" winner, loser = (a, b) if a_wins else (b, a) - w_score, l_score = (sa, sb) if a_wins else (sb, sa) - dmg = combat.damage(rnd["stage"], w_score, l_score, len(winner.board), self.cfg) + survivors = res.survivors_a if a_wins else res.survivors_b + dmg = combat.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: