diff --git a/backend/tests/test_game.py b/backend/tests/test_game.py index 722fc90..404409c 100644 --- a/backend/tests/test_game.py +++ b/backend/tests/test_game.py @@ -221,3 +221,22 @@ def test_augment_offer_single_tier(art, cfg): tiers = {mixed[a]["tier"] for a in game.player.augment_offer} assert len(game.player.augment_offer) == 3 assert len(tiers) == 1 # alle Angebote aus derselben Stufe + + +def test_equip_crafts_completed_item(art, cfg): + game = Game(art, cfg, seed=1) + craft_art = {**art, "component_pool": ["C_A", "C_B"], + "recipes": {"C_A|C_B": "COMPLETED"}} + game.artifact = craft_art + game.board = [{"api_name": "U1_0", "stars": 1, "items": ["C_A"]}] + game.items = ["C_B", "C_A"] + game.equip(0, 0) # C_B auf Unit mit C_A -> COMPLETED + assert game.board[0]["items"] == ["COMPLETED"] + game.equip(0, 0) # C_A ohne Partner -> eigener Slot + assert game.board[0]["items"] == ["COMPLETED", "C_A"] + + +def test_loot_drops_components(art, cfg): + comp_art = {**art, "component_pool": ["C_A", "C_B"]} + game = Game(comp_art, cfg, seed=2) + assert all(i in ("C_A", "C_B") for i in game.items) # Carousel-Drop diff --git a/backend/tft/model/artifact.py b/backend/tft/model/artifact.py index 5e9b1ab..3a4aac8 100644 --- a/backend/tft/model/artifact.py +++ b/backend/tft/model/artifact.py @@ -13,7 +13,7 @@ SPATULA_COMPONENTS = {"TFT_Item_Spatula", "TFT_Item_FryingPan"} def craftable_items(static_items: dict) -> list[str]: - """Standard-kombinierbare Items — der Pool für Loot und Bot-Boards.""" + """Standard-kombinierbare Items — Basis für Rezepte und Komponenten-Pool.""" pool = [] for api, item in static_items.items(): comp = item.get("composition") or [] @@ -26,12 +26,25 @@ def craftable_items(static_items: dict) -> list[str]: return sorted(pool) -def tier_profiles(static: dict, roles: dict) -> dict: - """Mittleres Mechanik-Profil (eHP/DPS, 1★, itemlos) pro Kostenstufe. +def recipes(static_items: dict) -> dict: + """'CompA|CompB' (sortiert) -> fertiges Item.""" + return { + "|".join(sorted(static_items[api]["composition"])): api + for api in craftable_items(static_items) + } - Anker für den mechanischen Scorer: Riot balanciert um die Kosten, - die Mechanik differenziert innerhalb der Stufe. - """ + +def component_pool(static_items: dict) -> list[str]: + """Die Basis-Komponenten — das, was Loot tatsächlich droppt.""" + return sorted({ + comp + for api in craftable_items(static_items) + for comp in static_items[api]["composition"] + }) + + +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] = {} @@ -49,6 +62,22 @@ def tier_profiles(static: dict, roles: dict) -> dict: } +def spell_dps_cap(static: dict, roles: dict) -> float | None: + """90. Perzentil der Spell-DPS aller Units (1★, itemlos) — Ausreißer-Guard.""" + from tft.model import statsheet + + values = sorted( + p["spell_dps"] + for api, unit in static["units"].items() + if (p := statsheet.unit_stats( + unit, 1, [], static["items"], None, roles.get(api) + ))["spell_dps"] > 0 + ) + if not values: + return None + return values[int(len(values) * 0.9)] + + def build(static: dict, learned: dict | None = None, extra_meta: dict | None = None) -> dict: roles = { api: baseline.classify_role(unit) for api, unit in static["units"].items() @@ -70,7 +99,10 @@ def build(static: dict, learned: dict | None = None, extra_meta: dict | None = N "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"]), + "recipes": recipes(static["items"]), # Riot-Match-API liefert keine Augments (Feld entfernt) — leer heißt: # der Sim nutzt alle geparsten Augments als Pool. "augment_pool": [], diff --git a/backend/tft/model/score.py b/backend/tft/model/score.py index 9016144..db22b86 100644 --- a/backend/tft/model/score.py +++ b/backend/tft/model/score.py @@ -94,12 +94,13 @@ def score_mechanical(board_units: list[dict], augments: list[str], artifact: dic tiers = active_trait_tiers(board_units, static_traits, static_units) team_buffs, recognized = statsheet.trait_buffs(tiers, static_traits) + spell_cap = artifact.get("spell_dps_cap") tier_profiles = artifact.get("tier_profiles", {}) - # Anker: 1★ itemlos zählt exakt "Kosten" in beiden Dimensionen. - # Mechanik (Stats, Items, Trait-Buffs, Sterne) verschiebt relativ dazu; - # Extraktions-Ausreißer werden pro Stufe gekappt. + # 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 @@ -109,24 +110,24 @@ def score_mechanical(board_units: list[dict], augments: list[str], artifact: dic continue profile = statsheet.unit_stats( unit, u["stars"], u.get("items", []), static_items, - team_buffs, roles.get(u["api_name"]), + 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"])) - base = baseline.unit_value(unit["cost"], u["stars"]) if ref: star_ehp = statsheet.HP_STAR_MULT ** (u["stars"] - 1) star_dps = statsheet.AD_STAR_MULT ** (u["stars"] - 1) - r_ehp = profile["ehp"] / (ref["ehp"] * star_ehp) - r_dps = profile["dps"] / (ref["dps"] * star_dps) - r_ehp = min(max(r_ehp, RATIO_CAP[0]), RATIO_CAP[1]) - r_dps = min(max(r_dps, RATIO_CAP[0]), RATIO_CAP[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: - r_ehp = r_dps = 1.0 - mult = unit_mults.get(u["api_name"], 1.0) - total_ehp += mult * base * r_ehp - total_dps += mult * base * r_dps + total_ehp += mult * profile["ehp"] + total_dps += mult * profile["dps"] - strength = (total_ehp * total_dps) ** 0.5 + # /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}" diff --git a/backend/tft/model/statsheet.py b/backend/tft/model/statsheet.py index 34347c4..f199c94 100644 --- a/backend/tft/model/statsheet.py +++ b/backend/tft/model/statsheet.py @@ -115,7 +115,8 @@ def trait_buffs(active_tiers: dict, static_traits: dict) -> tuple[dict, set]: def unit_stats(unit: dict, stars: int, item_apis: list[str], static_items: dict, - team_buffs: dict | None, role: str | None) -> dict: + team_buffs: dict | None, role: str | None, + spell_cap: float | None = None) -> dict: stats = unit["stats"] hp = (stats.get("hp") or 0) * HP_STAR_MULT ** (stars - 1) ad = (stats.get("damage") or 0) * AD_STAR_MULT ** (stars - 1) @@ -155,5 +156,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)) - return {"ehp": ehp, "dps": (auto_dps + spell_dps) * (1 + acc["damage_amp"])} + return { + "ehp": ehp, + "dps": (auto_dps + spell_dps) * (1 + acc["damage_amp"]), + "spell_dps": spell_dps, + } diff --git a/backend/tft/sim/game.py b/backend/tft/sim/game.py index 42ea973..b05f3e0 100644 --- a/backend/tft/sim/game.py +++ b/backend/tft/sim/game.py @@ -121,7 +121,7 @@ class Game: player.move(self.player, where, idx) def equip(self, item_idx: int, board_idx: int) -> None: - player.equip(self.player, item_idx, board_idx) + player.equip(self.player, item_idx, board_idx, self.artifact) def toggle_lock(self) -> None: self.player.shop_locked = not self.player.shop_locked diff --git a/backend/tft/sim/player.py b/backend/tft/sim/player.py index c9c2e58..9f37265 100644 --- a/backend/tft/sim/player.py +++ b/backend/tft/sim/player.py @@ -140,14 +140,28 @@ def move(p: PlayerState, where: str, idx: int) -> None: dst.append(src.pop(idx)) -def equip(p: PlayerState, item_idx: int, board_idx: int) -> None: +def equip(p: PlayerState, item_idx: int, board_idx: int, artifact: dict | None = None) -> None: if not (0 <= item_idx < len(p.items)): raise InvalidAction("no such item") if not (0 <= board_idx < len(p.board)): raise InvalidAction("no such unit") - if len(p.board[board_idx]["items"]) >= MAX_ITEMS_PER_UNIT: + unit = p.board[board_idx] + new_item = p.items[item_idx] + recipes = (artifact or {}).get("recipes", {}) + components = set((artifact or {}).get("component_pool", [])) + + # Komponente auf Unit mit passender Komponente -> fertiges Item craften. + if new_item in components: + for slot, held in enumerate(unit["items"]): + crafted = recipes.get("|".join(sorted((held, new_item)))) + if crafted: + unit["items"][slot] = crafted + p.items.pop(item_idx) + return + + if len(unit["items"]) >= MAX_ITEMS_PER_UNIT: raise InvalidAction("unit has 3 items") - p.board[board_idx]["items"].append(p.items.pop(item_idx)) + unit["items"].append(p.items.pop(item_idx)) def pick_augment(p: PlayerState, choice: int) -> None: @@ -161,9 +175,11 @@ def pick_augment(p: PlayerState, choice: int) -> None: def grant_loot(p: PlayerState, components: int, gold: int, artifact: dict, rng: random.Random) -> None: - items = artifact.get("item_pool") or list(artifact["static"]["items"]) + # Loot droppt Komponenten; fertige Items entstehen nur durch Craften. + pool = (artifact.get("component_pool") or artifact.get("item_pool") + or list(artifact["static"]["items"])) for _ in range(components): - p.items.append(rng.choice(items)) + p.items.append(rng.choice(pool)) p.gold += gold diff --git a/backend/tft/sim/policy.py b/backend/tft/sim/policy.py index 4dbc457..5236966 100644 --- a/backend/tft/sim/policy.py +++ b/backend/tft/sim/policy.py @@ -45,7 +45,7 @@ def act(p: PlayerState, pool: Pool, artifact: dict, cfg: dict, rng: random.Rando except InvalidAction: break - _equip_items(p) + _equip_items(p, artifact) player.fill_board(p, artifact) @@ -87,10 +87,10 @@ def _sell_junk(p: PlayerState, pool: Pool, artifact: dict) -> None: player.sell(p, "bench", idx, pool, artifact) -def _equip_items(p: PlayerState) -> None: +def _equip_items(p: PlayerState, artifact: dict) -> None: while p.items and any(len(u["items"]) < 3 for u in p.board): target = min(range(len(p.board)), key=lambda i: len(p.board[i]["items"])) try: - player.equip(p, 0, target) + player.equip(p, 0, target, artifact) except InvalidAction: break diff --git a/frontend/src/App.vue b/frontend/src/App.vue index e6de920..ce1e79c 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -20,7 +20,11 @@ const standings = computed(() => { }, ...s.value.opponents, ] - return rows.sort((a, b) => b.hp - a.hp || (a.placement ?? 9) - (b.placement ?? 9)) + return rows.sort((a, b) => { + if (a.alive !== b.alive) return a.alive ? -1 : 1 + if (!a.alive) return (a.placement ?? 9) - (b.placement ?? 9) + return (b.score ?? 0) - (a.score ?? 0) + }) })