commit 8783785283fb767fb165e645ddc2268dec524e80 Author: funman300 Date: Thu Jul 9 21:54:04 2026 -0700 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c5f206 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.claude/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ced9261 --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/JokerComboAdvisor.json b/JokerComboAdvisor.json new file mode 100644 index 0000000..a4445c2 --- /dev/null +++ b/JokerComboAdvisor.json @@ -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)"] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..19788fa --- /dev/null +++ b/README.md @@ -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) | diff --git a/config.lua b/config.lua new file mode 100644 index 0000000..cd0c827 --- /dev/null +++ b/config.lua @@ -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) +} diff --git a/main.lua b/main.lua new file mode 100644 index 0000000..891aba4 --- /dev/null +++ b/main.lua @@ -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 diff --git a/synergies.lua b/synergies.lua new file mode 100644 index 0000000..c584fe1 --- /dev/null +++ b/synergies.lua @@ -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 }