Augment-Effekte wirken: Gold/XP/Komponenten/Units/Spieler-HP bei Auswahl, Team-Buffs im Score, Junk gefiltert

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
team3
2026-07-23 10:30:56 +02:00
parent 28fb667005
commit f7e569e519
7 changed files with 249 additions and 3 deletions

View File

@@ -243,14 +243,49 @@ def test_loot_drops_components(art, cfg):
def test_bot_leveling_follows_targets(art, cfg): def test_bot_leveling_follows_targets(art, cfg):
from tft.sim import policy as policy_mod
game = Game(art, cfg, seed=12) game = Game(art, cfg, seed=12)
levels_at = {} levels_at = {}
while not game.over and game.round["label"] != "4-3": while not game.over and game.round["label"] != "4-3":
label = game.round["label"] label = game.round["label"]
if label in ("2-6", "3-3", "4-2"): if label in ("2-6", "3-3", "4-2"):
levels_at[label] = [b.level for b in game.bots if b.alive] levels_at[label] = [b.level for b in game.bots if b.alive]
policy_mod.act(game.player, game.pool, game.artifact, game.cfg, game.rng,
game.round, policy_mod.ARCHETYPES["fast8"])
game.step() game.step()
assert all(lvl >= 5 for lvl in levels_at["2-6"]) # L5 ab 2-5 assert all(lvl >= 5 for lvl in levels_at["2-6"]) # L5 ab 2-5
# L6 ab 3-1/3-2 — goldarme Bots dürfen 1-2 Runden nachhinken # L6 ab 3-1/3-2 — goldarme Bots dürfen 1-2 Runden nachhinken
assert sum(1 for lvl in levels_at["3-3"] if lvl >= 6) >= len(levels_at["3-3"]) - 2 assert sum(1 for lvl in levels_at["3-3"] if lvl >= 6) >= len(levels_at["3-3"]) - 2
assert any(lvl >= 7 for lvl in levels_at["4-2"]) # fast8/streaker auf 7 assert any(lvl >= 7 for lvl in levels_at["4-2"]) # fast8/streaker auf 7
def test_augment_effects_apply(art, cfg):
game = Game(art, cfg, seed=3)
game.artifact = {**art, "augment_specs": {
"AUG_GOLD": {"atoms": [{"kind": "gold_now", "amount": 7},
{"kind": "gold_per_stage", "amount": 7}], "offerable": True},
"AUG_UNIT": {"atoms": [{"kind": "unit_grant", "cost": 2, "count": 2}], "offerable": True},
}}
gold_before = game.player.gold
game.player.augment_offer = ["AUG_GOLD"]
game.pick_augment(0)
assert game.player.gold == gold_before + 7
assert game.player.gold_per_stage == 7
bench_before = len(game.player.bench)
pool_before = sum(game.pool.available(a) for a in game.pool.copies)
game.player.augment_offer = ["AUG_UNIT"]
game.pick_augment(0)
assert len(game.player.bench) == bench_before + 2
assert sum(game.pool.available(a) for a in game.pool.copies) == pool_before - 2
def test_team_buff_augment_raises_score(art, cfg):
from tft.model.score import score_mechanical
buffed_art = {**art, "augment_specs": {
"AUG_BUFF": {"atoms": [{"kind": "team_buff", "stat": "hp_pct", "value": 0.3}],
"offerable": True}}}
board = [{"api_name": "U2_0", "stars": 2, "items": []}]
assert score_mechanical(board, ["AUG_BUFF"], buffed_art) > \
score_mechanical(board, [], buffed_art)

View File

@@ -62,3 +62,27 @@ def test_unit_multipliers_learned():
learned = learn(rows) learned = learn(rows)
assert learned["units"]["TFT17_Strong"] > 1.05 assert learned["units"]["TFT17_Strong"] > 1.05
assert learned["units"]["TFT17_Weak"] < 0.95 assert learned["units"]["TFT17_Weak"] < 0.95
def test_augment_spec_builder():
from tft.model.augments import build_spec
units = {"TFT17_Briar": {"name": "Briar", "cost": 1}}
gold = build_spec({"desc": "Gain 6 gold now.", "effects": {"Gold": 6.0}}, units)
assert gold["atoms"] == [{"kind": "gold_now", "amount": 6}]
buff = build_spec({"desc": "Your team gains 35 Health and 10% Attack Speed.",
"effects": {"Health": 35.0, "AS": 0.10}}, units)
kinds = {(a["kind"], a.get("stat")) for a in buff["atoms"]}
assert ("team_buff", "hp_flat") in kinds
assert ("team_buff", "as_pct") in kinds
cond = build_spec({"desc": "Your team gains 10 Health for each item.",
"effects": {"Health": 10.0}}, units)
assert not any(a["kind"] == "team_buff" for a in cond["atoms"])
unit = build_spec({"desc": "Gain a Briar and 2 gold.", "effects": {"Gold": 2.0}}, units)
assert {"kind": "unit_grant", "units": ["TFT17_Briar"]} in unit["atoms"]
junk = build_spec({"desc": "", "effects": {}}, units)
assert junk["offerable"] is False

View File

@@ -82,6 +82,12 @@ def spell_dps_cap(static: dict, roles: dict) -> float | None:
return values[int(len(values) * 0.9)] return values[int(len(values) * 0.9)]
def _augment_specs(static: dict) -> dict:
from tft.model.augments import build_specs
return build_specs(static["augments"], static["units"])
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()
@@ -107,6 +113,7 @@ def build(static: dict, learned: dict | None = None, extra_meta: dict | None = N
"item_pool": craftable_items(static["items"]), "item_pool": craftable_items(static["items"]),
"component_pool": component_pool(static["items"]), "component_pool": component_pool(static["items"]),
"recipes": recipes(static["items"]), "recipes": recipes(static["items"]),
"augment_specs": _augment_specs(static),
# 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

@@ -0,0 +1,121 @@
"""Augment-Atome: mechanisch anwendbare Effekte aus effects-Keys und Beschreibung.
Deterministische Atome (~200/432): Gold, XP, Komponenten, Units, Team-Buffs,
Spieler-HP. Bedingte und Kampf-Spezialeffekte bleiben beim gelernten Fallback.
"""
import re
from tft.model.statsheet import _fraction
GOLD_KEYS = ("Gold", "InstantGold", "GoldNow", "GoldAmount")
XP_KEYS = ("XP", "InstantXP", "Experience", "UpfrontXP")
COMPONENT_KEYS = ("NumComponents", "ComponentsToGive")
ANVIL_KEYS = ("CompletedAnvils",)
PLAYER_HP_KEYS = ("PlayerHealth", "Heal", "Health")
# effects-Key -> (Accumulator-Stat, Normalisierung)
STAT_KEYS = {
"AD": ("ad_pct", "frac"), "BonusAD": ("ad_pct", "frac"), "ADBonus": ("ad_pct", "frac"),
"AP": ("ap_flat", "flat"), "AbilityPower": ("ap_flat", "flat"), "BonusAP": ("ap_flat", "flat"),
"AS": ("as_pct", "frac"), "AttackSpeed": ("as_pct", "frac"),
"BonusAS": ("as_pct", "frac"), "TeamAttackSpeed": ("as_pct", "frac"),
"Health": ("hp_flat", "flat"), "BaseHP": ("hp_flat", "flat"), "BonusHealth": ("hp_flat", "flat"),
"Armor": ("armor_flat", "flat"), "BonusMR": ("mr_flat", "flat"),
"DamageAmp": ("damage_amp", "frac"), "Durability": ("dr", "frac"),
"Omnivamp": ("dr", "frac_half"), "CritChance": ("crit_chance", "frac"),
}
TEAM_RE = re.compile(r"(your team|allies|units|champions|army) gain", re.I)
CONDITIONAL_RE = re.compile(
r"per |for each|each level|per level|combat start|when|whenever|casting"
r"|(?:on|after) attack|takedown|dies|each round|after each", re.I)
GOLD_RE = re.compile(r"[Gg]ain (\d+) gold(?! each| per)")
GOLD_PER_STAGE_RE = re.compile(r"(\d+) gold (?:at the start of|each) (?:every )?stage", re.I)
XP_RE = re.compile(r"[Gg]ain (\d+) XP")
COMP_RE = re.compile(r"(\d+) random(?:\w| )*components?", re.I)
COST_UNITS_RE = re.compile(r"[Gg]ain (\d+) (\d)-cost champions?")
TACTICIAN_HP_RE = re.compile(r"[Gg]ain (\d+) Tactician Health")
def _num(effects: dict, keys: tuple) -> float | None:
for key in keys:
val = effects.get(key)
if isinstance(val, (int, float)) and val > 0:
return val
return None
def build_spec(aug: dict, static_units: dict) -> dict:
desc = aug.get("desc") or ""
effects = aug.get("effects") or {}
atoms = []
offerable = len(desc) >= 10 and "_Desc" not in desc and "@" not in desc
if not offerable:
return {"atoms": [], "offerable": False}
if TACTICIAN_HP_RE.search(desc):
m = TACTICIAN_HP_RE.search(desc)
atoms.append({"kind": "player_hp",
"amount": int(_num(effects, PLAYER_HP_KEYS) or m.group(1))})
gold = _num(effects, GOLD_KEYS)
m = GOLD_RE.search(desc)
if gold is None and m:
gold = int(m.group(1))
if gold:
atoms.append({"kind": "gold_now", "amount": int(gold)})
m = GOLD_PER_STAGE_RE.search(desc)
if m:
atoms.append({"kind": "gold_per_stage", "amount": int(m.group(1))})
xp = _num(effects, XP_KEYS)
m = XP_RE.search(desc)
if xp is None and m:
xp = int(m.group(1))
if xp:
kind = "xp_per_round" if "each round" in desc.lower() else "xp_now"
atoms.append({"kind": kind, "amount": int(xp)})
comps = _num(effects, COMPONENT_KEYS)
m = COMP_RE.search(desc)
if comps is None and m:
comps = int(m.group(1))
if comps:
atoms.append({"kind": "components", "count": int(comps)})
anvils = _num(effects, ANVIL_KEYS)
if anvils:
atoms.append({"kind": "completed_items", "count": int(anvils)})
# Benannte Unit-Geschenke: "Gain a Briar, a Jinx, ..."
if re.search(r"[Gg]ain (a|an) [A-Z]", desc):
granted = [api for api, u in static_units.items()
if u.get("name") and re.search(rf"[Gg]ain (?:a|an) {re.escape(u['name'])}\b", desc)]
if granted:
atoms.append({"kind": "unit_grant", "units": sorted(granted)})
m = COST_UNITS_RE.search(desc)
if m:
atoms.append({"kind": "unit_grant", "cost": int(m.group(2)), "count": int(m.group(1))})
# Team-Stat-Buffs nur bei flachem "your team gains ..." ohne Bedingung.
if TEAM_RE.search(desc) and not CONDITIONAL_RE.search(desc):
for key, (stat, norm) in STAT_KEYS.items():
val = effects.get(key)
if not isinstance(val, (int, float)) or val == 0:
continue
if norm == "frac":
value = _fraction(val)
elif norm == "frac_half":
value = _fraction(val) * 0.5
else:
value = val
atoms.append({"kind": "team_buff", "stat": stat, "value": value})
return {"atoms": atoms, "offerable": True}
def build_specs(static_augments: dict, static_units: dict) -> dict:
return {api: build_spec(aug, static_units) for api, aug in static_augments.items()}

View File

@@ -94,6 +94,11 @@ 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)
# Augment-Team-Buffs (z.B. "+35 Health für dein Team") in die Stats mischen.
for a in augments:
for atom in artifact.get("augment_specs", {}).get(a, {}).get("atoms", []):
if atom["kind"] == "team_buff":
team_buffs[atom["stat"]] += atom["value"]
spell_cap = artifact.get("spell_dps_cap") spell_cap = artifact.get("spell_dps_cap")
tier_profiles = artifact.get("tier_profiles", {}) tier_profiles = artifact.get("tier_profiles", {})

View File

@@ -84,8 +84,9 @@ class Game:
if not self.round.get("augment"): if not self.round.get("augment"):
return return
augments = self.artifact["static"]["augments"] augments = self.artifact["static"]["augments"]
specs = self.artifact.get("augment_specs", {})
pool = [a for a in (self.artifact.get("augment_pool") or list(augments)) pool = [a for a in (self.artifact.get("augment_pool") or list(augments))
if a in augments] if a in augments and specs.get(a, {}).get("offerable", True)]
# Pro Runde eine Stufe rollen; alle Angebote (Spieler + Bots) kommen daraus. # Pro Runde eine Stufe rollen; alle Angebote (Spieler + Bots) kommen daraus.
weights = self.cfg.get("augments", {}).get("tier_weights", {}).get( weights = self.cfg.get("augments", {}).get("tier_weights", {}).get(
self.round["label"], [35, 50, 15] self.round["label"], [35, 50, 15]
@@ -101,7 +102,9 @@ class Game:
continue continue
choices = [a for a in tier_pool if a not in b.augments] choices = [a for a in tier_pool if a not in b.augments]
if choices: if choices:
b.augments.append(self.rng.choice(choices)) chosen = self.rng.choice(choices)
b.augments.append(chosen)
self._apply_augment(b, chosen)
# ---- human actions (delegation) ---- # ---- human actions (delegation) ----
@@ -127,7 +130,51 @@ class Game:
self.player.shop_locked = not self.player.shop_locked self.player.shop_locked = not self.player.shop_locked
def pick_augment(self, choice: int) -> None: def pick_augment(self, choice: int) -> None:
api = self.player.augment_offer[choice] \
if 0 <= choice < len(self.player.augment_offer) else None
player.pick_augment(self.player, choice) player.pick_augment(self.player, choice)
if api:
self._apply_augment(self.player, api)
def _apply_augment(self, p: PlayerState, api: str) -> None:
"""Deterministische Augment-Atome sofort bzw. als Dauereffekt anwenden."""
spec = self.artifact.get("augment_specs", {}).get(api, {})
for atom in spec.get("atoms", []):
kind = atom["kind"]
if kind == "gold_now":
p.gold += atom["amount"]
elif kind == "gold_per_stage":
p.gold_per_stage += atom["amount"]
elif kind == "xp_now":
p.level, p.xp = economy.apply_xp(p.level, p.xp, atom["amount"], self.cfg)
elif kind == "xp_per_round":
p.xp_per_round += atom["amount"]
elif kind == "player_hp":
p.hp += atom["amount"]
elif kind == "components":
player.grant_loot(p, atom["count"], 0, self.artifact, self.rng)
elif kind == "completed_items":
pool = self.artifact.get("item_pool") or []
for _ in range(atom["count"]):
if pool:
p.items.append(self.rng.choice(pool))
elif kind == "unit_grant":
self._grant_units(p, atom)
# team_buff wirkt im Score, nicht hier.
def _grant_units(self, p: PlayerState, atom: dict) -> None:
apis = list(atom.get("units", []))
if "cost" in atom:
for _ in range(atom.get("count", 1)):
candidates = self.pool.units_of_cost(atom["cost"])
if candidates:
apis.append(self.rng.choice(candidates))
for api in apis:
if len(p.bench) >= player.BENCH_SIZE or self.pool.available(api) < 1:
continue
self.pool.take(api)
p.bench.append({"api_name": api, "stars": 1, "items": []})
player.merge(p, api, 1)
# ---- round resolution ---- # ---- round resolution ----
@@ -161,7 +208,8 @@ class Game:
p.gold, p.streak if is_pvp else 0, is_pvp and won, rnd["label"], self.cfg p.gold, p.streak if is_pvp else 0, is_pvp and won, rnd["label"], self.cfg
) )
p.level, p.xp = economy.apply_xp( p.level, p.xp = economy.apply_xp(
p.level, p.xp, self.cfg["xp"]["passive_per_round"], self.cfg p.level, p.xp,
self.cfg["xp"]["passive_per_round"] + p.xp_per_round, self.cfg
) )
self._advance() self._advance()
@@ -228,6 +276,7 @@ class Game:
def _advance(self) -> None: def _advance(self) -> None:
"""Zur nächsten Planungsrunde; Carousels laufen dabei automatisch für alle ab.""" """Zur nächsten Planungsrunde; Carousels laufen dabei automatisch für alle ab."""
prev_stage = self.round["stage"]
while True: while True:
if self.idx + 1 >= len(self.rounds): if self.idx + 1 >= len(self.rounds):
self._finish_by_hp() self._finish_by_hp()
@@ -238,6 +287,9 @@ class Game:
# Carousel gibt weder Einkommen noch XP — nur Loot und Unit. # Carousel gibt weder Einkommen noch XP — nur Loot und Unit.
for p in self._alive(): for p in self._alive():
self._carousel(p) self._carousel(p)
if self.round["stage"] != prev_stage:
for p in self._alive():
p.gold += p.gold_per_stage # Augment: Gold zum Stage-Start
for p in self._alive(): for p in self._alive():
if p is self.player and p.shop_locked: if p is self.player and p.shop_locked:
continue continue

View File

@@ -34,6 +34,8 @@ class PlayerState:
augment_offer: list = field(default_factory=list) augment_offer: list = field(default_factory=list)
placement: int | None = None placement: int | None = None
last_opponent: str | None = None last_opponent: str | None = None
gold_per_stage: int = 0
xp_per_round: int = 0
@property @property
def alive(self) -> bool: def alive(self) -> bool: