From 319f79bc5dacea35076dc1143d969cf799136562 Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 22 Jul 2026 15:16:14 -0700 Subject: [PATCH] Answer the fuse button: advise on fusions when Fusion Jokers is installed Hovering a joker that is a fusion component now names the fusion it feeds, its cost with discounts applied, and what is still missing. In the shop the card is counted as if already bought, so it answers the buy-side question ("Buy to fuse into Diamond Bard ($12 to fuse)") instead of restating what you already own. The line worth having is the last one. Fusing CONSUMES its components -- fuse_card calls ingredient:remove() -- so every combo those jokers held with the rest of the board dies with them, and the FUSE button cannot tell you that. "Fusing drops combos with: Smeared" is computed from the surviving board, and a component being spent is not counted as a loss. Three things about their code shaped this: * Card:get_card_fusion() would have answered most of it, but it drives its price flicker with math.randomseed(love.timer.getTime() * 8). An advisor reseeding Lua's RNG on every hover has no business doing that, so this reads FusionJokers.fusions directly and mirrors their discount arithmetic (flat then percentage, per-result and universal, floored, min $1). * Recipes take repeated components -- their own debug fusion needs 3x Joker -- so components are counted by quantity, not presence. * Affordability is judged only on plain numbers: Fusion Jokers uses to_big, and Talisman turns G.GAME.dollars into an object. Guessing wrong about someone's money is worse than staying quiet, so the money line just does not appear. Ownership is board membership, not card.area, matching copy_source and dagger_victim -- the area pointer answers differently for the same card depending on who built it. Verified against the shipped recipe table: all 15 resolve at the right cost, and all 30 components are jokers the database already knows. Not installed means silence, like every other integration. 918 tests pass on 5.4 and LuaJIT (31 new), and 18/18 in the real game. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 16 +++++ CLAUDE.md | 26 ++++++- README.md | 18 +++++ integrations.lua | 182 +++++++++++++++++++++++++++++++++++++++++++++++ main.lua | 41 +++++++++++ test.lua | 144 +++++++++++++++++++++++++++++++++++++ 6 files changed, 426 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d875ac0..1587712 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## Unreleased + +### Fusion Jokers integration +- When [Fusion Jokers](https://github.com/wingedcatgirl/Fusion-Jokers) is + installed, hovering any joker that is a fusion component names the fusion it + feeds, the discounted cost, and what is still missing ("Fuses into Diamond + Bard ($12) — still needs Rough Gem"). In the shop it answers the buy-side + question instead: "Buy to fuse into Diamond Bard ($12 to fuse)". +- Fusing **consumes** its components, so a ready fusion also reports the combos + that die with them ("Fusing drops combos with: Smeared"). No other mod can + answer that — it is the reason the integration exists rather than just + restating the recipe. +- Costs mirror Fusion Jokers' own discount arithmetic, and components are + counted by quantity, so a recipe needing 3× Joker asks for three. +- Not installed means silence, like every other integration. + ## 1.1.0 — 2026-07-15 ### New advice surfaces diff --git a/CLAUDE.md b/CLAUDE.md index 0ba91be..70633a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -208,7 +208,8 @@ Three-layer split; `JCA` is the single global namespace: - `integrations.lua` — cross-mod integrations, loaded at the very end of `main.lua` (pcall-wrapped; counts land in `JCA.integrations`). Every integration gates on its target's global — a missing mod is silence — and - both targets load before us (JokerDisplay priority -1e9, TMJ -1000, ours 0): + every target loads before us (JokerDisplay priority -1e9, Fusion Jokers + -10000, TMJ -1000, ours 0): - **JokerDisplay**: appends a `(2 combos)` counter element to each known joker's `reminder_text` (created when absent) and wraps the definition's `calc_function` to fill `card.joker_display_values.jca_combos` from @@ -219,6 +220,22 @@ Three-layer split; `JCA` is the single global namespace: api.md's documented hook) returning the joker's `TAG_LABEL`s on both sides, so the collection search finds jokers by 'money', 'Hearts', 'held-card retriggers'... Unknown centers return nil, never an error. + - **Fusion Jokers**: installs `JCA.fusion_plans(card)`, which reports every + fusion a joker is a component of — result, discounted cost, what is still + missing, and **what fusing would cost you in combos**. That last part is + the reason the integration exists: `fuse_card` calls `ingredient:remove()` + on the components, so their partnerships with the rest of the board die, + and nothing but this advisor knows what those were. Rendered in + `tooltip_rows` (max 2 plans), ready fusions sorted first. + Read the recipe table (`FusionJokers.fusions`, an array with the + `ingredience[component][result]` reverse index hung off the same table) — + **never call `Card:get_card_fusion()`**: it drives its price flicker with + `math.randomseed(love.timer.getTime() * 8)`, so an advisor calling it + would reseed Lua's RNG on every hover. Cost mirrors their arithmetic + (flat then percentage discount, per-result and universal, floored, min $1) + and affordability is only judged on plain numbers, because Talisman turns + `G.GAME.dollars` into a big-number object. Components are counted by + **quantity**, not presence — their own debug recipe needs 3× Joker. The smoke scenario asserts the JokerDisplay patch against the real mod. ## Conventions and gotchas @@ -290,6 +307,13 @@ partner, the clash and caution lines, discovery on emplace, and — the reason i exists — that catalog cards really do `start_materialize` on tab build *and* on page switch (asserted via `card.dissolve > 0` in the live UIBox). +It is also the only place the cross-mod integrations meet the real thing: the +JokerDisplay counter against vanilla definitions, and the Fusion Jokers advice +against the **real** recipe table (`test.lua` can only drive `JCA.fusion_plans` +against a stub). Both scenarios no-op with a pass when their mod is absent, so +the harness stays green on a clean install. Note a fake run starts at **$0** — +a fusion affordability assertion has to set `G.GAME.dollars` itself. + The harness suppresses every save path and verifies from outside that no discovery state leaked, so running it can never spoil the catalog. Do not "fix" that by reversing writes after the fact — a crashed scenario would skip the reversal. diff --git a/README.md b/README.md index 7704ba0..7feb4f4 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,24 @@ In-game: **Mods > Combo Advisor > Config**. Changes persist across sessions. Defaults live in `config.lua`. +## Works with other mods + +Each integration gates on the other mod's presence — if it isn't installed, +nothing happens and nothing is logged. + +- **[Fusion Jokers](https://github.com/wingedcatgirl/Fusion-Jokers)** — hover + any joker that is a fusion component and the advisor names the fusion it + feeds, its cost (discounts included), and what you are still missing: + *"Fuses into Diamond Bard ($12) — still needs Rough Gem"*. In the shop it + answers the buy-side question instead: *"Buy to fuse into Diamond Bard + ($12 to fuse)"*. Because fusing **consumes** its components, a ready fusion + also tells you what you'd give up — *"Fusing drops combos with: Smeared"* — + which is the one thing the FUSE button can't tell you itself. +- **[JokerDisplay](https://github.nh6574.com/JokerDisplay)** — a live + `(2 combos)` counter under every joker, toggleable in the config tab. +- **Too Many Jokers** — its collection search also matches synergy tags, so you + can search for `money`, `Hearts`, or `held-card retriggers`. + ## Installation 1. Install [Lovely](https://github.com/ethangreen-dev/lovely-injector) and diff --git a/integrations.lua b/integrations.lua index a70569b..b9f2839 100644 --- a/integrations.lua +++ b/integrations.lua @@ -78,4 +78,186 @@ function M.toomanyjokers() return 1 end +--- Fusion Jokers: advice for the FUSE button. +--- Its model: FusionJokers.fusions is an ARRAY of recipes +--- {jokers = {{name = key}, ...}, result_joker = key, cost = n, requirement = fn} +--- with a reverse index hung off the same table, +--- FusionJokers.fusions.ingredience[component_key][result_key] = true, +--- so `ipairs` walks recipes and `.ingredience` is just another field. +--- +--- We only READ that data. Card:get_card_fusion() would answer most of this +--- for us, but it drives its price flicker with +--- `math.randomseed(math.floor(love.timer.getTime() * 8))` (FusionJokers.lua), +--- so calling it would reseed Lua's RNG on every hover -- a side effect an +--- advisor has no business having. Everything below is derived from the +--- recipe table instead, mirroring their arithmetic where it matters. +--- +--- Installs JCA.fusion_plans; when the mod is absent nothing is installed and +--- the tooltip stays silent, exactly like an unknown joker scoring 0. +function M.fusionjokers() + if not (FusionJokers and FusionJokers.fusions) then return 0 end + local FUSIONS = FusionJokers.fusions + + -- Owned copies per joker key, plus whether `card` is itself on the board. + -- Fusing pulls components out of G.jokers (their has_joker walks + -- G.jokers.cards), so that is the pool that counts -- and board membership + -- is what "owned" means here, the same test copy_source and dagger_victim + -- use. Asking the card for its area would answer differently for the same + -- card depending on who built it. + local function board_state(card) + local n, owned = {}, false + for _, c in ipairs((G.jokers and G.jokers.cards) or {}) do + local key = c.config and c.config.center and c.config.center.key + if key then n[key] = (n[key] or 0) + 1 end + if c == card then owned = true end + end + return n, owned + end + + -- Same arithmetic as get_card_fusion: flat discount then percentage, each + -- with a per-result and a universal term, floored, never below $1. + local function fusion_cost(recipe) + local cost = recipe.cost + if type(cost) ~= 'number' then return nil end + local flat = G.GAME and G.GAME.fujo_fusion_discount + if flat then + cost = cost - (flat[recipe.result_joker] or 0) - (flat.universal or 0) + end + local pct = G.GAME and G.GAME.fujo_fusion_discountpercent + if pct then + cost = cost * (1 - (pct[recipe.result_joker] or 0)) + * (1 - (pct.universal or 0)) + end + return math.max(math.floor(cost), 1) + end + + -- The cards this recipe would eat, one per component slot, first match in + -- board order. FusionJokers prefers highlighted copies over board order, + -- but which duplicate it takes cannot change WHICH KEYS leave the board, + -- and that is all the combo maths below depends on. + local function consumed_by(recipe) + local cards = (G.jokers and G.jokers.cards) or {} + local consumed, taken = {}, {} + for _, comp in ipairs(recipe.jokers) do + for i, c in ipairs(cards) do + local key = not taken[i] and c.config and c.config.center + and c.config.center.key + if key == comp.name then + taken[i] = true + consumed[#consumed + 1] = c + break + end + end + end + return consumed, taken + end + + -- What fusing costs you beyond the money: a fusion CONSUMES its components + -- (fuse_card calls ingredient:remove(), and the survivor is re-abilitied + -- into the result), so every combo those jokers had with the rest of the + -- board dies with them. No other mod can answer this -- it is the whole + -- reason this integration exists rather than just restating the recipe. + -- Returns the surviving owned jokers whose partnerships would be lost. + local function losses(recipe) + local consumed, taken = consumed_by(recipe) + if #consumed == 0 then return {} end + local cards = (G.jokers and G.jokers.cards) or {} + local lost, seen = {}, {} + for _, gone in ipairs(consumed) do + local gone_key = gone.config.center.key + for i, other in ipairs(cards) do + local key = not taken[i] and other.config and other.config.center + and other.config.center.set == 'Joker' + and other.config.center.key + if key and not seen[key] and JCA.score(gone_key, key) >= 2 then + seen[key] = true + lost[#lost + 1] = key + end + end + end + return lost + end + + --- Every fusion `card` is a component of, as plans the tooltip can render. + --- A card in a buy area is counted as if already bought, so the shop + --- tooltip answers "what does buying this unlock" rather than "what do you + --- have" -- the question the player is actually asking. + --- Each plan: {result, cost, ready, affordable, dollars, missing, loses} + --- missing - {{key = ..., n = }, ...} in recipe order + --- loses - owned joker keys whose combos die if this fusion happens + function JCA.fusion_plans(card) + local center = card.config and card.config.center + local key = center and center.key + local index = FUSIONS.ingredience + if not (key and index and index[key]) then return {} end + + local counts, owned = board_state(card) + if not owned then counts[key] = (counts[key] or 0) + 1 end + + local plans = {} + for _, recipe in ipairs(FUSIONS) do + -- ingredience is only a fast reject: confirm against the recipe + -- itself, which is the list fuse_card actually walks. + if index[key][recipe.result_joker] then + local need = {} + for _, comp in ipairs(recipe.jokers) do + need[comp.name] = (need[comp.name] or 0) + 1 + end + if need[key] then + local ready, missing, listed = true, {}, {} + for _, comp in ipairs(recipe.jokers) do + if not listed[comp.name] then + listed[comp.name] = true + local short = need[comp.name] - (counts[comp.name] or 0) + if short > 0 then + ready = false + missing[#missing + 1] = {key = comp.name, n = short} + end + end + end + -- A third-party recipe may gate itself behind a predicate; + -- it is someone else's code, so a throw means "unknown", + -- never a crash and never a false "ready". + if ready and type(recipe.requirement) == 'function' then + local ok, allowed = pcall(recipe.requirement) + if not ok or allowed == false then ready = false end + end + local cost = fusion_cost(recipe) + local dollars = G.GAME and G.GAME.dollars + local affordable + -- Only judge affordability on plain numbers: Talisman turns + -- dollars into a big-number object, and guessing wrong about + -- someone's money is worse than staying quiet. + if cost and type(dollars) == 'number' then + local floor_ = G.GAME.bankrupt_at + affordable = cost + (type(floor_) == 'number' and floor_ or 0) + <= dollars + end + plans[#plans + 1] = { + result = recipe.result_joker, + cost = cost, + ready = ready, + affordable = affordable, + dollars = type(dollars) == 'number' and dollars or nil, + missing = missing, + loses = ready and losses(recipe) or {}, + } + end + end + end + -- Fusions you can act on first, then cheapest, then by result key so a + -- hover never reshuffles its own advice. + table.sort(plans, function(a, b) + if a.ready ~= b.ready then return a.ready end + if (a.cost or 0) ~= (b.cost or 0) then return (a.cost or 0) < (b.cost or 0) end + return a.result < b.result + end) + return plans + end + + local n = 0 + for _ in ipairs(FUSIONS) do n = n + 1 end + return n +end + return M diff --git a/main.lua b/main.lua index d78da90..98710f7 100644 --- a/main.lua +++ b/main.lua @@ -793,6 +793,47 @@ local function tooltip_rows(card) end end + -- Fusion Jokers (see integrations.lua). The FUSE button consumes the + -- components, so the advice is the recipe, the price, and -- the part only + -- this advisor can answer -- which of your current combos die with them. + -- Two plans max: a joker can be a component of several recipes. These are + -- mechanical facts, not verdicts, so learning mode keeps them. + if JCA.fusion_plans then + local ok, plans = pcall(JCA.fusion_plans, card) + for i = 1, (ok and math.min(2, #plans) or 0) do + local p = plans[i] + local rname = name_of(p.result) + local price = p.cost and (' ($' .. p.cost .. ')') or '' + if not p.ready then + local want = {} + for _, m in ipairs(p.missing) do + want[#want + 1] = (m.n > 1 and (m.n .. 'x ') or '') .. name_of(m.key) + end + text_row(('Fuses into %s%s - still needs %s') + :format(rname, price, table.concat(want, ', '))) + elseif in_buy_area(card) then + -- Deliberately no affordability check here: the player would be + -- paying for the card AND the fusion, and guessing at that sum + -- would be advice about money we have not actually done. + text_row(('Buy to fuse into %s%s'):format(rname, + p.cost and (' ($' .. p.cost .. ' to fuse)') or ''), G.C.GREEN) + elseif p.affordable == false then + text_row(('Fuses into %s - $%d, you have $%d') + :format(rname, p.cost, p.dollars or 0), G.C.RED) + else + text_row(('Fusion ready: %s%s'):format(rname, price), G.C.GREEN) + end + if #p.loses > 0 then + local names = {} + for _, k in ipairs(p.loses) do names[#names + 1] = {name = name_of(k)} end + for li, l in ipairs(wrap_names(names, 30, 2)) do + text_row((li == 1 and 'Fusing drops combos with: ' or ' ') .. l, + G.C.RED) + end + end + end + end + -- Run context: this deck, these hand levels. Facts that frame the tags, -- never part of the score; capped at two lines so multi-suit jokers do -- not flood the tooltip. Fixed iteration order keeps hovers stable. diff --git a/test.lua b/test.lua index 89c711d..4c4cca6 100644 --- a/test.lua +++ b/test.lua @@ -124,6 +124,30 @@ JokerDisplay = {Definitions = { }} TMJ = {SEARCH_FIELD_FUNCS = {}} +-- Fusion Jokers (priority -10000, so it too is loaded before us). Its recipe +-- table is an array with the reverse index hung off the same table. Three +-- recipes, shaped like the real ones: a plain two-component fusion, a second +-- recipe sharing a component (so one joker can feed two fusions), and a +-- repeated-component recipe -- their own debug fusion needs 3x Joker, so +-- counting by quantity rather than presence is a real requirement, not a +-- hypothetical. +FusionJokers = {fusions = { + {jokers = {{name = 'j_greedy_joker'}, {name = 'j_rough_gem'}}, + result_joker = 'j_fuse_diamond_bard', cost = 12}, + {jokers = {{name = 'j_greedy_joker'}, {name = 'j_blackboard'}}, + result_joker = 'j_fuse_test_two', cost = 4}, + {jokers = {{name = 'j_joker'}, {name = 'j_joker'}, {name = 'j_joker'}}, + result_joker = 'j_fuse_three_jimbos', cost = 2}, +}} +FusionJokers.fusions.ingredience = {} +for _, f in ipairs(FusionJokers.fusions) do + for _, c in ipairs(f.jokers) do + FusionJokers.fusions.ingredience[c.name] = + FusionJokers.fusions.ingredience[c.name] or {} + FusionJokers.fusions.ingredience[c.name][f.result_joker] = true + end +end + local data = dofile('synergies.lua') dofile('main.lua') @@ -851,6 +875,126 @@ ok(tmj_hit, 'TMJ search finds Baron under its tag labels') eq(TMJ.SEARCH_FIELD_FUNCS[1]({key = 'j_totally_modded'}), nil, 'an unknown center returns nil, not an error') +-------------------------------------------------------------------------------- +section('Integrations: Fusion Jokers recipes, cost and what fusing costs you') +-------------------------------------------------------------------------------- + +eq(JCA.integrations.fusionjokers, 3, 'every stub recipe was indexed') + +-- A shop card is priced as if already bought: the question in the shop is +-- "what does buying this unlock", not "what do I have". +local function shop_card(key) + return {config = {center = {key = key, set = 'Joker'}}} +end +local function plan_for(card, result) + for _, p in ipairs(JCA.fusion_plans(card)) do + if p.result == result then return p end + end +end + +G.GAME.dollars = 20 + +-- A joker in no recipe stays silent, and so does an unknown modded one. +field('j_baron', 'j_mime') +eq(#JCA.fusion_plans(G.jokers.cards[1]), 0, 'a joker in no recipe has no plans') +eq(#JCA.fusion_plans(shop_card('j_totally_modded')), 0, + 'an unknown modded joker has no plans, not an error') + +-- Owned, with the partner component missing. +field('j_greedy_joker', 'j_baron') +local p = plan_for(G.jokers.cards[1], 'j_fuse_diamond_bard') +ok(p and not p.ready, 'a half-assembled fusion is not ready') +eq(p and #p.missing, 1, 'and names exactly the component still missing') +eq(p and p.missing[1].key, 'j_rough_gem', 'which is Rough Gem') +eq(p and p.missing[1].n, 1, 'one copy short') +eq(p and #p.loses, 0, 'an unready fusion claims no losses') + +-- Both components owned: ready, priced, affordable at $20. +field('j_greedy_joker', 'j_rough_gem') +p = plan_for(G.jokers.cards[1], 'j_fuse_diamond_bard') +ok(p and p.ready, 'both components fielded makes the fusion ready') +eq(p and p.cost, 12, 'at the recipe cost') +eq(p and p.affordable, true, '$20 covers a $12 fusion') +G.GAME.dollars = 5 +p = plan_for(G.jokers.cards[1], 'j_fuse_diamond_bard') +eq(p and p.affordable, false, 'but $5 does not') +G.GAME.dollars = 20 + +-- The whole point of the integration: fusing EATS the components, so the +-- combos they hold with the rest of the board die with them. Smeared feeds +-- both Diamond jokers their suit_flex, and survives the fusion. +field('j_greedy_joker', 'j_rough_gem', 'j_smeared') +p = plan_for(G.jokers.cards[1], 'j_fuse_diamond_bard') +eq(p and #p.loses, 1, 'fusing reports the combo it would cost you') +eq(p and p.loses[1], 'j_smeared', 'naming the surviving partner left stranded') + +-- A component of the fusion is not "lost" -- it is being spent, not stranded. +field('j_greedy_joker', 'j_rough_gem') +p = plan_for(G.jokers.cards[1], 'j_fuse_diamond_bard') +eq(p and #p.loses, 0, 'the other component is spent, not counted as a loss') + +-- In the shop, the card counts as bought. +field('j_rough_gem') +p = plan_for(shop_card('j_greedy_joker'), 'j_fuse_diamond_bard') +ok(p and p.ready, 'a shop card completes a recipe as if already bought') +eq(#JCA.fusion_plans(G.jokers.cards[1]), 1, + 'while the owned half alone still reads as unready') +ok(not JCA.fusion_plans(G.jokers.cards[1])[1].ready, '(and says so)') + +-- Repeated components are counted by QUANTITY, not presence: their own debug +-- recipe needs three Jokers, and owning one is not owning three. +field('j_joker') +p = plan_for(G.jokers.cards[1], 'j_fuse_three_jimbos') +ok(p and not p.ready, 'one Joker does not satisfy a 3x Joker recipe') +eq(p and p.missing[1].n, 2, 'and the shortfall is the missing two') +field('j_joker', 'j_joker', 'j_joker') +p = plan_for(G.jokers.cards[1], 'j_fuse_three_jimbos') +ok(p and p.ready, 'three Jokers do satisfy it') + +-- Discounts, mirroring get_card_fusion: flat then percentage, floored, min $1. +field('j_greedy_joker', 'j_rough_gem') +G.GAME.fujo_fusion_discount = {j_fuse_diamond_bard = 5} +eq(plan_for(G.jokers.cards[1], 'j_fuse_diamond_bard').cost, 7, + 'a per-fusion flat discount comes off the price') +G.GAME.fujo_fusion_discount.universal = 2 +eq(plan_for(G.jokers.cards[1], 'j_fuse_diamond_bard').cost, 5, + 'and stacks with the universal one') +G.GAME.fujo_fusion_discountpercent = {universal = 0.5} +eq(plan_for(G.jokers.cards[1], 'j_fuse_diamond_bard').cost, 2, + 'percentages apply after, and round down') +G.GAME.fujo_fusion_discount.universal = 100 +eq(plan_for(G.jokers.cards[1], 'j_fuse_diamond_bard').cost, 1, + 'a fusion never costs less than $1') +G.GAME.fujo_fusion_discount, G.GAME.fujo_fusion_discountpercent = nil, nil + +-- Ready fusions sort ahead of unready ones, so the two lines the tooltip has +-- room for are the two the player can act on. +field('j_greedy_joker', 'j_blackboard') +local plans = JCA.fusion_plans(G.jokers.cards[1]) +eq(#plans, 2, 'a joker feeding two recipes gets a plan for each') +eq(plans[1].result, 'j_fuse_test_two', 'the ready fusion sorts first') +ok(not plans[2].ready, 'the unready one follows') + +-- A third-party recipe can gate itself; a predicate that says no, or throws, +-- must never produce a false "ready". +FusionJokers.fusions[2].requirement = function() return false end +ok(not plan_for(G.jokers.cards[1], 'j_fuse_test_two').ready, + 'a requirement returning false blocks readiness') +FusionJokers.fusions[2].requirement = function() error('someone elses bug') end +ok(not plan_for(G.jokers.cards[1], 'j_fuse_test_two').ready, + 'and a requirement that throws is treated as unmet, not a crash') +FusionJokers.fusions[2].requirement = nil + +-- No Fusion Jokers installed: silence, exactly like an unknown joker. +do + local real = FusionJokers + FusionJokers = nil + eq(dofile('integrations.lua').fusionjokers(), 0, + 'a missing Fusion Jokers install patches nothing and does not error') + FusionJokers = real +end +G.GAME.dollars = nil + -------------------------------------------------------------------------------- section('UI: catalog pages build and animate') --------------------------------------------------------------------------------