diff --git a/backend/tests/test_game.py b/backend/tests/test_game.py
index 494859f..722fc90 100644
--- a/backend/tests/test_game.py
+++ b/backend/tests/test_game.py
@@ -208,3 +208,16 @@ def test_natural_level_progression(art, cfg):
assert checks["2-1"] == (3, 2)
assert checks["2-5"] == (4, 2)
assert checks["3-2"] == (5, 0)
+
+
+def test_augment_offer_single_tier(art, cfg):
+ game = Game(art, cfg, seed=9)
+ mixed = {f"AUG_T{t}_{i}": {"tier": t} for t in (1, 2, 3) for i in range(4)}
+ game.artifact = {**art, "static": {**art["static"], "augments": mixed},
+ "augment_pool": []}
+ game.idx = next(i for i, r in enumerate(game.rounds) if r["augment"])
+ game.player.augment_offer = []
+ game._maybe_offer_augment()
+ 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
diff --git a/backend/tests/test_parse.py b/backend/tests/test_parse.py
index 43aa3a6..6739090 100644
--- a/backend/tests/test_parse.py
+++ b/backend/tests/test_parse.py
@@ -75,3 +75,18 @@ def test_resolve_spell_adaptive_and_fallback():
utility = {"desc": "Grant a shield.", "variables": [
{"name": "Shield", "value": [0, 300, 400, 500, 0, 0, 0]}]}
assert resolve_spell(utility)["spell_damage"] is None
+
+
+def test_augment_tier_from_icon():
+ from tft.staticdata.parse import augment_tier
+ assert augment_tier("ASSETS/x/GodAugmentEkko_II.TFT_Set17.tex") == 2
+ assert augment_tier("ASSETS/x/LeonaHero_I.TFT_Set17.tex") == 1
+ assert augment_tier("ASSETS/x/Big_III.tex") == 3
+ assert augment_tier("ASSETS/x/NoSuffix.tex") is None
+
+
+def test_render_desc():
+ from tft.staticdata.parse import render_desc
+ desc = "Gain
Rest."
+ assert render_desc(desc, {"Gold": 4, "ADAP": 0.15}) == "Gain 4 gold and 15% AD. Rest."
+ assert render_desc("@Unknown@ gold", {}) == "? gold"
diff --git a/backend/tft/api/app.py b/backend/tft/api/app.py
index 1a6669a..c29d00c 100644
--- a/backend/tft/api/app.py
+++ b/backend/tft/api/app.py
@@ -183,6 +183,8 @@ def serialize(game: Game) -> dict:
"traits": traits_panel,
"augment_offer": [
{"api_name": a, "name": static["augments"].get(a, {}).get("name", a),
+ "desc": static["augments"].get(a, {}).get("desc", ""),
+ "tier": static["augments"].get(a, {}).get("tier"),
"icon": static["augments"].get(a, {}).get("icon")}
for a in game.augment_offer
],
diff --git a/backend/tft/constants/set17.toml b/backend/tft/constants/set17.toml
index de2939f..ba4c17b 100644
--- a/backend/tft/constants/set17.toml
+++ b/backend/tft/constants/set17.toml
@@ -64,6 +64,11 @@ stage1 = ["carousel", "pve", "pve", "pve"]
stage_n = ["pvp", "pvp", "pvp", "carousel", "pvp", "pvp", "pve"]
augment_rounds = ["2-1", "3-2", "4-2"]
+[augments]
+# Tier-Gewichte [Silber, Gold, Prismatisch] pro Augment-Runde.
+# Alle drei Angebote einer Runde haben dieselbe Stufe (wie im Spiel).
+tier_weights = { "2-1" = [50, 45, 5], "3-2" = [35, 50, 15], "4-2" = [20, 55, 25] }
+
[loot]
# Vereinfachtes Loot: PvE-Drops als [components, gold].
stage1_pve = [1, 1] # pro Stage-1-PvE-Runde
diff --git a/backend/tft/model/artifact.py b/backend/tft/model/artifact.py
index 91fa63c..5e9b1ab 100644
--- a/backend/tft/model/artifact.py
+++ b/backend/tft/model/artifact.py
@@ -71,6 +71,9 @@ def build(static: dict, learned: dict | None = None, extra_meta: dict | None = N
"stat_mults": baseline.compute_stat_mults(static["units"]),
"tier_profiles": tier_profiles(static, roles),
"item_pool": craftable_items(static["items"]),
+ # Riot-Match-API liefert keine Augments (Feld entfernt) — leer heißt:
+ # der Sim nutzt alle geparsten Augments als Pool.
+ "augment_pool": [],
"learned": learned or {},
}
diff --git a/backend/tft/sim/game.py b/backend/tft/sim/game.py
index 1f6e18b..42ea973 100644
--- a/backend/tft/sim/game.py
+++ b/backend/tft/sim/game.py
@@ -83,15 +83,23 @@ class Game:
def _maybe_offer_augment(self) -> None:
if not self.round.get("augment"):
return
- available = [a for a in self.artifact["static"]["augments"]
- if a not in self.player.augments]
+ augments = self.artifact["static"]["augments"]
+ pool = [a for a in (self.artifact.get("augment_pool") or list(augments))
+ if a in augments]
+ # 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]
+ )
+ tier = self.rng.choices((1, 2, 3), weights=weights)[0]
+ tier_pool = [a for a in pool if (augments[a].get("tier") or 2) == tier] or pool
+
+ available = [a for a in tier_pool if a not in self.player.augments]
if available and not self.player.augment_offer:
self.player.augment_offer = self.rng.sample(available, min(3, len(available)))
for b in self.bots:
if not b.alive:
continue
- choices = [a for a in self.artifact["static"]["augments"]
- if a not in b.augments]
+ choices = [a for a in tier_pool if a not in b.augments]
if choices:
b.augments.append(self.rng.choice(choices))
diff --git a/backend/tft/staticdata/parse.py b/backend/tft/staticdata/parse.py
index 473f633..75d8bca 100644
--- a/backend/tft/staticdata/parse.py
+++ b/backend/tft/staticdata/parse.py
@@ -18,6 +18,34 @@ DAMAGE_FALLBACKS = (
)
TAG_TO_TYPE = {"magicDamage": "magic", "physicalDamage": "physical", "trueDamage": "true"}
+# Augment-Stufe steckt im Icon-Dateinamen: _I / _II / _III.
+AUGMENT_TIER_RE = re.compile(r"[-_](I{1,3})\.", re.I)
+PLACEHOLDER_RE = re.compile(r"@([A-Za-z0-9_]+)(?:\*([\d.]+))?@")
+
+
+def augment_tier(icon_path: str | None) -> int | None:
+ m = AUGMENT_TIER_RE.search(icon_path or "")
+ return len(m.group(1)) if m else None
+
+
+def render_desc(desc: str | None, effects: dict) -> str:
+ """Tooltip-Platzhalter mit Effekt-Werten füllen, Markup entfernen."""
+ lowered = {k.lower(): v for k, v in (effects or {}).items()
+ if isinstance(v, (int, float))}
+
+ def repl(m: re.Match) -> str:
+ value = lowered.get(m.group(1).lower())
+ if value is None:
+ return "?"
+ if m.group(2):
+ value *= float(m.group(2))
+ return str(int(value)) if float(value).is_integer() else str(round(value, 1))
+
+ text = PLACEHOLDER_RE.sub(repl, desc or "")
+ text = re.sub(r"<[^>]+>", " ", text)
+ text = re.sub(r"%i:[^%]+%", "", text)
+ return re.sub(r"\s+", " ", text).strip()
+
def resolve_spell(ability: dict) -> dict:
"""Spell-Schaden pro Stern aus Desc-Markup + Variablen auflösen.
@@ -126,7 +154,11 @@ def parse(raw: dict, set_override: int | None = None) -> dict:
augments = {}
for i in raw["items"]:
api = i["apiName"]
- if not (api.startswith(set_prefix) or api.startswith("TFT_Item_")):
+ is_augment = "Augment" in api
+ # Augments: Set-eigene + generischer Evergreen-Pool (TFT_Augment_*).
+ prefixes = (set_prefix, "TFT_Item_", "TFT_Augment_") if is_augment \
+ else (set_prefix, "TFT_Item_")
+ if not api.startswith(prefixes):
continue
entry = {
"api_name": api,
@@ -135,7 +167,12 @@ def parse(raw: dict, set_override: int | None = None) -> dict:
"effects": i.get("effects") or {},
"icon": icon_url(i["icon"]) if i["icon"] else None,
}
- if "Augment" in api:
+ if is_augment:
+ # Müll raus: Shop-Mechanik-Angebote und unaufgelöste Tooltip-Namen.
+ if "MarketOffering" in api or "@" in (i["name"] or "") or "{" in (i["name"] or ""):
+ continue
+ entry["tier"] = augment_tier(i.get("icon"))
+ entry["desc"] = render_desc(i.get("desc"), i.get("effects") or {})
augments[api] = entry
else:
items[api] = entry
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index bb02d6a..e6de920 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -59,9 +59,16 @@ const standings = computed(() => {