Initial release: Joker Combo Advisor v1.0.0

Steamodded mod that teaches Balatro joker synergies: hover tooltips
explain why jokers combo (famous-pair blurbs + tag-based reasons),
combo discovery tracking with catalog grey-out and run recap, an
in-game synergy catalog with real card sprites, learning mode, and
a HUD shortcut.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-07-09 21:54:04 -07:00
commit 8783785283
7 changed files with 1392 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
.claude/
+124
View File
@@ -0,0 +1,124 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
"Joker Combo Advisor" — a Steamodded (SMODS) mod for Balatro that scores shop/pack
jokers against the player's current jokers, shows a "Combo Advisor" tooltip on hover,
and pulses strongly recommended cards. Pure Lua, no build step.
## Commands
Syntax check everything:
```sh
luac -p main.lua synergies.lua config.lua
```
There is no test framework. Test the engine standalone by stubbing the game globals
before loading `main.lua` (the game itself cannot be launched headlessly here):
```sh
lua - <<'EOF'
SMODS = { load_file = function(f) return loadfile(f) end,
current_mod = { config = dofile('config.lua') } }
Card = { generate_UIBox_ability_table = function() end, update = function() end }
G = { UIT = {ROOT='ROOT', R='R', T='T'},
C = { UI = {TEXT_DARK='dark'}, GREEN='green', CLEAR='clear' },
STAGES = {RUN=1}, FUNCS = {} }
dofile('main.lua')
print(JCA.score('j_baron', 'j_mime')) -- pairwise score
-- simulate owned jokers via G.jokers.cards; each card needs
-- { config = { center = { key=..., name=..., set='Joker' } } }
EOF
```
Expected score tiers: famous explicit pair + tags ≈ 6, hand-type/suit cluster = 4,
single tag match = 2, generic mult+xmult complement = 1, unrelated = 0.
## In-game verification (this machine)
- The mod is **symlinked** into the live Mods folder:
`~/.local/share/Balatro/Mods/JokerComboAdvisor -> ~/Documents/Balatro Mod`.
Edits here go live on next game launch — do not copy files anywhere.
- Game: Steam/Proton at `/mnt/games/Steam/steamapps/common/Balatro` (note: second
Steam library, not the default paths). Lovely injector + Steamodded 1.0.0-beta
are already installed; mods are managed via Balatro Mod Manager.
- Crash/load logs: `~/.local/share/Balatro/Mods/lovely/log/` (newest file).
## Architecture
Three-layer split; `JCA` is the single global namespace:
- `synergies.lua` — data only. Returns `{ jokers = {...}, pairs = {...} }`. Each of
the 150 vanilla jokers gets `gives` (capabilities it adds) and `wants`
(capabilities that strengthen it) tag lists, converted to sets at the bottom of
the file. `PAIRS` lists famous explicit combos worth a flat +4.
- `main.lua` — engine + game integration:
- Scoring: `JCA.score(a, b)` = explicit pair bonus (+4) + 2 per give→want tag
match in each direction + 1 for the generic "+Mult/+Chips source with an
×Mult joker" complement. `JCA.partners_for(card)` aggregates over
`G.jokers.cards`; total ≥ `JCA.config.threshold` ⇒ recommended.
- UI hooks: wraps `Card:generate_UIBox_ability_table` to append a tooltip entry
to `aut.info` (format: array of rows-of-UIT-nodes plus a `.name` string —
must match what vanilla `info_tip_from_rows` expects), and wraps
`Card:update` for the ~2.5s pulse on recommended shop cards. Both hook bodies
are pcall-wrapped so a scoring bug can never crash a run — keep it that way.
- Config tab: registered on `SMODS.current_mod.config_tab`, guarded by
`if SMODS.current_mod` so standalone tests don't need full stubs.
- Synergy catalog: `SMODS.current_mod.extra_tabs` adds Combos/Engine/
Economy/Hands tabs that render real `Card` objects in `CardArea`s
(collection-style, so hover shows each joker's own tooltip). One theme
per page; pagers swap the `tab_contents` UIBox from an option-cycle
callback — the same pattern as SMODS's achievements tab. Because pages
contain live Card/CardArea objects, tab definitions must be rebuilt on
every call — never cache the node trees. The same tabs open in-run via
a "Combos" HUD button (wrapped `create_UIBox_HUD`, injected into the
`button_area` node under Run Info/Options); the pagers work there
because vanilla `create_tabs` also names its body `tab_contents`.
- Discovery: `CardArea:emplace` hook calls `JCA.check_discoveries` when a
joker lands in `G.jokers`; famous pairs fielded together persist in
`config.discovered` (saved immediately via `SMODS.save_mod_config`) and
per-run in `G.GAME.jca_run_combos`. Undiscovered pairs render face-down
with a `???` caption in the Combos tab; blurb replaces it once fielded.
- Post-run recap: wraps `create_UIBox_game_over`/`create_UIBox_win` and
inserts a "Combos fielded this run" row (from `G.GAME.jca_run_combos`)
relative to vanilla button ids (`from_game_over`; `from_game_won` or
`win_cta`) via a parent-trail walk — anchor depths differ per screen.
- Learning mode (`config.learning_mode`): suppresses the pulse and the
"Strong pick!" line but never the explanations. No-synergy buy-area
tooltips show "Looking for:"/"Offers:" hints from `TAG_LABEL`.
`synergies.lua` also exports `theme_info` (one teaching sentence per
catalog tag — keep ≤ ~90 chars and cover every THEME_TABS tag).
- The real (Lovely-patched) game source is readable at
`~/.local/share/Balatro/Mods/lovely/dump/` — check it before assuming
what a vanilla function returns or expects.
- `config.lua` — default settings (`pulse`, `owned_tooltip`, `threshold`).
Steamodded loads and persists this table itself; read it only via
`SMODS.current_mod.config` (exposed as `JCA.config`).
## Conventions and gotchas
- **Cluster tags** (hand types `pair`/`straight`/`flush`/…, suits, `planet`,
`spectral_gen`): put the same tag in *both* `gives` and `wants` of every member
so members mutually reinforce (2+2 = 4). Pure enablers (e.g. Four Fingers,
Smeared, Pareidolia) only `give`; pure payoffs only `want`.
- Every `wants` tag must be given by at least one joker — the standalone test's
orphan-tag check enforces this idea; re-run it after database edits.
- Tooltips teach: `JCA.explain(hovered, partner)` picks one reason per partner
— famous-pair blurb (third element of each `PAIRS` entry), else the first
`TAG_ORDER` match phrased from `TAG_TEXT` (give/want/both variants), else
the generic mult×xmult line. Every non-engine tag (anything besides
mult/chips/xmult) MUST have a `TAG_TEXT` entry; new `PAIRS` entries need a
blurb under ~34 chars so name + blurb fits one tooltip row.
- Vanilla center keys contain intentional misspellings/abbreviations — do not
"fix" them: `j_gluttenous_joker`, `j_selzer`, `j_ticket` (Golden Ticket),
`j_trousers` (Spare Trousers), `j_ring_master` (Showman), `j_caino` (Canio),
`j_delayed_grat`, `j_todo_list`.
- Unknown joker keys (from other mods) must keep scoring 0 — never index
`JCA.db[key]` without a nil guard.
- Display names go through `localize{type='name_text', set='Joker', ...}` with a
pcall + `center.name` fallback; anti-synergies are deliberately not modeled.
- The UI hooks are the only untested-in-game surface; if a tooltip regression is
reported, suspect the `aut.info` entry format first and check the lovely log.
+13
View File
@@ -0,0 +1,13 @@
{
"id": "JokerComboAdvisor",
"name": "Joker Combo Advisor",
"display_name": "Combo Advisor",
"author": ["Alex"],
"description": "Recommends joker combos: highlights shop jokers that synergize with your current build and explains why on hover.",
"prefix": "jca",
"main_file": "main.lua",
"priority": 0,
"badge_colour": "F5A623",
"version": "1.0.0",
"dependencies": ["Steamodded (>=1.0.0~BETA)"]
}
+95
View File
@@ -0,0 +1,95 @@
# Joker Combo Advisor
A [Steamodded](https://github.com/Steamodded/smods) mod for Balatro that
recommends joker combos while you play:
- **Hover any joker in the shop or a Buffoon Pack** and a *Combo Advisor*
tooltip appears next to its description, naming which of your current
jokers it synergizes with **and why** ("Mime: retriggers cards in hand"),
so you learn the mechanics rather than just the verdict. Famous pairs get
hand-written explanations; everything else is phrased from the synergy
tags. Jokers with no current synergy teach instead what to pair them with
("Looking for: Tarot cards" / "Offers: face cards"). Strong picks also get
a "Strong pick for this build!" callout and **pulse periodically** in the
shop so you can spot them without hovering.
- **Combo discovery**: the first time you field a famous pair together, a
"New combo!" toast fires and the pair is permanently inked into the
catalog — until then its cards sit face-down with a "???" caption, so
filling in the Combos tab becomes its own collection hunt.
- **Hover a joker you own** to see its active combos with the rest of your
lineup.
- **Browse the synergy catalog** any time: a purple **Combos** button in the
run HUD (under Run Info / Options) opens it in an overlay, and the same
tabs live under Mods > Combo Advisor. The *Combos* tab shows the famous
pairs as card duos with their explanation once fielded; *Engine* /
*Economy* / *Hands* tabs page through each synergy theme with a one-line
explanation of the mechanic and its **Sources** and **Payoffs** as real
cards — hover any card for its normal description tooltip. All of it is generated
from the same database the advisor scores with. Jokers you haven't
discovered yet show face-down (no spoilers) unless the profile has used
the game's own **Unlock All** setting, in which case everything is shown.
- **Post-run combo recap**: the win and game-over screens list the famous
pairs you fielded during that run.
## How recommendations work
`synergies.lua` tags all 150 vanilla jokers with what they *give* a build
(e.g. `retrig_scoring`, `gold_gen`, `xmult`) and what they *want*
(e.g. `faces`, `probability`, `money`). Two jokers score:
| Signal | Points |
| --- | --- |
| Famous explicit pair (Baron + Mime, Midas Mask + Golden Ticket, …) | +4 |
| Each *gives* tag the other *wants* (both directions) | +2 |
| Generic engine complement (+Mult/+Chips source with an ×Mult joker) | +1 |
A shop joker is *recommended* (pulses) when its total score against your
owned jokers reaches the configured threshold (default 4). Partners with a
pairwise score of 2+ are listed in the tooltip, strongest first.
## Settings
In-game: **Mods > Combo Advisor > Config**. Changes persist across sessions.
- **Pulse recommended jokers in shop** — turn the visual pulse on/off.
- **Show combo tooltip on owned jokers** — the "Active combos" section on
jokers you already hold.
- **Learning mode** — hides the verdicts (pulse and "Strong pick!") while
keeping all the explanations, for players who want to learn the synergies
rather than be told what to buy.
- **Recommendation threshold** — 2 (chatty) to 8 (only slam-dunk combos).
Defaults live in `config.lua`.
## Installation
1. Install [Lovely](https://github.com/ethangreen-dev/lovely-injector) and
[Steamodded](https://github.com/Steamodded/smods) (follow their READMEs).
2. Copy this whole folder into your Balatro `Mods` directory:
- **Windows:** `%AppData%/Balatro/Mods/`
- **Linux (Steam/Proton):**
`~/.local/share/Steam/steamapps/compatdata/2379780/pfx/drive_c/users/steamuser/AppData/Roaming/Balatro/Mods/`
(or the same path under `~/.steam/steam/`, or
`~/.var/app/com.valvesoftware.Steam/.local/share/Steam/...` for Flatpak Steam)
- **macOS:** `~/Library/Application Support/Balatro/Mods/`
3. Launch Balatro. The mod should appear in the in-game **Mods** menu.
If something goes wrong, check the Lovely log
(`Mods/lovely/log/` next to the game, or the console window on launch).
## Extending it
- **Add or fix a synergy:** edit `synergies.lua`. Give a joker a tag with
`j('j_key', {gives...}, {wants...})`, or add a famous pair to the `PAIRS`
table at the bottom. Modded jokers can be added the same way using their
full key (e.g. `j_mymod_cooljoker`) — unknown jokers simply score 0.
- **Tune sensitivity:** use the in-game Config tab, or change the defaults
in `config.lua`.
## Files
| File | Purpose |
| --- | --- |
| `JokerComboAdvisor.json` | Steamodded mod metadata |
| `main.lua` | Scoring engine, hover tooltip, shop pulse, config tab |
| `synergies.lua` | Tag database for all 150 vanilla jokers + famous pairs |
| `config.lua` | Default settings (persisted by Steamodded once changed in-game) |
+9
View File
@@ -0,0 +1,9 @@
-- Default settings for Joker Combo Advisor. Steamodded loads this table,
-- persists changes made in the in-game Config tab, and restores them on boot.
return {
pulse = true, -- recommended shop jokers pulse periodically
owned_tooltip = true, -- show "Active combos" tooltip on owned jokers
threshold = 4, -- total synergy score needed to recommend a card
learning_mode = false, -- hide verdicts (pulse, "Strong pick!"), keep reasons
discovered = {}, -- famous pairs the player has fielded (pair keys)
}
+842
View File
@@ -0,0 +1,842 @@
--- Joker Combo Advisor
--- Scores jokers in the shop (and in booster packs) against the jokers you
--- currently own, shows a "Combo Advisor" tooltip on hover explaining which
--- of your jokers they synergize with, and makes strong picks pulse.
JCA = JCA or {}
-- Diagnostics: prints land in Mods/lovely/log as "[G] JCA: ..." lines.
JCA.DEBUG = false
local function dbg(...)
if JCA.DEBUG then print('JCA:', ...) end
end
local data = assert(SMODS.load_file('synergies.lua'))()
JCA.db = data.jokers
-- Settings from config.lua, persisted by Steamodded across sessions.
-- Fallback defaults keep the engine usable outside the game (tests).
JCA.mod = SMODS.current_mod
JCA.config = SMODS.current_mod and SMODS.current_mod.config
or { pulse = true, owned_tooltip = true, threshold = 4, discovered = {} }
JCA.config.discovered = JCA.config.discovered or {}
-- Explicit famous pairs, stored under a canonical "a|b" key (a < b).
JCA.pair_list = data.pairs
JCA.pair_bonus = {}
JCA.pair_blurb = {}
local function pair_key(a, b)
if a < b then return a .. '|' .. b end
return b .. '|' .. a
end
for _, p in ipairs(data.pairs) do
JCA.pair_bonus[pair_key(p[1], p[2])] = true
JCA.pair_blurb[pair_key(p[1], p[2])] = p[3]
end
-- Scoring -------------------------------------------------------------------
-- Synergy score between two joker center keys. 0 = no known synergy.
function JCA.score(a, b)
local s = 0
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 always help each other a little.
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
end
end
return s
end
-- One short reason why `partner` works with `hovered`, phrased from the
-- partner's side ("Mime: retriggers cards in hand"). Most specific wins:
-- famous-pair blurb, then the first tag match in TAG_ORDER (what the partner
-- gives to this card before what it takes), then the generic engine
-- complement.
function JCA.explain(hovered, partner)
local blurb = JCA.pair_blurb[pair_key(hovered, partner)]
if blurb then return blurb end
local A, B = JCA.db[hovered], JCA.db[partner]
if not (A and B) then return nil end
for _, t in ipairs(data.tag_order) do
local text = data.tag_text[t]
if text then
local cluster = text.both
and ((A.gives[t] and A.wants[t]) or (B.gives[t] and B.wants[t]))
if B.gives[t] and A.wants[t] then
return cluster and text.both or text.give
elseif A.gives[t] and B.wants[t] then
return cluster and text.both or text.want
end
end
end
if 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
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.
function JCA.partners_for(card)
local partners, total = {}, 0
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 = JCA.score(key, owned.config.center.key)
total = total + s
if s >= 2 then
partners[#partners + 1] = {
key = owned.config.center.key,
name = name_of(owned.config.center.key),
score = s,
}
end
end
end
table.sort(partners, function(x, y) return x.score > y.score end)
return partners, total
end
-- A shop/pack joker is "recommended" (pulses) when its combined synergy with
-- the current build clears the configured threshold.
function JCA.is_recommended(card)
local _, total = JCA.partners_for(card)
return total >= JCA.config.threshold
end
local function in_buy_area(card)
return card.area and (card.area == G.shop_jokers or card.area == G.pack_cards)
end
local function is_joker(card)
return card.config and card.config.center and card.config.center.set == 'Joker'
end
-- Hover tooltip ---------------------------------------------------------------
-- Wrap partner names into short tooltip lines.
local function wrap_names(partners, max_width, max_lines)
local lines, line = {}, nil
for i, p in ipairs(partners) do
local nxt = line and (line .. ', ' .. p.name) or p.name
if line and #nxt > max_width then
lines[#lines + 1] = line
line = p.name
else
line = nxt
end
if #lines >= max_lines then
lines[#lines] = lines[#lines] .. ' (+' .. (#partners - i + 1) .. ' more)'
return lines
end
end
if line then lines[#lines + 1] = line end
return lines
end
-- Short theme nouns for the "Looking for:"/"Offers:" hints (engine roles
-- mult/chips/xmult are deliberately absent — they hint nothing specific).
local TAG_LABEL = {
faces = 'face cards', retrig_scoring = 'played-card retriggers',
retrig_held = 'held-card retriggers', hand_size = 'hand size',
hands_up = 'extra hands', discards_up = 'discards',
probability = 'listed odds', money = 'money', hand_level = 'hand levels',
tarot_gen = 'Tarot cards', planet = 'Planet cards',
spectral_gen = 'Spectral cards', gold_gen = 'Gold cards',
stone_gen = 'Stone cards', enhance_gen = 'card enhancing',
card_gen = 'deck additions', joker_gen = 'Joker creation',
sell_value = 'sell value', copy_target = 'copy targets',
suit_flex = 'suit conversion', pair = 'Pairs', two_pair = 'Two Pair',
three_kind = 'Three of a Kind', four_kind = 'Four of a Kind',
straight = 'Straights', flush = 'Flushes', hearts = 'Hearts',
diamonds = 'Diamonds', spades = 'Spades', clubs = 'Clubs',
}
-- Labels for a joker's tag set, in stable data.tag_order order.
local function tag_labels(tag_set)
local labels = {}
for _, t in ipairs(data.tag_order) do
if tag_set[t] and TAG_LABEL[t] then labels[#labels + 1] = {name = TAG_LABEL[t]} end
end
return labels
end
local function tooltip_rows(card)
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,
}}}
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,
}}}
end
end
end
else
if in_buy_area(card) then
text_row('Synergizes with your jokers:')
else
text_row('Active combos with:')
end
local key = card.config.center.key
local shown = math.min(3, #partners)
for i = 1, shown do
local p = partners[i]
local reason = JCA.explain(key, p.key) or 'shared build theme'
rows[#rows + 1] = {
{n = G.UIT.T, config = {text = p.name .. ': ',
colour = G.C.RED, scale = 0.3}},
{n = G.UIT.T, config = {text = reason,
colour = G.C.UI.TEXT_DARK, scale = 0.3}},
}
end
if #partners > shown then
local rest = {}
for i = shown + 1, #partners do rest[#rest + 1] = partners[i] end
for i, l in ipairs(wrap_names(rest, 34, 2)) do
text_row((i == 1 and 'also: ' or ' ') .. l, G.C.UI.TEXT_DARK)
end
end
if in_buy_area(card) and total >= JCA.config.threshold
and not JCA.config.learning_mode then
text_row('Strong pick for this build!', G.C.GREEN)
end
end
rows.name = 'Combo Advisor'
return rows
end
local orig_gen_aut = Card.generate_UIBox_ability_table
function Card:generate_UIBox_ability_table(vars_only)
-- vars_only returns multiple values; pass that path through untouched
if vars_only then return orig_gen_aut(self, vars_only) end
local aut = orig_gen_aut(self)
if aut and aut.info and is_joker(self) then
dbg('hover joker', self.config.center.key,
'in_buy_area=', in_buy_area(self), 'owned=', self.area == G.jokers)
if in_buy_area(self)
or (self.area == G.jokers and JCA.config.owned_tooltip) then
local ok, rows = pcall(tooltip_rows, self)
if not ok then
dbg('tooltip_rows ERROR:', rows)
elseif rows then
aut.info[#aut.info + 1] = rows
dbg('tooltip appended,', #rows, 'rows')
end
end
end
return aut
end
-- Shop pulse ------------------------------------------------------------------
local orig_update = Card.update
function Card:update(dt)
orig_update(self, dt)
if JCA.config.pulse and not JCA.config.learning_mode
and G.STAGE == G.STAGES.RUN
and is_joker(self) and in_buy_area(self) then
self.jca_timer = (self.jca_timer or 2.5) + dt
if self.jca_timer >= 2.5 then
self.jca_timer = 0
local ok, rec = pcall(JCA.is_recommended, self)
if ok and rec then self:juice_up(0.25, 0.15) end
end
end
end
-- Combo discovery tracking -----------------------------------------------------
-- A famous pair counts as discovered the first time both jokers are fielded
-- together. All-time discoveries persist in config.discovered; pairs fielded
-- during the current run are also recorded in G.GAME.jca_run_combos (saved
-- with the run) for end-of-run display.
function JCA.check_discoveries()
if not (G.jokers and G.jokers.cards) then return end
local keys = {}
for _, c in ipairs(G.jokers.cards) do
if is_joker(c) then keys[#keys + 1] = c.config.center.key end
end
local new_pair
for i = 1, #keys do
for j = i + 1, #keys do
local pk = pair_key(keys[i], keys[j])
if JCA.pair_bonus[pk] then
if G.GAME then
G.GAME.jca_run_combos = G.GAME.jca_run_combos or {}
G.GAME.jca_run_combos[pk] = true
end
if not JCA.config.discovered[pk] then
JCA.config.discovered[pk] = true
new_pair = new_pair or (name_of(keys[i]) .. ' + ' .. name_of(keys[j]))
end
end
end
end
if new_pair then
if JCA.mod and SMODS.save_mod_config then SMODS.save_mod_config(JCA.mod) end
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)
end
end
if CardArea and type(CardArea) == 'table' then
local orig_emplace = CardArea.emplace
function CardArea:emplace(...)
orig_emplace(self, ...)
if self == G.jokers then
pcall(JCA.check_discoveries)
end
end
end
-- Post-run combo recap -----------------------------------------------------------
-- Lists the famous pairs fielded during the run (G.GAME.jca_run_combos) on the
-- win and game-over screens.
local function recap_row()
local combos = G.GAME and G.GAME.jca_run_combos
if not combos or not next(combos) then return nil end
local names = {}
for pk in pairs(combos) do
local a, b = pk:match('^(.-)|(.+)$')
if a then names[#names + 1] = name_of(a) .. ' + ' .. name_of(b) end
end
if #names == 0 then return nil end
table.sort(names)
local rows = {{n = G.UIT.R, config = {align = 'cm', padding = 0.03}, nodes = {
{n = G.UIT.T, config = {text = 'Combos fielded this run',
colour = G.C.GOLD, scale = 0.35, shadow = true}},
}}}
for i = 1, math.min(#names, 6) do
rows[#rows + 1] = {n = G.UIT.R, config = {align = 'cm', padding = 0.02}, nodes = {
{n = G.UIT.T, config = {text = names[i],
colour = G.C.WHITE, scale = 0.3, shadow = true}},
}}
end
if #names > 6 then
rows[#rows + 1] = {n = G.UIT.R, config = {align = 'cm', padding = 0.02}, nodes = {
{n = G.UIT.T, config = {text = '+' .. (#names - 6) .. ' more',
colour = G.C.UI.TEXT_LIGHT, scale = 0.3}},
}}
end
return {n = G.UIT.R, config = {align = 'cm', padding = 0.06}, nodes = rows}
end
-- Trail of {node, index} pairs from `node` down to the child carrying the id,
-- so callers can insert siblings at any ancestor level.
local function find_trail(node, id, trail)
trail = trail or {}
for i, child in ipairs(node.nodes or {}) do
trail[#trail + 1] = {node = node, index = i}
if child.config and child.config.id == id then return trail end
local hit = find_trail(child, id, trail)
if hit then return hit end
trail[#trail] = nil
end
end
-- Insert the recap `up` ancestor levels above the anchor's parent, before
-- (offset 0) or after (offset 1) that branch. Returns true on success.
local function insert_recap(ui, id, up, offset)
local row = recap_row()
if not row then return end
local trail = find_trail(ui, id)
if not trail or #trail <= up then return end
local spot = trail[#trail - up]
table.insert(spot.node.nodes, spot.index + offset, row)
return true
end
-- Game over: 'from_game_over' sits in the buttons row of the central column;
-- one level up places the recap between the stats box and the buttons.
if create_UIBox_game_over then
local orig_game_over = create_UIBox_game_over
function create_UIBox_game_over(...)
local ui = orig_game_over(...)
pcall(insert_recap, ui, 'from_game_over', 1, 0)
return ui
end
end
-- Win: the buttons live inside the right-hand stats column; two levels up
-- places the recap below the whole two-column stats row. 'win_cta' replaces
-- 'from_game_won' when the CTA variant of the screen is shown.
if create_UIBox_win then
local orig_win = create_UIBox_win
function create_UIBox_win(...)
local ui = orig_win(...)
local _, inserted = pcall(insert_recap, ui, 'from_game_won', 2, 1)
if not inserted then pcall(insert_recap, ui, 'win_cta', 2, 1) end
return ui
end
end
-- Synergy catalog (Mods menu > Combo Advisor > Combos/Engine/Economy/Hands) --
-- Generated from the database at menu time so it can never drift from the
-- scores the advisor actually uses.
local THEME_TABS = {
{ label = 'Engine', themes = {
{tag = 'faces', name = 'Face cards'},
{tag = 'retrig_scoring', name = 'Retrigger played cards'},
{tag = 'retrig_held', name = 'Retrigger held cards'},
{tag = 'copy_target', name = 'Copy targets (Blueprint/Brainstorm)'},
{tag = 'probability', name = 'Probability'},
{tag = 'hand_size', name = 'Hand size'},
{tag = 'hands_up', name = 'Extra hands'},
{tag = 'discards_up', name = 'Discards'},
}},
{ label = 'Economy', themes = {
{tag = 'money', name = 'Money'},
{tag = 'sell_value', name = 'Sell value'},
{tag = 'joker_gen', name = 'Joker creation'},
{tag = 'tarot_gen', name = 'Tarot cards'},
{tag = 'planet', name = 'Planet cards'},
{tag = 'spectral_gen', name = 'Spectral cards'},
{tag = 'gold_gen', name = 'Gold cards'},
{tag = 'stone_gen', name = 'Stone cards'},
{tag = 'enhance_gen', name = 'Card enhancing'},
{tag = 'card_gen', name = 'Deck additions'},
}},
{ label = 'Hands', themes = {
{tag = 'pair', name = 'Pairs'},
{tag = 'two_pair', name = 'Two Pair'},
{tag = 'three_kind', name = 'Three of a Kind'},
{tag = 'four_kind', name = 'Four of a Kind'},
{tag = 'straight', name = 'Straights'},
{tag = 'flush', name = 'Flushes'},
{tag = 'suit_flex', name = 'Suit conversion'},
{tag = 'hearts', name = 'Hearts'},
{tag = 'diamonds', name = 'Diamonds'},
{tag = 'spades', name = 'Spades'},
{tag = 'clubs', name = 'Clubs'},
}},
}
-- Joker keys for a tag, split into pure sources (give only), pure payoffs
-- (want only), and cluster members (both). Sorted by display name.
local function theme_members(tag)
local sources, payoffs, both = {}, {}, {}
for key, entry in pairs(JCA.db) do
local g, w = entry.gives[tag], entry.wants[tag]
if g and w then both[#both + 1] = key
elseif g then sources[#sources + 1] = key
elseif w then payoffs[#payoffs + 1] = key
end
end
local by_name = function(a, b) return name_of(a) < name_of(b) end
table.sort(sources, by_name); table.sort(payoffs, by_name); table.sort(both, by_name)
return sources, payoffs, both
end
-- Card display: real Card objects in CardAreas (like the Collection), so
-- hovering shows each joker's own description tooltip. Areas and cards are
-- built fresh on every tab/page build and are cleaned up with the UIBox, so
-- none of these node trees may be cached.
local CARDS_PER_ROW = 8
-- Undiscovered jokers show face-down (vanilla spoiler protection), unless
-- the player has pressed Unlock All in the settings. That button already
-- marks every center discovered, but the profile flag also covers centers
-- added later (e.g. new mods); pages rebuild on every open, so checking the
-- flag at build time reacts to Unlock All without any event hook.
local function reveal_params()
local profile = G.PROFILES and G.SETTINGS and G.PROFILES[G.SETTINGS.profile]
if profile and profile.all_unlocked then
return {bypass_discovery_center = true, bypass_discovery_ui = true}
end
end
-- `face_down` renders the cards back-up (used for unfielded famous pairs);
-- face-down cards also show no hover tooltip, keeping the mystery.
local function card_row_node(keys, scale, face_down)
local area = CardArea(
G.ROOM.T.x + 0.2 * G.ROOM.T.w / 2, G.ROOM.T.h,
(scale * #keys + 0.25) * G.CARD_W,
scale * G.CARD_H,
{card_limit = #keys, type = 'title', highlight_limit = 0, collection = true})
local reveal = reveal_params()
for _, key in ipairs(keys) do
local center = G.P_CENTERS[key]
if center then
local card = Card(area.T.x + area.T.w / 2, area.T.y,
G.CARD_W * scale, G.CARD_H * scale, G.P_CARDS.empty, center, reveal)
if face_down then
card.facing = 'back'
card.sprite_facing = 'back'
end
area:emplace(card)
end
end
return {n = G.UIT.O, config = {object = area}}
end
local function add_caption(nodes, str)
nodes[#nodes + 1] = {n = G.UIT.R, config = {align = 'cm', padding = 0.02}, nodes = {
{n = G.UIT.T, config = {text = str, colour = G.C.GOLD, scale = 0.3}},
}}
end
-- Word-wrap a plain sentence for small caption rows.
local function wrap_plain(s, width)
local lines, line = {}, nil
for word in s:gmatch('%S+') do
local nxt = line and (line .. ' ' .. word) or word
if line and #nxt > width then
lines[#lines + 1] = line
line = word
else
line = nxt
end
end
if line then lines[#lines + 1] = line end
return lines
end
-- Explainer sentence rendered as small light rows under a caption.
local function add_info_rows(nodes, sentence, width, scale)
for _, l in ipairs(wrap_plain(sentence or '', width)) do
nodes[#nodes + 1] = {n = G.UIT.R, config = {align = 'cm', padding = 0.01}, nodes = {
{n = G.UIT.T, config = {text = l, colour = G.C.UI.TEXT_LIGHT, scale = scale}},
}}
end
end
local function add_card_rows(nodes, keys, scale, per_row)
per_row = per_row or CARDS_PER_ROW
for i = 1, #keys, per_row do
local row = {}
for j = i, math.min(i + per_row - 1, #keys) do
row[#row + 1] = keys[j]
end
nodes[#nodes + 1] = {n = G.UIT.R,
config = {align = 'cm', padding = 0.05, no_fill = true},
nodes = {card_row_node(row, scale)}}
end
end
-- Card layouts from largest to most compact; all stay under ~8.4 units wide.
-- A page uses the largest layout whose estimated height fits the menu box,
-- so dense themes (Money, Retriggers) shrink instead of overflowing.
local CARD_LAYOUTS = {
{scale = 0.68, per_row = 8},
{scale = 0.60, per_row = 9},
{scale = 0.52, per_row = 11},
{scale = 0.44, per_row = 13},
}
local PAGE_HEIGHT_BUDGET = 5.5
local function pick_layout(groups, text_rows)
for _, layout in ipairs(CARD_LAYOUTS) do
local card_rows = 0
for _, keys in ipairs(groups) do
card_rows = card_rows + math.ceil(#keys / layout.per_row)
end
local est = text_rows * 0.33 + 0.6 -- captions/blurb + pager
+ card_rows * (layout.scale * G.CARD_H + 0.1)
if est <= PAGE_HEIGHT_BUDGET then return layout end
end
return CARD_LAYOUTS[#CARD_LAYOUTS]
end
local function pager_row(options, current, callback)
return {n = G.UIT.R, config = {align = 'cm', padding = 0.05}, nodes = {
create_option_cycle{
options = options, current_option = current, w = 4.5,
cycle_shoulders = true, no_pips = true, colour = G.C.RED,
opt_callback = callback,
focus_args = {snap_to = true, nav = 'wide'},
},
}}
end
-- One theme per page; the pager is labelled with theme names.
local function theme_tab_def(group, page_no)
page_no = math.min(page_no or 1, #group.themes)
local theme = group.themes[page_no]
local sources, payoffs, both = theme_members(theme.tag)
-- A tiny cluster list (e.g. To the Moon under Money) folds into the
-- sources/payoffs lists instead of costing its own row.
if #both > 0 and #both <= 2 and (#sources > 0 or #payoffs > 0) then
for _, k in ipairs(both) do
if #sources > 0 then sources[#sources + 1] = k end
if #payoffs > 0 then payoffs[#payoffs + 1] = k end
end
both = {}
local by_name = function(a, b) return name_of(a) < name_of(b) end
table.sort(sources, by_name)
table.sort(payoffs, by_name)
end
local blurb_lines = wrap_plain(data.theme_info[theme.tag] or '', 70)
local groups, captions = {}, 0
if #both > 0 then groups[#groups + 1] = both end
if #sources > 0 then groups[#groups + 1] = sources; captions = captions + 1 end
if #payoffs > 0 then groups[#groups + 1] = payoffs; captions = captions + 1 end
local layout = pick_layout(groups, 1 + #blurb_lines + captions)
local nodes = {}
add_caption(nodes, theme.name)
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 options = {}
for i, t in ipairs(group.themes) do options[i] = t.name end
nodes[#nodes + 1] = pager_row(options, page_no, 'jca_page_' .. group.label)
return {n = G.UIT.ROOT, config = {align = 'tm', padding = 0.05,
colour = G.C.CLEAR, minw = 8.5, minh = 5.6}, nodes = nodes}
end
-- Famous pairs as side-by-side card duos, six pairs per page.
local PAIRS_PER_PAGE = 6
local sorted_pairs
local function combos_tab_def(page_no)
if not sorted_pairs then
sorted_pairs = {}
for _, p in ipairs(JCA.pair_list) do
sorted_pairs[#sorted_pairs + 1] = {p[1], p[2], score = JCA.score(p[1], p[2])}
end
table.sort(sorted_pairs, function(a, b)
if a.score ~= b.score then return a.score > b.score end
return name_of(a[1]) < name_of(b[1])
end)
end
local pages = math.ceil(#sorted_pairs / PAIRS_PER_PAGE)
page_no = math.min(page_no or 1, pages)
local nodes = {}
local found = 0
for _, p in ipairs(sorted_pairs) do
if JCA.config.discovered[pair_key(p[1], p[2])] then found = found + 1 end
end
add_caption(nodes, ('Famous pairs - fielded %d of %d (face-down pairs are undiscovered)')
:format(found, #sorted_pairs))
local first = (page_no - 1) * PAIRS_PER_PAGE
for r = 0, 1 do
local row = {n = G.UIT.R, config = {align = 'cm', padding = 0.05}, nodes = {}}
for c = 1, 3 do
local pair = sorted_pairs[first + r * 3 + c]
if pair then
local pk = pair_key(pair[1], pair[2])
local fielded = JCA.config.discovered[pk]
-- blurb caption once fielded; '???' keeps the mystery until then
local col = {{n = G.UIT.R, config = {align = 'cm'},
nodes = {card_row_node({pair[1], pair[2]}, 0.62, not fielded)}}}
local cap = fielded and wrap_plain(JCA.pair_blurb[pk] or '', 30) or {'???'}
for i = 1, math.min(#cap, 2) do
col[#col + 1] = {n = G.UIT.R, config = {align = 'cm', padding = 0.01}, nodes = {
{n = G.UIT.T, config = {text = cap[i],
colour = G.C.UI.TEXT_LIGHT, scale = 0.22}},
}}
end
row.nodes[#row.nodes + 1] = {n = G.UIT.C,
config = {align = 'cm', padding = 0.08}, nodes = col}
end
end
if #row.nodes > 0 then nodes[#nodes + 1] = row end
end
if pages > 1 then
local options = {}
for i = 1, pages do options[i] = 'Page ' .. i .. '/' .. pages end
nodes[#nodes + 1] = pager_row(options, page_no, 'jca_page_Combos')
end
return {n = G.UIT.ROOT, config = {align = 'tm', padding = 0.05,
colour = G.C.CLEAR, minw = 8.5, minh = 5.6}, nodes = nodes}
end
-- Page cyclers swap the tab body in place (same pattern SMODS uses for its
-- achievements tab).
local function register_pager(name, def_fn)
G.FUNCS['jca_page_' .. name] = function(args)
if not args or not args.cycle_config then return end
local tab_contents = G.OVERLAY_MENU:get_UIE_by_ID('tab_contents')
tab_contents.config.object:remove()
tab_contents.config.object = UIBox{
definition = def_fn(args.cycle_config.current_option),
config = {offset = {x = 0, y = 0}, parent = tab_contents, type = 'cm'},
}
tab_contents.UIBox:recalculate()
end
end
register_pager('Combos', combos_tab_def)
for _, group in ipairs(THEME_TABS) do
register_pager(group.label, function(p) return theme_tab_def(group, p) end)
end
local function catalog_tabs()
local tabs = {{
label = 'Combos',
chosen = true,
tab_definition_function = function() return combos_tab_def(1) end,
}}
for _, t in ipairs(THEME_TABS) do
tabs[#tabs + 1] = {
label = t.label,
tab_definition_function = function() return theme_tab_def(t, 1) end,
}
end
return tabs
end
if SMODS.current_mod then
SMODS.current_mod.extra_tabs = catalog_tabs
end
-- In-run "Combos" HUD button ---------------------------------------------------
-- Adds a third button under Run Info / Options that opens the synergy catalog
-- in an overlay. The overlay reuses catalog_tabs(); the page cyclers work
-- unchanged because vanilla create_tabs also names its body 'tab_contents'.
G.FUNCS.jca_open_catalog = function()
G.FUNCS.overlay_menu{definition = create_UIBox_generic_options({
back_func = 'exit_overlay_menu',
contents = {
{n = G.UIT.R, config = {align = 'tm', padding = 0}, nodes = {
create_tabs({
tabs = catalog_tabs(),
snap_to_nav = true,
colour = G.C.BOOSTER,
}),
}},
},
})}
end
local function find_by_id(node, id)
if node.config and node.config.id == id then return node end
for _, child in ipairs(node.nodes or {}) do
local hit = find_by_id(child, id)
if hit then return hit end
end
end
if create_UIBox_HUD then
local orig_hud = create_UIBox_HUD
function create_UIBox_HUD(...)
local hud = orig_hud(...)
local ok, area = pcall(find_by_id, hud, 'button_area')
if ok and area and area.nodes then
area.nodes[#area.nodes + 1] =
{n = G.UIT.R, config = {align = 'cm', minh = 0.9, minw = 1.5,
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}, nodes = {
{n = G.UIT.T, config = {text = 'Combos', scale = 0.38,
colour = G.C.UI.TEXT_LIGHT, shadow = true}},
}},
}}
end
return hud
end
end
-- Config tab (Mods menu > Combo Advisor > Config) -----------------------------
JCA.THRESHOLD_OPTIONS = {2, 3, 4, 5, 6, 8}
G.FUNCS.jca_set_threshold = function(args)
JCA.config.threshold = args.to_val
end
local db_count = 0
for _ in pairs(JCA.db) do db_count = db_count + 1 end
dbg('loaded;', db_count, 'jokers in db; config threshold =', JCA.config.threshold)
if SMODS.current_mod then
SMODS.current_mod.config_tab = function()
local current = 3 -- index of default (4) in THRESHOLD_OPTIONS
for i, v in ipairs(JCA.THRESHOLD_OPTIONS) do
if v == JCA.config.threshold then current = i end
end
return {n = G.UIT.ROOT, config = {align = 'cm', padding = 0.1, colour = G.C.CLEAR}, nodes = {
{n = G.UIT.R, config = {align = 'cm', padding = 0.05}, nodes = {
create_toggle{
label = 'Pulse recommended jokers in shop',
ref_table = JCA.config, ref_value = 'pulse',
},
}},
{n = G.UIT.R, config = {align = 'cm', padding = 0.05}, nodes = {
create_toggle{
label = 'Show combo tooltip on owned jokers',
ref_table = JCA.config, ref_value = 'owned_tooltip',
},
}},
{n = G.UIT.R, config = {align = 'cm', padding = 0.05}, nodes = {
create_toggle{
label = 'Learning mode (no verdicts, just reasons)',
ref_table = JCA.config, ref_value = 'learning_mode',
},
}},
{n = G.UIT.R, config = {align = 'cm', padding = 0.05}, nodes = {
create_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
+308
View File
@@ -0,0 +1,308 @@
--- Synergy database for Joker Combo Advisor.
--- Each joker lists tags it GIVES (capabilities it adds to a build) and tags
--- it WANTS (capabilities that make it stronger). The scorer matches one
--- joker's gives against another's wants, in both directions.
---
--- Tag glossary:
--- mult / chips / xmult engine roles (also used for the generic
--- "scaler + multiplier" complement rule)
--- money earns money; money = wants money (econ payoffs)
--- faces makes cards count as face cards / wants face cards
--- retrig_scoring retriggers (or multiplies triggers of) played cards
--- retrig_held retriggers cards held in hand
--- hand_size / hands_up / discards_up resource increases
--- probability improves listed probabilities / cares about them
--- tarot_gen / spectral_gen / planet consumable economies
--- stone_gen / gold_gen / enhance_gen / card_gen deck modification
--- joker_gen / sell_value sell-fodder economies
--- copy_target a strong effect worth copying (for Blueprint et al.)
--- pair, two_pair, three_kind, four_kind, straight, flush hand-type
--- clusters (jokers keying the same hand reinforce
--- each other; enablers like Four Fingers give these)
--- hearts, diamonds, spades, clubs, suit_flex suit clusters
---
--- Jokers not in this table (e.g. from other mods) simply score 0.
local J = {}
local function j(key, gives, wants)
J[key] = { gives = gives or {}, wants = wants or {} }
end
-- Common jokers ------------------------------------------------------------
j('j_joker', {'mult'})
j('j_greedy_joker', {'mult','diamonds'}, {'diamonds','suit_flex'})
j('j_lusty_joker', {'mult','hearts'}, {'hearts','suit_flex'})
j('j_wrathful_joker', {'mult','spades'}, {'spades','suit_flex'})
j('j_gluttenous_joker', {'mult','clubs'}, {'clubs','suit_flex'})
j('j_jolly', {'mult','pair'}, {'pair'})
j('j_zany', {'mult','three_kind'}, {'three_kind'})
j('j_mad', {'mult','two_pair'}, {'two_pair'})
j('j_crazy', {'mult','straight'}, {'straight'})
j('j_droll', {'mult','flush'}, {'flush'})
j('j_sly', {'chips','pair'}, {'pair'})
j('j_wily', {'chips','three_kind'}, {'three_kind'})
j('j_clever', {'chips','two_pair'}, {'two_pair'})
j('j_devious', {'chips','straight'}, {'straight'})
j('j_crafty', {'chips','flush'}, {'flush'})
j('j_half', {'mult'})
j('j_credit_card', {})
j('j_banner', {'chips'}, {'discards_up'})
j('j_mystic_summit', {'mult'})
j('j_8_ball', {'tarot_gen'}, {'probability'})
j('j_misprint', {'mult'})
j('j_raised_fist', {'mult'}, {'retrig_held','hand_size'})
j('j_chaos', {})
j('j_scary_face', {'chips'}, {'faces','retrig_scoring'})
j('j_abstract', {'mult'})
j('j_delayed_grat', {'money'})
j('j_gros_michel', {'mult'})
j('j_even_steven', {'mult'}, {'retrig_scoring'})
j('j_odd_todd', {'chips'}, {'retrig_scoring'})
j('j_scholar', {'chips','mult'}, {'retrig_scoring'})
j('j_business', {'money'}, {'faces','probability','retrig_scoring'})
j('j_supernova', {'mult'})
j('j_ride_the_bus', {'mult'})
j('j_egg', {'sell_value'})
j('j_runner', {'chips','straight'}, {'straight'})
j('j_ice_cream', {'chips'})
j('j_splash', {'retrig_scoring'}) -- makes every played card score
j('j_blue_joker', {'chips'})
j('j_faceless', {'money'}, {'faces','discards_up'})
j('j_green_joker', {'mult'})
j('j_superposition', {'tarot_gen','straight'}, {'straight'})
j('j_todo_list', {'money'})
j('j_cavendish', {'xmult'})
j('j_red_card', {'mult'})
j('j_square', {'chips'})
j('j_riff_raff', {'joker_gen'})
j('j_shortcut', {'straight'})
j('j_vagabond', {'tarot_gen'})
j('j_cloud_9', {'money'})
j('j_rocket', {'money'})
j('j_luchador', {})
j('j_gift', {'sell_value'})
j('j_turtle_bean', {'hand_size'})
j('j_erosion', {'mult'})
j('j_reserved_parking', {'money'}, {'faces','retrig_held','probability','hand_size'})
j('j_mail', {'money'}, {'discards_up'})
j('j_to_the_moon', {'money'}, {'money'})
j('j_hallucination', {'tarot_gen'}, {'probability'})
j('j_fortune_teller', {'mult'}, {'tarot_gen'})
j('j_juggler', {'hand_size'})
j('j_drunkard', {'discards_up'})
j('j_golden', {'money'})
j('j_popcorn', {'mult'})
j('j_walkie_talkie', {'chips','mult'}, {'retrig_scoring'})
j('j_smiley', {'mult'}, {'faces'})
j('j_ticket', {'money'}, {'gold_gen'})
j('j_swashbuckler', {'mult'}, {'sell_value','joker_gen'})
j('j_hanging_chad', {'retrig_scoring'})
j('j_shoot_the_moon', {'mult'}, {'retrig_held','hand_size'})
-- Uncommon jokers ----------------------------------------------------------
j('j_stencil', {'xmult'})
j('j_four_fingers', {'straight','flush'})
j('j_mime', {'retrig_held','copy_target'})
j('j_ceremonial', {'mult'})
j('j_marble', {'stone_gen','card_gen','enhance_gen'})
j('j_loyalty_card', {'xmult'})
j('j_dusk', {'retrig_scoring'})
j('j_fibonacci', {'mult'}, {'retrig_scoring'})
j('j_steel_joker', {'xmult'})
j('j_hack', {'retrig_scoring'})
j('j_pareidolia', {'faces'})
j('j_space', {'hand_level'}, {'probability'})
j('j_burglar', {'hands_up'})
j('j_blackboard', {'xmult'}, {'spades','clubs'})
j('j_sixth_sense', {'spectral_gen'}, {'spectral_gen'})
j('j_constellation', {'xmult','planet'}, {'planet'})
j('j_hiker', {'chips'}, {'retrig_scoring'})
j('j_card_sharp', {'xmult'})
j('j_madness', {'xmult','copy_target'})
j('j_seance', {'spectral_gen'}, {'straight','flush','spectral_gen'})
j('j_vampire', {'xmult'}, {'enhance_gen'})
j('j_hologram', {'xmult','copy_target'}, {'card_gen'})
j('j_cartomancer', {'tarot_gen'})
j('j_astronomer', {'planet'}, {'planet'})
j('j_burnt', {'hand_level'}, {'discards_up'})
j('j_bootstraps', {'mult'}, {'money'})
j('j_dna', {'card_gen'})
j('j_midas_mask', {'gold_gen','enhance_gen'}, {'faces'})
j('j_photograph', {'xmult','copy_target'}, {'faces','retrig_scoring'})
j('j_stone', {'chips'}, {'stone_gen'})
j('j_lucky_cat', {'xmult'}, {'probability'})
j('j_baseball', {'xmult'})
j('j_bull', {'chips'}, {'money'})
j('j_diet_cola', {})
j('j_trading', {'money'}, {'discards_up'})
j('j_flash', {'mult'})
j('j_trousers', {'mult','two_pair'}, {'two_pair'})
j('j_ramen', {'xmult'})
j('j_selzer', {'retrig_scoring'})
j('j_castle', {'chips'}, {'discards_up'})
j('j_mr_bones', {})
j('j_acrobat', {'xmult'}, {'hands_up'})
j('j_sock_and_buskin', {'retrig_scoring'}, {'faces'})
j('j_troubadour', {'hand_size'})
j('j_certificate', {'card_gen'})
j('j_smeared', {'flush','suit_flex'})
j('j_throwback', {'xmult'})
j('j_rough_gem', {'money','diamonds'}, {'retrig_scoring','diamonds','suit_flex'})
j('j_bloodstone', {'xmult','hearts'}, {'retrig_scoring','hearts','suit_flex','probability'})
j('j_arrowhead', {'chips','spades'}, {'retrig_scoring','spades','suit_flex'})
j('j_onyx_agate', {'mult','clubs'}, {'retrig_scoring','clubs','suit_flex'})
j('j_glass', {'xmult'})
j('j_ring_master', {})
j('j_flower_pot', {'xmult'}, {'suit_flex'})
j('j_merry_andy', {'discards_up'})
j('j_oops', {'probability'})
j('j_idol', {'xmult','copy_target'}, {'retrig_scoring'})
j('j_seeing_double', {'xmult','clubs'}, {'clubs','suit_flex'})
j('j_matador', {'money'})
j('j_hit_the_road', {'xmult'}, {'discards_up'})
j('j_satellite', {'money','planet'}, {'planet'})
-- Rare jokers ---------------------------------------------------------------
j('j_obelisk', {'xmult'})
j('j_ancient', {'xmult'}, {'suit_flex'})
j('j_campfire', {'xmult'}, {'joker_gen','sell_value'})
j('j_baron', {'xmult','copy_target'}, {'retrig_held','hand_size'})
j('j_duo', {'xmult','pair'}, {'pair'})
j('j_trio', {'xmult','three_kind'}, {'three_kind'})
j('j_family', {'xmult','four_kind'}, {'four_kind'})
j('j_order', {'xmult','straight'}, {'straight'})
j('j_tribe', {'xmult','flush'}, {'flush'})
j('j_stuntman', {'chips'})
j('j_invisible', {})
j('j_blueprint', {}, {'copy_target'})
j('j_brainstorm', {}, {'copy_target'})
j('j_drivers_license', {'xmult'}, {'enhance_gen'})
j('j_wee', {'chips'}, {'retrig_scoring'})
-- Legendary jokers ----------------------------------------------------------
j('j_caino', {'xmult','copy_target'}, {'faces'})
j('j_triboulet', {'xmult','copy_target'}, {'retrig_scoring'})
j('j_yorick', {'xmult','copy_target'}, {'discards_up'})
j('j_chicot', {})
j('j_perkeo', {'tarot_gen'}, {'tarot_gen'})
-- Famous explicit combos (flat bonus on top of tag matching), each with a
-- short "why it works" line shown in the tooltip. Keep blurbs under ~34
-- characters so a name + blurb fits on one tooltip row.
local PAIRS = {
{'j_baron', 'j_mime', 'held Kings trigger twice'},
{'j_baron', 'j_shoot_the_moon', 'Kings and Queens pay in hand'},
{'j_burglar', 'j_mystic_summit', 'zero discards, max Mult'},
{'j_burglar', 'j_acrobat', 'more hands, big final hand'},
{'j_hack', 'j_wee', 'retriggered 2s grow Wee fast'},
{'j_hanging_chad', 'j_photograph', 'first face scores three times'},
{'j_pareidolia', 'j_sock_and_buskin', 'every card is a face to retrigger'},
{'j_pareidolia', 'j_photograph', 'any first card is the face'},
{'j_pareidolia', 'j_midas_mask', 'every played card turns Gold'},
{'j_midas_mask', 'j_ticket', 'Gold cards feed Golden Ticket'},
{'j_midas_mask', 'j_vampire', 'eats the new Gold for xMult'},
{'j_dna', 'j_hologram', 'copied cards grow Hologram'},
{'j_constellation', 'j_astronomer', 'cheap Planets stack xMult'},
{'j_fortune_teller','j_cartomancer', 'free Tarots grow the Mult'},
{'j_egg', 'j_swashbuckler', 'growing sell value becomes Mult'},
{'j_gift', 'j_swashbuckler', 'inflated sell values become Mult'},
{'j_four_fingers', 'j_square', 'four-card hands suit them both'},
{'j_marble', 'j_stone', 'adds Stones that pay chips'},
{'j_oops', 'j_lucky_cat', 'better odds, more Lucky payoffs'},
}
-- How to phrase a tag link in the tooltip, from the partner's perspective:
-- `give` when the partner provides the tag, `want` when the partner consumes
-- it, `both` for cluster tags where the pair simply shares the theme.
-- Every tag that appears in the joker table must have an entry here.
local TAG_TEXT = {
faces = {give = 'turns cards into faces', want = 'pays off face cards'},
retrig_scoring = {give = 'retriggers scored cards', want = 'pays per extra trigger'},
retrig_held = {give = 'retriggers cards in hand', want = 'pays off held-card triggers'},
hand_size = {give = 'adds hand size', want = 'wants a big hand'},
hands_up = {give = 'grants extra hands', want = 'uses the extra hands'},
discards_up = {give = 'grants extra discards', want = 'feeds on discards'},
probability = {give = 'improves listed odds', want = 'relies on listed odds'},
money = {give = 'earns money', want = 'scales with your money'},
hand_level = {give = 'levels up poker hands', want = 'rewards hand levels'},
tarot_gen = {give = 'creates Tarot cards', want = 'rewards Tarot use'},
planet = {both = 'shares the Planet economy'},
spectral_gen = {both = 'shares the Spectral theme'},
gold_gen = {give = 'creates Gold cards', want = 'pays off Gold cards'},
stone_gen = {give = 'adds Stone cards', want = 'pays off Stone cards'},
enhance_gen = {give = 'enhances cards', want = 'feeds on enhancements'},
card_gen = {give = 'adds cards to the deck', want = 'grows as the deck grows'},
joker_gen = {give = 'creates spare Jokers', want = 'profits when Jokers sell'},
sell_value = {give = 'raises sell values', want = 'turns sell value into Mult'},
copy_target = {give = 'is a prime copy target', want = 'copies your best Joker'},
suit_flex = {give = 'makes suits count as others', want = 'cares which suits score'},
pair = {both = 'both reward Pairs'},
two_pair = {both = 'both reward Two Pair'},
three_kind = {both = 'both reward Three of a Kind'},
four_kind = {both = 'both reward Four of a Kind'},
straight = {both = 'both reward Straights'},
flush = {both = 'both reward Flushes'},
hearts = {both = 'both love Hearts'},
diamonds = {both = 'both love Diamonds'},
spades = {both = 'both love Spades'},
clubs = {both = 'both love Clubs'},
}
-- Order in which tags are considered when picking the one reason to show;
-- most specific mechanics first, broad engine roles last.
local TAG_ORDER = {
'retrig_held', 'retrig_scoring', 'faces',
'gold_gen', 'stone_gen', 'enhance_gen', 'card_gen',
'tarot_gen', 'spectral_gen', 'planet',
'probability', 'discards_up', 'hands_up', 'hand_size', 'hand_level',
'money', 'sell_value', 'joker_gen',
'hearts', 'diamonds', 'spades', 'clubs',
'pair', 'two_pair', 'three_kind', 'four_kind', 'straight', 'flush',
'copy_target', 'suit_flex',
}
-- One-sentence mechanic explainers shown at the top of each catalog theme
-- page. Teaching voice: explain how the mechanic works, not what to buy.
-- Every tag listed in main.lua's THEME_TABS needs an entry (max ~90 chars).
local THEME_INFO = {
faces = 'Face cards trigger these effects; makers like Pareidolia turn every card into one.',
retrig_scoring = 'Retriggers make each scored card count again, so per-card payoffs double up.',
retrig_held = 'Some jokers pay for cards sitting in hand; retriggering the hand pays them again.',
copy_target = 'Blueprint and Brainstorm mirror a joker - the stronger the target, the better.',
probability = 'Listed "1 in X" odds are dice rolls; odds boosters make gambling jokers hit more.',
hand_size = 'A bigger hand holds more Kings, steel and gold - held-card builds love the space.',
hands_up = 'Extra hands per round are fuel for jokers that reward or count hands played.',
discards_up = 'Extra discards are fuel: several jokers grow or pay out each time you discard.',
money = 'Money is an engine too - some jokers earn it, others turn savings into power.',
sell_value = 'Sell value can be inflated first, then cashed in as Mult or a big sale.',
joker_gen = 'Spare jokers are fodder: sell them for profit or feed them to scaling jokers.',
tarot_gen = 'Free Tarot cards fix your deck and feed the jokers that count Tarot use.',
planet = 'Planets level up poker hands; this crew makes them cheaper and rewards each one.',
spectral_gen = 'Spectral cards warp the deck; these jokers create or lean on that chaos.',
gold_gen = 'Gold cards pay while held - creators supply them, payoffs cash them in.',
stone_gen = 'Stone cards are pure chips; add them, then pay off the heavier deck.',
enhance_gen = 'Enhanced cards (gold, steel, stone...) power the jokers that eat or count them.',
card_gen = 'Adding cards grows the deck - and the jokers that scale with its size.',
pair = 'These all fire on a Pair, so one played hand triggers the whole set at once.',
two_pair = 'Two Pair sets off every one of these together - stack them and replay it.',
three_kind = 'Three of a Kind triggers all of these in a single played hand.',
four_kind = 'Four of a Kind is rare but sets off this whole crew at once.',
straight = 'Straights trigger all of these; enablers make gaps and 4-card runs count.',
flush = 'Flushes trigger all of these; enablers make them far easier to assemble.',
suit_flex = 'Suit benders make cards count as two suits, unlocking every suit joker.',
hearts = 'Hearts pay these jokers when scored - suit benders widen the supply.',
diamonds = 'Diamonds pay these jokers when scored - suit benders widen the supply.',
spades = 'Spades pay these jokers when scored - suit benders widen the supply.',
clubs = 'Clubs pay these jokers when scored - suit benders widen the supply.',
}
-- Convert gives/wants arrays into sets for fast lookup.
for _, entry in pairs(J) do
local g, w = {}, {}
for _, t in ipairs(entry.gives) do g[t] = true end
for _, t in ipairs(entry.wants) do w[t] = true end
entry.gives, entry.wants = g, w
end
return { jokers = J, pairs = PAIRS, tag_text = TAG_TEXT, tag_order = TAG_ORDER,
theme_info = THEME_INFO }