Spielerliste nach Boardstärke sortiert; Loot droppt Komponenten, Craften per Ziehen auf Unit

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
team3
2026-07-23 09:34:12 +02:00
parent f53f1e5c14
commit c82c9b0b01
8 changed files with 113 additions and 32 deletions

View File

@@ -221,3 +221,22 @@ def test_augment_offer_single_tier(art, cfg):
tiers = {mixed[a]["tier"] for a in game.player.augment_offer} tiers = {mixed[a]["tier"] for a in game.player.augment_offer}
assert len(game.player.augment_offer) == 3 assert len(game.player.augment_offer) == 3
assert len(tiers) == 1 # alle Angebote aus derselben Stufe 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

View File

@@ -13,7 +13,7 @@ SPATULA_COMPONENTS = {"TFT_Item_Spatula", "TFT_Item_FryingPan"}
def craftable_items(static_items: dict) -> list[str]: 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 = [] pool = []
for api, item in static_items.items(): for api, item in static_items.items():
comp = item.get("composition") or [] comp = item.get("composition") or []
@@ -26,12 +26,25 @@ def craftable_items(static_items: dict) -> list[str]:
return sorted(pool) return sorted(pool)
def tier_profiles(static: dict, roles: dict) -> dict: def recipes(static_items: dict) -> dict:
"""Mittleres Mechanik-Profil (eHP/DPS, 1★, itemlos) pro Kostenstufe. """'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 from tft.model import statsheet
by_cost: dict[int, list] = {} 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: def build(static: dict, learned: dict | None = None, extra_meta: dict | None = None) -> dict:
roles = { roles = {
api: baseline.classify_role(unit) for api, unit in static["units"].items() 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, "roles": roles,
"stat_mults": baseline.compute_stat_mults(static["units"]), "stat_mults": baseline.compute_stat_mults(static["units"]),
"tier_profiles": tier_profiles(static, roles), "tier_profiles": tier_profiles(static, roles),
"spell_dps_cap": spell_dps_cap(static, roles),
"item_pool": craftable_items(static["items"]), "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: # Riot-Match-API liefert keine Augments (Feld entfernt) — leer heißt:
# der Sim nutzt alle geparsten Augments als Pool. # der Sim nutzt alle geparsten Augments als Pool.
"augment_pool": [], "augment_pool": [],

View File

@@ -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) tiers = active_trait_tiers(board_units, static_traits, static_units)
team_buffs, recognized = statsheet.trait_buffs(tiers, static_traits) team_buffs, recognized = statsheet.trait_buffs(tiers, static_traits)
spell_cap = artifact.get("spell_dps_cap")
tier_profiles = artifact.get("tier_profiles", {}) tier_profiles = artifact.get("tier_profiles", {})
# Anker: 1★ itemlos zählt exakt "Kosten" in beiden Dimensionen. # Eigenschafts-Bewertung: eHP = defensiv, DPS = offensiv. Der Stufen-Maßstab
# Mechanik (Stats, Items, Trait-Buffs, Sterne) verschiebt relativ dazu; # ist der gemessene Tier-Durchschnitt; Ratio-Cap fängt Extraktionsfehler.
# Extraktions-Ausreißer werden pro Stufe gekappt.
RATIO_CAP = (0.5, 2.0) RATIO_CAP = (0.5, 2.0)
STAR_VALUE = 3.0 # Kopienwert pro Sternstufe (Endboard-validiert)
total_ehp = 0.0 total_ehp = 0.0
total_dps = 0.0 total_dps = 0.0
@@ -109,24 +110,24 @@ def score_mechanical(board_units: list[dict], augments: list[str], artifact: dic
continue continue
profile = statsheet.unit_stats( profile = statsheet.unit_stats(
unit, u["stars"], u.get("items", []), static_items, 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"])) ref = tier_profiles.get(str(unit["cost"]))
base = baseline.unit_value(unit["cost"], u["stars"])
if ref: if ref:
star_ehp = statsheet.HP_STAR_MULT ** (u["stars"] - 1) star_ehp = statsheet.HP_STAR_MULT ** (u["stars"] - 1)
star_dps = statsheet.AD_STAR_MULT ** (u["stars"] - 1) star_dps = statsheet.AD_STAR_MULT ** (u["stars"] - 1)
r_ehp = profile["ehp"] / (ref["ehp"] * star_ehp) r_ehp = min(max(profile["ehp"] / (ref["ehp"] * star_ehp), RATIO_CAP[0]), RATIO_CAP[1])
r_dps = profile["dps"] / (ref["dps"] * star_dps) r_dps = min(max(profile["dps"] / (ref["dps"] * star_dps), RATIO_CAP[0]), RATIO_CAP[1])
r_ehp = min(max(r_ehp, RATIO_CAP[0]), RATIO_CAP[1]) star = STAR_VALUE ** (u["stars"] - 1)
r_dps = min(max(r_dps, RATIO_CAP[0]), RATIO_CAP[1]) total_ehp += mult * ref["ehp"] * star * r_ehp
total_dps += mult * ref["dps"] * star * r_dps
else: else:
r_ehp = r_dps = 1.0 total_ehp += mult * profile["ehp"]
mult = unit_mults.get(u["api_name"], 1.0) total_dps += mult * profile["dps"]
total_ehp += mult * base * r_ehp
total_dps += mult * base * r_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(): for trait, ordinal in tiers.items():
key = f"{trait}@{ordinal}" key = f"{trait}@{ordinal}"

View File

@@ -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, 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"] stats = unit["stats"]
hp = (stats.get("hp") or 0) * HP_STAR_MULT ** (stars - 1) hp = (stats.get("hp") or 0) * HP_STAR_MULT ** (stars - 1)
ad = (stats.get("damage") or 0) * AD_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, CAST_RATE_CAP,
) )
spell_dps = dmg * cast_rate 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,
}

View File

@@ -121,7 +121,7 @@ class Game:
player.move(self.player, where, idx) player.move(self.player, where, idx)
def equip(self, item_idx: int, board_idx: int) -> None: 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: def toggle_lock(self) -> None:
self.player.shop_locked = not self.player.shop_locked self.player.shop_locked = not self.player.shop_locked

View File

@@ -140,14 +140,28 @@ def move(p: PlayerState, where: str, idx: int) -> None:
dst.append(src.pop(idx)) 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)): if not (0 <= item_idx < len(p.items)):
raise InvalidAction("no such item") raise InvalidAction("no such item")
if not (0 <= board_idx < len(p.board)): if not (0 <= board_idx < len(p.board)):
raise InvalidAction("no such unit") 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") 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: 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, def grant_loot(p: PlayerState, components: int, gold: int, artifact: dict,
rng: random.Random) -> None: 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): for _ in range(components):
p.items.append(rng.choice(items)) p.items.append(rng.choice(pool))
p.gold += gold p.gold += gold

View File

@@ -45,7 +45,7 @@ def act(p: PlayerState, pool: Pool, artifact: dict, cfg: dict, rng: random.Rando
except InvalidAction: except InvalidAction:
break break
_equip_items(p) _equip_items(p, artifact)
player.fill_board(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) 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): 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"])) target = min(range(len(p.board)), key=lambda i: len(p.board[i]["items"]))
try: try:
player.equip(p, 0, target) player.equip(p, 0, target, artifact)
except InvalidAction: except InvalidAction:
break break

View File

@@ -20,7 +20,11 @@ const standings = computed(() => {
}, },
...s.value.opponents, ...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)
})
}) })
</script> </script>