Mechanisches Boardstärke-Modell: Statsheet aus cdragon-Exaktdaten, sqrt(eHP×DPS), Tier-Anker, Tau-Fit

A/B auf 38 Holdout-Matches: mechanical 0.724 vs legacy 0.695. Promotion durchgeführt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
team3
2026-07-23 08:52:56 +02:00
parent 7c72761190
commit c450841822
10 changed files with 605 additions and 31 deletions

View File

@@ -0,0 +1,77 @@
from tft.staticdata.parse import parse, resolve_spell
RAW = {
"sets": {
"17": {
"name": "Set17",
"champions": [
{
"apiName": "TFT17_Mage",
"name": "Mage",
"cost": 2,
"traits": ["Caster"],
"role": "Caster",
"stats": {"hp": 600, "armor": 25, "magicResist": 25, "damage": 40,
"attackSpeed": 0.7, "mana": 60, "initialMana": 10},
"squareIcon": "ASSETS/x.TFT_Set17.tex",
"ability": {
"name": "Blast",
"desc": "Deal <magicDamage>@ModifiedDamage@ (%i:scaleAP%)</magicDamage> magic damage.",
"variables": [{"name": "Damage", "value": [0, 200, 300, 450, 700, 0, 0]}],
},
},
],
"traits": [
{
"apiName": "TFT17_Caster",
"name": "Caster",
"icon": "ASSETS/t.tex",
"effects": [
{"minUnits": 2, "maxUnits": 3, "style": 1,
"variables": {"BonusAP": 20.0}},
],
},
],
},
},
"items": [
{
"apiName": "TFT_Item_TestSword",
"name": "Test Sword",
"composition": ["TFT_Item_A", "TFT_Item_B"],
"effects": {"AD": 0.35, "CritChance": 35.0},
"icon": "ASSETS/i.tex",
},
],
}
def test_parse_keeps_effects_and_variables():
parsed = parse(RAW, set_override=17)
assert parsed["items"]["TFT_Item_TestSword"]["effects"] == {"AD": 0.35, "CritChance": 35.0}
bp = parsed["traits"]["TFT17_Caster"]["breakpoints"][0]
assert bp["variables"] == {"BonusAP": 20.0}
def test_parse_resolves_spell_damage():
unit = parse(RAW, set_override=17)["units"]["TFT17_Mage"]
assert unit["spell_damage"] == [0, 200, 300, 450, 700, 0, 0]
assert unit["spell_damage_type"] == "magic"
assert unit["spell_scaling"] == "ap"
def test_resolve_spell_adaptive_and_fallback():
adaptive = {
"desc": "Deal <magicDamage>@TotalDamage@</magicDamage> damage. %i:scaleAP% %i:scaleAD%",
"variables": [
{"name": "ADDamage", "value": [0, 100, 150, 225, 0, 0, 0]},
{"name": "APDamage", "value": [0, 50, 75, 110, 0, 0, 0]},
],
}
r = resolve_spell(adaptive)
assert r["spell_damage"][1] == 150 # 100 + 50
assert r["spell_scaling"] == "both"
utility = {"desc": "Grant a shield.", "variables": [
{"name": "Shield", "value": [0, 300, 400, 500, 0, 0, 0]}]}
assert resolve_spell(utility)["spell_damage"] is None

View File

@@ -112,7 +112,61 @@ def test_stat_mults_identical_units_are_neutral():
assert all(m == 1.0 for m in mults.values())
def test_stat_mult_raises_score():
def test_stat_mult_raises_score_legacy():
from tft.model.score import score_board_legacy as score_board
art = {**ARTIFACT, "stat_mults": {"TFT17_B": 1.1}}
board = [{"api_name": "TFT17_B", "stars": 1, "items": []}]
assert score_board(board, [], art) > score_board(board, [], ARTIFACT)
def _mech_artifact():
from tft.model import artifact as artifact_mod
static = {
"meta": {"set": 17, "patch": "test"},
"units": {
"TANK": {"cost": 2, "traits": ["T_AD"], "role": "Tank",
"stats": {"hp": 900, "armor": 50, "magicResist": 50, "damage": 45,
"attackSpeed": 0.6, "mana": 60, "initialMana": 0},
"spell_damage": None, "spell_scaling": None},
"CARRY": {"cost": 2, "traits": ["T_AD"], "role": "Marksman",
"stats": {"hp": 600, "armor": 20, "magicResist": 20, "damage": 70,
"attackSpeed": 0.8, "mana": 50, "initialMana": 0},
"spell_damage": [0, 250, 375, 560, 0, 0, 0], "spell_scaling": "ad"},
},
"traits": {"T_AD": {"breakpoints": [
{"min_units": 2, "style": 1, "variables": {"BonusAP": 20.0}}]}},
"items": {"SWORD": {"effects": {"AD": 0.35}}},
"augments": {},
}
return artifact_mod.build(static)
def test_mechanical_balanced_beats_lopsided():
from tft.model.score import score_mechanical
art = _mech_artifact()
balanced = [{"api_name": "TANK", "stars": 1, "items": []},
{"api_name": "CARRY", "stars": 1, "items": []}]
tanks = [{"api_name": "TANK", "stars": 1, "items": []} for _ in range(2)]
assert score_mechanical(balanced, [], art) > score_mechanical(tanks, [], art)
def test_mechanical_items_and_stars_raise_strength():
from tft.model.score import score_mechanical
art = _mech_artifact()
base = [{"api_name": "CARRY", "stars": 1, "items": []}]
starred = [{"api_name": "CARRY", "stars": 2, "items": []}]
equipped = [{"api_name": "CARRY", "stars": 1, "items": ["SWORD"]}]
assert score_mechanical(starred, [], art) > score_mechanical(base, [], art)
assert score_mechanical(equipped, [], art) > score_mechanical(base, [], art)
def test_mechanical_recognized_trait_buffs_stats():
from tft.model.score import score_mechanical
art = _mech_artifact()
pair = [{"api_name": "TANK", "stars": 1, "items": []},
{"api_name": "CARRY", "stars": 1, "items": []}]
solo_sum = (score_mechanical(pair[:1], [], art)
+ score_mechanical(pair[1:], [], art))
assert score_mechanical(pair, [], art) > solo_sum

View File

@@ -0,0 +1,73 @@
import pytest
from tft.model import statsheet
def unit(spell=None, scaling="ap", **overrides):
stats = {"hp": 700, "armor": 30, "magicResist": 30, "damage": 50,
"attackSpeed": 0.7, "mana": 60, "initialMana": 10,
"critChance": 0.25, "critMultiplier": 1.4}
stats.update(overrides)
return {"stats": stats, "spell_damage": spell, "spell_scaling": scaling}
ITEMS = {
"sword": {"effects": {"AD": 0.35, "CritChance": 35.0}},
"vest": {"effects": {"Armor": 20.0}},
"rod": {"effects": {"AP": 10.0}},
"unmodeled": {"effects": {"BurnPercent": 1.0}},
}
def test_star_scaling():
one = statsheet.unit_stats(unit(), 1, [], ITEMS, None, None)
two = statsheet.unit_stats(unit(), 2, [], ITEMS, None, None)
assert two["ehp"] == pytest.approx(one["ehp"] * 1.8)
assert two["dps"] == pytest.approx(one["dps"] * 1.5)
def test_spell_damage_uses_star_index_and_ap():
spell = [0, 200, 300, 450, 0, 0, 0]
bare = statsheet.unit_stats(unit(spell=spell), 1, [], ITEMS, None, None)
with_rod = statsheet.unit_stats(unit(spell=spell), 1, ["rod"], ITEMS, None, None)
no_spell = statsheet.unit_stats(unit(), 1, [], ITEMS, None, None)
assert bare["dps"] > no_spell["dps"]
assert with_rod["dps"] > bare["dps"] # +10 AP skaliert den Spell
def test_items_change_profile():
base = statsheet.unit_stats(unit(), 1, [], ITEMS, None, None)
sword = statsheet.unit_stats(unit(), 1, ["sword"], ITEMS, None, None)
vest = statsheet.unit_stats(unit(), 1, ["vest"], ITEMS, None, None)
assert sword["dps"] > base["dps"]
assert vest["ehp"] > base["ehp"]
assert statsheet.item_is_modeled(ITEMS["sword"])
assert not statsheet.item_is_modeled(ITEMS["unmodeled"])
def test_frontline_casts_more():
spell = [0, 300, 450, 700, 0, 0, 0]
tank = statsheet.unit_stats(unit(spell=spell), 1, [], ITEMS, None, "frontline")
carry = statsheet.unit_stats(unit(spell=spell), 1, [], ITEMS, None, "ad_carry")
assert tank["dps"] > carry["dps"]
def test_trait_buffs_keyword_mapping():
traits = {
"T_AD": {"breakpoints": [{"min_units": 2, "style": 1,
"variables": {"BonusAD": 0.15}}]},
"T_Mech": {"breakpoints": [{"min_units": 2, "style": 1,
"variables": {"Wolf_Gold": 1.0}}]},
"T_Skip": {"breakpoints": [{"min_units": 2, "style": 1,
"variables": {"StunDuration": 1.5}}]},
}
buffs, recognized = statsheet.trait_buffs(
{"T_AD": 1, "T_Mech": 1, "T_Skip": 1}, traits
)
assert recognized == {"T_AD"}
assert buffs["ad_pct"] == pytest.approx(0.15)
def test_fraction_normalization():
assert statsheet._fraction(0.15) == 0.15
assert statsheet._fraction(15.0) == 0.15