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:
@@ -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
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user