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