Mechanisches Boardstärke-Modell: Statsheet aus cdragon-Exaktdaten, sqrt(eHP×DPS), Tier-Anker, Tau-Fit
A/B auf 38 Holdout-Matches: mechanical 0.724 vs legacy 0.695. Promotion durchgeführt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
77
backend/tests/test_parse.py
Normal file
77
backend/tests/test_parse.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
from tft.staticdata.parse import parse, resolve_spell
|
||||||
|
|
||||||
|
RAW = {
|
||||||
|
"sets": {
|
||||||
|
"17": {
|
||||||
|
"name": "Set17",
|
||||||
|
"champions": [
|
||||||
|
{
|
||||||
|
"apiName": "TFT17_Mage",
|
||||||
|
"name": "Mage",
|
||||||
|
"cost": 2,
|
||||||
|
"traits": ["Caster"],
|
||||||
|
"role": "Caster",
|
||||||
|
"stats": {"hp": 600, "armor": 25, "magicResist": 25, "damage": 40,
|
||||||
|
"attackSpeed": 0.7, "mana": 60, "initialMana": 10},
|
||||||
|
"squareIcon": "ASSETS/x.TFT_Set17.tex",
|
||||||
|
"ability": {
|
||||||
|
"name": "Blast",
|
||||||
|
"desc": "Deal <magicDamage>@ModifiedDamage@ (%i:scaleAP%)</magicDamage> magic damage.",
|
||||||
|
"variables": [{"name": "Damage", "value": [0, 200, 300, 450, 700, 0, 0]}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"traits": [
|
||||||
|
{
|
||||||
|
"apiName": "TFT17_Caster",
|
||||||
|
"name": "Caster",
|
||||||
|
"icon": "ASSETS/t.tex",
|
||||||
|
"effects": [
|
||||||
|
{"minUnits": 2, "maxUnits": 3, "style": 1,
|
||||||
|
"variables": {"BonusAP": 20.0}},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"apiName": "TFT_Item_TestSword",
|
||||||
|
"name": "Test Sword",
|
||||||
|
"composition": ["TFT_Item_A", "TFT_Item_B"],
|
||||||
|
"effects": {"AD": 0.35, "CritChance": 35.0},
|
||||||
|
"icon": "ASSETS/i.tex",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_keeps_effects_and_variables():
|
||||||
|
parsed = parse(RAW, set_override=17)
|
||||||
|
assert parsed["items"]["TFT_Item_TestSword"]["effects"] == {"AD": 0.35, "CritChance": 35.0}
|
||||||
|
bp = parsed["traits"]["TFT17_Caster"]["breakpoints"][0]
|
||||||
|
assert bp["variables"] == {"BonusAP": 20.0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_resolves_spell_damage():
|
||||||
|
unit = parse(RAW, set_override=17)["units"]["TFT17_Mage"]
|
||||||
|
assert unit["spell_damage"] == [0, 200, 300, 450, 700, 0, 0]
|
||||||
|
assert unit["spell_damage_type"] == "magic"
|
||||||
|
assert unit["spell_scaling"] == "ap"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_spell_adaptive_and_fallback():
|
||||||
|
adaptive = {
|
||||||
|
"desc": "Deal <magicDamage>@TotalDamage@</magicDamage> damage. %i:scaleAP% %i:scaleAD%",
|
||||||
|
"variables": [
|
||||||
|
{"name": "ADDamage", "value": [0, 100, 150, 225, 0, 0, 0]},
|
||||||
|
{"name": "APDamage", "value": [0, 50, 75, 110, 0, 0, 0]},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
r = resolve_spell(adaptive)
|
||||||
|
assert r["spell_damage"][1] == 150 # 100 + 50
|
||||||
|
assert r["spell_scaling"] == "both"
|
||||||
|
|
||||||
|
utility = {"desc": "Grant a shield.", "variables": [
|
||||||
|
{"name": "Shield", "value": [0, 300, 400, 500, 0, 0, 0]}]}
|
||||||
|
assert resolve_spell(utility)["spell_damage"] is None
|
||||||
@@ -112,7 +112,61 @@ def test_stat_mults_identical_units_are_neutral():
|
|||||||
assert all(m == 1.0 for m in mults.values())
|
assert all(m == 1.0 for m in mults.values())
|
||||||
|
|
||||||
|
|
||||||
def test_stat_mult_raises_score():
|
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}}
|
art = {**ARTIFACT, "stat_mults": {"TFT17_B": 1.1}}
|
||||||
board = [{"api_name": "TFT17_B", "stars": 1, "items": []}]
|
board = [{"api_name": "TFT17_B", "stars": 1, "items": []}]
|
||||||
assert score_board(board, [], art) > score_board(board, [], ARTIFACT)
|
assert score_board(board, [], art) > score_board(board, [], ARTIFACT)
|
||||||
|
|
||||||
|
|
||||||
|
def _mech_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_mechanical_balanced_beats_lopsided():
|
||||||
|
from tft.model.score import score_mechanical
|
||||||
|
art = _mech_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)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mechanical_items_and_stars_raise_strength():
|
||||||
|
from tft.model.score import score_mechanical
|
||||||
|
art = _mech_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
|
||||||
|
|||||||
73
backend/tests/test_statsheet.py
Normal file
73
backend/tests/test_statsheet.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
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_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
|
||||||
@@ -22,7 +22,9 @@ 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")
|
||||||
sub.add_parser("calibrate", help="backtest score vs real placements (holdout)")
|
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")
|
||||||
|
|
||||||
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")
|
||||||
@@ -81,7 +83,8 @@ 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
|
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.staticdata.fetch import load_static
|
||||||
|
|
||||||
conn = db.connect()
|
conn = db.connect()
|
||||||
@@ -89,10 +92,19 @@ def main() -> None:
|
|||||||
baseline_art = artifact_mod.build(load_static(), {})
|
baseline_art = artifact_mod.build(load_static(), {})
|
||||||
r_base = calibrate(conn, baseline_art, holdout_only=True)
|
r_base = calibrate(conn, baseline_art, holdout_only=True)
|
||||||
r_learned = calibrate(conn, learned_art, holdout_only=True)
|
r_learned = calibrate(conn, learned_art, holdout_only=True)
|
||||||
conn.close()
|
|
||||||
print(f"holdout matches: {r_learned['matches']}")
|
print(f"holdout matches: {r_learned['matches']}")
|
||||||
print(f"spearman baseline: {r_base['mean_spearman']:.3f}")
|
print(f"spearman baseline: {r_base['mean_spearman']:.3f}")
|
||||||
print(f"spearman learned: {r_learned['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()
|
||||||
|
|
||||||
elif args.command == "autoplay":
|
elif args.command == "autoplay":
|
||||||
from tft.constants.loader import load_constants
|
from tft.constants.loader import load_constants
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ hp_multiplier = 1.8
|
|||||||
ad_multiplier = 1.5
|
ad_multiplier = 1.5
|
||||||
|
|
||||||
[combat]
|
[combat]
|
||||||
# p_win = 1 / (1 + exp(-(score_a - score_b) / tau)); tau wird in M6 kalibriert.
|
# p_win = 1 / (1 + exp(-(score_a - score_b) / tau)).
|
||||||
tau = 0.15
|
# tau = 0.28: Log-Loss-Fit über 1064 Holdout-Platzierungspaare (mechanical Scorer).
|
||||||
|
tau = 0.28
|
||||||
variance = 0.08
|
variance = 0.08
|
||||||
|
|||||||
@@ -26,6 +26,29 @@ def craftable_items(static_items: dict) -> list[str]:
|
|||||||
return sorted(pool)
|
return sorted(pool)
|
||||||
|
|
||||||
|
|
||||||
|
def tier_profiles(static: dict, roles: dict) -> dict:
|
||||||
|
"""Mittleres Mechanik-Profil (eHP/DPS, 1★, itemlos) pro Kostenstufe.
|
||||||
|
|
||||||
|
Anker für den mechanischen Scorer: Riot balanciert um die Kosten,
|
||||||
|
die Mechanik differenziert innerhalb der Stufe.
|
||||||
|
"""
|
||||||
|
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 build(static: dict, learned: dict | None = None, extra_meta: dict | None = None) -> dict:
|
def build(static: dict, learned: dict | None = None, extra_meta: dict | None = None) -> dict:
|
||||||
roles = {
|
roles = {
|
||||||
api: baseline.classify_role(unit) for api, unit in static["units"].items()
|
api: baseline.classify_role(unit) for api, unit in static["units"].items()
|
||||||
@@ -46,6 +69,7 @@ def build(static: dict, learned: dict | None = None, extra_meta: dict | None = N
|
|||||||
},
|
},
|
||||||
"roles": roles,
|
"roles": roles,
|
||||||
"stat_mults": baseline.compute_stat_mults(static["units"]),
|
"stat_mults": baseline.compute_stat_mults(static["units"]),
|
||||||
|
"tier_profiles": tier_profiles(static, roles),
|
||||||
"item_pool": craftable_items(static["items"]),
|
"item_pool": craftable_items(static["items"]),
|
||||||
"learned": learned or {},
|
"learned": learned or {},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,10 +42,7 @@ def board_from_row(units_json: str, augments_json: str) -> tuple[list[dict], lis
|
|||||||
return units, json.loads(augments_json)
|
return units, json.loads(augments_json)
|
||||||
|
|
||||||
|
|
||||||
def calibrate(conn, artifact: dict, holdout_only: bool = False) -> dict:
|
def _holdout_scores(conn, artifact: dict, holdout_only: bool, score_fn) -> dict:
|
||||||
"""Mean Spearman between board score and placement (negated: higher = better)."""
|
|
||||||
from tft.model.score import score_board
|
|
||||||
|
|
||||||
set_number = artifact["meta"]["set"]
|
set_number = artifact["meta"]["set"]
|
||||||
where = "WHERE set_number = ?"
|
where = "WHERE set_number = ?"
|
||||||
if holdout_only:
|
if holdout_only:
|
||||||
@@ -59,8 +56,17 @@ def calibrate(conn, artifact: dict, holdout_only: bool = False) -> dict:
|
|||||||
by_match: dict[str, list] = {}
|
by_match: dict[str, list] = {}
|
||||||
for match_id, placement, units_json, augments_json in rows:
|
for match_id, placement, units_json, augments_json in rows:
|
||||||
board, augments = board_from_row(units_json, augments_json)
|
board, augments = board_from_row(units_json, augments_json)
|
||||||
s = score_board(board, augments, artifact)
|
by_match.setdefault(match_id, []).append(
|
||||||
by_match.setdefault(match_id, []).append((placement, s))
|
(placement, score_fn(board, augments, artifact))
|
||||||
|
)
|
||||||
|
return by_match
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
by_match = _holdout_scores(conn, artifact, holdout_only, score_fn or score_board)
|
||||||
|
|
||||||
correlations = []
|
correlations = []
|
||||||
for players in by_match.values():
|
for players in by_match.values():
|
||||||
@@ -75,3 +81,38 @@ def calibrate(conn, artifact: dict, holdout_only: bool = False) -> dict:
|
|||||||
"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)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""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."""
|
||||||
|
|
||||||
from tft.model import baseline
|
from tft.model import baseline, statsheet
|
||||||
|
|
||||||
|
|
||||||
def active_trait_tiers(board_units: list[dict], static_traits: dict, static_units: dict) -> dict:
|
def active_trait_tiers(board_units: list[dict], static_traits: dict, static_units: dict) -> dict:
|
||||||
@@ -27,16 +27,30 @@ def active_trait_tiers(board_units: list[dict], static_traits: dict, static_unit
|
|||||||
return tiers
|
return tiers
|
||||||
|
|
||||||
|
|
||||||
def score_board(board_units: list[dict], augments: list[str], artifact: dict) -> float:
|
def _apply_shared_multipliers(total: float, board_units: list[dict], augments: list[str],
|
||||||
"""board_units: [{api_name, stars, items: [item api names]}]."""
|
learned: dict) -> float:
|
||||||
|
augment_mults = learned.get("augments", {})
|
||||||
|
for augment in augments:
|
||||||
|
total *= augment_mults.get(augment, 1.0)
|
||||||
|
|
||||||
|
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 :]
|
||||||
|
)
|
||||||
|
return total * (1 + min(max(lift_sum * 0.01, -0.10), 0.10))
|
||||||
|
|
||||||
|
|
||||||
|
def score_board_legacy(board_units: list[dict], augments: list[str], artifact: dict) -> float:
|
||||||
|
"""Heuristik-Scorer: Kostenwert × Multiplikatoren."""
|
||||||
static_units = artifact["static"]["units"]
|
static_units = artifact["static"]["units"]
|
||||||
static_traits = artifact["static"]["traits"]
|
static_traits = artifact["static"]["traits"]
|
||||||
learned = artifact.get("learned", {})
|
learned = artifact.get("learned", {})
|
||||||
unit_mults = learned.get("units", {})
|
unit_mults = learned.get("units", {})
|
||||||
item_mults = learned.get("items", {})
|
item_mults = learned.get("items", {})
|
||||||
trait_mults = learned.get("traits", {})
|
trait_mults = learned.get("traits", {})
|
||||||
augment_mults = learned.get("augments", {})
|
|
||||||
pair_lifts = learned.get("pairs", {})
|
|
||||||
roles = artifact.get("roles", {})
|
roles = artifact.get("roles", {})
|
||||||
stat_mults = artifact.get("stat_mults", {})
|
stat_mults = artifact.get("stat_mults", {})
|
||||||
|
|
||||||
@@ -64,15 +78,75 @@ def score_board(board_units: list[dict], augments: list[str], artifact: dict) ->
|
|||||||
]
|
]
|
||||||
total *= trait_mults.get(f"{trait}@{ordinal}", default)
|
total *= trait_mults.get(f"{trait}@{ordinal}", default)
|
||||||
|
|
||||||
for augment in augments:
|
return _apply_shared_multipliers(total, board_units, augments, learned)
|
||||||
total *= augment_mults.get(augment, 1.0)
|
|
||||||
|
|
||||||
names = sorted({u["api_name"] for u in board_units})
|
|
||||||
lift_sum = sum(
|
def score_mechanical(board_units: list[dict], augments: list[str], artifact: dict) -> float:
|
||||||
pair_lifts.get(f"{a}|{b}", 0.0)
|
"""Kampf-Approximation: strength = sqrt(Σ eHP × Σ DPS) aus exakten Stats."""
|
||||||
for i, a in enumerate(names)
|
static_units = artifact["static"]["units"]
|
||||||
for b in names[i + 1 :]
|
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)
|
||||||
|
tier_profiles = artifact.get("tier_profiles", {})
|
||||||
|
|
||||||
|
# Anker: 1★ itemlos zählt exakt "Kosten" in beiden Dimensionen.
|
||||||
|
# Mechanik (Stats, Items, Trait-Buffs, Sterne) verschiebt relativ dazu;
|
||||||
|
# Extraktions-Ausreißer werden pro Stufe gekappt.
|
||||||
|
RATIO_CAP = (0.5, 2.0)
|
||||||
|
|
||||||
|
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"]),
|
||||||
)
|
)
|
||||||
total *= 1 + min(max(lift_sum * 0.01, -0.10), 0.10)
|
ref = tier_profiles.get(str(unit["cost"]))
|
||||||
|
base = baseline.unit_value(unit["cost"], u["stars"])
|
||||||
|
if ref:
|
||||||
|
star_ehp = statsheet.HP_STAR_MULT ** (u["stars"] - 1)
|
||||||
|
star_dps = statsheet.AD_STAR_MULT ** (u["stars"] - 1)
|
||||||
|
r_ehp = profile["ehp"] / (ref["ehp"] * star_ehp)
|
||||||
|
r_dps = profile["dps"] / (ref["dps"] * star_dps)
|
||||||
|
r_ehp = min(max(r_ehp, RATIO_CAP[0]), RATIO_CAP[1])
|
||||||
|
r_dps = min(max(r_dps, RATIO_CAP[0]), RATIO_CAP[1])
|
||||||
|
else:
|
||||||
|
r_ehp = r_dps = 1.0
|
||||||
|
mult = unit_mults.get(u["api_name"], 1.0)
|
||||||
|
total_ehp += mult * base * r_ehp
|
||||||
|
total_dps += mult * base * r_dps
|
||||||
|
|
||||||
return total
|
strength = (total_ehp * total_dps) ** 0.5
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
159
backend/tft/model/statsheet.py
Normal file
159
backend/tft/model/statsheet.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
MAPPED_ITEM_KEYS = ("AD", "AP", "AS", "CritChance", "Health", "Armor",
|
||||||
|
"MagicResist", "ManaRegen")
|
||||||
|
|
||||||
|
_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, "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
|
||||||
|
# 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) -> 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)
|
||||||
|
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"]
|
||||||
|
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
|
||||||
|
|
||||||
|
return {"ehp": ehp, "dps": (auto_dps + spell_dps) * (1 + acc["damage_amp"])}
|
||||||
@@ -3,8 +3,67 @@
|
|||||||
Fails loudly on missing keys — never guess through structure drift.
|
Fails loudly on missing keys — never guess through structure drift.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
CDRAGON_GAME = "https://raw.communitydragon.org/latest/game"
|
CDRAGON_GAME = "https://raw.communitydragon.org/latest/game"
|
||||||
|
|
||||||
|
DAMAGE_TAG_RE = re.compile(
|
||||||
|
r"<(magicDamage|physicalDamage|trueDamage)>(.*?)</\1>", re.S
|
||||||
|
)
|
||||||
|
VAR_RE = re.compile(r"@([A-Za-z0-9_]+?)(?:\*[\d.]+)?@")
|
||||||
|
# Reihenfolge = Priorität; ADDamage/APDamage werden gesondert addiert.
|
||||||
|
DAMAGE_FALLBACKS = (
|
||||||
|
"Damage", "DamageAP", "DamageAD", "SpellDamage",
|
||||||
|
"DamagePerSecond", "TrueDamagePerSecond", "BonusDamageOnAttack",
|
||||||
|
)
|
||||||
|
TAG_TO_TYPE = {"magicDamage": "magic", "physicalDamage": "physical", "trueDamage": "true"}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_spell(ability: dict) -> dict:
|
||||||
|
"""Spell-Schaden pro Stern aus Desc-Markup + Variablen auflösen.
|
||||||
|
|
||||||
|
Stern-Konvention der Arrays: value[1..3] = 1-3 Sterne (verifiziert).
|
||||||
|
"""
|
||||||
|
desc = ability.get("desc") or ""
|
||||||
|
variables = {v["name"]: v["value"] for v in ability.get("variables", [])
|
||||||
|
if v.get("value")}
|
||||||
|
|
||||||
|
def get(name: str):
|
||||||
|
val = variables.get(name)
|
||||||
|
return val if val and any(val) else None
|
||||||
|
|
||||||
|
dmg_type = None
|
||||||
|
array = None
|
||||||
|
for m in DAMAGE_TAG_RE.finditer(desc):
|
||||||
|
for vm in VAR_RE.finditer(m.group(2)):
|
||||||
|
name = vm.group(1)
|
||||||
|
array = get(name) or get(name.removeprefix("Modified"))
|
||||||
|
if array:
|
||||||
|
dmg_type = TAG_TO_TYPE[m.group(1)]
|
||||||
|
break
|
||||||
|
if array:
|
||||||
|
break
|
||||||
|
|
||||||
|
if array is None:
|
||||||
|
ad, ap = get("ADDamage"), get("APDamage")
|
||||||
|
if ad and ap:
|
||||||
|
array = [a + b for a, b in zip(ad, ap)]
|
||||||
|
else:
|
||||||
|
for name in DAMAGE_FALLBACKS:
|
||||||
|
array = get(name)
|
||||||
|
if array:
|
||||||
|
break
|
||||||
|
|
||||||
|
has_ap = "%i:scaleAP%" in desc
|
||||||
|
has_ad = "%i:scaleAD%" in desc
|
||||||
|
scaling = "both" if has_ap and has_ad else "ap" if has_ap else "ad" if has_ad else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"spell_damage": array,
|
||||||
|
"spell_damage_type": dmg_type or ("magic" if array else None),
|
||||||
|
"spell_scaling": scaling,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def icon_url(asset_path: str) -> str:
|
def icon_url(asset_path: str) -> str:
|
||||||
p = asset_path.lower()
|
p = asset_path.lower()
|
||||||
@@ -39,10 +98,8 @@ def parse(raw: dict, set_override: int | None = None) -> dict:
|
|||||||
"role": c.get("role"),
|
"role": c.get("role"),
|
||||||
"stats": c["stats"],
|
"stats": c["stats"],
|
||||||
"ability_name": c["ability"]["name"],
|
"ability_name": c["ability"]["name"],
|
||||||
"ability_variables": {
|
|
||||||
v["name"]: v["value"] for v in c["ability"]["variables"]
|
|
||||||
},
|
|
||||||
"icon": icon_url(c["squareIcon"]),
|
"icon": icon_url(c["squareIcon"]),
|
||||||
|
**resolve_spell(c["ability"]),
|
||||||
}
|
}
|
||||||
|
|
||||||
traits = {}
|
traits = {}
|
||||||
@@ -52,7 +109,8 @@ def parse(raw: dict, set_override: int | None = None) -> dict:
|
|||||||
"api_name": t["apiName"],
|
"api_name": t["apiName"],
|
||||||
"name": t["name"],
|
"name": t["name"],
|
||||||
"breakpoints": [
|
"breakpoints": [
|
||||||
{"min_units": e["minUnits"], "style": e["style"]}
|
{"min_units": e["minUnits"], "style": e["style"],
|
||||||
|
"variables": e.get("variables") or {}}
|
||||||
for e in t["effects"]
|
for e in t["effects"]
|
||||||
],
|
],
|
||||||
"icon": icon_url(t["icon"]),
|
"icon": icon_url(t["icon"]),
|
||||||
@@ -74,6 +132,7 @@ def parse(raw: dict, set_override: int | None = None) -> dict:
|
|||||||
"api_name": api,
|
"api_name": api,
|
||||||
"name": i["name"],
|
"name": i["name"],
|
||||||
"composition": i["composition"],
|
"composition": i["composition"],
|
||||||
|
"effects": i.get("effects") or {},
|
||||||
"icon": icon_url(i["icon"]) if i["icon"] else None,
|
"icon": icon_url(i["icon"]) if i["icon"] else None,
|
||||||
}
|
}
|
||||||
if "Augment" in api:
|
if "Augment" in api:
|
||||||
|
|||||||
Reference in New Issue
Block a user