update
This commit is contained in:
92
backend/tests/test_combat.py
Normal file
92
backend/tests/test_combat.py
Normal file
@@ -0,0 +1,92 @@
|
||||
import random
|
||||
|
||||
from tft.sim import combat
|
||||
|
||||
|
||||
def prof(off, dfn, defensive=True, name="u"):
|
||||
return {"off": off, "def": dfn, "defensive": defensive,
|
||||
"unit": {"api_name": name, "stars": 1, "items": []}}
|
||||
|
||||
|
||||
CFG = {
|
||||
"combat": {"max_frames": 1800},
|
||||
"damage": {"stage_base": [0, 2, 5, 8, 10, 12, 17, 17],
|
||||
"per_surviving_unit": 1, "player_hp": 100},
|
||||
}
|
||||
|
||||
|
||||
def test_kill_time_matches_dps_at_60fps():
|
||||
# Def 10 gegen Gesamt-Off 5 -> 2 s = 120 Frames.
|
||||
a = [prof(5, 1000)]
|
||||
b = [prof(0, 10)]
|
||||
res = combat.fight(a, b, random.Random(1), CFG)
|
||||
assert res.winner == "a"
|
||||
assert res.frames == 120
|
||||
assert res.survivors_b == []
|
||||
|
||||
|
||||
def test_overflow_carries_to_next_unit():
|
||||
# Zwei Units à 10 Def gegen Off 5 -> exakt 4 s, kein Schaden verpufft.
|
||||
a = [prof(5, 1000)]
|
||||
b = [prof(0, 10), prof(0, 10)]
|
||||
res = combat.fight(a, b, random.Random(1), CFG)
|
||||
assert res.winner == "a"
|
||||
assert res.frames == 240
|
||||
|
||||
|
||||
def test_dead_units_stop_contributing_offense():
|
||||
# B killt As Front nach 2 s; danach kämpft A nur noch mit halber Offense.
|
||||
a = [prof(6, 12, defensive=True), prof(6, 1000, defensive=False)]
|
||||
b = [prof(6, 60)]
|
||||
res = combat.fight(a, b, random.Random(1), CFG)
|
||||
assert res.winner == "a"
|
||||
assert res.frames == 480 # 2 s + 36/6 s statt 60/12 s bei konstanter Offense
|
||||
assert len(res.survivors_a) == 1
|
||||
|
||||
|
||||
def test_queue_targets_defensive_units_first():
|
||||
team = [prof(1, 1, defensive=False, name="carry1"),
|
||||
prof(1, 1, defensive=True, name="tank1"),
|
||||
prof(1, 1, defensive=False, name="carry2"),
|
||||
prof(1, 1, defensive=True, name="tank2")]
|
||||
q = combat._queue(team, random.Random(5))
|
||||
assert [p["defensive"] for p in q] == [True, True, False, False]
|
||||
|
||||
|
||||
def test_simultaneous_last_kill_is_draw():
|
||||
a = [prof(5, 10)]
|
||||
b = [prof(5, 10)]
|
||||
res = combat.fight(a, b, random.Random(1), CFG)
|
||||
assert res.winner is None
|
||||
assert res.survivors_a == [] and res.survivors_b == []
|
||||
|
||||
|
||||
def test_empty_boards():
|
||||
res = combat.fight([], [], random.Random(1), CFG)
|
||||
assert res.winner is None and res.frames == 0
|
||||
res = combat.fight([prof(1, 1)], [], random.Random(1), CFG)
|
||||
assert res.winner == "a"
|
||||
|
||||
|
||||
def test_stall_hits_time_limit_more_defense_wins():
|
||||
a = [prof(0, 100)]
|
||||
b = [prof(0, 50)]
|
||||
res = combat.fight(a, b, random.Random(1), CFG)
|
||||
assert res.winner == "a"
|
||||
assert res.frames == CFG["combat"]["max_frames"]
|
||||
|
||||
|
||||
def test_same_seed_same_result():
|
||||
team_a = [prof(7, 300), prof(12, 150, defensive=False), prof(5, 400)]
|
||||
team_b = [prof(9, 250), prof(10, 200, defensive=False)]
|
||||
r1 = combat.fight(team_a, team_b, random.Random(42), CFG)
|
||||
r2 = combat.fight(team_a, team_b, random.Random(42), CFG)
|
||||
assert (r1.winner, r1.frames) == (r2.winner, r2.frames)
|
||||
assert [p["api_name"] for p in r1.survivors_a] == \
|
||||
[p["api_name"] for p in r2.survivors_a]
|
||||
|
||||
|
||||
def test_player_damage_counts_exact_survivors():
|
||||
survivors = [{"api_name": f"u{i}"} for i in range(4)]
|
||||
assert combat.player_damage(3, survivors, CFG) == 5 + 4
|
||||
assert combat.player_damage(1, [], CFG) == 0
|
||||
@@ -173,17 +173,24 @@ def test_pairing():
|
||||
|
||||
def test_ghost_source_takes_no_damage(art, cfg):
|
||||
game = Game(art, cfg, seed=3)
|
||||
game.bots[0].hp = 0
|
||||
game.bots[0].placement = 8
|
||||
game.bots[0].board, game.bots[0].bench = [], []
|
||||
# 7 Lebende -> jede PvP-Runde hat einen Ghost-Kampf; HP-Summe der
|
||||
# Ghost-Quelle darf nur durch ihren echten Kampf sinken (kein Doppelschaden).
|
||||
hp_before = {p.name: p.hp for p in game.players}
|
||||
while game.round["kind"] != "pvp":
|
||||
# Auf 3 Lebende reduzieren -> genau 1 Paar + 1 Ghost-Kampf.
|
||||
for b in game.bots[2:]:
|
||||
b.hp, b.placement, b.board, b.bench = 0, 8, [], []
|
||||
boards = [
|
||||
(game.player, [{"api_name": "U5_0", "stars": 3, "items": []}]),
|
||||
(game.bots[0], [{"api_name": "U3_0", "stars": 2, "items": []}]),
|
||||
(game.bots[1], [{"api_name": "U1_0", "stars": 1, "items": []}]),
|
||||
]
|
||||
for p, board in boards:
|
||||
p.board, p.bench, p.gold = board, [], 0
|
||||
game.idx = next(i for i, r in enumerate(game.rounds) if r["kind"] == "pvp")
|
||||
hp_before = {p.name: p.hp for p in game._alive()}
|
||||
game.step()
|
||||
game.step()
|
||||
drops = sum(1 for p in game.players if p.hp < hp_before[p.name])
|
||||
assert drops <= 4 # max. 3 Paar-Verlierer + 1 Ghost-Verlierer
|
||||
# Nur Paar-Verlierer + ggf. Ghost-Verlierer nehmen Schaden; die
|
||||
# Ghost-Quelle darf durch ihren Klon-Kampf nicht getroffen werden.
|
||||
drops = sum(1 for p in game.players if p.name in hp_before
|
||||
and p.hp < hp_before[p.name])
|
||||
assert drops <= 2
|
||||
|
||||
|
||||
def test_pve_freezes_streak_and_pays_no_win_bonus(art, cfg):
|
||||
@@ -279,13 +286,3 @@ def test_augment_effects_apply(art, cfg):
|
||||
game.pick_augment(0)
|
||||
assert len(game.player.bench) == bench_before + 2
|
||||
assert sum(game.pool.available(a) for a in game.pool.copies) == pool_before - 2
|
||||
|
||||
|
||||
def test_team_buff_augment_raises_score(art, cfg):
|
||||
from tft.model.score import score_mechanical
|
||||
buffed_art = {**art, "augment_specs": {
|
||||
"AUG_BUFF": {"atoms": [{"kind": "team_buff", "stat": "hp_pct", "value": 0.3}],
|
||||
"offerable": True}}}
|
||||
board = [{"api_name": "U2_0", "stars": 2, "items": []}]
|
||||
assert score_mechanical(board, ["AUG_BUFF"], buffed_art) > \
|
||||
score_mechanical(board, [], buffed_art)
|
||||
|
||||
@@ -2,7 +2,7 @@ import pytest
|
||||
|
||||
from tft.model.baseline import classify_role, unit_value
|
||||
from tft.model.calibrate import spearman
|
||||
from tft.model.score import active_trait_tiers, score_board
|
||||
from tft.model.score import active_trait_tiers, board_profiles, score_board
|
||||
|
||||
STATIC = {
|
||||
"units": {
|
||||
@@ -28,8 +28,15 @@ STATIC = {
|
||||
},
|
||||
}
|
||||
|
||||
ARTIFACT = {"meta": {"set": 17}, "static": {**STATIC, "items": {}, "augments": {}},
|
||||
"roles": {}, "learned": {}}
|
||||
ARTIFACT = {
|
||||
"meta": {"set": 17},
|
||||
"static": {
|
||||
**STATIC,
|
||||
"items": {"TFT_Item_InfinityEdge": {"effects": {"AD": 0.35, "CritChance": 35.0}}},
|
||||
"augments": {},
|
||||
},
|
||||
"roles": {},
|
||||
}
|
||||
|
||||
|
||||
def test_unit_value():
|
||||
@@ -57,19 +64,30 @@ def test_score_ordering():
|
||||
assert score_board(strong, [], ARTIFACT) > score_board(weak, [], ARTIFACT)
|
||||
|
||||
|
||||
def test_active_trait_beats_inactive():
|
||||
pair = [{"api_name": "TFT17_A", "stars": 1, "items": []},
|
||||
{"api_name": "TFT17_B", "stars": 1, "items": []}]
|
||||
solo_sum = score_board(pair[:1], [], ARTIFACT) + score_board(pair[1:], [], ARTIFACT)
|
||||
assert score_board(pair, [], ARTIFACT) > solo_sum
|
||||
|
||||
|
||||
def test_items_increase_score():
|
||||
bare = [{"api_name": "TFT17_B", "stars": 1, "items": []}]
|
||||
with_item = [{"api_name": "TFT17_B", "stars": 1, "items": ["TFT_Item_InfinityEdge"]}]
|
||||
assert score_board(with_item, [], ARTIFACT) > score_board(bare, [], ARTIFACT)
|
||||
|
||||
|
||||
def test_augments_ignored():
|
||||
board = [{"api_name": "TFT17_B", "stars": 1, "items": []}]
|
||||
assert score_board(board, ["TFT17_Augment_X"], ARTIFACT) == \
|
||||
score_board(board, [], ARTIFACT)
|
||||
|
||||
|
||||
def test_board_profiles_roles_and_unknown_units():
|
||||
board = [{"api_name": "TFT17_A", "stars": 1, "items": []},
|
||||
{"api_name": "TFT17_B", "stars": 1, "items": []},
|
||||
{"api_name": "UNKNOWN", "stars": 1, "items": []}]
|
||||
profiles = board_profiles(board, ARTIFACT)
|
||||
assert len(profiles) == 2 # unbekannte Units fallen raus
|
||||
tank, carry = profiles
|
||||
assert tank["defensive"] and not carry["defensive"]
|
||||
assert tank["def"] > carry["def"]
|
||||
assert carry["off"] > tank["off"]
|
||||
|
||||
|
||||
def test_spearman():
|
||||
assert spearman([1, 2, 3, 4], [10, 20, 30, 40]) == pytest.approx(1.0)
|
||||
assert spearman([1, 2, 3, 4], [40, 30, 20, 10]) == pytest.approx(-1.0)
|
||||
@@ -88,39 +106,7 @@ def test_craftable_item_pool():
|
||||
assert craftable_items(items) == ["TFT_Item_InfinityEdge"]
|
||||
|
||||
|
||||
def test_stat_mults_rank_within_cost():
|
||||
from tft.model.baseline import compute_stat_mults
|
||||
|
||||
def unit(hp, armor, ad, aspd):
|
||||
return {"cost": 2, "traits": [], "role": "Tank",
|
||||
"stats": {"hp": hp, "armor": armor, "magicResist": armor,
|
||||
"damage": ad, "attackSpeed": aspd, "mana": 60,
|
||||
"initialMana": 0, "range": 1}}
|
||||
|
||||
units = {"tanky": unit(900, 60, 45, 0.6), "avg": unit(700, 40, 55, 0.7),
|
||||
"weak": unit(550, 25, 45, 0.6)}
|
||||
mults = compute_stat_mults(units)
|
||||
assert mults["tanky"] > mults["avg"] > mults["weak"]
|
||||
assert all(0.9 <= m <= 1.1 for m in mults.values())
|
||||
|
||||
|
||||
def test_stat_mults_identical_units_are_neutral():
|
||||
from tft.model.baseline import compute_stat_mults
|
||||
from tests.test_sim import make_static
|
||||
|
||||
mults = compute_stat_mults(make_static()["units"])
|
||||
assert all(m == 1.0 for m in mults.values())
|
||||
|
||||
|
||||
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}}
|
||||
board = [{"api_name": "TFT17_B", "stars": 1, "items": []}]
|
||||
assert score_board(board, [], art) > score_board(board, [], ARTIFACT)
|
||||
|
||||
|
||||
def _mech_artifact():
|
||||
def _built_artifact():
|
||||
from tft.model import artifact as artifact_mod
|
||||
|
||||
static = {
|
||||
@@ -143,30 +129,18 @@ def _mech_artifact():
|
||||
return artifact_mod.build(static)
|
||||
|
||||
|
||||
def test_mechanical_balanced_beats_lopsided():
|
||||
from tft.model.score import score_mechanical
|
||||
art = _mech_artifact()
|
||||
def test_balanced_beats_lopsided():
|
||||
art = _built_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)
|
||||
assert score_board(balanced, [], art) > score_board(tanks, [], art)
|
||||
|
||||
|
||||
def test_mechanical_items_and_stars_raise_strength():
|
||||
from tft.model.score import score_mechanical
|
||||
art = _mech_artifact()
|
||||
def test_items_and_stars_raise_strength():
|
||||
art = _built_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
|
||||
assert score_board(starred, [], art) > score_board(base, [], art)
|
||||
assert score_board(equipped, [], art) > score_board(base, [], art)
|
||||
|
||||
@@ -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 :]
|
||||
)
|
||||
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", {})
|
||||
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"])
|
||||
"defensive" steuert die Zielreihenfolge im Kampf: Frontline/Utility
|
||||
sterben zuerst, Carries zuletzt.
|
||||
"""
|
||||
unit = artifact["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)
|
||||
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"),
|
||||
)
|
||||
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)
|
||||
return {"off": stats["dps"], "def": stats["ehp"],
|
||||
"defensive": role in ("frontline", "utility"), "unit": u}
|
||||
|
||||
|
||||
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)
|
||||
def board_profiles(board_units: list[dict], artifact: dict) -> list[dict]:
|
||||
return [p for u in board_units if (p := unit_profile(u, artifact))]
|
||||
|
||||
|
||||
# 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
|
||||
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))
|
||||
# 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:
|
||||
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