9364603db1
The Water removes every discard at blind start (blind.lua:208, returned only after the fight), idling discard granters and payoffs alike; The Eye blocks a repeated hand type outright (blind.lua:189/577), giving a one-hand build exactly one shot. BOSS_KILLS entries now carry a tag SET and their own factual phrase, since "debuffs X" stopped being the only true sentence. The Arm and The Ox were chased and rejected -- level drain does not touch the planet jokers, and The Ox fires on the player's own hand choice, the same reasoning that rejected the Card Sharp/Obelisk clash -- recorded in the BOSS_KILLS comment so nobody re-adds them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1853 lines
78 KiB
Lua
1853 lines
78 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. The list is
|
|
-- kept alongside the blurb map so the Traps tab can page over it; blurbs are
|
|
-- always read back through clash_blurb so register_clash updates apply.
|
|
JCA.clash_list = data.clashes or {}
|
|
JCA.clash_blurb = {}
|
|
for _, p in ipairs(JCA.clash_list) 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
|
|
|
|
-- What a copier still IN THE SHOP would copy if bought right now. A purchase
|
|
-- always lands in the RIGHTMOST slot (buy_from_shop hands the card to
|
|
-- G.jokers:emplace, button_callbacks.lua:2279, and emplace appends,
|
|
-- cardarea.lua:53) -- so a bought Blueprint arrives with no right-hand
|
|
-- neighbour and copies nothing until the player reorders, while a bought
|
|
-- Brainstorm reads the leftmost joker the moment it lands. Same returns as
|
|
-- copy_source; a leftmost joker that is itself a copier is followed through
|
|
-- copy_source so the chain resolves to the joker actually copied.
|
|
function JCA.copy_source_if_bought(key)
|
|
if not (COPIERS[key] and G.jokers and G.jokers.cards) then return nil, 'none', {} end
|
|
if COPIERS[key] == 'right' then return nil, 'none', {} end
|
|
local target = G.jokers.cards[1]
|
|
local center = target and target.config and target.config.center
|
|
if not center then return nil, 'none', {} end
|
|
if COPIERS[center.key] then
|
|
local final, status, chain = JCA.copy_source(target)
|
|
table.insert(chain, 1, target)
|
|
return final, status, chain
|
|
end
|
|
if not center.blueprint_compat then return target, 'incompatible', {} end
|
|
if target.debuff then return target, 'debuffed', {} end
|
|
return target, 'ok', {}
|
|
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
|
|
|
|
-- Boss-blind cautions: bosses that mechanically shut off a class of jokers
|
|
-- for the fight. Warning-only, like every trap: this never touches a score.
|
|
-- Each entry maps the boss to the catalog tags it silences and the factual
|
|
-- phrase the tooltip prints ("This ante: <boss> <what>").
|
|
--
|
|
-- Five bosses debuff a whole class of cards (game.lua P_BLINDS; applied in
|
|
-- Blind:debuff_card, blind.lua:708): The Club/Goad/Head/Window each kill a
|
|
-- suit, The Plant kills face cards. A debuffed card scores nothing and
|
|
-- triggers nothing, so a joker that wants that class is walking into a dead
|
|
-- ante -- and a joker that GIVES it is worse (Pareidolia under The Plant
|
|
-- makes every card a debuffed face).
|
|
--
|
|
-- Two more silence a mechanic rather than a card class:
|
|
-- The Water removes every discard at blind start (blind.lua:208 eases
|
|
-- discards_left to 0; given back only after the fight, blind.lua:405), so
|
|
-- discard granters and discard payoffs both idle.
|
|
-- The Eye records each played hand type and BLOCKS a repeat outright
|
|
-- (blind.lua:189 builds the table, debuff_hand rejects at blind.lua:577),
|
|
-- so a build that spams one hand type gets exactly one shot at it.
|
|
--
|
|
-- Chased and REJECTED, so nobody re-adds them: The Arm lowers the played
|
|
-- hand's LEVEL (blind.lua:584) -- that drains hand-level investment, not the
|
|
-- planet-economy jokers, so warning them off would be wrong. The Ox fires
|
|
-- only when the player chooses to play their most played hand -- the same
|
|
-- "hand choice decides, not the effect" reasoning that rejected the Card
|
|
-- Sharp/Obelisk clash (see AUDIT.md).
|
|
local BOSS_KILLS = {
|
|
bl_club = {tags = {clubs = true}, what = 'debuffs Clubs'},
|
|
bl_goad = {tags = {spades = true}, what = 'debuffs Spades'},
|
|
bl_head = {tags = {hearts = true}, what = 'debuffs Hearts'},
|
|
bl_window = {tags = {diamonds = true}, what = 'debuffs Diamonds'},
|
|
bl_plant = {tags = {faces = true}, what = 'debuffs face cards'},
|
|
bl_water = {tags = {discards_up = true}, what = 'sets discards to 0'},
|
|
bl_eye = {tags = {pair = true, two_pair = true, three_kind = true,
|
|
four_kind = true, straight = true, flush = true},
|
|
what = 'blocks repeat hand types'},
|
|
}
|
|
JCA.boss_kills = BOSS_KILLS
|
|
|
|
-- How much of the deck actually belongs to a suit (base suits, the field
|
|
-- vanilla sorts and scores by -- card.lua:145). Context for suit jokers:
|
|
-- the score never reads the deck, but the player deciding on Bloodstone
|
|
-- deserves to know whether it is 21 Hearts or 6.
|
|
function JCA.deck_suit_count(suit)
|
|
local n, total = 0, 0
|
|
for _, c in ipairs(G.playing_cards or {}) do
|
|
if c.base and c.base.suit then
|
|
total = total + 1
|
|
if c.base.suit == suit then n = n + 1 end
|
|
end
|
|
end
|
|
return n, total
|
|
end
|
|
|
|
-- The boss still ahead this ante, or nil once it is dealt with. Both fields
|
|
-- live in G.GAME.round_resets (game.lua:2019, misc_functions.lua:398).
|
|
function JCA.upcoming_boss()
|
|
local rr = G.GAME and G.GAME.round_resets
|
|
if not (rr and rr.blind_choices) then return nil end
|
|
local state = rr.blind_states and rr.blind_states.Boss
|
|
if state == 'Defeated' or state == 'Skipped' then return nil end
|
|
return rr.blind_choices.Boss
|
|
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')
|
|
local pk = pair_key(a, b)
|
|
if not JCA.clash_blurb[pk] then
|
|
JCA.clash_list[#JCA.clash_list + 1] = {a, b, warning}
|
|
end
|
|
JCA.clash_blurb[pk] = warning
|
|
JCA._traps_dirty = true -- traps tab re-sorts on next open
|
|
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
|
|
|
|
-- Copiers in the shop: a purchase always lands rightmost (see
|
|
-- copy_source_if_bought), so say what THIS board hands the copier the
|
|
-- moment it arrives -- by name, before the player pays. Explanations,
|
|
-- not verdicts, so learning mode keeps them.
|
|
if in_buy_area(card) and COPIERS[key] then
|
|
if key == 'j_blueprint' then
|
|
-- The caution above already says a rightmost Blueprint is dead;
|
|
-- add the one thing it cannot know: which joker to slot it against.
|
|
local best = JCA.best_copy_target(card)
|
|
if best then
|
|
text_row('Fix after buying: slot it just left of '
|
|
.. name_of(best.config.center.key), G.C.GREEN)
|
|
end
|
|
else
|
|
local target, status, chain = JCA.copy_source_if_bought(key)
|
|
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('Will copy ' .. via .. tname .. ' as soon as it lands', G.C.GREEN)
|
|
elseif status == 'incompatible' then
|
|
text_row('Will copy ' .. via .. tname .. ' - which cannot be copied', G.C.RED)
|
|
elseif status == 'debuffed' then
|
|
text_row('Will copy ' .. via .. tname .. ' - debuffed, so nothing', G.C.RED)
|
|
else
|
|
local best = JCA.best_copy_target(card)
|
|
text_row(best
|
|
and ('Will copy nothing - make ' .. name_of(best.config.center.key)
|
|
.. ' your leftmost joker first')
|
|
or 'Will copy nothing - no copyable joker on your board', G.C.RED)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Run context: this deck, these hand levels. Facts that frame the tags,
|
|
-- never part of the score; capped at two lines so multi-suit jokers do
|
|
-- not flood the tooltip. Fixed iteration order keeps hovers stable.
|
|
if in_buy_area(card) then
|
|
local entry = JCA.db[key]
|
|
local ctx = 0
|
|
if entry then
|
|
for _, s in ipairs({{ 'hearts', 'Hearts' }, { 'diamonds', 'Diamonds' },
|
|
{ 'spades', 'Spades' }, { 'clubs', 'Clubs' }}) do
|
|
if ctx < 2 and (entry.wants[s[1]] or entry.gives[s[1]]) then
|
|
local n, total = JCA.deck_suit_count(s[2])
|
|
if total > 0 then
|
|
text_row(('Your deck: %d of %d cards are %s'):format(n, total, s[2]))
|
|
ctx = ctx + 1
|
|
end
|
|
end
|
|
end
|
|
for _, h in ipairs({{ 'pair', 'Pair' }, { 'two_pair', 'Two Pair' },
|
|
{ 'three_kind', 'Three of a Kind' },
|
|
{ 'four_kind', 'Four of a Kind' },
|
|
{ 'straight', 'Straight' }, { 'flush', 'Flush' }}) do
|
|
if ctx < 2 and entry.wants[h[1]] then
|
|
local lvl = G.GAME and G.GAME.hands and G.GAME.hands[h[2]]
|
|
and G.GAME.hands[h[2]].level
|
|
if (lvl or 1) >= 2 then
|
|
text_row(('Your %s hand is level %d'):format(h[2], lvl))
|
|
ctx = ctx + 1
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Boss caution: buying a joker whose class the upcoming boss silences
|
|
-- is paying for a dead ante. Warnings survive learning mode.
|
|
if in_buy_area(card) then
|
|
local boss = JCA.upcoming_boss()
|
|
local kill = boss and BOSS_KILLS[boss]
|
|
local entry = JCA.db[key]
|
|
if kill and entry then
|
|
local hit
|
|
for t in pairs(kill.tags) do
|
|
if entry.wants[t] or entry.gives[t] then hit = true break end
|
|
end
|
|
if hit then
|
|
local bname = G.P_BLINDS and G.P_BLINDS[boss] and G.P_BLINDS[boss].name
|
|
text_row(('This ante: %s %s'):format(bname or 'the boss', kill.what),
|
|
G.C.RED)
|
|
end
|
|
end
|
|
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.
|
|
|
|
-- Total pairwise synergy across the current board: every owned pair scored
|
|
-- once. The run's high-water mark is kept in G.GAME.jca_peak_synergy for the
|
|
-- recap; it can only rise when a joker arrives, so the emplace hook that
|
|
-- already drives discovery is the one place that needs to look.
|
|
function JCA.board_synergy()
|
|
if not (G.jokers and G.jokers.cards) then return 0 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 sum = 0
|
|
for i = 1, #keys do
|
|
for j = i + 1, #keys do
|
|
sum = sum + JCA.score(keys[i], keys[j])
|
|
end
|
|
end
|
|
return sum
|
|
end
|
|
|
|
function JCA.check_discoveries()
|
|
if not (G.jokers and G.jokers.cards) then return end
|
|
if G.GAME then
|
|
local s = JCA.board_synergy()
|
|
if s > (G.GAME.jca_peak_synergy or 0) then G.GAME.jca_peak_synergy = s end
|
|
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
|
|
local peak = (G.GAME and G.GAME.jca_peak_synergy) or 0
|
|
local names = {}
|
|
for pk in pairs(combos or {}) do
|
|
local a, b = pk:match('^(.-)|(.+)$')
|
|
if a then names[#names + 1] = name_of(a) .. ' + ' .. name_of(b) end
|
|
end
|
|
if #names == 0 and peak <= 0 then return nil end
|
|
table.sort(names)
|
|
local rows = {}
|
|
if #names > 0 then
|
|
rows[#rows + 1] = {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
|
|
end
|
|
if peak > 0 then
|
|
rows[#rows + 1] = {n = G.UIT.R, config = {align = 'cm', padding = 0.02}, nodes = {
|
|
{n = G.UIT.T, config = {text = 'Peak board synergy: ' .. peak,
|
|
colour = G.C.GOLD, scale = 0.3, shadow = true}},
|
|
}}
|
|
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
|
|
|
|
-- Traps tab: the known clashes and cautions as an open book. Deliberately NO
|
|
-- discovery gating and no Progress entry: the Combos tab hides pairs to keep
|
|
-- the fun of finding them, but a warning locked behind falling into the trap
|
|
-- would reward fielding exactly the pairs the advisor exists to warn against.
|
|
-- Clash pages come first (card duos, like Combos), then caution pages (single
|
|
-- jokers); one pager spans both. Blurbs are read through clash_blurb /
|
|
-- cautions at build time, so register_clash updates show without a rebuild.
|
|
|
|
local TRAPS_PER_PAGE = 6 -- 2 rows x 3 columns, clash and caution pages alike
|
|
|
|
-- One catalog column: the card(s) above the warning that explains the trap.
|
|
local function trap_col(keys, blurb, wrap_width, max_lines)
|
|
local col = {{n = G.UIT.R, config = {align = 'cm'},
|
|
nodes = {card_row_node(keys, 0.62)}}}
|
|
local cap = wrap_plain(blurb or '', wrap_width)
|
|
for i = 1, math.min(#cap, max_lines) 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.RED, scale = 0.22}},
|
|
}}
|
|
end
|
|
return {n = G.UIT.C, config = {align = 'cm', padding = 0.08}, nodes = col}
|
|
end
|
|
|
|
local sorted_clashes, sorted_cautions
|
|
local function traps_tab_def(page_no)
|
|
begin_page()
|
|
if not sorted_clashes or JCA._traps_dirty then
|
|
JCA._traps_dirty = false
|
|
sorted_clashes = {}
|
|
for _, c in ipairs(JCA.clash_list) do
|
|
sorted_clashes[#sorted_clashes + 1] = c
|
|
end
|
|
table.sort(sorted_clashes, function(a, b)
|
|
local an, bn = name_of(a[1]), name_of(b[1])
|
|
if an ~= bn then return an < bn end
|
|
return name_of(a[2]) < name_of(b[2])
|
|
end)
|
|
sorted_cautions = {}
|
|
for key in pairs(JCA.cautions) do
|
|
sorted_cautions[#sorted_cautions + 1] = key
|
|
end
|
|
table.sort(sorted_cautions, function(a, b) return name_of(a) < name_of(b) end)
|
|
end
|
|
local clash_pages = math.ceil(#sorted_clashes / TRAPS_PER_PAGE)
|
|
local caution_pages = math.ceil(#sorted_cautions / TRAPS_PER_PAGE)
|
|
local pages = math.max(clash_pages + caution_pages, 1)
|
|
page_no = math.min(page_no or 1, pages)
|
|
|
|
local nodes = {}
|
|
if page_no <= clash_pages then
|
|
add_caption(nodes, ('Clashes - %d known pairs where one eats the other\'s payoff')
|
|
:format(#sorted_clashes))
|
|
local first = (page_no - 1) * TRAPS_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 clash = sorted_clashes[first + r * 3 + c]
|
|
if clash then
|
|
row.nodes[#row.nodes + 1] = trap_col({clash[1], clash[2]},
|
|
JCA.clash_blurb[pair_key(clash[1], clash[2])], 30, 2)
|
|
end
|
|
end
|
|
if #row.nodes > 0 then nodes[#nodes + 1] = row end
|
|
end
|
|
else
|
|
add_caption(nodes, ('Cautions - %d jokers that carry their own trap')
|
|
:format(#sorted_cautions))
|
|
local first = (page_no - clash_pages - 1) * TRAPS_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 key = sorted_cautions[first + r * 3 + c]
|
|
if key then
|
|
row.nodes[#row.nodes + 1] = trap_col({key}, JCA.cautions[key], 26, 3)
|
|
end
|
|
end
|
|
if #row.nodes > 0 then nodes[#nodes + 1] = row end
|
|
end
|
|
end
|
|
if pages > 1 then
|
|
local options = {}
|
|
for i = 1, clash_pages do
|
|
options[#options + 1] = ('Clashes %d/%d'):format(i, clash_pages)
|
|
end
|
|
for i = 1, caution_pages do
|
|
options[#options + 1] = ('Cautions %d/%d'):format(i, caution_pages)
|
|
end
|
|
nodes[#nodes + 1] = pager_row(options, page_no, 'jca_page_Traps')
|
|
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)
|
|
register_pager('Traps', traps_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 = 'Traps',
|
|
tab_definition_function = function() return traps_tab_def(1) 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_toggle{
|
|
label = 'Combo counter under jokers (JokerDisplay)',
|
|
ref_table = JCA.config, ref_value = 'jd_combos',
|
|
},
|
|
}},
|
|
{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
|
|
|
|
-- Cross-mod integrations -------------------------------------------------------
|
|
-- integrations.lua patches JokerDisplay (combo counter under jokers) and Too
|
|
-- Many Jokers (search the collection by synergy tags) when they are present.
|
|
-- pcall on the whole load: a partner mod's surprise API change must degrade
|
|
-- to a log line, never take the advisor down with it.
|
|
|
|
do
|
|
local loaded, integrations = pcall(function()
|
|
return assert(SMODS.load_file('integrations.lua'))()
|
|
end)
|
|
if loaded and integrations then
|
|
JCA.integrations = {}
|
|
for name, apply in pairs(integrations) do
|
|
local ok, count = pcall(apply)
|
|
JCA.integrations[name] = ok and count or 0
|
|
if not ok then print('JCA: integration ' .. name .. ' failed: ' .. tostring(count)) end
|
|
end
|
|
dbg('integrations:', (JCA.integrations.jokerdisplay or 0) .. ' JokerDisplay defs,',
|
|
(JCA.integrations.toomanyjokers or 0) .. ' TMJ search hooks')
|
|
else
|
|
print('JCA: integrations failed to load: ' .. tostring(integrations))
|
|
end
|
|
end
|