319f79bc5d
Hovering a joker that is a fusion component now names the fusion it feeds,
its cost with discounts applied, and what is still missing. In the shop the
card is counted as if already bought, so it answers the buy-side question
("Buy to fuse into Diamond Bard ($12 to fuse)") instead of restating what
you already own.
The line worth having is the last one. Fusing CONSUMES its components --
fuse_card calls ingredient:remove() -- so every combo those jokers held with
the rest of the board dies with them, and the FUSE button cannot tell you
that. "Fusing drops combos with: Smeared" is computed from the surviving
board, and a component being spent is not counted as a loss.
Three things about their code shaped this:
* Card:get_card_fusion() would have answered most of it, but it drives its
price flicker with math.randomseed(love.timer.getTime() * 8). An advisor
reseeding Lua's RNG on every hover has no business doing that, so this
reads FusionJokers.fusions directly and mirrors their discount arithmetic
(flat then percentage, per-result and universal, floored, min $1).
* Recipes take repeated components -- their own debug fusion needs 3x Joker
-- so components are counted by quantity, not presence.
* Affordability is judged only on plain numbers: Fusion Jokers uses to_big,
and Talisman turns G.GAME.dollars into an object. Guessing wrong about
someone's money is worse than staying quiet, so the money line just
does not appear.
Ownership is board membership, not card.area, matching copy_source and
dagger_victim -- the area pointer answers differently for the same card
depending on who built it.
Verified against the shipped recipe table: all 15 resolve at the right cost,
and all 30 components are jokers the database already knows. Not installed
means silence, like every other integration.
918 tests pass on 5.4 and LuaJIT (31 new), and 18/18 in the real game.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1997 lines
84 KiB
Lua
1997 lines
84 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
|
|
JCA.scalers = data.scalers
|
|
|
|
-- 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
|
|
|
|
-- Sticker traps on a specific shop card, as caution strings. Per-instance,
|
|
-- unlike JCA.cautions (which warns about the joker itself): the same joker is
|
|
-- a fine buy without the sticker. Perishable debuffs for good after
|
|
-- G.GAME.perishable_rounds (card.lua:2652); rental charges rental_rate ($3)
|
|
-- every round (card.lua:2647), which can eat a money joker's whole payout.
|
|
function JCA.sticker_cautions(card)
|
|
local out = {}
|
|
local key = card.config and card.config.center and card.config.center.key
|
|
local a = card.ability or {}
|
|
if a.perishable and key and JCA.scalers[key] then
|
|
out[#out + 1] = 'perishable: gains die in 5 rounds'
|
|
end
|
|
if a.rental and key and JCA.db[key] and JCA.db[key].gives.money then
|
|
out[#out + 1] = 'rental: rent may eat its payout'
|
|
end
|
|
return out
|
|
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
|
|
|
|
-- Consumable advice ------------------------------------------------------------
|
|
|
|
-- A shop consumable or booster pack is fuel for the tag-driven jokers the
|
|
-- player already owns: a Planet card feeds Constellation, an Arcana Pack
|
|
-- feeds Fortune Teller, a Standard Pack permanently adds playing cards,
|
|
-- which is what card_gen wanters grow on. Booster centers carry their
|
|
-- family in `kind` (game.lua: p_celestial_* has kind = 'Celestial').
|
|
local CONSUMABLE_TAG = {
|
|
Planet = 'planet', Tarot = 'tarot_gen', Spectral = 'spectral_gen',
|
|
Celestial = 'planet', Arcana = 'tarot_gen', Standard = 'card_gen',
|
|
}
|
|
|
|
function JCA.consumable_tag(center)
|
|
if not center then return nil end
|
|
if center.set == 'Booster' then return CONSUMABLE_TAG[center.kind] end
|
|
return CONSUMABLE_TAG[center.set]
|
|
end
|
|
|
|
-- Returns the owned jokers a consumable/pack feeds ({key, name} each, may be
|
|
-- empty) plus the tag that linked them, or nil when the card is not advice
|
|
-- material at all. Unknown (modded) jokers on the board are skipped, never
|
|
-- an error.
|
|
function JCA.consumable_partners(card)
|
|
local tag = JCA.consumable_tag(card.config and card.config.center)
|
|
if not tag then return nil end
|
|
local fed = {}
|
|
for _, j in ipairs((G.jokers and G.jokers.cards) or {}) do
|
|
local key = j.config and j.config.center and j.config.center.key
|
|
local entry = key and JCA.db[key]
|
|
if entry and entry.wants[tag] then
|
|
fed[#fed + 1] = {key = key, name = name_of(key)}
|
|
end
|
|
end
|
|
return fed, tag
|
|
end
|
|
|
|
local function owns(key)
|
|
for _, j in ipairs((G.jokers and G.jokers.cards) or {}) do
|
|
if j.config and j.config.center and j.config.center.key == key then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
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
|
|
if in_buy_area(card) then
|
|
for _, warn in ipairs(JCA.sticker_cautions(card)) do
|
|
text_row('Caution: ' .. warn, G.C.RED)
|
|
end
|
|
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
|
|
|
|
-- Fusion Jokers (see integrations.lua). The FUSE button consumes the
|
|
-- components, so the advice is the recipe, the price, and -- the part only
|
|
-- this advisor can answer -- which of your current combos die with them.
|
|
-- Two plans max: a joker can be a component of several recipes. These are
|
|
-- mechanical facts, not verdicts, so learning mode keeps them.
|
|
if JCA.fusion_plans then
|
|
local ok, plans = pcall(JCA.fusion_plans, card)
|
|
for i = 1, (ok and math.min(2, #plans) or 0) do
|
|
local p = plans[i]
|
|
local rname = name_of(p.result)
|
|
local price = p.cost and (' ($' .. p.cost .. ')') or ''
|
|
if not p.ready then
|
|
local want = {}
|
|
for _, m in ipairs(p.missing) do
|
|
want[#want + 1] = (m.n > 1 and (m.n .. 'x ') or '') .. name_of(m.key)
|
|
end
|
|
text_row(('Fuses into %s%s - still needs %s')
|
|
:format(rname, price, table.concat(want, ', ')))
|
|
elseif in_buy_area(card) then
|
|
-- Deliberately no affordability check here: the player would be
|
|
-- paying for the card AND the fusion, and guessing at that sum
|
|
-- would be advice about money we have not actually done.
|
|
text_row(('Buy to fuse into %s%s'):format(rname,
|
|
p.cost and (' ($' .. p.cost .. ' to fuse)') or ''), G.C.GREEN)
|
|
elseif p.affordable == false then
|
|
text_row(('Fuses into %s - $%d, you have $%d')
|
|
:format(rname, p.cost, p.dollars or 0), G.C.RED)
|
|
else
|
|
text_row(('Fusion ready: %s%s'):format(rname, price), G.C.GREEN)
|
|
end
|
|
if #p.loses > 0 then
|
|
local names = {}
|
|
for _, k in ipairs(p.loses) do names[#names + 1] = {name = name_of(k)} end
|
|
for li, l in ipairs(wrap_names(names, 30, 2)) do
|
|
text_row((li == 1 and 'Fusing drops combos with: ' or ' ') .. l,
|
|
G.C.RED)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Run context: this deck, these hand levels. Facts that frame the tags,
|
|
-- never part of the score; capped at two lines so multi-suit jokers do
|
|
-- not flood the tooltip. Fixed iteration order keeps hovers stable.
|
|
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
|
|
|
|
-- The consumable counterpart of tooltip_rows: names the owned jokers a
|
|
-- Planet/Tarot/Spectral card or its pack feeds. Quiet unless an owned joker
|
|
-- actually cares, so the tooltip adds nothing for everyone else.
|
|
local function consumable_rows(card)
|
|
local S = JCA.config.touch_mode and 1.4 or 1
|
|
local fed, tag = JCA.consumable_partners(card)
|
|
if not tag then return nil end
|
|
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 #fed > 0 then
|
|
for i, l in ipairs(wrap_names(fed, 34, 2)) do
|
|
text_row((i == 1 and 'Feeds: ' or ' ') .. l)
|
|
end
|
|
end
|
|
-- A Standard Pack permanently adds playing cards, and Erosion is paid per
|
|
-- card MISSING from the deck (card.lua:4318) -- the same verified mechanism
|
|
-- as its clashes with DNA/Marble/Certificate, from a pack instead.
|
|
if tag == 'card_gen' and owns('j_erosion') then
|
|
text_row("Caution: shrinks Erosion's Mult", G.C.RED)
|
|
end
|
|
if #rows == 0 then return nil 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 not is_joker(self)
|
|
and (in_buy_area(self) or (G.shop_booster and self.area == G.shop_booster)) then
|
|
local ok, rows = pcall(consumable_rows, self)
|
|
if ok and rows then aut.info[#aut.info + 1] = rows end
|
|
end
|
|
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
|