--- Joker Combo Advisor --- Scores jokers in the shop (and in booster packs) against the jokers you --- currently own, shows a "Combo Advisor" tooltip on hover explaining which --- of your jokers they synergize with, and makes strong picks pulse. JCA = JCA or {} -- Diagnostics: prints land in Mods/lovely/log as "[G] JCA: ..." lines. JCA.DEBUG = false local function dbg(...) if JCA.DEBUG then print('JCA:', ...) end end local data = assert(SMODS.load_file('synergies.lua'))() JCA.db = data.jokers -- Settings from config.lua, persisted by Steamodded across sessions. -- Fallback defaults keep the engine usable outside the game (tests). JCA.mod = SMODS.current_mod JCA.config = SMODS.current_mod and SMODS.current_mod.config or { pulse = true, owned_tooltip = true, threshold = 4, discovered = {}, themes_found = {} } JCA.config.discovered = JCA.config.discovered or {} JCA.config.themes_found = JCA.config.themes_found or {} -- Explicit famous pairs, stored under a canonical "a|b" key (a < b). JCA.pair_list = data.pairs JCA.pair_bonus = {} JCA.pair_blurb = {} local function pair_key(a, b) if a < b then return a .. '|' .. b end return b .. '|' .. a end for _, p in ipairs(data.pairs) do JCA.pair_bonus[pair_key(p[1], p[2])] = true JCA.pair_blurb[pair_key(p[1], p[2])] = p[3] end -- Known traps: warning-only, never part of the synergy score. JCA.clash_blurb = {} for _, p in ipairs(data.clashes or {}) do JCA.clash_blurb[pair_key(p[1], p[2])] = p[3] end JCA.cautions = data.cautions or {} -- Scoring ------------------------------------------------------------------- -- Synergy score between two joker center keys. 0 = no known synergy. -- Second return marks scores whose +1 came from the generic engine -- complement, so partners_for can count that bonus once per candidate. function JCA.score(a, b) local s, generic = 0, false if JCA.pair_bonus[pair_key(a, b)] then s = s + 4 end local A, B = JCA.db[a], JCA.db[b] if A and B then for t in pairs(A.gives) do if B.wants[t] then s = s + 2 end end for t in pairs(B.gives) do if A.wants[t] then s = s + 2 end end -- Generic engine complement: a +Mult/+Chips source and an xMult -- multiplier usually help each other a little. no_generic opts out -- jokers whose multiplier this reasoning misreads (Joker Stencil). if not (A.no_generic or B.no_generic) then local a_scaler = A.gives.mult or A.gives.chips local b_scaler = B.gives.mult or B.gives.chips if (A.gives.xmult and b_scaler) or (B.gives.xmult and a_scaler) then s = s + 1 generic = true end end end return s, generic end -- One short reason why `partner` works with `hovered`, phrased from the -- partner's side ("Mime: retriggers cards in hand"). Most specific wins: -- famous-pair blurb, then the first tag match in TAG_ORDER (what the partner -- gives to this card before what it takes), then the generic engine -- complement. function JCA.explain(hovered, partner) local blurb = JCA.pair_blurb[pair_key(hovered, partner)] if blurb then return blurb end local A, B = JCA.db[hovered], JCA.db[partner] if not (A and B) then return nil end for _, t in ipairs(data.tag_order) do local text = data.tag_text[t] if text then local cluster = text.both and ((A.gives[t] and A.wants[t]) or (B.gives[t] and B.wants[t])) if B.gives[t] and A.wants[t] then return cluster and text.both or text.give elseif A.gives[t] and B.wants[t] then return cluster and text.both or text.want end end end if not (A.no_generic or B.no_generic) then if B.gives.xmult and (A.gives.mult or A.gives.chips) then return 'multiplies what this adds' elseif A.gives.xmult and (B.gives.mult or B.gives.chips) then return 'feeds this multiplier' end end return nil end local function name_of(key) local ok, name = pcall(function() return localize{type = 'name_text', set = 'Joker', key = key} end) if ok and type(name) == 'string' and name ~= 'ERROR' then return name end local center = G.P_CENTERS and G.P_CENTERS[key] return center and center.name or key end -- Owned jokers with synergy score >= 2 against `card`, strongest first. -- Returns the partner list and the total score across all owned jokers. -- The generic mult/xmult complement counts toward the total once per -- candidate, not once per owned joker — otherwise any xMult card would -- pulse "recommended" on a board of four unrelated +Mult jokers. function JCA.partners_for(card) local partners, total, generic_seen = {}, 0, false if not (G.jokers and G.jokers.cards) then return partners, 0 end local key = card.config.center.key for _, owned in ipairs(G.jokers.cards) do if owned ~= card and owned.config.center.set == 'Joker' then local s, generic = JCA.score(key, owned.config.center.key) total = total + s if generic then if generic_seen then total = total - 1 end generic_seen = true end if s >= 2 then partners[#partners + 1] = { key = owned.config.center.key, name = name_of(owned.config.center.key), score = s, } end end end table.sort(partners, function(x, y) return x.score > y.score end) return partners, total end -- A shop/pack joker is "recommended" (pulses) when its combined synergy with -- the current build clears the configured threshold. function JCA.is_recommended(card) local _, total = JCA.partners_for(card) return total >= JCA.config.threshold end -- Sell advisor: on a full board, the owned joker contributing the least -- total synergy to the rest is the natural cut when something better shows -- up. Returns nil when slots are free, the board is tiny, or every joker -- scores the same (nothing stands out to cut). function JCA.weakest_link() if not (G.jokers and G.jokers.cards) then return nil end local cards = G.jokers.cards local limit = G.jokers.config and G.jokers.config.card_limit or #cards if #cards < 3 or #cards < limit then return nil end local worst, worst_total, best_total for _, c in ipairs(cards) do if c.config and c.config.center and c.config.center.set == 'Joker' then local _, total = JCA.partners_for(c) if not worst_total or total < worst_total then worst, worst_total = c, total end if not best_total or total > best_total then best_total = total end end end if worst and worst_total < best_total then return worst end end local function in_buy_area(card) return card.area and (card.area == G.shop_jokers or card.area == G.pack_cards) end local function is_joker(card) return card.config and card.config.center and card.config.center.set == 'Joker' end -- Hover tooltip --------------------------------------------------------------- -- Wrap partner names into short tooltip lines. local function wrap_names(partners, max_width, max_lines) local lines, line = {}, nil for i, p in ipairs(partners) do local nxt = line and (line .. ', ' .. p.name) or p.name if line and #nxt > max_width then lines[#lines + 1] = line line = p.name else line = nxt end if #lines >= max_lines then lines[#lines] = lines[#lines] .. ' (+' .. (#partners - i + 1) .. ' more)' return lines end end if line then lines[#lines + 1] = line end return lines end -- Short theme nouns for the "Looking for:"/"Offers:" hints (engine roles -- mult/chips/xmult are deliberately absent — they hint nothing specific). local TAG_LABEL = { faces = 'face cards', retrig_scoring = 'played-card retriggers', retrig_held = 'held-card retriggers', hand_size = 'hand size', hands_up = 'extra hands', discards_up = 'discards', probability = 'listed odds', money = 'money', hand_level = 'hand levels', tarot_gen = 'Tarot cards', planet = 'Planet cards', spectral_gen = 'Spectral cards', gold_gen = 'Gold cards', stone_gen = 'Stone cards', enhance_gen = 'card enhancing', card_gen = 'deck additions', joker_gen = 'Joker creation', sell_value = 'sell value', copy_target = 'copy targets', suit_flex = 'suit conversion', pair = 'Pairs', two_pair = 'Two Pair', three_kind = 'Three of a Kind', four_kind = 'Four of a Kind', straight = 'Straights', flush = 'Flushes', hearts = 'Hearts', diamonds = 'Diamonds', spades = 'Spades', clubs = 'Clubs', } -- Labels for a joker's tag set, in stable data.tag_order order. local function tag_labels(tag_set) local labels = {} for _, t in ipairs(data.tag_order) do if tag_set[t] and TAG_LABEL[t] then labels[#labels + 1] = {name = TAG_LABEL[t]} end end return labels end local function tooltip_rows(card) -- Touch mode enlarges every advice row for phone screens (see config.lua). local S = JCA.config.touch_mode and 1.4 or 1 local key = card.config.center.key local partners, total = JCA.partners_for(card) local rows = {} local function text_row(str, colour) rows[#rows + 1] = {{n = G.UIT.T, config = { text = str, colour = colour or G.C.UI.TEXT_DARK, scale = 0.32 * S, }}} end if #partners == 0 then text_row(in_buy_area(card) and 'No synergy with your jokers.' or 'No combos with your other jokers.') -- Teach what to pair it with later: what it wants, else what it gives. local entry = in_buy_area(card) and JCA.db[card.config.center.key] if entry then local labels, prefix = tag_labels(entry.wants), 'Looking for: ' if #labels == 0 then labels, prefix = tag_labels(entry.gives), 'Offers: ' end if #labels > 0 then for i, l in ipairs(wrap_names(labels, 36, 2)) do rows[#rows + 1] = {{n = G.UIT.T, config = { text = (i == 1 and prefix or ' ') .. l, colour = G.C.UI.TEXT_DARK, scale = 0.3 * S, }}} end end end else if in_buy_area(card) then text_row('Synergizes with your jokers:') else text_row('Active combos with:') end local shown = math.min(3, #partners) for i = 1, shown do local p = partners[i] local reason = JCA.explain(key, p.key) or 'shared build theme' rows[#rows + 1] = { {n = G.UIT.T, config = {text = p.name .. ': ', colour = G.C.RED, scale = 0.3 * S}}, {n = G.UIT.T, config = {text = reason, colour = G.C.UI.TEXT_DARK, scale = 0.3 * S}}, } end if #partners > shown then local rest = {} for i = shown + 1, #partners do rest[#rest + 1] = partners[i] end for i, l in ipairs(wrap_names(rest, 34, 2)) do text_row((i == 1 and 'also: ' or ' ') .. l, G.C.UI.TEXT_DARK) end end if in_buy_area(card) and total >= JCA.config.threshold and not JCA.config.learning_mode then text_row('Strong pick for this build!', G.C.GREEN) end end -- Anti-synergy warnings: known traps against the board you keep. These -- never touch the score (learning mode keeps them too — they teach). if G.jokers and G.jokers.cards then for _, owned in ipairs(G.jokers.cards) do if owned ~= card and owned.config.center.set == 'Joker' then local warn = JCA.clash_blurb[pair_key(key, owned.config.center.key)] if warn then rows[#rows + 1] = { {n = G.UIT.T, config = { text = 'Clashes - ' .. name_of(owned.config.center.key) .. ': ', colour = G.C.RED, scale = 0.3 * S}}, {n = G.UIT.T, config = {text = warn, colour = G.C.UI.TEXT_DARK, scale = 0.3 * S}}, } end end end end local caution = in_buy_area(card) and JCA.cautions[key] if caution then text_row('Caution: ' .. caution, G.C.RED) end -- Sell advisor verdict (owned jokers only; suppressed in learning mode -- like the other verdicts). if JCA.config.sell_advisor and not JCA.config.learning_mode and card.area == G.jokers and JCA.weakest_link() == card then text_row('Weakest combo piece - a sell candidate', G.C.RED) end rows.name = 'Combo Advisor' return rows end local orig_gen_aut = Card.generate_UIBox_ability_table function Card:generate_UIBox_ability_table(vars_only) -- vars_only returns multiple values; pass that path through untouched if vars_only then return orig_gen_aut(self, vars_only) end local aut = orig_gen_aut(self) if aut and aut.info and is_joker(self) then dbg('hover joker', self.config.center.key, 'in_buy_area=', in_buy_area(self), 'owned=', self.area == G.jokers) if in_buy_area(self) or (self.area == G.jokers and JCA.config.owned_tooltip) then local ok, rows = pcall(tooltip_rows, self) if not ok then dbg('tooltip_rows ERROR:', rows) elseif rows then aut.info[#aut.info + 1] = rows dbg('tooltip appended,', #rows, 'rows') end end end return aut end -- Shop pulse ------------------------------------------------------------------ -- Partner highlight: while a joker is hovered, the owned jokers it combos -- with pulse gently so your eyes can find them on the board. The partner -- set is computed once per hover (scoring every frame would be wasteful) -- and dropped when the hover ends, so board changes are picked up. local function highlight_partners(card, dt) if not (card.states and card.states.hover and card.states.hover.is) then card.jca_hl = nil return end if not card.jca_hl then local set = {} for _, p in ipairs(JCA.partners_for(card)) do set[p.key] = true end card.jca_hl = {set = set, t = 99} -- oversized t: first pulse now end card.jca_hl.t = card.jca_hl.t + dt if card.jca_hl.t >= 0.9 then card.jca_hl.t = 0 for _, owned in ipairs(G.jokers and G.jokers.cards or {}) do if owned ~= card and owned.config and owned.config.center and card.jca_hl.set[owned.config.center.key] then owned:juice_up(0.15, 0.08) end end end end local orig_update = Card.update function Card:update(dt) orig_update(self, dt) if JCA.config.pulse and not JCA.config.learning_mode and G.STAGE == G.STAGES.RUN and is_joker(self) and in_buy_area(self) then self.jca_timer = (self.jca_timer or 2.5) + dt if self.jca_timer >= 2.5 then self.jca_timer = 0 local ok, rec = pcall(JCA.is_recommended, self) if ok and rec then self:juice_up(0.25, 0.15) end end end if JCA.config.partner_highlight and G.STAGE == G.STAGES.RUN and is_joker(self) and (in_buy_area(self) or self.area == G.jokers) then pcall(highlight_partners, self, dt) end end -- Combo discovery tracking ----------------------------------------------------- -- A famous pair counts as discovered the first time both jokers are fielded -- together. All-time discoveries persist in config.discovered; pairs fielded -- during the current run are also recorded in G.GAME.jca_run_combos (saved -- with the run) for end-of-run display. function JCA.check_discoveries() if not (G.jokers and G.jokers.cards) then return end local keys = {} for _, c in ipairs(G.jokers.cards) do if is_joker(c) then keys[#keys + 1] = c.config.center.key end end local new_pair for i = 1, #keys do for j = i + 1, #keys do local pk = pair_key(keys[i], keys[j]) if JCA.pair_bonus[pk] then if G.GAME then G.GAME.jca_run_combos = G.GAME.jca_run_combos or {} G.GAME.jca_run_combos[pk] = true end if not JCA.config.discovered[pk] then JCA.config.discovered[pk] = true new_pair = new_pair or (name_of(keys[i]) .. ' + ' .. name_of(keys[j])) end end end end -- Theme discovery: a theme is explored once two DIFFERENT fielded jokers -- connect it — one gives the tag, another wants it (cluster members do -- both, so any two of them qualify). local new_theme for tag in pairs(data.theme_info or {}) do if not JCA.config.themes_found[tag] then local giver, wanter for idx, k in ipairs(keys) do local e = JCA.db[k] if e then if e.gives[tag] and not giver then giver = idx end if e.wants[tag] and not wanter then wanter = idx end end end if giver and wanter and giver == wanter then wanter = nil -- same joker on both sides: find a second one for idx, k in ipairs(keys) do local e = JCA.db[k] if e and idx ~= giver and (e.gives[tag] or e.wants[tag]) then wanter = idx break end end end if giver and wanter then JCA.config.themes_found[tag] = true new_theme = new_theme or tag end end end if (new_pair or new_theme) and JCA.mod and SMODS.save_mod_config then SMODS.save_mod_config(JCA.mod) end -- One toast per emplacement; a famous pair outranks a theme (both stay -- recorded either way). if new_pair then pcall(function() attention_text({ text = 'New combo: ' .. new_pair .. '!', scale = 0.7, hold = 4, major = G.play, align = 'cm', offset = {x = 0, y = -3.5}, silent = true, backdrop_colour = G.C.SECONDARY_SET.Tarot, }) play_sound('card1', 1.1, 0.6) end) elseif new_theme then pcall(function() attention_text({ text = 'Theme explored: ' .. ((JCA.theme_names and JCA.theme_names[new_theme]) or new_theme) .. '!', scale = 0.7, hold = 4, major = G.play, align = 'cm', offset = {x = 0, y = -3.5}, silent = true, backdrop_colour = G.C.SECONDARY_SET.Planet, }) play_sound('card1', 1.1, 0.6) end) end end if CardArea and type(CardArea) == 'table' then local orig_emplace = CardArea.emplace function CardArea:emplace(...) orig_emplace(self, ...) if self == G.jokers then pcall(JCA.check_discoveries) end end end -- Post-run combo recap ----------------------------------------------------------- -- Lists the famous pairs fielded during the run (G.GAME.jca_run_combos) on the -- win and game-over screens. local function recap_row() local combos = G.GAME and G.GAME.jca_run_combos if not combos or not next(combos) then return nil end local names = {} for pk in pairs(combos) do local a, b = pk:match('^(.-)|(.+)$') if a then names[#names + 1] = name_of(a) .. ' + ' .. name_of(b) end end if #names == 0 then return nil end table.sort(names) local rows = {{n = G.UIT.R, config = {align = 'cm', padding = 0.03}, nodes = { {n = G.UIT.T, config = {text = 'Combos fielded this run', colour = G.C.GOLD, scale = 0.35, shadow = true}}, }}} for i = 1, math.min(#names, 6) do rows[#rows + 1] = {n = G.UIT.R, config = {align = 'cm', padding = 0.02}, nodes = { {n = G.UIT.T, config = {text = names[i], colour = G.C.WHITE, scale = 0.3, shadow = true}}, }} end if #names > 6 then rows[#rows + 1] = {n = G.UIT.R, config = {align = 'cm', padding = 0.02}, nodes = { {n = G.UIT.T, config = {text = '+' .. (#names - 6) .. ' more', colour = G.C.UI.TEXT_LIGHT, scale = 0.3}}, }} end return {n = G.UIT.R, config = {align = 'cm', padding = 0.06}, nodes = rows} end -- Trail of {node, index} pairs from `node` down to the child carrying the id, -- so callers can insert siblings at any ancestor level. local function find_trail(node, id, trail) trail = trail or {} for i, child in ipairs(node.nodes or {}) do trail[#trail + 1] = {node = node, index = i} if child.config and child.config.id == id then return trail end local hit = find_trail(child, id, trail) if hit then return hit end trail[#trail] = nil end end -- Insert the recap `up` ancestor levels above the anchor's parent, before -- (offset 0) or after (offset 1) that branch. Returns true on success. local function insert_recap(ui, id, up, offset) local row = recap_row() if not row then return end local trail = find_trail(ui, id) if not trail or #trail <= up then return end local spot = trail[#trail - up] table.insert(spot.node.nodes, spot.index + offset, row) return true end -- Game over: 'from_game_over' sits in the buttons row of the central column; -- one level up places the recap between the stats box and the buttons. if create_UIBox_game_over then local orig_game_over = create_UIBox_game_over function create_UIBox_game_over(...) local ui = orig_game_over(...) pcall(insert_recap, ui, 'from_game_over', 1, 0) return ui end end -- Win: the buttons live inside the right-hand stats column; two levels up -- places the recap below the whole two-column stats row. 'win_cta' replaces -- 'from_game_won' when the CTA variant of the screen is shown. if create_UIBox_win then local orig_win = create_UIBox_win function create_UIBox_win(...) local ui = orig_win(...) local _, inserted = pcall(insert_recap, ui, 'from_game_won', 2, 1) if not inserted then pcall(insert_recap, ui, 'win_cta', 2, 1) end return ui end end -- Synergy catalog (Mods menu > Combo Advisor > Combos/Engine/Economy/Hands) -- -- Generated from the database at menu time so it can never drift from the -- scores the advisor actually uses. local THEME_TABS = { { label = 'Engine', themes = { {tag = 'faces', name = 'Face cards'}, {tag = 'retrig_scoring', name = 'Retrigger played cards'}, {tag = 'retrig_held', name = 'Retrigger held cards'}, {tag = 'copy_target', name = 'Copy targets (Blueprint/Brainstorm)'}, {tag = 'probability', name = 'Probability'}, {tag = 'hand_size', name = 'Hand size'}, {tag = 'hands_up', name = 'Extra hands'}, {tag = 'discards_up', name = 'Discards'}, }}, { label = 'Economy', themes = { {tag = 'money', name = 'Money'}, {tag = 'sell_value', name = 'Sell value'}, {tag = 'joker_gen', name = 'Joker creation'}, {tag = 'tarot_gen', name = 'Tarot cards'}, {tag = 'planet', name = 'Planet cards'}, {tag = 'spectral_gen', name = 'Spectral cards'}, {tag = 'gold_gen', name = 'Gold cards'}, {tag = 'stone_gen', name = 'Stone cards'}, {tag = 'enhance_gen', name = 'Card enhancing'}, {tag = 'card_gen', name = 'Deck additions'}, }}, { label = 'Hands', themes = { {tag = 'pair', name = 'Pairs'}, {tag = 'two_pair', name = 'Two Pair'}, {tag = 'three_kind', name = 'Three of a Kind'}, {tag = 'four_kind', name = 'Four of a Kind'}, {tag = 'straight', name = 'Straights'}, {tag = 'flush', name = 'Flushes'}, {tag = 'suit_flex', name = 'Suit conversion'}, {tag = 'hearts', name = 'Hearts'}, {tag = 'diamonds', name = 'Diamonds'}, {tag = 'spades', name = 'Spades'}, {tag = 'clubs', name = 'Clubs'}, }}, } -- Display names for theme tags (used by the discovery toast, which runs -- before this point in the file but only ever fires during gameplay). JCA.theme_names = {} for _, group in ipairs(THEME_TABS) do for _, t in ipairs(group.themes) do JCA.theme_names[t.tag] = t.name end end -- Joker keys for a tag, split into pure sources (give only), pure payoffs -- (want only), and cluster members (both). Sorted by display name. local function theme_members(tag) local sources, payoffs, both = {}, {}, {} for key, entry in pairs(JCA.db) do local g, w = entry.gives[tag], entry.wants[tag] if g and w then both[#both + 1] = key elseif g then sources[#sources + 1] = key elseif w then payoffs[#payoffs + 1] = key end end local by_name = function(a, b) return name_of(a) < name_of(b) end table.sort(sources, by_name); table.sort(payoffs, by_name); table.sort(both, by_name) return sources, payoffs, both end -- Card display: real Card objects in CardAreas (like the Collection), so -- hovering shows each joker's own description tooltip. Areas and cards are -- built fresh on every tab/page build and are cleaned up with the UIBox, so -- none of these node trees may be cached. local CARDS_PER_ROW = 8 -- Undiscovered jokers show face-down (vanilla spoiler protection), unless -- the player has pressed Unlock All in the settings. That button already -- marks every center discovered, but the profile flag also covers centers -- added later (e.g. new mods); pages rebuild on every open, so checking the -- flag at build time reacts to Unlock All without any event hook. local function reveal_params() local profile = G.PROFILES and G.SETTINGS and G.PROFILES[G.SETTINGS.profile] if profile and profile.all_unlocked then return {bypass_discovery_center = true, bypass_discovery_ui = true} end end -- `face_down` renders the cards back-up (used for unfielded famous pairs); -- face-down cards also show no hover tooltip, keeping the mystery. local function card_row_node(keys, scale, face_down) local area = CardArea( G.ROOM.T.x + 0.2 * G.ROOM.T.w / 2, G.ROOM.T.h, (scale * #keys + 0.25) * G.CARD_W, scale * G.CARD_H, {card_limit = #keys, type = 'title', highlight_limit = 0, collection = true}) local reveal = reveal_params() for _, key in ipairs(keys) do local center = G.P_CENTERS[key] if center then local card = Card(area.T.x + area.T.w / 2, area.T.y, G.CARD_W * scale, G.CARD_H * scale, G.P_CARDS.empty, center, reveal) if face_down then card.facing = 'back' card.sprite_facing = 'back' end area:emplace(card) end end return {n = G.UIT.O, config = {object = area}} end local function add_caption(nodes, str) nodes[#nodes + 1] = {n = G.UIT.R, config = {align = 'cm', padding = 0.02}, nodes = { {n = G.UIT.T, config = {text = str, colour = G.C.GOLD, scale = 0.3}}, }} end -- Word-wrap a plain sentence for small caption rows. local function wrap_plain(s, width) local lines, line = {}, nil for word in s:gmatch('%S+') do local nxt = line and (line .. ' ' .. word) or word if line and #nxt > width then lines[#lines + 1] = line line = word else line = nxt end end if line then lines[#lines + 1] = line end return lines end -- Explainer sentence rendered as small light rows under a caption. local function add_info_rows(nodes, sentence, width, scale) for _, l in ipairs(wrap_plain(sentence or '', width)) do nodes[#nodes + 1] = {n = G.UIT.R, config = {align = 'cm', padding = 0.01}, nodes = { {n = G.UIT.T, config = {text = l, colour = G.C.UI.TEXT_LIGHT, scale = scale}}, }} end end local function add_card_rows(nodes, keys, scale, per_row) per_row = per_row or CARDS_PER_ROW for i = 1, #keys, per_row do local row = {} for j = i, math.min(i + per_row - 1, #keys) do row[#row + 1] = keys[j] end nodes[#nodes + 1] = {n = G.UIT.R, config = {align = 'cm', padding = 0.05, no_fill = true}, nodes = {card_row_node(row, scale)}} end end -- Card layouts from largest to most compact; all stay under ~8.4 units wide. -- A page uses the largest layout whose estimated height fits the menu box, -- so dense themes (Money, Retriggers) shrink instead of overflowing. local CARD_LAYOUTS = { {scale = 0.68, per_row = 8}, {scale = 0.60, per_row = 9}, {scale = 0.52, per_row = 11}, {scale = 0.44, per_row = 13}, } local PAGE_HEIGHT_BUDGET = 5.5 local function pick_layout(groups, text_rows) for _, layout in ipairs(CARD_LAYOUTS) do local card_rows = 0 for _, keys in ipairs(groups) do card_rows = card_rows + math.ceil(#keys / layout.per_row) end local est = text_rows * 0.33 + 0.6 -- captions/blurb + pager + card_rows * (layout.scale * G.CARD_H + 0.1) if est <= PAGE_HEIGHT_BUDGET then return layout end end return CARD_LAYOUTS[#CARD_LAYOUTS] end local function pager_row(options, current, callback) return {n = G.UIT.R, config = {align = 'cm', padding = 0.05}, nodes = { create_option_cycle{ options = options, current_option = current, w = 4.5, cycle_shoulders = true, no_pips = true, colour = G.C.RED, opt_callback = callback, focus_args = {snap_to = true, nav = 'wide'}, }, }} end -- One theme per page; the pager is labelled with theme names. local function theme_tab_def(group, page_no) page_no = math.min(page_no or 1, #group.themes) local theme = group.themes[page_no] local sources, payoffs, both = theme_members(theme.tag) -- A tiny cluster list (e.g. To the Moon under Money) folds into the -- sources/payoffs lists instead of costing its own row. if #both > 0 and #both <= 2 and (#sources > 0 or #payoffs > 0) then for _, k in ipairs(both) do if #sources > 0 then sources[#sources + 1] = k end if #payoffs > 0 then payoffs[#payoffs + 1] = k end end both = {} local by_name = function(a, b) return name_of(a) < name_of(b) end table.sort(sources, by_name) table.sort(payoffs, by_name) end local blurb_lines = wrap_plain(data.theme_info[theme.tag] or '', 70) local groups, captions = {}, 0 if #both > 0 then groups[#groups + 1] = both end if #sources > 0 then groups[#groups + 1] = sources; captions = captions + 1 end if #payoffs > 0 then groups[#groups + 1] = payoffs; captions = captions + 1 end -- +2 text rows: the title and the group progress line above the pager local layout = pick_layout(groups, 2 + #blurb_lines + captions) local nodes = {} if JCA.config.themes_found[theme.tag] then nodes[#nodes + 1] = {n = G.UIT.R, config = {align = 'cm', padding = 0.02}, nodes = { {n = G.UIT.T, config = {text = theme.name, colour = G.C.GOLD, scale = 0.3}}, {n = G.UIT.T, config = {text = ' explored!', colour = G.C.GREEN, scale = 0.3}}, }} else add_caption(nodes, theme.name) end add_info_rows(nodes, data.theme_info[theme.tag], 70, 0.26) if #both > 0 then add_card_rows(nodes, both, layout.scale, layout.per_row) end if #sources > 0 then add_caption(nodes, 'Sources - these provide it') add_card_rows(nodes, sources, layout.scale, layout.per_row) end if #payoffs > 0 then add_caption(nodes, 'Payoffs - these want it') add_card_rows(nodes, payoffs, layout.scale, layout.per_row) end local explored = 0 for _, t in ipairs(group.themes) do if JCA.config.themes_found[t.tag] then explored = explored + 1 end end nodes[#nodes + 1] = {n = G.UIT.R, config = {align = 'cm', padding = 0.02}, nodes = { {n = G.UIT.T, config = { text = ('%d of %d themes explored - field a source and a payoff together') :format(explored, #group.themes), colour = G.C.UI.TEXT_LIGHT, scale = 0.24}}, }} local options = {} for i, t in ipairs(group.themes) do options[i] = t.name end nodes[#nodes + 1] = pager_row(options, page_no, 'jca_page_' .. group.label) return {n = G.UIT.ROOT, config = {align = 'tm', padding = 0.05, colour = G.C.CLEAR, minw = 8.5, minh = 5.6}, nodes = nodes} end -- Famous pairs as side-by-side card duos, six pairs per page. local PAIRS_PER_PAGE = 6 local sorted_pairs local function combos_tab_def(page_no) if not sorted_pairs then sorted_pairs = {} for _, p in ipairs(JCA.pair_list) do sorted_pairs[#sorted_pairs + 1] = {p[1], p[2], score = JCA.score(p[1], p[2])} end table.sort(sorted_pairs, function(a, b) if a.score ~= b.score then return a.score > b.score end return name_of(a[1]) < name_of(b[1]) end) end local pages = math.ceil(#sorted_pairs / PAIRS_PER_PAGE) page_no = math.min(page_no or 1, pages) local nodes = {} local found = 0 for _, p in ipairs(sorted_pairs) do if JCA.config.discovered[pair_key(p[1], p[2])] then found = found + 1 end end add_caption(nodes, ('Famous pairs - fielded %d of %d (face-down pairs are undiscovered)') :format(found, #sorted_pairs)) local first = (page_no - 1) * PAIRS_PER_PAGE for r = 0, 1 do local row = {n = G.UIT.R, config = {align = 'cm', padding = 0.05}, nodes = {}} for c = 1, 3 do local pair = sorted_pairs[first + r * 3 + c] if pair then local pk = pair_key(pair[1], pair[2]) local fielded = JCA.config.discovered[pk] -- blurb caption once fielded; '???' keeps the mystery until then local col = {{n = G.UIT.R, config = {align = 'cm'}, nodes = {card_row_node({pair[1], pair[2]}, 0.62, not fielded)}}} local cap = fielded and wrap_plain(JCA.pair_blurb[pk] or '', 30) or {'???'} for i = 1, math.min(#cap, 2) do col[#col + 1] = {n = G.UIT.R, config = {align = 'cm', padding = 0.01}, nodes = { {n = G.UIT.T, config = {text = cap[i], colour = G.C.UI.TEXT_LIGHT, scale = 0.22}}, }} end row.nodes[#row.nodes + 1] = {n = G.UIT.C, config = {align = 'cm', padding = 0.08}, nodes = col} end end if #row.nodes > 0 then nodes[#nodes + 1] = row end end if pages > 1 then local options = {} for i = 1, pages do options[i] = 'Page ' .. i .. '/' .. pages end nodes[#nodes + 1] = pager_row(options, page_no, 'jca_page_Combos') end return {n = G.UIT.ROOT, config = {align = 'tm', padding = 0.05, colour = G.C.CLEAR, minw = 8.5, minh = 5.6}, nodes = nodes} end -- Progress tab: discovery completion at a glance. Text-only (no live Card -- objects), so unlike the other tabs nothing here would break if cached — -- but it is rebuilt each open like the rest, which keeps counts fresh. local function progress_bar(nodes, label, found, total) local frac = total > 0 and found / total or 0 local W = 3.6 local bar_nodes = {} if frac > 0 then bar_nodes[#bar_nodes + 1] = {n = G.UIT.C, config = {minw = W * frac, minh = 0.26, colour = G.C.GREEN, r = 0.1}} end if frac < 1 then bar_nodes[#bar_nodes + 1] = {n = G.UIT.C, config = {minw = W * (1 - frac), minh = 0.26, colour = G.C.BLACK, r = 0.1}} end nodes[#nodes + 1] = {n = G.UIT.R, config = {align = 'cm', padding = 0.04}, nodes = { {n = G.UIT.C, config = {align = 'cl', minw = 2.6}, nodes = { {n = G.UIT.T, config = {text = label, colour = G.C.UI.TEXT_LIGHT, scale = 0.3}}, }}, {n = G.UIT.C, config = {align = 'cl'}, nodes = bar_nodes}, {n = G.UIT.C, config = {align = 'cl', minw = 1.1}, nodes = { {n = G.UIT.T, config = {text = (' %d/%d'):format(found, total), colour = G.C.UI.TEXT_LIGHT, scale = 0.3}}, }}, }} end local function progress_tab_def() local nodes = {} add_caption(nodes, 'Collection progress') local pairs_found, pairs_total = 0, #JCA.pair_list for _, p in ipairs(JCA.pair_list) do if JCA.config.discovered[pair_key(p[1], p[2])] then pairs_found = pairs_found + 1 end end progress_bar(nodes, 'Famous pairs', pairs_found, pairs_total) local themes_found, themes_total = 0, 0 for _, group in ipairs(THEME_TABS) do local found = 0 for _, t in ipairs(group.themes) do if JCA.config.themes_found[t.tag] then found = found + 1 end end progress_bar(nodes, group.label .. ' themes', found, #group.themes) themes_found = themes_found + found themes_total = themes_total + #group.themes end if G.GAME and G.GAME.jca_run_combos and next(G.GAME.jca_run_combos) then local run = 0 for _ in pairs(G.GAME.jca_run_combos) do run = run + 1 end nodes[#nodes + 1] = {n = G.UIT.R, config = {align = 'cm', padding = 0.04}, nodes = { {n = G.UIT.T, config = { text = run .. (run == 1 and ' famous pair' or ' famous pairs') .. ' fielded this run', colour = G.C.GOLD, scale = 0.3}}, }} end add_info_rows(nodes, ('%d of %d discoveries logged. Field both jokers of a famous pair, or a source and a payoff of a theme, to log it.') :format(pairs_found + themes_found, pairs_total + themes_total), 70, 0.26) return {n = G.UIT.ROOT, config = {align = 'tm', padding = 0.05, colour = G.C.CLEAR, minw = 8.5, minh = 5.6}, nodes = nodes} end -- Page cyclers swap the tab body in place (same pattern SMODS uses for its -- achievements tab). local function register_pager(name, def_fn) G.FUNCS['jca_page_' .. name] = function(args) if not args or not args.cycle_config then return end local tab_contents = G.OVERLAY_MENU:get_UIE_by_ID('tab_contents') tab_contents.config.object:remove() tab_contents.config.object = UIBox{ definition = def_fn(args.cycle_config.current_option), config = {offset = {x = 0, y = 0}, parent = tab_contents, type = 'cm'}, } tab_contents.UIBox:recalculate() end end register_pager('Combos', combos_tab_def) for _, group in ipairs(THEME_TABS) do register_pager(group.label, function(p) return theme_tab_def(group, p) end) end local function catalog_tabs() local tabs = {{ label = 'Combos', chosen = true, tab_definition_function = function() return combos_tab_def(1) end, }} for _, t in ipairs(THEME_TABS) do tabs[#tabs + 1] = { label = t.label, tab_definition_function = function() return theme_tab_def(t, 1) end, } end tabs[#tabs + 1] = { label = 'Progress', tab_definition_function = progress_tab_def, } return tabs end if SMODS.current_mod then SMODS.current_mod.extra_tabs = catalog_tabs end -- In-run "Combos" HUD button --------------------------------------------------- -- Adds a third button under Run Info / Options that opens the synergy catalog -- in an overlay. The overlay reuses catalog_tabs(); the page cyclers work -- unchanged because vanilla create_tabs also names its body 'tab_contents'. G.FUNCS.jca_open_catalog = function() G.FUNCS.overlay_menu{definition = create_UIBox_generic_options({ back_func = 'exit_overlay_menu', contents = { {n = G.UIT.R, config = {align = 'tm', padding = 0}, nodes = { create_tabs({ tabs = catalog_tabs(), snap_to_nav = true, colour = G.C.BOOSTER, }), }}, }, })} end local function find_by_id(node, id) if node.config and node.config.id == id then return node end for _, child in ipairs(node.nodes or {}) do local hit = find_by_id(child, id) if hit then return hit end end end if create_UIBox_HUD then local orig_hud = create_UIBox_HUD function create_UIBox_HUD(...) local hud = orig_hud(...) local ok, area = pcall(find_by_id, hud, 'button_area') if ok and area and area.nodes then local B = JCA.config.touch_mode and 1.5 or 1 area.nodes[#area.nodes + 1] = {n = G.UIT.R, config = {align = 'cm', minh = 0.9 * B, minw = 1.5 * B, padding = 0.05, r = 0.1, hover = true, colour = G.C.PURPLE, button = 'jca_open_catalog', shadow = true}, nodes = { {n = G.UIT.C, config = {align = 'cm', maxw = 1.4 * B}, nodes = { {n = G.UIT.T, config = {text = 'Combos', scale = 0.38 * B, colour = G.C.UI.TEXT_LIGHT, shadow = true}}, }}, }} end return hud end end -- Config tab (Mods menu > Combo Advisor > Config) ----------------------------- JCA.THRESHOLD_OPTIONS = {2, 3, 4, 5, 6, 8} G.FUNCS.jca_set_threshold = function(args) JCA.config.threshold = args.to_val end local db_count = 0 for _ in pairs(JCA.db) do db_count = db_count + 1 end dbg('loaded;', db_count, 'jokers in db; config threshold =', JCA.config.threshold) if SMODS.current_mod then SMODS.current_mod.config_tab = function() local current = 3 -- index of default (4) in THRESHOLD_OPTIONS for i, v in ipairs(JCA.THRESHOLD_OPTIONS) do if v == JCA.config.threshold then current = i end end return {n = G.UIT.ROOT, config = {align = 'cm', padding = 0.1, colour = G.C.CLEAR}, nodes = { {n = G.UIT.R, config = {align = 'cm', padding = 0.05}, nodes = { create_toggle{ label = 'Pulse recommended jokers in shop', ref_table = JCA.config, ref_value = 'pulse', }, }}, {n = G.UIT.R, config = {align = 'cm', padding = 0.05}, nodes = { create_toggle{ label = 'Show combo tooltip on owned jokers', ref_table = JCA.config, ref_value = 'owned_tooltip', }, }}, {n = G.UIT.R, config = {align = 'cm', padding = 0.05}, nodes = { create_toggle{ label = 'Highlight combo partners on hover', ref_table = JCA.config, ref_value = 'partner_highlight', }, }}, {n = G.UIT.R, config = {align = 'cm', padding = 0.05}, nodes = { create_toggle{ label = 'Flag weakest joker when board is full', ref_table = JCA.config, ref_value = 'sell_advisor', }, }}, {n = G.UIT.R, config = {align = 'cm', padding = 0.05}, nodes = { create_toggle{ label = 'Learning mode (no verdicts, just reasons)', ref_table = JCA.config, ref_value = 'learning_mode', }, }}, {n = G.UIT.R, config = {align = 'cm', padding = 0.05}, nodes = { create_toggle{ label = 'Touch mode (larger advice text & buttons)', ref_table = JCA.config, ref_value = 'touch_mode', }, }}, {n = G.UIT.R, config = {align = 'cm', padding = 0.05}, nodes = { create_option_cycle{ label = 'Recommendation threshold (lower = more picks)', scale = 0.8, w = 4, options = JCA.THRESHOLD_OPTIONS, current_option = current, opt_callback = 'jca_set_threshold', }, }}, }} end end