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:
@@ -22,7 +22,9 @@ def main() -> None:
|
||||
|
||||
sub.add_parser("extract", help="extract endboards from raw matches")
|
||||
sub.add_parser("build-artifact", help="build analysis.json from static data + endboards")
|
||||
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.add_argument("--policy", choices=["afk", "econ"], default="econ")
|
||||
@@ -81,7 +83,8 @@ def main() -> None:
|
||||
elif args.command == "calibrate":
|
||||
from tft import db
|
||||
from tft.model import artifact as artifact_mod
|
||||
from tft.model.calibrate import calibrate
|
||||
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
|
||||
|
||||
conn = db.connect()
|
||||
@@ -89,10 +92,19 @@ def main() -> None:
|
||||
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)
|
||||
conn.close()
|
||||
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()
|
||||
|
||||
elif args.command == "autoplay":
|
||||
from tft.constants.loader import load_constants
|
||||
|
||||
@@ -75,6 +75,7 @@ hp_multiplier = 1.8
|
||||
ad_multiplier = 1.5
|
||||
|
||||
[combat]
|
||||
# p_win = 1 / (1 + exp(-(score_a - score_b) / tau)); tau wird in M6 kalibriert.
|
||||
tau = 0.15
|
||||
# p_win = 1 / (1 + exp(-(score_a - score_b) / tau)).
|
||||
# tau = 0.28: Log-Loss-Fit über 1064 Holdout-Platzierungspaare (mechanical Scorer).
|
||||
tau = 0.28
|
||||
variance = 0.08
|
||||
|
||||
@@ -26,6 +26,29 @@ def craftable_items(static_items: dict) -> list[str]:
|
||||
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:
|
||||
roles = {
|
||||
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,
|
||||
"stat_mults": baseline.compute_stat_mults(static["units"]),
|
||||
"tier_profiles": tier_profiles(static, roles),
|
||||
"item_pool": craftable_items(static["items"]),
|
||||
"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)
|
||||
|
||||
|
||||
def calibrate(conn, artifact: dict, holdout_only: bool = False) -> dict:
|
||||
"""Mean Spearman between board score and placement (negated: higher = better)."""
|
||||
from tft.model.score import score_board
|
||||
|
||||
def _holdout_scores(conn, artifact: dict, holdout_only: bool, score_fn) -> dict:
|
||||
set_number = artifact["meta"]["set"]
|
||||
where = "WHERE set_number = ?"
|
||||
if holdout_only:
|
||||
@@ -59,8 +56,17 @@ def calibrate(conn, artifact: dict, holdout_only: bool = False) -> dict:
|
||||
by_match: dict[str, list] = {}
|
||||
for match_id, placement, units_json, augments_json in rows:
|
||||
board, augments = board_from_row(units_json, augments_json)
|
||||
s = score_board(board, augments, artifact)
|
||||
by_match.setdefault(match_id, []).append((placement, s))
|
||||
by_match.setdefault(match_id, []).append(
|
||||
(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 = []
|
||||
for players in by_match.values():
|
||||
@@ -75,3 +81,38 @@ def calibrate(conn, artifact: dict, holdout_only: bool = False) -> dict:
|
||||
"matches": n,
|
||||
"mean_spearman": sum(correlations) / n if n else 0.0,
|
||||
}
|
||||
|
||||
|
||||
def fit_tau(conn, artifact: dict, score_fn) -> dict:
|
||||
"""Tau per Log-Loss über alle Platzierungspaare der Holdout-Matches fitten."""
|
||||
import math
|
||||
|
||||
by_match = _holdout_scores(conn, artifact, True, score_fn)
|
||||
pairs = []
|
||||
for players in by_match.values():
|
||||
if len(players) < 8:
|
||||
continue
|
||||
for i in range(len(players)):
|
||||
for j in range(i + 1, len(players)):
|
||||
(pl_a, s_a), (pl_b, s_b) = players[i], players[j]
|
||||
mean = (s_a + s_b) / 2
|
||||
if mean <= 0:
|
||||
continue
|
||||
d = (s_a - s_b) / mean
|
||||
pairs.append((d, pl_a < pl_b)) # kleinere Platzierung = besser
|
||||
|
||||
if not pairs:
|
||||
return {"tau": None, "pairs": 0}
|
||||
|
||||
def log_loss(tau: float) -> float:
|
||||
eps = 1e-9
|
||||
total = 0.0
|
||||
for d, a_wins in pairs:
|
||||
p = 1 / (1 + math.exp(-d / tau))
|
||||
p = min(max(p, eps), 1 - eps)
|
||||
total += -math.log(p if a_wins else 1 - p)
|
||||
return total / len(pairs)
|
||||
|
||||
taus = [t / 100 for t in range(2, 51, 2)]
|
||||
best = min(taus, key=log_loss)
|
||||
return {"tau": best, "pairs": len(pairs), "log_loss": round(log_loss(best), 4)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""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:
|
||||
@@ -27,16 +27,30 @@ def active_trait_tiers(board_units: list[dict], static_traits: dict, static_unit
|
||||
return tiers
|
||||
|
||||
|
||||
def score_board(board_units: list[dict], augments: list[str], artifact: dict) -> float:
|
||||
"""board_units: [{api_name, stars, items: [item api names]}]."""
|
||||
def _apply_shared_multipliers(total: float, board_units: list[dict], augments: list[str],
|
||||
learned: dict) -> float:
|
||||
augment_mults = learned.get("augments", {})
|
||||
for augment in augments:
|
||||
total *= augment_mults.get(augment, 1.0)
|
||||
|
||||
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_traits = artifact["static"]["traits"]
|
||||
learned = artifact.get("learned", {})
|
||||
unit_mults = learned.get("units", {})
|
||||
item_mults = learned.get("items", {})
|
||||
trait_mults = learned.get("traits", {})
|
||||
augment_mults = learned.get("augments", {})
|
||||
pair_lifts = learned.get("pairs", {})
|
||||
roles = artifact.get("roles", {})
|
||||
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)
|
||||
|
||||
for augment in augments:
|
||||
total *= augment_mults.get(augment, 1.0)
|
||||
return _apply_shared_multipliers(total, board_units, augments, learned)
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
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:
|
||||
p = asset_path.lower()
|
||||
@@ -39,10 +98,8 @@ def parse(raw: dict, set_override: int | None = None) -> dict:
|
||||
"role": c.get("role"),
|
||||
"stats": c["stats"],
|
||||
"ability_name": c["ability"]["name"],
|
||||
"ability_variables": {
|
||||
v["name"]: v["value"] for v in c["ability"]["variables"]
|
||||
},
|
||||
"icon": icon_url(c["squareIcon"]),
|
||||
**resolve_spell(c["ability"]),
|
||||
}
|
||||
|
||||
traits = {}
|
||||
@@ -52,7 +109,8 @@ def parse(raw: dict, set_override: int | None = None) -> dict:
|
||||
"api_name": t["apiName"],
|
||||
"name": t["name"],
|
||||
"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"]
|
||||
],
|
||||
"icon": icon_url(t["icon"]),
|
||||
@@ -74,6 +132,7 @@ def parse(raw: dict, set_override: int | None = None) -> dict:
|
||||
"api_name": api,
|
||||
"name": i["name"],
|
||||
"composition": i["composition"],
|
||||
"effects": i.get("effects") or {},
|
||||
"icon": icon_url(i["icon"]) if i["icon"] else None,
|
||||
}
|
||||
if "Augment" in api:
|
||||
|
||||
Reference in New Issue
Block a user