From f7e569e51959ff45e0e28ded354b206ea52ea373 Mon Sep 17 00:00:00 2001 From: team3 Date: Thu, 23 Jul 2026 10:30:56 +0200 Subject: [PATCH] Augment-Effekte wirken: Gold/XP/Komponenten/Units/Spieler-HP bei Auswahl, Team-Buffs im Score, Junk gefiltert Co-Authored-By: Claude Fable 5 --- backend/tests/test_game.py | 35 ++++++++++ backend/tests/test_learn.py | 24 +++++++ backend/tft/model/artifact.py | 7 ++ backend/tft/model/augments.py | 121 ++++++++++++++++++++++++++++++++++ backend/tft/model/score.py | 5 ++ backend/tft/sim/game.py | 58 +++++++++++++++- backend/tft/sim/player.py | 2 + 7 files changed, 249 insertions(+), 3 deletions(-) create mode 100644 backend/tft/model/augments.py diff --git a/backend/tests/test_game.py b/backend/tests/test_game.py index 6d5cf8f..81b3a7e 100644 --- a/backend/tests/test_game.py +++ b/backend/tests/test_game.py @@ -243,14 +243,49 @@ def test_loot_drops_components(art, cfg): def test_bot_leveling_follows_targets(art, cfg): + from tft.sim import policy as policy_mod + game = Game(art, cfg, seed=12) levels_at = {} while not game.over and game.round["label"] != "4-3": label = game.round["label"] if label in ("2-6", "3-3", "4-2"): 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() 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 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 + + +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) diff --git a/backend/tests/test_learn.py b/backend/tests/test_learn.py index 037e600..d52f9ac 100644 --- a/backend/tests/test_learn.py +++ b/backend/tests/test_learn.py @@ -62,3 +62,27 @@ def test_unit_multipliers_learned(): learned = learn(rows) assert learned["units"]["TFT17_Strong"] > 1.05 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 diff --git a/backend/tft/model/artifact.py b/backend/tft/model/artifact.py index c3b03af..e0e08d2 100644 --- a/backend/tft/model/artifact.py +++ b/backend/tft/model/artifact.py @@ -82,6 +82,12 @@ def spell_dps_cap(static: dict, roles: dict) -> float | None: 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: roles = { 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"]), "component_pool": component_pool(static["items"]), "recipes": recipes(static["items"]), + "augment_specs": _augment_specs(static), # 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/augments.py b/backend/tft/model/augments.py new file mode 100644 index 0000000..04028d5 --- /dev/null +++ b/backend/tft/model/augments.py @@ -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()} diff --git a/backend/tft/model/score.py b/backend/tft/model/score.py index db22b86..d656aff 100644 --- a/backend/tft/model/score.py +++ b/backend/tft/model/score.py @@ -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) 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") tier_profiles = artifact.get("tier_profiles", {}) diff --git a/backend/tft/sim/game.py b/backend/tft/sim/game.py index b05f3e0..35b7db5 100644 --- a/backend/tft/sim/game.py +++ b/backend/tft/sim/game.py @@ -84,8 +84,9 @@ class Game: if not self.round.get("augment"): return augments = self.artifact["static"]["augments"] + specs = self.artifact.get("augment_specs", {}) 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. weights = self.cfg.get("augments", {}).get("tier_weights", {}).get( self.round["label"], [35, 50, 15] @@ -101,7 +102,9 @@ class Game: continue choices = [a for a in tier_pool if a not in b.augments] 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) ---- @@ -127,7 +130,51 @@ class Game: self.player.shop_locked = not self.player.shop_locked 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) + 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 ---- @@ -161,7 +208,8 @@ class Game: 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, 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() @@ -228,6 +276,7 @@ class Game: def _advance(self) -> None: """Zur nächsten Planungsrunde; Carousels laufen dabei automatisch für alle ab.""" + prev_stage = self.round["stage"] while True: if self.idx + 1 >= len(self.rounds): self._finish_by_hp() @@ -238,6 +287,9 @@ class Game: # Carousel gibt weder Einkommen noch XP — nur Loot und Unit. for p in self._alive(): 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(): if p is self.player and p.shop_locked: continue diff --git a/backend/tft/sim/player.py b/backend/tft/sim/player.py index 52d97e4..294ff99 100644 --- a/backend/tft/sim/player.py +++ b/backend/tft/sim/player.py @@ -34,6 +34,8 @@ class PlayerState: augment_offer: list = field(default_factory=list) placement: int | None = None last_opponent: str | None = None + gold_per_stage: int = 0 + xp_per_round: int = 0 @property def alive(self) -> bool: