Files
tft/backend/tests/test_metatft.py
2026-09-07 07:44:09 +02:00

204 lines
9.4 KiB
Python

"""Der MetaTFT-Parser muss dieselben Tabellen liefern wie der cdragon-Parser.
Die Fixtures sind gekürzte, aber echte Ausschnitte aus
data.metatft.com/lookups/TFTSet18_latest_en_us.json.
"""
import pytest
from tft.staticdata import metatft
@pytest.fixture
def raw():
return {
"_metadata": {"set": "TFTSet18", "setName": "Enchanted Wilds", "patch": "pbe"},
"roles": {"DA_Role_AttackCaster": "Attack Caster"},
"units": [
{
"apiName": "TFT18_Ashe", "characterName": "TFT18_Ashe", "name": "Ashe",
"cost": 5, "shopUnit": True, "role": "DA_Role_AttackCaster",
"traits": ["Blossom", "Hunter"],
"traitApiNames": ["DA_18_Blossom", "DA_18_Hunter"],
"stats": {"hp": 900, "damage": 75, "armor": 45, "magicResist": 45,
"attackSpeed": 0.8, "mana": 80, "initialMana": 20,
"critChance": 0.25, "critMultiplier": 1.4, "range": 5},
"curveValues": {"AbilityShield": [[1, 60], [2, 90], [3, 200], [4, 310]],
"ChillPercent": [[1, 20], [4, 20]]},
"ability": {
"name": "Spirit Rift",
# Wie im Original: erst ein Mana-Tag, dann zwei Schadenszahlen.
"desc": ('Costs <TFTAttribute attributeID="TFTCalculationAttributes.ManaCalc1"'
' style="colorStat" icon="icon.AP"/> mana. Deals'
' <TFTAttribute attributeID="TFTCalculationAttributes.PhysicalDamageCalc2"'
' style="colorPhysical" icon="icon.AD"/> then'
' <TFTAttribute attributeID="TFTCalculationAttributes.PhysicalDamageCalc1"'
' style="colorPhysical" icon="icon.AD"/> physical damage,'
' shields for <TFTCurveTable row="AbilityShield"/>, slowing by'
' <TFTCurveTable row="ChillPercent"/>%.'),
"variables": [],
"attributeValues": {
"TFTCalculationAttributes.ManaCalc1": [3, 3, 100, 100],
"TFTCalculationAttributes.PhysicalDamageCalc1": [440, 660, 1000, 1000],
"TFTCalculationAttributes.PhysicalDamageCalc2": [7, 11, 220, 220],
},
},
},
{ # Verwandlungsform: kein shopUnit, gehört nicht in den Pool.
"apiName": "TFT18_EliseSpider", "name": "Elise", "cost": 2,
"shopUnit": False, "traits": ["Coven"], "traitApiNames": ["DA_18_Coven"],
"stats": {"hp": 700}, "ability": {},
},
{ # Lücke im PBE-Dump: ohne HP wäre die eHP 0.
"apiName": "TFT18_Ivern", "name": "Ivern", "cost": 5, "shopUnit": True,
"traits": ["Greenfather"], "traitApiNames": ["DA_18_Greenfather"],
"stats": {"damage": 60, "armor": 55}, "ability": {},
},
],
"traits": [
{"apiName": "DA_18_Brawler", "name": "Brawler", "type": "class",
"icon": "T_TraitIcon_18_Brawler",
"effects": [
{"minUnits": 2, "style": 1,
"desc": '(2) <TFTCurveTable row="BrawlerHealthPercent" column="1"'
' format="percent"/>'},
{"minUnits": 4, "style": 3,
"desc": '(4) <TFTCurveTable row="BrawlerHealthPercent" column="2"'
' format="percent"/>'},
],
"curveValues": {"BrawlerHealthPercent": [[0, 0], [1, 0.25], [2, 0.4]]}},
{"apiName": "DA_18_Blossom", "name": "Blossom", "type": "origin",
"icon": "T_TraitIcon_18_Blossom", "effects": [], "curveValues": {}},
{"apiName": "DA_18_Hunter", "name": "Hunter", "type": "class",
"icon": "T_TraitIcon_18_Hunter", "effects": [], "curveValues": {}},
],
"items": [
{"apiName": "DA_Component_RecurveBow", "name": "Recurve Bow", "composition": [],
"icon": "T_RecurveBow",
"statLine": '<TFTCurveTable row="AS" icon="icon.AS" format="percentMinusOne"/>',
"curveValues": {"AS": [[1, 1.1]]}},
{"apiName": "DA_Component_NeedlesslyLargeRod", "name": "Rod", "composition": [],
"icon": "T_Rod",
"statLine": '<TFTCurveTable row="AP" icon="icon.AP" format="percent"/>',
"curveValues": {"AP": [[1, 0.1]]}},
{"apiName": "DA_Component_GiantsBelt", "name": "Belt", "composition": [],
"icon": "T_Belt",
"statLine": '<TFTCurveTable row="Health" icon="icon.Health"/>',
"curveValues": {"Health": [[1, 150]]}},
],
"augments": [
{"apiName": "DA_AdvancedLoan", "name": "Advanced Loan", "rarity": "Gold",
"icon": "T_AugmentIcon_AdvancedLoan",
"desc": 'Gain <TFTCurveTable row="GoldInstantBasic"/> Gold.',
"curveValues": {"GoldInstantBasic": [[1, 20]]}},
],
}
def test_meta_and_unit_filter(raw):
tables = metatft.parse(raw)
assert tables["meta"]["set"] == 18
assert tables["meta"]["set_name"] == "Enchanted Wilds"
# EliseSpider ist kein Shop-Unit, Ivern hat keine HP.
assert list(tables["units"]) == ["TFT18_Ashe"]
assert tables["meta"]["incomplete_units"] == ["Ivern (hp)"]
def test_missing_attack_damage_is_reported_but_kept(raw):
"""Ohne AD ist der Auto-DPS 0 — die Unit wird trotzdem gespielt.
Anders als bei fehlender HP fliegt sie deshalb nicht aus dem Pool.
"""
del raw["units"][0]["stats"]["damage"]
tables = metatft.parse(raw)
assert "TFT18_Ashe" in tables["units"]
assert "Ashe (damage)" in tables["meta"]["incomplete_units"]
def test_unit_matches_cdragon_shape(raw):
unit = metatft.parse(raw)["units"]["TFT18_Ashe"]
assert unit["traits"] == ["DA_18_Blossom", "DA_18_Hunter"]
assert unit["role"] == "AttackCaster"
assert unit["stats"]["hp"] == 900
# Der erste Tag im Text ist die Manazahl 3 — der Schaden ist 440.
# cdragon zählt Sterne ab Index 1; MetaTFT ab 0.
assert unit["spell_damage"] == [0, 440, 660, 1000]
assert unit["spell_damage_type"] == "physical"
assert unit["spell_scaling"] == "ad"
# Kurven sind Treppen: Stufe 4 fehlt, Stufe 1 gilt weiter.
assert unit["ability_vars"]["AbilityShield"] == [0, 60, 90, 200]
assert unit["ability_vars"]["ChillPercent"] == [0, 20, 20, 20]
def test_trait_breakpoints_carry_their_curve_values(raw):
brawler = metatft.parse(raw)["traits"]["DA_18_Brawler"]
assert [b["min_units"] for b in brawler["breakpoints"]] == [2, 4]
assert brawler["breakpoints"][0]["variables"] == {"BrawlerHealthPercent": 0.25}
assert brawler["breakpoints"][1]["variables"] == {"BrawlerHealthPercent": 0.4}
def test_unknown_traits_are_dropped(raw):
# Greenfather steht in keiner Trait-Tabelle; ein Verweis darauf würde
# active_trait_tiers durchlaufen und im UI als Geist-Trait auftauchen.
raw["units"][0]["traitApiNames"].append("DA_18_DoesNotExist")
assert metatft.parse(raw)["units"]["TFT18_Ashe"]["traits"] == [
"DA_18_Blossom", "DA_18_Hunter"]
def test_item_effects_follow_statsheet_conventions(raw):
items = metatft.parse(raw)["items"]
# AS und AP sind Prozentzahlen, Health ist flach — wie in Set 17.
assert items["DA_Component_RecurveBow"]["effects"] == {"AS": pytest.approx(10.0)}
assert items["DA_Component_NeedlesslyLargeRod"]["effects"] == {"AP": pytest.approx(10.0)}
assert items["DA_Component_GiantsBelt"]["effects"] == {"Health": 150}
def test_augment_tier_and_rendered_desc(raw):
aug = metatft.parse(raw)["augments"]["DA_AdvancedLoan"]
assert aug["tier"] == 2 # Gold
assert aug["desc"] == "Gain 20 Gold."
assert aug["effects"] == {"GoldInstantBasic": 20}
def test_curve_steps_hold_the_last_value():
curve = [[1, 5], [3, 9]]
assert [metatft.curve_at(curve, i) for i in (1, 2, 3, 4)] == [5, 5, 9, 9]
assert metatft.curve_at([], 1) is None
@pytest.mark.parametrize("value,fmt,want", [
(0.25, "percent", 0.25),
(1.10, "percentMinusOne", pytest.approx(0.10)),
# Durability wird als Resthärte angegeben: 0.96 = 4% weniger Schaden.
(0.96, "invertedPercent", pytest.approx(0.04)),
(175, None, 175),
])
def test_format_drives_the_conversion(value, fmt, want):
assert metatft.as_fraction(value, fmt) == want
def test_spell_picks_the_biggest_damage_not_the_first_tag(raw):
"""Lux nennt im Tooltip zuerst 3 Mana, dann 330 Schaden.
Wer den ersten <TFTAttribute> nimmt, modelliert die Manazahl als
Schaden — das betraf 33 von 63 Units.
"""
unit = metatft.parse(raw)["units"]["TFT18_Ashe"]
assert unit["spell_damage"][1] == 440
assert unit["spell_scaling"] == "ad"
assert unit["spell_damage_type"] == "physical"
def test_on_attack_damage_is_flagged(raw):
ability = raw["units"][0]["ability"]
ability["desc"] = ('Attacks deal <TFTAttribute'
' attributeID="TFTCalculationAttributes.PhysicalDamageCalc1"'
' style="colorPhysical" icon="icon.AD"/> bonus damage.')
assert metatft.parse(raw)["units"]["TFT18_Ashe"]["spell_on_attack"] is True
def test_asset_names_are_kept_for_the_match_api(raw):
# Riot nennt Units beim Asset-Namen; ohne die Liste findet learn.py nichts.
raw["units"][0]["assetNames"] = ["DA_18_Ashe"]
assert metatft.parse(raw)["units"]["TFT18_Ashe"]["asset_names"] == ["DA_18_Ashe"]