update
This commit is contained in:
@@ -22,9 +22,7 @@ def main() -> None:
|
||||
|
||||
sub.add_parser("extract", help="extract endboards from raw matches")
|
||||
sub.add_parser("build-artifact", help="build analysis.json from static data + endboards")
|
||||
p_cal = sub.add_parser("calibrate", help="backtest score vs real placements (holdout)")
|
||||
p_cal.add_argument("--compare", action="store_true",
|
||||
help="A/B legacy vs. mechanical scorer + tau-Fit")
|
||||
sub.add_parser("calibrate", help="backtest score vs real placements (holdout)")
|
||||
|
||||
p_auto = sub.add_parser("autoplay", help="run scripted games headless")
|
||||
p_auto.add_argument("--policy", choices=["afk", "econ"], default="econ")
|
||||
@@ -83,28 +81,13 @@ def main() -> None:
|
||||
elif args.command == "calibrate":
|
||||
from tft import db
|
||||
from tft.model import artifact as artifact_mod
|
||||
from tft.model.calibrate import calibrate, fit_tau
|
||||
from tft.model.score import score_board_legacy, score_mechanical
|
||||
from tft.staticdata.fetch import load_static
|
||||
from tft.model.calibrate import calibrate
|
||||
|
||||
conn = db.connect()
|
||||
learned_art = artifact_mod.load(current_set())
|
||||
baseline_art = artifact_mod.build(load_static(), {})
|
||||
r_base = calibrate(conn, baseline_art, holdout_only=True)
|
||||
r_learned = calibrate(conn, learned_art, holdout_only=True)
|
||||
print(f"holdout matches: {r_learned['matches']}")
|
||||
print(f"spearman baseline: {r_base['mean_spearman']:.3f}")
|
||||
print(f"spearman learned: {r_learned['mean_spearman']:.3f}")
|
||||
if args.compare:
|
||||
r_leg = calibrate(conn, learned_art, holdout_only=True, score_fn=score_board_legacy)
|
||||
r_mech = calibrate(conn, learned_art, holdout_only=True, score_fn=score_mechanical)
|
||||
delta = r_mech["mean_spearman"] - r_leg["mean_spearman"]
|
||||
tau = fit_tau(conn, learned_art, score_mechanical)
|
||||
print(f"A/B legacy: {r_leg['mean_spearman']:.3f}")
|
||||
print(f"A/B mechanical: {r_mech['mean_spearman']:.3f} (delta {delta:+.3f})")
|
||||
print(f"tau-fit (mechanical): {tau['tau']} über {tau['pairs']} Paare, "
|
||||
f"log-loss {tau.get('log_loss')}")
|
||||
result = calibrate(conn, artifact_mod.load(current_set()), holdout_only=True)
|
||||
conn.close()
|
||||
print(f"holdout matches: {result['matches']}")
|
||||
print(f"spearman: {result['mean_spearman']:.3f}")
|
||||
|
||||
elif args.command == "autoplay":
|
||||
from tft.constants.loader import load_constants
|
||||
|
||||
@@ -80,7 +80,6 @@ hp_multiplier = 1.8
|
||||
ad_multiplier = 1.5
|
||||
|
||||
[combat]
|
||||
# p_win = 1 / (1 + exp(-(score_a - score_b) / tau)).
|
||||
# tau = 0.28: Log-Loss-Fit über 1064 Holdout-Platzierungspaare (mechanical Scorer).
|
||||
tau = 0.28
|
||||
variance = 0.08
|
||||
# Deterministische DPS-Simulation mit 60 FPS; Zeitlimit 30 s.
|
||||
# Bei Ablauf gewinnt die Seite mit mehr verbleibender Gesamt-Defense.
|
||||
max_frames = 1800
|
||||
|
||||
@@ -47,27 +47,12 @@ def component_pool(static_items: dict) -> list[str]:
|
||||
return sorted(c for c, n in counts.items() if n >= 3)
|
||||
|
||||
|
||||
def tier_profiles(static: dict, roles: dict) -> dict:
|
||||
"""Mittleres Mechanik-Profil (eHP/DPS, 1★, itemlos) pro Kostenstufe."""
|
||||
from tft.model import statsheet
|
||||
|
||||
by_cost: dict[int, list] = {}
|
||||
for api, unit in static["units"].items():
|
||||
profile = statsheet.unit_stats(
|
||||
unit, 1, [], static["items"], None, roles.get(api)
|
||||
)
|
||||
by_cost.setdefault(unit["cost"], []).append(profile)
|
||||
return {
|
||||
str(cost): {
|
||||
"ehp": sum(p["ehp"] for p in profiles) / len(profiles),
|
||||
"dps": max(sum(p["dps"] for p in profiles) / len(profiles), 1.0),
|
||||
}
|
||||
for cost, profiles in by_cost.items()
|
||||
}
|
||||
|
||||
|
||||
def spell_dps_cap(static: dict, roles: dict) -> float | None:
|
||||
"""90. Perzentil der Spell-DPS aller Units (1★, itemlos) — Ausreißer-Guard."""
|
||||
"""90. Perzentil der Spell-DPS aller Units (1★, itemlos).
|
||||
|
||||
Dient im Score als Floor des relativen Spell-Deckels — rettet Caster
|
||||
ohne Auto-Schaden (siehe statsheet.unit_stats).
|
||||
"""
|
||||
from tft.model import statsheet
|
||||
|
||||
values = sorted(
|
||||
@@ -107,8 +92,6 @@ def build(static: dict, learned: dict | None = None, extra_meta: dict | None = N
|
||||
"augments": static["augments"],
|
||||
},
|
||||
"roles": roles,
|
||||
"stat_mults": baseline.compute_stat_mults(static["units"]),
|
||||
"tier_profiles": tier_profiles(static, roles),
|
||||
"spell_dps_cap": spell_dps_cap(static, roles),
|
||||
"item_pool": craftable_items(static["items"]),
|
||||
"component_pool": component_pool(static["items"]),
|
||||
|
||||
@@ -1,56 +1,10 @@
|
||||
"""Rule-based baseline: unit values, stat proxies, role classification."""
|
||||
"""Rule-based baseline: unit values and role classification."""
|
||||
|
||||
|
||||
def unit_value(cost: int, stars: int) -> float:
|
||||
return cost * 3 ** (stars - 1)
|
||||
|
||||
|
||||
def stat_proxies(stats: dict) -> dict:
|
||||
# cdragon liefert für manche Units null-Werte — als 0 behandeln.
|
||||
hp = stats.get("hp") or 0
|
||||
resists = ((stats.get("armor") or 0) + (stats.get("magicResist") or 0)) / 2
|
||||
ad = stats.get("damage") or 0
|
||||
aspd = stats.get("attackSpeed") or 0
|
||||
mana_gap = max((stats.get("mana") or 0) - (stats.get("initialMana") or 0), 1)
|
||||
return {
|
||||
"ehp": hp * (1 + resists / 100),
|
||||
"auto_dps": ad * aspd,
|
||||
"cast_rate": aspd * 10 / mana_gap,
|
||||
}
|
||||
|
||||
|
||||
STAT_MULT_RANGE = (0.9, 1.1)
|
||||
|
||||
|
||||
def compute_stat_mults(static_units: dict) -> dict:
|
||||
"""Stat-Stärke relativ zur eigenen Kostenstufe: (eHP + Offense) / 2, gekappt.
|
||||
|
||||
Offense = Maximum aus Auto-DPS und Cast-Rate (je normalisiert), damit
|
||||
Caster nicht gegen Auto-Attacker abfallen.
|
||||
"""
|
||||
proxies = {api: stat_proxies(u["stats"]) for api, u in static_units.items()}
|
||||
by_cost: dict[int, list[str]] = {}
|
||||
for api, u in static_units.items():
|
||||
by_cost.setdefault(u["cost"], []).append(api)
|
||||
|
||||
mults = {}
|
||||
for apis in by_cost.values():
|
||||
n = len(apis)
|
||||
avg = {
|
||||
key: sum(proxies[a][key] for a in apis) / n or 1.0
|
||||
for key in ("ehp", "auto_dps", "cast_rate")
|
||||
}
|
||||
for a in apis:
|
||||
ehp_norm = proxies[a]["ehp"] / avg["ehp"]
|
||||
offense = max(
|
||||
proxies[a]["auto_dps"] / avg["auto_dps"],
|
||||
proxies[a]["cast_rate"] / avg["cast_rate"],
|
||||
)
|
||||
raw = (ehp_norm + offense) / 2
|
||||
mults[a] = round(min(max(raw, STAT_MULT_RANGE[0]), STAT_MULT_RANGE[1]), 4)
|
||||
return mults
|
||||
|
||||
|
||||
def classify_role(unit: dict) -> str:
|
||||
"""frontline | ad_carry | ap_carry | utility, from cdragon role with stat fallback."""
|
||||
role = (unit.get("role") or "").lower()
|
||||
@@ -64,19 +18,3 @@ def classify_role(unit: dict) -> str:
|
||||
return "utility"
|
||||
stats = unit["stats"]
|
||||
return "frontline" if stats["range"] <= 1 else "ad_carry"
|
||||
|
||||
|
||||
# Default multipliers, replaced by learned values when the artifact has them.
|
||||
DEFAULT_ITEM_MULT = 1.15
|
||||
ROLE_FIT_BONUS = 1.05
|
||||
DEFAULT_TRAIT_TIER_MULT = [1.0, 1.03, 1.07, 1.12, 1.20] # index = reached breakpoint ordinal
|
||||
|
||||
# Item tags that fit a role (checked against item api_name, crude but stable).
|
||||
AD_HINTS = ("Deathblade", "InfinityEdge", "GiantSlayer", "LastWhisper", "RunaansHurricane", "GuinsoosRageblade")
|
||||
AP_HINTS = ("RabadonsDeathcap", "ArchangelsStaff", "JeweledGauntlet", "HextechGunblade", "NashorsTooth", "Morellonomicon")
|
||||
TANK_HINTS = ("BrambleVest", "DragonsClaw", "WarmogsArmor", "GargoyleStoneplate", "Redemption", "SunfireCape")
|
||||
|
||||
|
||||
def item_fits_role(item_api_name: str, role: str) -> bool:
|
||||
hints = {"ad_carry": AD_HINTS, "ap_carry": AP_HINTS, "frontline": TANK_HINTS}.get(role, ())
|
||||
return any(h in item_api_name for h in hints)
|
||||
|
||||
@@ -81,38 +81,3 @@ def calibrate(conn, artifact: dict, holdout_only: bool = False, score_fn=None) -
|
||||
"matches": n,
|
||||
"mean_spearman": sum(correlations) / n if n else 0.0,
|
||||
}
|
||||
|
||||
|
||||
def fit_tau(conn, artifact: dict, score_fn) -> dict:
|
||||
"""Tau per Log-Loss über alle Platzierungspaare der Holdout-Matches fitten."""
|
||||
import math
|
||||
|
||||
by_match = _holdout_scores(conn, artifact, True, score_fn)
|
||||
pairs = []
|
||||
for players in by_match.values():
|
||||
if len(players) < 8:
|
||||
continue
|
||||
for i in range(len(players)):
|
||||
for j in range(i + 1, len(players)):
|
||||
(pl_a, s_a), (pl_b, s_b) = players[i], players[j]
|
||||
mean = (s_a + s_b) / 2
|
||||
if mean <= 0:
|
||||
continue
|
||||
d = (s_a - s_b) / mean
|
||||
pairs.append((d, pl_a < pl_b)) # kleinere Platzierung = besser
|
||||
|
||||
if not pairs:
|
||||
return {"tau": None, "pairs": 0}
|
||||
|
||||
def log_loss(tau: float) -> float:
|
||||
eps = 1e-9
|
||||
total = 0.0
|
||||
for d, a_wins in pairs:
|
||||
p = 1 / (1 + math.exp(-d / tau))
|
||||
p = min(max(p, eps), 1 - eps)
|
||||
total += -math.log(p if a_wins else 1 - p)
|
||||
return total / len(pairs)
|
||||
|
||||
taus = [t / 100 for t in range(2, 51, 2)]
|
||||
best = min(taus, key=log_loss)
|
||||
return {"tau": best, "pairs": len(pairs), "log_loss": round(log_loss(best), 4)}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
"""The one board-scoring entry point. Sim, bots, calibration, and UI all call this."""
|
||||
"""The one board-scoring entry point. Sim, bots, calibration, and UI all call this.
|
||||
|
||||
Off/Def-Modell: Off = Auto- + Spell-DPS, Def = eHP — pro Unit aus exakten
|
||||
cdragon-Werten (Sterne und Items eingerechnet). Traits und Augments werden
|
||||
bewusst ignoriert (spätere Ausbaustufe); active_trait_tiers bleibt fürs UI.
|
||||
"""
|
||||
|
||||
from tft.model import baseline, statsheet
|
||||
|
||||
@@ -27,132 +32,34 @@ def active_trait_tiers(board_units: list[dict], static_traits: dict, static_unit
|
||||
return tiers
|
||||
|
||||
|
||||
def _apply_shared_multipliers(total: float, board_units: list[dict], augments: list[str],
|
||||
learned: dict) -> float:
|
||||
augment_mults = learned.get("augments", {})
|
||||
for augment in augments:
|
||||
total *= augment_mults.get(augment, 1.0)
|
||||
def unit_profile(u: dict, artifact: dict) -> dict | None:
|
||||
"""Kampfprofil einer Board-Unit: {"off", "def", "defensive", "unit"}.
|
||||
|
||||
pair_lifts = learned.get("pairs", {})
|
||||
names = sorted({u["api_name"] for u in board_units})
|
||||
lift_sum = sum(
|
||||
pair_lifts.get(f"{a}|{b}", 0.0)
|
||||
for i, a in enumerate(names)
|
||||
for b in names[i + 1 :]
|
||||
"defensive" steuert die Zielreihenfolge im Kampf: Frontline/Utility
|
||||
sterben zuerst, Carries zuletzt.
|
||||
"""
|
||||
unit = artifact["static"]["units"].get(u["api_name"])
|
||||
if not unit:
|
||||
return None
|
||||
role = artifact.get("roles", {}).get(u["api_name"]) or baseline.classify_role(unit)
|
||||
# role=None: der Frontline-Cast-Bonus verschlechtert die Holdout-Korrelation
|
||||
# (0.659 vs. 0.679) — die Rolle steuert nur die Zielreihenfolge im Kampf.
|
||||
stats = statsheet.unit_stats(
|
||||
unit, u["stars"], u.get("items", []), artifact["static"]["items"],
|
||||
None, None, spell_cap=artifact.get("spell_dps_cap"),
|
||||
)
|
||||
return total * (1 + min(max(lift_sum * 0.01, -0.10), 0.10))
|
||||
return {"off": stats["dps"], "def": stats["ehp"],
|
||||
"defensive": role in ("frontline", "utility"), "unit": u}
|
||||
|
||||
|
||||
def score_board_legacy(board_units: list[dict], augments: list[str], artifact: dict) -> float:
|
||||
"""Heuristik-Scorer: Kostenwert × Multiplikatoren."""
|
||||
static_units = artifact["static"]["units"]
|
||||
static_traits = artifact["static"]["traits"]
|
||||
learned = artifact.get("learned", {})
|
||||
unit_mults = learned.get("units", {})
|
||||
item_mults = learned.get("items", {})
|
||||
trait_mults = learned.get("traits", {})
|
||||
roles = artifact.get("roles", {})
|
||||
stat_mults = artifact.get("stat_mults", {})
|
||||
|
||||
total = 0.0
|
||||
for u in board_units:
|
||||
unit = static_units.get(u["api_name"])
|
||||
if not unit:
|
||||
continue
|
||||
value = (
|
||||
baseline.unit_value(unit["cost"], u["stars"])
|
||||
* stat_mults.get(u["api_name"], 1.0)
|
||||
* unit_mults.get(u["api_name"], 1.0)
|
||||
)
|
||||
role = roles.get(u["api_name"]) or baseline.classify_role(unit)
|
||||
for item in u.get("items", []):
|
||||
mult = item_mults.get(item, baseline.DEFAULT_ITEM_MULT)
|
||||
if baseline.item_fits_role(item, role):
|
||||
mult *= baseline.ROLE_FIT_BONUS
|
||||
value *= mult
|
||||
total += value
|
||||
|
||||
for trait, ordinal in active_trait_tiers(board_units, static_traits, static_units).items():
|
||||
default = baseline.DEFAULT_TRAIT_TIER_MULT[
|
||||
min(ordinal, len(baseline.DEFAULT_TRAIT_TIER_MULT) - 1)
|
||||
]
|
||||
total *= trait_mults.get(f"{trait}@{ordinal}", default)
|
||||
|
||||
return _apply_shared_multipliers(total, board_units, augments, learned)
|
||||
def board_profiles(board_units: list[dict], artifact: dict) -> list[dict]:
|
||||
return [p for u in board_units if (p := unit_profile(u, artifact))]
|
||||
|
||||
|
||||
def score_mechanical(board_units: list[dict], augments: list[str], artifact: dict) -> float:
|
||||
"""Kampf-Approximation: strength = sqrt(Σ eHP × Σ DPS) aus exakten Stats."""
|
||||
static_units = artifact["static"]["units"]
|
||||
static_items = artifact["static"]["items"]
|
||||
static_traits = artifact["static"]["traits"]
|
||||
learned = artifact.get("learned", {})
|
||||
unit_mults = learned.get("units", {})
|
||||
item_mults = learned.get("items", {})
|
||||
trait_mults = learned.get("traits", {})
|
||||
roles = artifact.get("roles", {})
|
||||
|
||||
tiers = active_trait_tiers(board_units, static_traits, static_units)
|
||||
team_buffs, recognized = statsheet.trait_buffs(tiers, static_traits)
|
||||
# Augment-Team-Buffs (z.B. "+35 Health für dein Team") in die Stats mischen.
|
||||
for a in augments:
|
||||
for atom in artifact.get("augment_specs", {}).get(a, {}).get("atoms", []):
|
||||
if atom["kind"] == "team_buff":
|
||||
team_buffs[atom["stat"]] += atom["value"]
|
||||
spell_cap = artifact.get("spell_dps_cap")
|
||||
tier_profiles = artifact.get("tier_profiles", {})
|
||||
|
||||
# Eigenschafts-Bewertung: eHP = defensiv, DPS = offensiv. Der Stufen-Maßstab
|
||||
# ist der gemessene Tier-Durchschnitt; Ratio-Cap fängt Extraktionsfehler.
|
||||
RATIO_CAP = (0.5, 2.0)
|
||||
STAR_VALUE = 3.0 # Kopienwert pro Sternstufe (Endboard-validiert)
|
||||
|
||||
total_ehp = 0.0
|
||||
total_dps = 0.0
|
||||
for u in board_units:
|
||||
unit = static_units.get(u["api_name"])
|
||||
if not unit:
|
||||
continue
|
||||
profile = statsheet.unit_stats(
|
||||
unit, u["stars"], u.get("items", []), static_items,
|
||||
team_buffs, roles.get(u["api_name"]), spell_cap=spell_cap,
|
||||
)
|
||||
mult = unit_mults.get(u["api_name"], 1.0)
|
||||
ref = tier_profiles.get(str(unit["cost"]))
|
||||
if ref:
|
||||
star_ehp = statsheet.HP_STAR_MULT ** (u["stars"] - 1)
|
||||
star_dps = statsheet.AD_STAR_MULT ** (u["stars"] - 1)
|
||||
r_ehp = min(max(profile["ehp"] / (ref["ehp"] * star_ehp), RATIO_CAP[0]), RATIO_CAP[1])
|
||||
r_dps = min(max(profile["dps"] / (ref["dps"] * star_dps), RATIO_CAP[0]), RATIO_CAP[1])
|
||||
star = STAR_VALUE ** (u["stars"] - 1)
|
||||
total_ehp += mult * ref["ehp"] * star * r_ehp
|
||||
total_dps += mult * ref["dps"] * star * r_dps
|
||||
else:
|
||||
total_ehp += mult * profile["ehp"]
|
||||
total_dps += mult * profile["dps"]
|
||||
|
||||
# /100: nur Anzeige-Skalierung, für den Kampfvergleich irrelevant.
|
||||
strength = (total_ehp * total_dps) ** 0.5 / 100
|
||||
|
||||
for trait, ordinal in tiers.items():
|
||||
key = f"{trait}@{ordinal}"
|
||||
if trait in recognized:
|
||||
# Buff steckt schon in den Stats — nur gelerntes Residuum.
|
||||
strength *= trait_mults.get(key, 1.0)
|
||||
else:
|
||||
default = baseline.DEFAULT_TRAIT_TIER_MULT[
|
||||
min(ordinal, len(baseline.DEFAULT_TRAIT_TIER_MULT) - 1)
|
||||
]
|
||||
strength *= trait_mults.get(key, default)
|
||||
|
||||
for u in board_units:
|
||||
for item in u.get("items", []):
|
||||
modeled = statsheet.item_is_modeled(static_items.get(item))
|
||||
strength *= item_mults.get(item, 1.0 if modeled else baseline.DEFAULT_ITEM_MULT)
|
||||
|
||||
return _apply_shared_multipliers(strength, board_units, augments, learned)
|
||||
|
||||
|
||||
# Aktiver Scorer: mechanical (A/B 23.07.: 0.724 vs. legacy 0.695 auf 38 Holdout-Matches).
|
||||
# Legacy bleibt für `calibrate --compare` erhalten.
|
||||
score_board = score_mechanical
|
||||
def score_board(board_units: list[dict], augments: list[str], artifact: dict) -> float:
|
||||
"""sqrt(Σoff × Σdef). augments bleibt nur für Signatur-Kompatibilität."""
|
||||
profiles = board_profiles(board_units, artifact)
|
||||
off = sum(p["off"] for p in profiles)
|
||||
dfn = sum(p["def"] for p in profiles)
|
||||
# /100: nur Anzeige-Skalierung, für Vergleiche irrelevant.
|
||||
return (off * dfn) ** 0.5 / 100
|
||||
|
||||
@@ -13,8 +13,11 @@ MANA_PER_ATTACK = 10
|
||||
FRONTLINE_MANA_PER_SEC = 10
|
||||
CAST_RATE_CAP = 1.5
|
||||
|
||||
SPELL_AUTO_CAP = 3.0 # Spell-DPS max. 3× eigene Auto-DPS (Ausreißer-Guard)
|
||||
|
||||
MAPPED_ITEM_KEYS = ("AD", "AP", "AS", "CritChance", "Health", "Armor",
|
||||
"MagicResist", "ManaRegen")
|
||||
"MagicResist", "ManaRegen", "BonusDamage", "DamageAmp",
|
||||
"BonusPercentHP", "CritDamageToGive")
|
||||
|
||||
_TOKEN_RE = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|\d+")
|
||||
# Variablen mit diesen Tokens sind Mechanik-Parameter, keine Stat-Buffs.
|
||||
@@ -25,7 +28,8 @@ _SKIP_TOKENS = {"duration", "threshold", "rounds", "per", "num", "gold",
|
||||
def _empty_acc() -> dict:
|
||||
return {"ad_pct": 0.0, "ap_flat": 0.0, "as_pct": 0.0, "hp_flat": 0.0,
|
||||
"hp_pct": 0.0, "armor_flat": 0.0, "mr_flat": 0.0,
|
||||
"crit_chance": 0.0, "dr": 0.0, "mana_regen": 0.0, "damage_amp": 0.0}
|
||||
"crit_chance": 0.0, "crit_dmg": 0.0, "dr": 0.0, "mana_regen": 0.0,
|
||||
"damage_amp": 0.0}
|
||||
|
||||
|
||||
def _apply_item_effects(item_apis: list[str], static_items: dict, acc: dict) -> None:
|
||||
@@ -50,6 +54,12 @@ def _apply_item_effects(item_apis: list[str], static_items: dict, acc: dict) ->
|
||||
acc["mr_flat"] += val
|
||||
elif key == "ManaRegen":
|
||||
acc["mana_regen"] += val
|
||||
elif key in ("BonusDamage", "DamageAmp"):
|
||||
acc["damage_amp"] += _fraction(val)
|
||||
elif key == "BonusPercentHP":
|
||||
acc["hp_pct"] += _fraction(val)
|
||||
elif key == "CritDamageToGive":
|
||||
acc["crit_dmg"] += _fraction(val)
|
||||
# alle übrigen Keys: bespoke Mechanik, bewusst ignoriert
|
||||
|
||||
|
||||
@@ -139,6 +149,7 @@ def unit_stats(unit: dict, stars: int, item_apis: list[str], static_items: dict,
|
||||
|
||||
as_eff = aspd * (1 + acc["as_pct"])
|
||||
crit_c = min(crit + acc["crit_chance"], 1.0)
|
||||
crit_mult += acc["crit_dmg"]
|
||||
auto_dps = ad * (1 + acc["ad_pct"]) * as_eff * (1 + crit_c * (crit_mult - 1))
|
||||
|
||||
spell_dps = 0.0
|
||||
@@ -156,10 +167,13 @@ def unit_stats(unit: dict, stars: int, item_apis: list[str], static_items: dict,
|
||||
CAST_RATE_CAP,
|
||||
)
|
||||
spell_dps = dmg * cast_rate
|
||||
# Ausreißer-Guard: Spell-Rohwerte sind zwischen Champions nicht
|
||||
# vergleichbar (per-Hit vs. total). Relativer Deckel: max. 3× eigene
|
||||
# Auto-DPS; das Populations-Perzentil dient als Floor für AD-lose Caster.
|
||||
limit = SPELL_AUTO_CAP * auto_dps
|
||||
if spell_cap is not None:
|
||||
# Ausreißer-Guard: Spell-Rohwerte sind zwischen Champions nicht
|
||||
# vergleichbar (per-Hit vs. total) — Kappung am Populations-Perzentil.
|
||||
spell_dps = min(spell_dps, spell_cap * AD_STAR_MULT ** (stars - 1))
|
||||
limit = max(limit, spell_cap * AD_STAR_MULT ** (stars - 1))
|
||||
spell_dps = min(spell_dps, limit)
|
||||
|
||||
return {
|
||||
"ehp": ehp,
|
||||
|
||||
@@ -1,27 +1,107 @@
|
||||
"""Combat = score comparison. Relative score difference -> win probability -> damage."""
|
||||
"""Deterministischer Kampf: Gesamt-Offense arbeitet die Gegner-Units nacheinander ab.
|
||||
|
||||
Zielreihenfolge pro Seite: erst defensive Units (seeded zufällig gemischt),
|
||||
dann offensive. Tote Units tragen keine Offense mehr — die Gesamt-Offense
|
||||
einer Seite ist die Suffix-Summe ab dem aktuellen Ziel.
|
||||
|
||||
Ereignisgesteuert statt Frame-Schleife: zwischen zwei Todesereignissen sind
|
||||
beide Offensen konstant, die Zeit bis zum nächsten Kill ist exakt def/off.
|
||||
Das ist der Grenzwert der 60-FPS-Rechnung (Überschuss-Schaden trägt verlustfrei
|
||||
über); die Frames dienen nur als Raster für Dauer und Zeitlimit.
|
||||
"""
|
||||
|
||||
import math
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
|
||||
FPS = 60
|
||||
EPS = 1e-9
|
||||
|
||||
|
||||
def win_probability(score_a: float, score_b: float, cfg: dict) -> float:
|
||||
mean = (score_a + score_b) / 2
|
||||
if mean <= 0:
|
||||
return 0.5
|
||||
d = (score_a - score_b) / mean
|
||||
return 1 / (1 + math.exp(-d / cfg["combat"]["tau"]))
|
||||
@dataclass
|
||||
class FightResult:
|
||||
winner: str | None # "a" | "b" | None = Unentschieden
|
||||
frames: int
|
||||
survivors_a: list[dict]
|
||||
survivors_b: list[dict]
|
||||
|
||||
|
||||
def resolve(score_a: float, score_b: float, cfg: dict, rng: random.Random) -> bool:
|
||||
"""True if A wins. Adds noise on top of the probability."""
|
||||
noise = rng.gauss(0, cfg["combat"]["variance"])
|
||||
return rng.random() < min(max(win_probability(score_a, score_b, cfg) + noise, 0.02), 0.98)
|
||||
def _queue(team: list[dict], rng: random.Random) -> list[dict]:
|
||||
"""Sterbereihenfolge: defensive Units zuerst, innerhalb der Gruppe zufällig."""
|
||||
defensive = [p for p in team if p["defensive"]]
|
||||
offensive = [p for p in team if not p["defensive"]]
|
||||
rng.shuffle(defensive)
|
||||
rng.shuffle(offensive)
|
||||
return defensive + offensive
|
||||
|
||||
|
||||
def damage(stage: int, winner_score: float, loser_score: float, winner_units: int, cfg: dict) -> int:
|
||||
def _suffix_off(queue: list[dict]) -> list[float]:
|
||||
suffix = [0.0] * (len(queue) + 1)
|
||||
for i in range(len(queue) - 1, -1, -1):
|
||||
suffix[i] = suffix[i + 1] + queue[i]["off"]
|
||||
return suffix
|
||||
|
||||
|
||||
def fight(team_a: list[dict], team_b: list[dict], rng: random.Random,
|
||||
cfg: dict) -> FightResult:
|
||||
"""team_a/team_b: Profile aus score.board_profiles(). Beide Seiten ticken
|
||||
gleichzeitig; same seed -> same outcome."""
|
||||
qa, qb = _queue(team_a, rng), _queue(team_b, rng)
|
||||
off_a, off_b = _suffix_off(qa), _suffix_off(qb)
|
||||
limit = cfg["combat"]["max_frames"] / FPS
|
||||
|
||||
pa = pb = 0 # Zeiger auf die aktuell beschossene eigene Unit
|
||||
ra = qa[0]["def"] if qa else 0.0 # deren Rest-Defense
|
||||
rb = qb[0]["def"] if qb else 0.0
|
||||
elapsed = 0.0
|
||||
|
||||
while pa < len(qa) and pb < len(qb):
|
||||
oa, ob = off_a[pa], off_b[pb]
|
||||
ta = rb / oa if oa > EPS else math.inf # Zeit bis A das B-Ziel killt
|
||||
tb = ra / ob if ob > EPS else math.inf
|
||||
dt = min(ta, tb)
|
||||
if math.isinf(dt) or elapsed + dt >= limit:
|
||||
dt = limit - elapsed
|
||||
ra -= ob * dt
|
||||
rb -= oa * dt
|
||||
elapsed = limit
|
||||
break
|
||||
elapsed += dt
|
||||
a_kills = ta <= tb + EPS
|
||||
b_kills = tb <= ta + EPS
|
||||
if a_kills:
|
||||
pb += 1
|
||||
rb = qb[pb]["def"] if pb < len(qb) else 0.0
|
||||
else:
|
||||
rb -= oa * dt
|
||||
if b_kills:
|
||||
pa += 1
|
||||
ra = qa[pa]["def"] if pa < len(qa) else 0.0
|
||||
else:
|
||||
ra -= ob * dt
|
||||
|
||||
a_alive, b_alive = pa < len(qa), pb < len(qb)
|
||||
if a_alive and b_alive:
|
||||
# Zeitlimit erreicht: mehr verbleibende Gesamt-Defense gewinnt.
|
||||
rest_a = ra + sum(p["def"] for p in qa[pa + 1:])
|
||||
rest_b = rb + sum(p["def"] for p in qb[pb + 1:])
|
||||
winner = "a" if rest_a > rest_b + EPS else "b" if rest_b > rest_a + EPS else None
|
||||
elif a_alive:
|
||||
winner = "a"
|
||||
elif b_alive:
|
||||
winner = "b"
|
||||
else:
|
||||
winner = None
|
||||
|
||||
return FightResult(
|
||||
winner=winner,
|
||||
frames=math.ceil(elapsed * FPS),
|
||||
survivors_a=[p["unit"] for p in qa[pa:]],
|
||||
survivors_b=[p["unit"] for p in qb[pb:]],
|
||||
)
|
||||
|
||||
|
||||
def player_damage(stage: int, survivors: list[dict], cfg: dict) -> int:
|
||||
d = cfg["damage"]
|
||||
base = d["stage_base"][min(stage - 1, len(d["stage_base"]) - 1)]
|
||||
mean = (winner_score + loser_score) / 2 or 1
|
||||
margin = abs(winner_score - loser_score) / mean
|
||||
surviving = max(1, round(winner_units * min(margin, 1.0)))
|
||||
return base + d["per_surviving_unit"] * surviving
|
||||
return base + d["per_surviving_unit"] * len(survivors)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import random
|
||||
|
||||
from tft.model.score import board_profiles
|
||||
from tft.sim import combat, economy, player, policy
|
||||
from tft.sim.player import BENCH_SIZE, MAX_ITEMS_PER_UNIT, InvalidAction, PlayerState # noqa: F401
|
||||
from tft.sim.pool import Pool
|
||||
@@ -227,17 +228,19 @@ class Game:
|
||||
pairs, odd, ghost_src = pair_players(self._alive(), self.rng)
|
||||
for a, b in pairs:
|
||||
a_wins = self._fight(rnd, a, b)
|
||||
results[a.name] = a_wins
|
||||
results[b.name] = not a_wins
|
||||
# Unentschieden (None) zählt für beide als nicht gewonnen.
|
||||
results[a.name] = a_wins is True
|
||||
results[b.name] = a_wins is False
|
||||
if odd is not None:
|
||||
# Ghost-Kampf: Klon-Board, Schaden nur beim echten Spieler.
|
||||
s_odd = player.score(odd, self.artifact)
|
||||
s_ghost = player.score(ghost_src, self.artifact)
|
||||
won = combat.resolve(s_odd, s_ghost, self.cfg, self.rng)
|
||||
res = combat.fight(board_profiles(odd.board, self.artifact),
|
||||
board_profiles(ghost_src.board, self.artifact),
|
||||
self.rng, self.cfg)
|
||||
won = res.winner == "a"
|
||||
results[odd.name] = won
|
||||
if not won:
|
||||
dmg = combat.damage(rnd["stage"], s_ghost, s_odd,
|
||||
len(ghost_src.board), self.cfg)
|
||||
survivors = res.survivors_b if res.winner == "b" else []
|
||||
dmg = combat.player_damage(rnd["stage"], survivors, self.cfg)
|
||||
odd.hp -= dmg
|
||||
if odd is self.player:
|
||||
self.log.append(
|
||||
@@ -246,13 +249,23 @@ class Game:
|
||||
self.log.append(f"{rnd['label']}: Sieg vs Ghost ({ghost_src.name})")
|
||||
return results
|
||||
|
||||
def _fight(self, rnd: dict, a: PlayerState, b: PlayerState) -> bool:
|
||||
sa = player.score(a, self.artifact)
|
||||
sb = player.score(b, self.artifact)
|
||||
a_wins = combat.resolve(sa, sb, self.cfg, self.rng)
|
||||
def _fight(self, rnd: dict, a: PlayerState, b: PlayerState) -> bool | None:
|
||||
res = combat.fight(board_profiles(a.board, self.artifact),
|
||||
board_profiles(b.board, self.artifact),
|
||||
self.rng, self.cfg)
|
||||
if res.winner is None:
|
||||
# Unentschieden: beide nehmen den Basis-Schaden der Stage.
|
||||
dmg = combat.player_damage(rnd["stage"], [], self.cfg)
|
||||
a.hp -= dmg
|
||||
b.hp -= dmg
|
||||
if a is self.player or b is self.player:
|
||||
other = b if a is self.player else a
|
||||
self.log.append(f"{rnd['label']}: Unentschieden vs {other.name} (-{dmg} HP)")
|
||||
return None
|
||||
a_wins = res.winner == "a"
|
||||
winner, loser = (a, b) if a_wins else (b, a)
|
||||
w_score, l_score = (sa, sb) if a_wins else (sb, sa)
|
||||
dmg = combat.damage(rnd["stage"], w_score, l_score, len(winner.board), self.cfg)
|
||||
survivors = res.survivors_a if a_wins else res.survivors_b
|
||||
dmg = combat.player_damage(rnd["stage"], survivors, self.cfg)
|
||||
loser.hp -= dmg
|
||||
if a is self.player or b is self.player:
|
||||
if winner is self.player:
|
||||
|
||||
Reference in New Issue
Block a user