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 @Gold@ gold and @ADAP*100@% AD.
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(() => {

Augment wählen

-
@@ -171,9 +178,20 @@ header { } .augments { background: #1d2438; border-radius: 8px; padding: 10px 14px; } .augments h3 { font-size: 12px; text-transform: uppercase; color: #8a92a8; margin-bottom: 6px; } -.offer { display: flex; gap: 8px; } -.offer button { display: flex; align-items: center; gap: 8px; flex: 1; } -.offer img { width: 28px; height: 28px; } +.offer { display: flex; gap: 10px; } +.augcard { + flex: 1; + display: flex; flex-direction: column; align-items: center; gap: 6px; + padding: 14px 12px; + text-align: center; + border-width: 2px; +} +.augcard img { width: 44px; height: 44px; } +.augname { font-weight: 700; font-size: 14px; } +.augdesc { font-size: 12px; color: #aab2c8; line-height: 1.35; } +.augcard.tier1 { border-color: #b9bec9; } +.augcard.tier2 { border-color: #e8c35a; } +.augcard.tier3 { border-color: #b153d8; box-shadow: 0 0 10px #b153d855; } main { flex: 1; min-height: 0;