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:
team3
2026-07-23 08:52:56 +02:00
parent 7c72761190
commit c450841822
10 changed files with 605 additions and 31 deletions

View 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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(
pair_lifts.get(f"{a}|{b}", 0.0)
for i, a in enumerate(names)
for b in names[i + 1 :]
)
total *= 1 + min(max(lift_sum * 0.01, -0.10), 0.10)
return total 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)
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"]),
)
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
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

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

View File

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