Files
JokerComboAdvisor/main.lua
T
funman300 ea8faac434 Respect the other two stickers: rentals bleed, perished jokers are dead
JCA.is_rental and JCA.is_perished read ability.rental and
ability.perishable/perish_tally, the same flags vanilla uses
(card.lua:684/676). The sell advisor now docks a rental 2 points (it
drains $3 every round, game.lua:1957) and treats a perished joker --
tally 0, debuffed permanently per card.lua:716 -- as dead weight that
outweighs any synergy score. Tooltips gain the mechanical facts:
countdown on ticking perishables, drain rate on rentals (shop phrasing
notes the $1 price), and 'Perished - it will never work again' instead
of the misleading 'does nothing this blind' on permanently dead cards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:44:52 -07:00

1509 lines
62 KiB
Lua

--- 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
-- "both reward Straights" only reads true when BOTH jokers sit on both
-- sides of the tag. A pure enabler (Four Fingers, DNA) gives it without
-- wanting it, so it gets the give/want phrasing instead — it does not
-- reward the hand, it makes the hand reachable.
local cluster = text.both
and (A.gives[t] and A.wants[t])
and (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
-- Copy jokers are positional, and the board order is right there in
-- G.jokers.cards -- so the advisor can name what a copier is ACTUALLY copying
-- rather than guess. Blueprint copies the joker to its right (card.lua:4663);
-- Brainstorm copies the leftmost one, which is nothing when it is itself leftmost.
--
-- Copiers CHAIN: Blueprint's own blueprint_compat is true, and
-- SMODS.blueprint_effect recurses into the copied card (utils.lua:2269), so
-- Blueprint -> Blueprint -> Baron really does give you two Barons. Answering
-- "Copying Blueprint" would be the one reply that tells the player nothing, so we
-- walk the chain to the joker whose ability actually gets copied.
--
-- Returns: target, status, chain
-- target - the joker ultimately copied (nil when the chain yields nothing)
-- status - 'ok' | 'none' | 'incompatible' | 'debuffed'
-- chain - the copiers walked through on the way there (Blueprints in between)
local COPIERS = {j_blueprint = 'right', j_brainstorm = 'leftmost'}
function JCA.copy_source(card)
if not (G.jokers and G.jokers.cards and card.config and card.config.center) then
return nil, 'none', {}
end
if not COPIERS[card.config.center.key] then return nil, 'none', {} end
local cards = G.jokers.cards
local chain, seen, current = {}, {}, card
-- Vanilla caps the recursion at the board size (utils.lua:2255); a
-- Blueprint/Brainstorm ring would otherwise loop forever and yields nothing.
for _ = 1, #cards + 1 do
if seen[current] then return nil, 'none', chain end
seen[current] = true
local mode = COPIERS[current.config.center.key]
local idx
for i, c in ipairs(cards) do
if c == current then idx = i break end
end
if not idx then return nil, 'none', chain end
-- NOT the `a and b or c` idiom: for a rightmost Blueprint cards[idx+1] is
-- nil, and the idiom would fall through to `or cards[1]` and confidently
-- report it copying the LEFTMOST joker.
local target
if mode == 'right' then target = cards[idx + 1] else target = cards[1] end
if not target or target == current then return nil, 'none', chain end
local center = target.config and target.config.center
if not center then return nil, 'none', chain end
-- Vanilla tests blueprint_compat for TRUTH, not for `~= false`
-- (utils.lua:2254). A modded joker that simply omits the flag is NOT
-- copyable, and saying otherwise would be a lie about someone else's mod.
if not center.blueprint_compat then return target, 'incompatible', chain end
-- A debuffed joker is copied as nothing at all (same line).
if target.debuff then return target, 'debuffed', chain end
if not COPIERS[center.key] then return target, 'ok', chain end
chain[#chain + 1] = target -- another copier: follow it
current = target
end
return nil, 'none', chain
end
-- Ceremonial Dagger is positional too, and it is the dangerous one: when the
-- blind is selected it DESTROYS the joker to its right and eats twice its sell
-- value as Mult (card.lua:2945). The card text says so; what it cannot tell you
-- is *which joker on your board* is about to die. Vanilla skips Eternal jokers,
-- so an Eternal neighbour is safe.
--
-- Returns the joker it will eat (or nil), and whether that joker is safe.
function JCA.dagger_victim(card)
if not (G.jokers and G.jokers.cards and card.config and card.config.center) then
return nil
end
if card.config.center.key ~= 'j_ceremonial' then return nil end
local cards, idx = G.jokers.cards, nil
for i, c in ipairs(cards) do
if c == card then idx = i break end
end
if not idx then return nil end
local target = cards[idx + 1]
if not target or not (target.config and target.config.center) then return nil end
-- Same reasoning as JCA.is_eternal: read the sticker, never call
-- SMODS.is_eternal() from an advisor path.
return target, JCA.is_eternal(target)
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).
--
-- Clashes count here, and ONLY here. JCA.score stays pure -- an anti-synergy
-- never changes a synergy score or a recommendation (a trap must never make a
-- card look like a combo). But when picking which joker to cut, a joker that is
-- actively eating another one's payoff is exactly the one you want gone, so a
-- clash with an owned joker is a penalty against keeping it. The penalty is odd
-- so it always breaks a tie against a clashing joker.
local CLASH_PENALTY = 3
-- Sticker weights, same philosophy: mechanical facts only. A rental bleeds
-- $3 every round, so between two equally-scoring jokers, cut the rental. A
-- PERISHED joker (perishable, tally at 0) is debuffed forever -- dead weight
-- at any synergy score, so its penalty dwarfs every possible total.
local RENTAL_PENALTY = 2
local PERISHED_PENALTY = 1000
local function keep_score(card, cards)
local _, total = JCA.partners_for(card)
local key = card.config.center.key
for _, other in ipairs(cards) do
local center = other ~= card and other.config and other.config.center
if center and JCA.clash_blurb[pair_key(key, center.key)] then
total = total - CLASH_PENALTY
end
end
if JCA.is_rental(card) then total = total - RENTAL_PENALTY end
if JCA.is_perished(card) then total = total - PERISHED_PENALTY end
return total
end
-- Eternal jokers CANNOT be sold: Card:can_sell_card bails on them outright
-- (card.lua:1993). Advice you are not allowed to take is worse than no advice, so
-- an Eternal joker is never the sell candidate -- but it still counts towards the
-- best score, because it is genuinely part of the build you are keeping.
-- Read the sticker directly, and do NOT call SMODS.is_eternal(): it runs a full
-- SMODS.calculate_context on every call (utils.lua:3079), which is far too heavy
-- for something a hover invokes once per joker -- and it does not answer honestly
-- outside a live run. In the smoke harness it reported EVERY joker as Eternal,
-- which silenced the sell advisor completely. `ability.eternal` is the same flag
-- vanilla reads to draw the Eternal badge (card.lua:1141).
function JCA.is_eternal(card)
return not not (card.ability and card.ability.eternal)
end
-- Rental and Perishable are the other two stickers the advisor must respect,
-- and both are plain ability flags like eternal (card.lua:684, 676). A rental
-- drains G.GAME.rental_rate every round (card.lua:2646; $3, game.lua:1957).
-- A perishable joker counts perish_tally down each round (card.lua:2652) and
-- once it hits 0 the card is debuffed PERMANENTLY (card.lua:716) -- not "this
-- blind", forever. Eternal and perishable are mutually exclusive (set_perishable
-- bails on eternals), so a perished joker is always legal to sell.
function JCA.is_rental(card)
return not not (card.ability and card.ability.rental)
end
function JCA.is_perished(card)
local a = card.ability
return not not (a and a.perishable and (a.perish_tally or 0) <= 0)
end
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 = keep_score(c, cards)
if not JCA.is_eternal(c) and (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
-- Public API for other mods ----------------------------------------------------
-- Lets a mod teach the advisor its own jokers (which otherwise score 0).
-- Call any time after this mod has loaded. Tags must come from the sets in
-- synergies.lua; unknown tags are dropped with a log line rather than
-- crashing someone else's load.
local KNOWN_TAGS = { mult = true, chips = true, xmult = true }
for t in pairs(data.tag_text) do KNOWN_TAGS[t] = true end
local function checked_tag_set(key, list)
local set = {}
for _, t in ipairs(list or {}) do
if KNOWN_TAGS[t] then
set[t] = true
else
print(('JCA: %s: dropping unknown tag %q'):format(key, tostring(t)))
end
end
return set
end
-- gives/wants are arrays of tag names. opts: {no_generic = true} excludes
-- the joker from the generic mult/xmult complement (see Joker Stencil).
-- Re-registering a key overwrites its entry.
function JCA.register(key, gives, wants, opts)
assert(type(key) == 'string' and key ~= '', 'JCA.register: key required')
JCA.db[key] = {
gives = checked_tag_set(key, gives),
wants = checked_tag_set(key, wants),
no_generic = opts and opts.no_generic or nil,
}
end
-- Famous pair: flat +4, shown in the Combos tab, discoverable in play.
-- Keep the blurb under ~34 chars so it fits one tooltip row.
function JCA.register_pair(a, b, blurb)
assert(type(a) == 'string' and type(b) == 'string',
'JCA.register_pair: two joker keys required')
if blurb and #blurb > 34 then
print(('JCA: pair %s|%s blurb exceeds 34 chars, may clip'):format(a, b))
end
local pk = pair_key(a, b)
if not JCA.pair_bonus[pk] then
JCA.pair_list[#JCA.pair_list + 1] = {a, b, blurb}
end
JCA.pair_bonus[pk] = true
JCA.pair_blurb[pk] = blurb
JCA._pairs_dirty = true -- combos tab re-sorts on next open
end
-- Known trap: warning-only red tooltip line, never affects the score.
function JCA.register_clash(a, b, warning)
assert(type(a) == 'string' and type(b) == 'string',
'JCA.register_clash: two joker keys required')
JCA.clash_blurb[pair_key(a, b)] = warning
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_low = 'low-card (2-5) retriggers', retrig_faces = 'face-card retriggers',
retrig_held = 'held-card retriggers', hand_size = 'hand size',
hands_up = 'extra hands', discards_up = 'discards',
probability = 'listed odds', money = 'money',
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',
}
-- Exposed so the test suite can check every catalog tag has a label: a tag with no
-- TAG_LABEL entry silently vanishes from learning mode's "Looking for:" hints.
JCA.tag_label = TAG_LABEL
-- 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
-- When a copier is copying nothing (or something it cannot use), the fix is a
-- reorder -- so name the best target actually on the board. Copyable means the
-- same thing it means in copy_source: blueprint_compat tested for TRUTH, not
-- debuffed, and not another copier (suggesting a copier would re-ask the same
-- question one slot over). Jokers the database marks copy_target are preferred;
-- the leftmost candidate wins a tie so the suggestion is stable across hovers.
-- Returns the card and whether it carried the copy_target tag.
function JCA.best_copy_target(copier)
if not (G.jokers and G.jokers.cards) then return nil end
local best, best_prime
for _, c in ipairs(G.jokers.cards) do
if c ~= copier and is_joker(c) and not c.debuff
and not COPIERS[c.config.center.key]
and c.config.center.blueprint_compat then
local entry = JCA.db[c.config.center.key]
local prime = not not (entry and entry.gives.copy_target)
if not best or (prime and not best_prime) then
best, best_prime = c, prime
end
end
end
return best, best_prime
end
-- Build gaps: what the current board is shopping for. A tag is a gap when
-- some owned joker wants it and no OTHER owned joker gives it (same rule as
-- scoring and theme discovery: a joker cannot feed itself). The reverse -- a
-- give no other owned joker wants -- is an untapped hook a future purchase
-- could pay off. Engine roles (mult/chips/xmult) are skipped for the same
-- reason they have no TAG_LABEL: they hint nothing specific. Returns two
-- tag arrays in TAG_ORDER, so the display is stable across hovers.
function JCA.build_gaps()
local looking, untapped = {}, {}
if not (G.jokers and G.jokers.cards) then return looking, untapped end
local entries = {}
for _, c in ipairs(G.jokers.cards) do
if is_joker(c) then
local e = JCA.db[c.config.center.key]
if e then entries[#entries + 1] = e end
end
end
-- Compare by index, not table identity: two copies of the same joker
-- share one db entry, and they DO feed each other's cluster tags.
local function others_have(side, tag, self_idx)
for i, e in ipairs(entries) do
if i ~= self_idx and e[side][tag] then return true end
end
end
local want_gap, give_hook = {}, {}
for i, e in ipairs(entries) do
for t in pairs(e.wants) do
if TAG_LABEL[t] and not others_have('gives', t, i) then want_gap[t] = true end
end
for t in pairs(e.gives) do
if TAG_LABEL[t] and not others_have('wants', t, i) then give_hook[t] = true end
end
end
for _, t in ipairs(data.tag_order) do
if want_gap[t] then looking[#looking + 1] = t end
if give_hook[t] then untapped[#untapped + 1] = t end
end
return looking, untapped
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
-- Sticker facts (any area -- higher stakes put stickers on shop jokers
-- too). Mechanical numbers from the game source, not judgements, so
-- learning mode keeps them.
if card.ability and card.ability.perishable and not JCA.is_perished(card) then
local n = card.ability.perish_tally or 0
text_row(('Perishable - %d round%s before it dies')
:format(n, n == 1 and '' or 's'), G.C.RED)
end
if JCA.is_rental(card) then
local rate = (G.GAME and G.GAME.rental_rate) or 3
text_row(in_buy_area(card)
and ('Rental - $1 now, but $' .. rate .. ' every round after')
or ('Rental - drains $' .. rate .. ' every round'), G.C.RED)
end
-- Positional jokers: say what this one is doing to its neighbour RIGHT NOW.
-- The board order decides it, so only an owned joker has an answer. These are
-- explanations, not verdicts, so learning mode keeps them.
if card.area == G.jokers then
-- A debuffed joker does nothing at all this blind, whatever its combos
-- say -- and a PERISHED one is debuffed for good (card.lua:716), which
-- deserves the honest tense.
if card.debuff then
text_row(JCA.is_perished(card)
and 'Perished - it will never work again'
or 'Debuffed - does nothing this blind', G.C.RED)
end
-- Copy jokers. A Blueprint in the wrong slot is a dead card that looks fine.
if COPIERS[key] then
local target, status, chain = JCA.copy_source(card)
-- Chained copiers: "Copying Blueprint -> Baron" beats "Copying
-- Blueprint", which is the one answer that helps nobody.
local via = ''
for _, link in ipairs(chain) do
via = via .. name_of(link.config.center.key) .. ' -> '
end
local tname = target and name_of(target.config.center.key)
if status == 'ok' then
text_row('Copying ' .. via .. tname, G.C.GREEN)
elseif status == 'incompatible' then
text_row('Copying ' .. via .. tname .. ' - which cannot be copied', G.C.RED)
elseif status == 'debuffed' then
text_row('Copying ' .. via .. tname .. ' - debuffed, so nothing', G.C.RED)
else
text_row(key == 'j_brainstorm'
and 'Copying nothing - it IS the leftmost joker'
or 'Copying nothing - no joker to its right', G.C.RED)
end
-- A dead copier usually has a live fix one reorder away. This is
-- an explanation of the mechanic, so learning mode keeps it.
if status ~= 'ok' then
local best = JCA.best_copy_target(card)
if best then
local bname = name_of(best.config.center.key)
text_row(key == 'j_brainstorm'
and ('Fix: make ' .. bname .. ' your leftmost joker')
or ('Fix: slot this just left of ' .. bname), G.C.GREEN)
end
end
end
-- Ceremonial Dagger. This one is about to eat a joker, permanently, at the
-- next blind — worth shouting about while there is still time to reorder.
local victim, safe = JCA.dagger_victim(card)
if victim then
local vname = name_of(victim.config.center.key)
if safe then
text_row(vname .. ' is Eternal - the blade cannot eat it', G.C.GREEN)
else
text_row('WILL DESTROY ' .. vname .. ' at the next blind', G.C.RED)
end
elseif key == 'j_ceremonial' then
text_row('Nothing to its right - the blade goes hungry', G.C.GREEN)
end
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_low', name = 'Retrigger low cards'},
{tag = 'retrig_faces', name = 'Retrigger face 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
-- Cards must be materialized as they are emplaced, the way every vanilla
-- collection page does it -- otherwise they pop in with no animation when a
-- tab or a page is built. Only the first card of a page build plays the
-- whoosh; the rest are silent (vanilla's `i>1 or j>1` flag). Each tab def
-- calls begin_page() so the counter tracks one page, not one row.
local page_cards = 0
local function begin_page() page_cards = 0 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
card:start_materialize(nil, page_cards > 0)
page_cards = page_cards + 1
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)
begin_page()
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)
begin_page()
if not sorted_pairs or JCA._pairs_dirty then
JCA._pairs_dirty = false
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'.
-- Shopping list shown at the top of the in-run overlay: the tags the board
-- wants but nothing else on it gives, and the hooks it gives that nothing
-- pays off yet. Teaching content, so learning mode keeps it.
local function build_gaps_rows()
local looking, untapped = JCA.build_gaps()
local nodes = {}
local function line(prefix, tags, colour)
if #tags == 0 then return end
local labels = {}
for _, t in ipairs(tags) do labels[#labels + 1] = {name = TAG_LABEL[t]} end
for i, l in ipairs(wrap_names(labels, 58, 2)) do
nodes[#nodes + 1] = {n = G.UIT.R, config = {align = 'cm', padding = 0.02}, nodes = {
{n = G.UIT.T, config = {text = (i == 1 and prefix or ' ') .. l,
colour = colour, scale = 0.3, shadow = true}},
}}
end
end
line('Your build is looking for: ', looking, G.C.GOLD)
line('Untapped on your board: ', untapped, G.C.UI.TEXT_LIGHT)
if #nodes == 0 then return nil end
return {n = G.UIT.R, config = {align = 'cm', padding = 0.04}, nodes = nodes}
end
G.FUNCS.jca_open_catalog = function()
local contents = {}
local ok, gaps = pcall(build_gaps_rows)
if ok and gaps then contents[#contents + 1] = gaps end
contents[#contents + 1] = {n = G.UIT.R, config = {align = 'tm', padding = 0}, nodes = {
create_tabs({
tabs = catalog_tabs(),
snap_to_nav = true,
colour = G.C.BOOSTER,
}),
}}
G.FUNCS.overlay_menu{definition = create_UIBox_generic_options({
back_func = 'exit_overlay_menu',
contents = contents,
})}
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