e37b56471b
Any xMult joker used to pulse 'recommended' on a board of four unrelated
+Mult/+Chips jokers: the generic engine-complement +1 in JCA.score
accumulated once per owned joker in partners_for, reaching the threshold
of 4 with zero listable partners (pairwise scores never hit 2), so the
card pulsed while its own tooltip said 'No synergy with your jokers.'
- partners_for now counts the generic +1 once per candidate. Tag/pair
scores are even, so the generic bonus can no longer flip a
recommendation at all; a pulsing card always has a nameable partner.
- Joker Stencil is the one xMult joker the generic rule actively
misreads (its multiplier grows with EMPTY slots), so entries support
{no_generic = true} which suppresses the rule in score() and the
generic fallback lines in explain(). Stencil also gains copy_target:
its real famous synergy is being copied by Blueprint/Brainstorm.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
941 lines
37 KiB
Lua
941 lines
37 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
|
|
|
|
-- 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
|
|
|
|
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 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 key = card.config.center.key
|
|
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
|
|
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 ------------------------------------------------------------------
|
|
|
|
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
|
|
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
|
|
|
|
-- 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
|
|
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 = '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
|