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>
264 lines
12 KiB
Lua
264 lines
12 KiB
Lua
--- Integrations with other installed mods, applied at the end of main.lua.
|
|
--- Every integration gates on its target mod's global, so a missing mod is
|
|
--- silence rather than an error, and each touches only jokers this database
|
|
--- knows. Both targets load before this mod does (JokerDisplay at priority
|
|
--- -1e9, Too Many Jokers at -1000, this mod at 0), so their tables exist by
|
|
--- the time these run. Each returns how much it patched, surfaced in
|
|
--- JCA.integrations for the tests and the debug log.
|
|
|
|
local M = {}
|
|
|
|
--- JokerDisplay: a live combo counter under every joker it displays.
|
|
--- Its model: JokerDisplay.Definitions[key] holds per-joker display specs;
|
|
--- dynamic text reads card.joker_display_values, which the definition's
|
|
--- calc_function fills on every display update. We append a counter element
|
|
--- in JokerDisplay's own parenthetical reminder style -- '(2 combos)', the
|
|
--- same partner count the hover tooltip names -- and wrap calc_function to
|
|
--- keep the value fresh. Definitions without a reminder_text row gain one.
|
|
--- The wrapper blanks the text when config.jd_combos is off, so the toggle
|
|
--- works mid-run in both directions; the count call is pcall-wrapped like
|
|
--- every other advisor path that runs inside the game's draw loop.
|
|
function M.jokerdisplay()
|
|
if not (JokerDisplay and JokerDisplay.Definitions) then return 0 end
|
|
local patched = 0
|
|
for key in pairs(JCA.db) do
|
|
local def = JokerDisplay.Definitions[key]
|
|
if def and not def.jca_combo_counter then
|
|
def.jca_combo_counter = true
|
|
local node = { ref_table = 'card.joker_display_values',
|
|
ref_value = 'jca_combos', colour = G.C.GREEN }
|
|
if def.reminder_text then
|
|
def.reminder_text[#def.reminder_text + 1] = { text = ' ' }
|
|
def.reminder_text[#def.reminder_text + 1] = node
|
|
else
|
|
def.reminder_text = { node }
|
|
end
|
|
local orig = def.calc_function
|
|
def.calc_function = function(card)
|
|
-- pcall like every advisor path in the draw loop: if the
|
|
-- original calc fails, the counter must still not crash.
|
|
if orig then pcall(orig, card) end
|
|
local text = ''
|
|
if JCA.config.jd_combos ~= false then
|
|
local ok, partners = pcall(JCA.partners_for, card)
|
|
local n = ok and #partners or 0
|
|
if n > 0 then
|
|
text = ('(%d combo%s)'):format(n, n == 1 and '' or 's')
|
|
end
|
|
end
|
|
card.joker_display_values = card.joker_display_values or {}
|
|
card.joker_display_values.jca_combos = text
|
|
end
|
|
patched = patched + 1
|
|
end
|
|
end
|
|
return patched
|
|
end
|
|
|
|
--- Too Many Jokers: its api.md asks for a TMJ.SEARCH_FIELD_FUNCS entry
|
|
--- mapping a center to searchable strings. We hand it every tag label on
|
|
--- both sides of a joker, so the collection search finds jokers by what
|
|
--- they give and want: 'money', 'held-card retriggers', 'Hearts'...
|
|
--- Unknown centers (other mods' jokers) return nil, the same "score 0"
|
|
--- silence the engine promises everywhere else.
|
|
function M.toomanyjokers()
|
|
if not (TMJ and TMJ.SEARCH_FIELD_FUNCS) then return 0 end
|
|
TMJ.SEARCH_FIELD_FUNCS[#TMJ.SEARCH_FIELD_FUNCS + 1] = function(center)
|
|
local entry = center and center.key and JCA.db[center.key]
|
|
if not entry then return nil end
|
|
local fields = {}
|
|
for t in pairs(entry.gives) do
|
|
if JCA.tag_label[t] then fields[#fields + 1] = JCA.tag_label[t] end
|
|
end
|
|
for t in pairs(entry.wants) do
|
|
if JCA.tag_label[t] then fields[#fields + 1] = JCA.tag_label[t] end
|
|
end
|
|
return fields
|
|
end
|
|
return 1
|
|
end
|
|
|
|
--- Fusion Jokers: advice for the FUSE button.
|
|
--- Its model: FusionJokers.fusions is an ARRAY of recipes
|
|
--- {jokers = {{name = key}, ...}, result_joker = key, cost = n, requirement = fn}
|
|
--- with a reverse index hung off the same table,
|
|
--- FusionJokers.fusions.ingredience[component_key][result_key] = true,
|
|
--- so `ipairs` walks recipes and `.ingredience` is just another field.
|
|
---
|
|
--- We only READ that data. Card:get_card_fusion() would answer most of this
|
|
--- for us, but it drives its price flicker with
|
|
--- `math.randomseed(math.floor(love.timer.getTime() * 8))` (FusionJokers.lua),
|
|
--- so calling it would reseed Lua's RNG on every hover -- a side effect an
|
|
--- advisor has no business having. Everything below is derived from the
|
|
--- recipe table instead, mirroring their arithmetic where it matters.
|
|
---
|
|
--- Installs JCA.fusion_plans; when the mod is absent nothing is installed and
|
|
--- the tooltip stays silent, exactly like an unknown joker scoring 0.
|
|
function M.fusionjokers()
|
|
if not (FusionJokers and FusionJokers.fusions) then return 0 end
|
|
local FUSIONS = FusionJokers.fusions
|
|
|
|
-- Owned copies per joker key, plus whether `card` is itself on the board.
|
|
-- Fusing pulls components out of G.jokers (their has_joker walks
|
|
-- G.jokers.cards), so that is the pool that counts -- and board membership
|
|
-- is what "owned" means here, the same test copy_source and dagger_victim
|
|
-- use. Asking the card for its area would answer differently for the same
|
|
-- card depending on who built it.
|
|
local function board_state(card)
|
|
local n, owned = {}, false
|
|
for _, c in ipairs((G.jokers and G.jokers.cards) or {}) do
|
|
local key = c.config and c.config.center and c.config.center.key
|
|
if key then n[key] = (n[key] or 0) + 1 end
|
|
if c == card then owned = true end
|
|
end
|
|
return n, owned
|
|
end
|
|
|
|
-- Same arithmetic as get_card_fusion: flat discount then percentage, each
|
|
-- with a per-result and a universal term, floored, never below $1.
|
|
local function fusion_cost(recipe)
|
|
local cost = recipe.cost
|
|
if type(cost) ~= 'number' then return nil end
|
|
local flat = G.GAME and G.GAME.fujo_fusion_discount
|
|
if flat then
|
|
cost = cost - (flat[recipe.result_joker] or 0) - (flat.universal or 0)
|
|
end
|
|
local pct = G.GAME and G.GAME.fujo_fusion_discountpercent
|
|
if pct then
|
|
cost = cost * (1 - (pct[recipe.result_joker] or 0))
|
|
* (1 - (pct.universal or 0))
|
|
end
|
|
return math.max(math.floor(cost), 1)
|
|
end
|
|
|
|
-- The cards this recipe would eat, one per component slot, first match in
|
|
-- board order. FusionJokers prefers highlighted copies over board order,
|
|
-- but which duplicate it takes cannot change WHICH KEYS leave the board,
|
|
-- and that is all the combo maths below depends on.
|
|
local function consumed_by(recipe)
|
|
local cards = (G.jokers and G.jokers.cards) or {}
|
|
local consumed, taken = {}, {}
|
|
for _, comp in ipairs(recipe.jokers) do
|
|
for i, c in ipairs(cards) do
|
|
local key = not taken[i] and c.config and c.config.center
|
|
and c.config.center.key
|
|
if key == comp.name then
|
|
taken[i] = true
|
|
consumed[#consumed + 1] = c
|
|
break
|
|
end
|
|
end
|
|
end
|
|
return consumed, taken
|
|
end
|
|
|
|
-- What fusing costs you beyond the money: a fusion CONSUMES its components
|
|
-- (fuse_card calls ingredient:remove(), and the survivor is re-abilitied
|
|
-- into the result), so every combo those jokers had with the rest of the
|
|
-- board dies with them. No other mod can answer this -- it is the whole
|
|
-- reason this integration exists rather than just restating the recipe.
|
|
-- Returns the surviving owned jokers whose partnerships would be lost.
|
|
local function losses(recipe)
|
|
local consumed, taken = consumed_by(recipe)
|
|
if #consumed == 0 then return {} end
|
|
local cards = (G.jokers and G.jokers.cards) or {}
|
|
local lost, seen = {}, {}
|
|
for _, gone in ipairs(consumed) do
|
|
local gone_key = gone.config.center.key
|
|
for i, other in ipairs(cards) do
|
|
local key = not taken[i] and other.config and other.config.center
|
|
and other.config.center.set == 'Joker'
|
|
and other.config.center.key
|
|
if key and not seen[key] and JCA.score(gone_key, key) >= 2 then
|
|
seen[key] = true
|
|
lost[#lost + 1] = key
|
|
end
|
|
end
|
|
end
|
|
return lost
|
|
end
|
|
|
|
--- Every fusion `card` is a component of, as plans the tooltip can render.
|
|
--- A card in a buy area is counted as if already bought, so the shop
|
|
--- tooltip answers "what does buying this unlock" rather than "what do you
|
|
--- have" -- the question the player is actually asking.
|
|
--- Each plan: {result, cost, ready, affordable, dollars, missing, loses}
|
|
--- missing - {{key = ..., n = <how many more>}, ...} in recipe order
|
|
--- loses - owned joker keys whose combos die if this fusion happens
|
|
function JCA.fusion_plans(card)
|
|
local center = card.config and card.config.center
|
|
local key = center and center.key
|
|
local index = FUSIONS.ingredience
|
|
if not (key and index and index[key]) then return {} end
|
|
|
|
local counts, owned = board_state(card)
|
|
if not owned then counts[key] = (counts[key] or 0) + 1 end
|
|
|
|
local plans = {}
|
|
for _, recipe in ipairs(FUSIONS) do
|
|
-- ingredience is only a fast reject: confirm against the recipe
|
|
-- itself, which is the list fuse_card actually walks.
|
|
if index[key][recipe.result_joker] then
|
|
local need = {}
|
|
for _, comp in ipairs(recipe.jokers) do
|
|
need[comp.name] = (need[comp.name] or 0) + 1
|
|
end
|
|
if need[key] then
|
|
local ready, missing, listed = true, {}, {}
|
|
for _, comp in ipairs(recipe.jokers) do
|
|
if not listed[comp.name] then
|
|
listed[comp.name] = true
|
|
local short = need[comp.name] - (counts[comp.name] or 0)
|
|
if short > 0 then
|
|
ready = false
|
|
missing[#missing + 1] = {key = comp.name, n = short}
|
|
end
|
|
end
|
|
end
|
|
-- A third-party recipe may gate itself behind a predicate;
|
|
-- it is someone else's code, so a throw means "unknown",
|
|
-- never a crash and never a false "ready".
|
|
if ready and type(recipe.requirement) == 'function' then
|
|
local ok, allowed = pcall(recipe.requirement)
|
|
if not ok or allowed == false then ready = false end
|
|
end
|
|
local cost = fusion_cost(recipe)
|
|
local dollars = G.GAME and G.GAME.dollars
|
|
local affordable
|
|
-- Only judge affordability on plain numbers: Talisman turns
|
|
-- dollars into a big-number object, and guessing wrong about
|
|
-- someone's money is worse than staying quiet.
|
|
if cost and type(dollars) == 'number' then
|
|
local floor_ = G.GAME.bankrupt_at
|
|
affordable = cost + (type(floor_) == 'number' and floor_ or 0)
|
|
<= dollars
|
|
end
|
|
plans[#plans + 1] = {
|
|
result = recipe.result_joker,
|
|
cost = cost,
|
|
ready = ready,
|
|
affordable = affordable,
|
|
dollars = type(dollars) == 'number' and dollars or nil,
|
|
missing = missing,
|
|
loses = ready and losses(recipe) or {},
|
|
}
|
|
end
|
|
end
|
|
end
|
|
-- Fusions you can act on first, then cheapest, then by result key so a
|
|
-- hover never reshuffles its own advice.
|
|
table.sort(plans, function(a, b)
|
|
if a.ready ~= b.ready then return a.ready end
|
|
if (a.cost or 0) ~= (b.cost or 0) then return (a.cost or 0) < (b.cost or 0) end
|
|
return a.result < b.result
|
|
end)
|
|
return plans
|
|
end
|
|
|
|
local n = 0
|
|
for _ in ipairs(FUSIONS) do n = n + 1 end
|
|
return n
|
|
end
|
|
|
|
return M
|