This commit is contained in:
team3
2026-07-23 11:54:46 +02:00
parent f7e569e519
commit f41ac76b57
12 changed files with 331 additions and 386 deletions

View File

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

View File

@@ -173,17 +173,24 @@ def test_pairing():
def test_ghost_source_takes_no_damage(art, cfg): def test_ghost_source_takes_no_damage(art, cfg):
game = Game(art, cfg, seed=3) game = Game(art, cfg, seed=3)
game.bots[0].hp = 0 # Auf 3 Lebende reduzieren -> genau 1 Paar + 1 Ghost-Kampf.
game.bots[0].placement = 8 for b in game.bots[2:]:
game.bots[0].board, game.bots[0].bench = [], [] b.hp, b.placement, b.board, b.bench = 0, 8, [], []
# 7 Lebende -> jede PvP-Runde hat einen Ghost-Kampf; HP-Summe der boards = [
# Ghost-Quelle darf nur durch ihren echten Kampf sinken (kein Doppelschaden). (game.player, [{"api_name": "U5_0", "stars": 3, "items": []}]),
hp_before = {p.name: p.hp for p in game.players} (game.bots[0], [{"api_name": "U3_0", "stars": 2, "items": []}]),
while game.round["kind"] != "pvp": (game.bots[1], [{"api_name": "U1_0", "stars": 1, "items": []}]),
game.step() ]
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() game.step()
drops = sum(1 for p in game.players if p.hp < hp_before[p.name]) # Nur Paar-Verlierer + ggf. Ghost-Verlierer nehmen Schaden; die
assert drops <= 4 # max. 3 Paar-Verlierer + 1 Ghost-Verlierer # 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): 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) game.pick_augment(0)
assert len(game.player.bench) == bench_before + 2 assert len(game.player.bench) == bench_before + 2
assert sum(game.pool.available(a) for a in game.pool.copies) == pool_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)

View File

@@ -2,7 +2,7 @@ import pytest
from tft.model.baseline import classify_role, unit_value from tft.model.baseline import classify_role, unit_value
from tft.model.calibrate import spearman 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 = { STATIC = {
"units": { "units": {
@@ -28,8 +28,15 @@ STATIC = {
}, },
} }
ARTIFACT = {"meta": {"set": 17}, "static": {**STATIC, "items": {}, "augments": {}}, ARTIFACT = {
"roles": {}, "learned": {}} "meta": {"set": 17},
"static": {
**STATIC,
"items": {"TFT_Item_InfinityEdge": {"effects": {"AD": 0.35, "CritChance": 35.0}}},
"augments": {},
},
"roles": {},
}
def test_unit_value(): def test_unit_value():
@@ -57,19 +64,30 @@ def test_score_ordering():
assert score_board(strong, [], ARTIFACT) > score_board(weak, [], ARTIFACT) 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(): def test_items_increase_score():
bare = [{"api_name": "TFT17_B", "stars": 1, "items": []}] bare = [{"api_name": "TFT17_B", "stars": 1, "items": []}]
with_item = [{"api_name": "TFT17_B", "stars": 1, "items": ["TFT_Item_InfinityEdge"]}] with_item = [{"api_name": "TFT17_B", "stars": 1, "items": ["TFT_Item_InfinityEdge"]}]
assert score_board(with_item, [], ARTIFACT) > score_board(bare, [], ARTIFACT) 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(): def test_spearman():
assert spearman([1, 2, 3, 4], [10, 20, 30, 40]) == pytest.approx(1.0) 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], [40, 30, 20, 10]) == pytest.approx(-1.0)
@@ -88,39 +106,7 @@ def test_craftable_item_pool():
assert craftable_items(items) == ["TFT_Item_InfinityEdge"] assert craftable_items(items) == ["TFT_Item_InfinityEdge"]
def test_stat_mults_rank_within_cost(): def _built_artifact():
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():
from tft.model import artifact as artifact_mod from tft.model import artifact as artifact_mod
static = { static = {
@@ -143,30 +129,18 @@ def _mech_artifact():
return artifact_mod.build(static) return artifact_mod.build(static)
def test_mechanical_balanced_beats_lopsided(): def test_balanced_beats_lopsided():
from tft.model.score import score_mechanical art = _built_artifact()
art = _mech_artifact()
balanced = [{"api_name": "TANK", "stars": 1, "items": []}, balanced = [{"api_name": "TANK", "stars": 1, "items": []},
{"api_name": "CARRY", "stars": 1, "items": []}] {"api_name": "CARRY", "stars": 1, "items": []}]
tanks = [{"api_name": "TANK", "stars": 1, "items": []} for _ in range(2)] 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(): def test_items_and_stars_raise_strength():
from tft.model.score import score_mechanical art = _built_artifact()
art = _mech_artifact()
base = [{"api_name": "CARRY", "stars": 1, "items": []}] base = [{"api_name": "CARRY", "stars": 1, "items": []}]
starred = [{"api_name": "CARRY", "stars": 2, "items": []}] starred = [{"api_name": "CARRY", "stars": 2, "items": []}]
equipped = [{"api_name": "CARRY", "stars": 1, "items": ["SWORD"]}] equipped = [{"api_name": "CARRY", "stars": 1, "items": ["SWORD"]}]
assert score_mechanical(starred, [], art) > score_mechanical(base, [], art) assert score_board(starred, [], art) > score_board(base, [], art)
assert score_mechanical(equipped, [], art) > score_mechanical(base, [], art) assert score_board(equipped, [], art) > score_board(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

View File

@@ -22,9 +22,7 @@ def main() -> None:
sub.add_parser("extract", help="extract endboards from raw matches") sub.add_parser("extract", help="extract endboards from raw matches")
sub.add_parser("build-artifact", help="build analysis.json from static data + endboards") 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)") 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")
p_auto = sub.add_parser("autoplay", help="run scripted games headless") 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("--policy", choices=["afk", "econ"], default="econ")
@@ -83,28 +81,13 @@ def main() -> None:
elif args.command == "calibrate": elif args.command == "calibrate":
from tft import db from tft import db
from tft.model import artifact as artifact_mod from tft.model import artifact as artifact_mod
from tft.model.calibrate import calibrate, fit_tau from tft.model.calibrate import calibrate
from tft.model.score import score_board_legacy, score_mechanical
from tft.staticdata.fetch import load_static
conn = db.connect() conn = db.connect()
learned_art = artifact_mod.load(current_set()) result = calibrate(conn, artifact_mod.load(current_set()), holdout_only=True)
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')}")
conn.close() conn.close()
print(f"holdout matches: {result['matches']}")
print(f"spearman: {result['mean_spearman']:.3f}")
elif args.command == "autoplay": elif args.command == "autoplay":
from tft.constants.loader import load_constants from tft.constants.loader import load_constants

View File

@@ -80,7 +80,6 @@ hp_multiplier = 1.8
ad_multiplier = 1.5 ad_multiplier = 1.5
[combat] [combat]
# p_win = 1 / (1 + exp(-(score_a - score_b) / tau)). # Deterministische DPS-Simulation mit 60 FPS; Zeitlimit 30 s.
# tau = 0.28: Log-Loss-Fit über 1064 Holdout-Platzierungspaare (mechanical Scorer). # Bei Ablauf gewinnt die Seite mit mehr verbleibender Gesamt-Defense.
tau = 0.28 max_frames = 1800
variance = 0.08

View File

@@ -47,27 +47,12 @@ def component_pool(static_items: dict) -> list[str]:
return sorted(c for c, n in counts.items() if n >= 3) 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: 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 from tft.model import statsheet
values = sorted( values = sorted(
@@ -107,8 +92,6 @@ def build(static: dict, learned: dict | None = None, extra_meta: dict | None = N
"augments": static["augments"], "augments": static["augments"],
}, },
"roles": roles, "roles": roles,
"stat_mults": baseline.compute_stat_mults(static["units"]),
"tier_profiles": tier_profiles(static, roles),
"spell_dps_cap": spell_dps_cap(static, roles), "spell_dps_cap": spell_dps_cap(static, roles),
"item_pool": craftable_items(static["items"]), "item_pool": craftable_items(static["items"]),
"component_pool": component_pool(static["items"]), "component_pool": component_pool(static["items"]),

View File

@@ -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: def unit_value(cost: int, stars: int) -> float:
return cost * 3 ** (stars - 1) 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: def classify_role(unit: dict) -> str:
"""frontline | ad_carry | ap_carry | utility, from cdragon role with stat fallback.""" """frontline | ad_carry | ap_carry | utility, from cdragon role with stat fallback."""
role = (unit.get("role") or "").lower() role = (unit.get("role") or "").lower()
@@ -64,19 +18,3 @@ def classify_role(unit: dict) -> str:
return "utility" return "utility"
stats = unit["stats"] stats = unit["stats"]
return "frontline" if stats["range"] <= 1 else "ad_carry" 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)

View File

@@ -81,38 +81,3 @@ def calibrate(conn, artifact: dict, holdout_only: bool = False, score_fn=None) -
"matches": n, "matches": n,
"mean_spearman": sum(correlations) / n if n else 0.0, "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)}

View File

@@ -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 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 return tiers
def _apply_shared_multipliers(total: float, board_units: list[dict], augments: list[str], def unit_profile(u: dict, artifact: dict) -> dict | None:
learned: dict) -> float: """Kampfprofil einer Board-Unit: {"off", "def", "defensive", "unit"}.
augment_mults = learned.get("augments", {})
for augment in augments:
total *= augment_mults.get(augment, 1.0)
pair_lifts = learned.get("pairs", {}) "defensive" steuert die Zielreihenfolge im Kampf: Frontline/Utility
names = sorted({u["api_name"] for u in board_units}) sterben zuerst, Carries zuletzt.
lift_sum = sum( """
pair_lifts.get(f"{a}|{b}", 0.0) unit = artifact["static"]["units"].get(u["api_name"])
for i, a in enumerate(names) if not unit:
for b in names[i + 1 :] 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: def board_profiles(board_units: list[dict], artifact: dict) -> list[dict]:
"""Heuristik-Scorer: Kostenwert × Multiplikatoren.""" return [p for u in board_units if (p := unit_profile(u, artifact))]
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 score_mechanical(board_units: list[dict], augments: list[str], artifact: dict) -> float: def score_board(board_units: list[dict], augments: list[str], artifact: dict) -> float:
"""Kampf-Approximation: strength = sqrt(Σ eHP × Σ DPS) aus exakten Stats.""" """sqrt(Σoff × Σdef). augments bleibt nur für Signatur-Kompatibilität."""
static_units = artifact["static"]["units"] profiles = board_profiles(board_units, artifact)
static_items = artifact["static"]["items"] off = sum(p["off"] for p in profiles)
static_traits = artifact["static"]["traits"] dfn = sum(p["def"] for p in profiles)
learned = artifact.get("learned", {}) # /100: nur Anzeige-Skalierung, für Vergleiche irrelevant.
unit_mults = learned.get("units", {}) return (off * dfn) ** 0.5 / 100
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

View File

@@ -13,8 +13,11 @@ MANA_PER_ATTACK = 10
FRONTLINE_MANA_PER_SEC = 10 FRONTLINE_MANA_PER_SEC = 10
CAST_RATE_CAP = 1.5 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", 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+") _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. # 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: def _empty_acc() -> dict:
return {"ad_pct": 0.0, "ap_flat": 0.0, "as_pct": 0.0, "hp_flat": 0.0, 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, "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: 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 acc["mr_flat"] += val
elif key == "ManaRegen": elif key == "ManaRegen":
acc["mana_regen"] += val 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 # 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"]) as_eff = aspd * (1 + acc["as_pct"])
crit_c = min(crit + acc["crit_chance"], 1.0) 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)) auto_dps = ad * (1 + acc["ad_pct"]) * as_eff * (1 + crit_c * (crit_mult - 1))
spell_dps = 0.0 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, CAST_RATE_CAP,
) )
spell_dps = dmg * cast_rate 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: if spell_cap is not None:
# Ausreißer-Guard: Spell-Rohwerte sind zwischen Champions nicht limit = max(limit, spell_cap * AD_STAR_MULT ** (stars - 1))
# vergleichbar (per-Hit vs. total) — Kappung am Populations-Perzentil. spell_dps = min(spell_dps, limit)
spell_dps = min(spell_dps, spell_cap * AD_STAR_MULT ** (stars - 1))
return { return {
"ehp": ehp, "ehp": ehp,

View File

@@ -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 math
import random import random
from dataclasses import dataclass
FPS = 60
EPS = 1e-9
def win_probability(score_a: float, score_b: float, cfg: dict) -> float: @dataclass
mean = (score_a + score_b) / 2 class FightResult:
if mean <= 0: winner: str | None # "a" | "b" | None = Unentschieden
return 0.5 frames: int
d = (score_a - score_b) / mean survivors_a: list[dict]
return 1 / (1 + math.exp(-d / cfg["combat"]["tau"])) survivors_b: list[dict]
def resolve(score_a: float, score_b: float, cfg: dict, rng: random.Random) -> bool: def _queue(team: list[dict], rng: random.Random) -> list[dict]:
"""True if A wins. Adds noise on top of the probability.""" """Sterbereihenfolge: defensive Units zuerst, innerhalb der Gruppe zufällig."""
noise = rng.gauss(0, cfg["combat"]["variance"]) defensive = [p for p in team if p["defensive"]]
return rng.random() < min(max(win_probability(score_a, score_b, cfg) + noise, 0.02), 0.98) 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"] d = cfg["damage"]
base = d["stage_base"][min(stage - 1, len(d["stage_base"]) - 1)] base = d["stage_base"][min(stage - 1, len(d["stage_base"]) - 1)]
mean = (winner_score + loser_score) / 2 or 1 return base + d["per_surviving_unit"] * len(survivors)
margin = abs(winner_score - loser_score) / mean
surviving = max(1, round(winner_units * min(margin, 1.0)))
return base + d["per_surviving_unit"] * surviving

View File

@@ -2,6 +2,7 @@
import random import random
from tft.model.score import board_profiles
from tft.sim import combat, economy, player, policy 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.player import BENCH_SIZE, MAX_ITEMS_PER_UNIT, InvalidAction, PlayerState # noqa: F401
from tft.sim.pool import Pool from tft.sim.pool import Pool
@@ -227,17 +228,19 @@ class Game:
pairs, odd, ghost_src = pair_players(self._alive(), self.rng) pairs, odd, ghost_src = pair_players(self._alive(), self.rng)
for a, b in pairs: for a, b in pairs:
a_wins = self._fight(rnd, a, b) a_wins = self._fight(rnd, a, b)
results[a.name] = a_wins # Unentschieden (None) zählt für beide als nicht gewonnen.
results[b.name] = not a_wins results[a.name] = a_wins is True
results[b.name] = a_wins is False
if odd is not None: if odd is not None:
# Ghost-Kampf: Klon-Board, Schaden nur beim echten Spieler. # Ghost-Kampf: Klon-Board, Schaden nur beim echten Spieler.
s_odd = player.score(odd, self.artifact) res = combat.fight(board_profiles(odd.board, self.artifact),
s_ghost = player.score(ghost_src, self.artifact) board_profiles(ghost_src.board, self.artifact),
won = combat.resolve(s_odd, s_ghost, self.cfg, self.rng) self.rng, self.cfg)
won = res.winner == "a"
results[odd.name] = won results[odd.name] = won
if not won: if not won:
dmg = combat.damage(rnd["stage"], s_ghost, s_odd, survivors = res.survivors_b if res.winner == "b" else []
len(ghost_src.board), self.cfg) dmg = combat.player_damage(rnd["stage"], survivors, self.cfg)
odd.hp -= dmg odd.hp -= dmg
if odd is self.player: if odd is self.player:
self.log.append( self.log.append(
@@ -246,13 +249,23 @@ class Game:
self.log.append(f"{rnd['label']}: Sieg vs Ghost ({ghost_src.name})") self.log.append(f"{rnd['label']}: Sieg vs Ghost ({ghost_src.name})")
return results return results
def _fight(self, rnd: dict, a: PlayerState, b: PlayerState) -> bool: def _fight(self, rnd: dict, a: PlayerState, b: PlayerState) -> bool | None:
sa = player.score(a, self.artifact) res = combat.fight(board_profiles(a.board, self.artifact),
sb = player.score(b, self.artifact) board_profiles(b.board, self.artifact),
a_wins = combat.resolve(sa, sb, self.cfg, self.rng) 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) winner, loser = (a, b) if a_wins else (b, a)
w_score, l_score = (sa, sb) if a_wins else (sb, sa) survivors = res.survivors_a if a_wins else res.survivors_b
dmg = combat.damage(rnd["stage"], w_score, l_score, len(winner.board), self.cfg) dmg = combat.player_damage(rnd["stage"], survivors, self.cfg)
loser.hp -= dmg loser.hp -= dmg
if a is self.player or b is self.player: if a is self.player or b is self.player:
if winner is self.player: if winner is self.player: