Unit-Stärken präzisiert: Stat-Proxy-Multiplikatoren + gelernte Unit-Gewichte

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
team3
2026-07-23 08:09:33 +02:00
parent 20fd8c15f3
commit 7c72761190
6 changed files with 97 additions and 6 deletions

View File

@@ -50,3 +50,15 @@ def test_empty_rows():
assert learned["traits"] == {}
assert learned["pairs"] == {}
assert learned["n_boards"] == 0
def test_unit_multipliers_learned():
def row(placement, unit):
units = [{"character_id": unit, "tier": 2, "itemNames": []}]
return (placement, json.dumps(units), json.dumps([]), json.dumps([]))
rows = [row(1, "TFT17_Strong") for _ in range(150)]
rows += [row(8, "TFT17_Weak") for _ in range(150)]
learned = learn(rows)
assert learned["units"]["TFT17_Strong"] > 1.05
assert learned["units"]["TFT17_Weak"] < 0.95

View File

@@ -86,3 +86,33 @@ def test_craftable_item_pool():
"TFT_Item_Radiant": {"composition": None},
}
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():
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)

View File

@@ -45,6 +45,7 @@ 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"]),
"item_pool": craftable_items(static["items"]),
"learned": learned or {},
}

View File

@@ -6,11 +6,49 @@ def unit_value(cost: int, stars: int) -> float:
def stat_proxies(stats: dict) -> dict:
ehp = stats["hp"] * (1 + (stats["armor"] + stats["magicResist"]) / 2 / 100)
auto_dps = stats["damage"] * stats["attackSpeed"]
mana_gap = max(stats["mana"] - stats["initialMana"], 1)
cast_rate = stats["attackSpeed"] * 10 / mana_gap
return {"ehp": ehp, "auto_dps": auto_dps, "cast_rate": cast_rate}
# 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:

View File

@@ -28,6 +28,7 @@ def learn(rows: list[tuple]) -> dict:
trait_placements = defaultdict(list)
item_placements = defaultdict(list)
augment_placements = defaultdict(list)
unit_placements = defaultdict(list)
unit_top4 = defaultdict(int)
pair_top4 = defaultdict(int)
n_boards = 0
@@ -40,6 +41,8 @@ def learn(rows: list[tuple]) -> dict:
units = json.loads(units_json)
names = sorted({u["character_id"] for u in units})
for name in names:
unit_placements[name].append(placement)
for u in units:
for item in u.get("itemNames", []):
item_placements[item].append(placement)
@@ -69,6 +72,7 @@ def learn(rows: list[tuple]) -> dict:
pairs[f"{a}|{b}"] = round(lift, 4)
return {
"units": {k: round(_multiplier(v), 4) for k, v in unit_placements.items()},
"traits": {k: round(_multiplier(v), 4) for k, v in trait_placements.items()},
"items": {k: round(_multiplier(v), 4) for k, v in item_placements.items()},
"augments": {k: round(_multiplier(v), 4) for k, v in augment_placements.items()},

View File

@@ -32,18 +32,24 @@ def score_board(board_units: list[dict], augments: list[str], artifact: dict) ->
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", {})
augment_mults = learned.get("augments", {})
pair_lifts = learned.get("pairs", {})
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"])
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)