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

View File

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

View File

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

View File

@@ -47,27 +47,12 @@ def component_pool(static_items: dict) -> list[str]:
return sorted(c for c, n in counts.items() if n >= 3)
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"]),

View File

@@ -1,56 +1,10 @@
"""Rule-based baseline: unit values, stat proxies, role classification."""
"""Rule-based baseline: unit values and role classification."""
def unit_value(cost: int, stars: int) -> float:
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)

View File

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

View File

@@ -1,4 +1,9 @@
"""The one board-scoring entry point. Sim, bots, calibration, and UI all call this."""
"""The one board-scoring entry point. Sim, bots, calibration, and UI all call this.
Off/Def-Modell: Off = Auto- + Spell-DPS, Def = eHP — pro Unit aus exakten
cdragon-Werten (Sterne und Items eingerechnet). Traits und Augments werden
bewusst ignoriert (spätere Ausbaustufe); active_trait_tiers bleibt fürs UI.
"""
from tft.model import baseline, statsheet
@@ -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

View File

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

View File

@@ -1,27 +1,107 @@
"""Combat = score comparison. Relative score difference -> win probability -> damage."""
"""Deterministischer Kampf: Gesamt-Offense arbeitet die Gegner-Units nacheinander ab.
Zielreihenfolge pro Seite: erst defensive Units (seeded zufällig gemischt),
dann offensive. Tote Units tragen keine Offense mehr — die Gesamt-Offense
einer Seite ist die Suffix-Summe ab dem aktuellen Ziel.
Ereignisgesteuert statt Frame-Schleife: zwischen zwei Todesereignissen sind
beide Offensen konstant, die Zeit bis zum nächsten Kill ist exakt def/off.
Das ist der Grenzwert der 60-FPS-Rechnung (Überschuss-Schaden trägt verlustfrei
über); die Frames dienen nur als Raster für Dauer und Zeitlimit.
"""
import math
import 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)

View File

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