--- 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 = }, ...} 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