# 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, then run the test suite — **always both, after every edit**: ```sh luac -p main.lua synergies.lua config.lua test.lua lua test.lua ``` `test.lua` stubs the handful of game globals `main.lua` touches (the game cannot be launched headlessly here) and then exercises the real engine, the real database and the real catalog tab builders. It is the only safety net; there is no other harness. It enforces, among other things: - Score tiers: famous explicit pair + tags = 6, hand-type/suit cluster = 4, single tag match = 2, generic mult+xmult complement = +1 on top, unrelated = 0. - **No orphan wants** — a `wants` tag nothing gives is a payoff with no enabler. - **No dead gives** — a non-engine tag given but never wanted can never change a score, so it is invisible dead weight. (This caught `hand_level`, given by Space and Burnt Joker and wanted by nobody.) - **Every theme is discoverable** — theme discovery needs a giver and a wanter on two *different* fielded jokers, so a theme with fewer than two members is an achievement no player can ever earn and pins the Progress tab under 100% forever. (This caught `four_kind`, whose only member was The Family.) - `theme_info` and `THEME_TABS` cover exactly the same tag set. - Pair/clash/caution keys all exist; every blurb fits its ~34-char row. - Every card the catalog pages emplace is materialized (see the UI notes below). - Jokers with no tags at all are listed in `EXPECTED_INERT` on purpose — a joker landing there by accident scores 0 against the entire game and says nothing. To poke at the engine by hand, copy the stub block from the top of `test.lua`. ## In-game verification (this machine) - The mod is **symlinked** into the live Mods folder: `~/.local/share/Balatro/Mods/JokerComboAdvisor -> ~/Documents/JokerComboAdvisor` (this repo). Edits here go live on next game launch — do not copy files anywhere. Lovely reads that folder as `C:\users\steamuser\AppData\Roaming\Balatro\Mods`. - 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). - Remote: `https://git.aleshym.co/funman300/JokerComboAdvisor` (private, user's Gitea). Pushes authenticate via the repo-local credential helper, which reads the token from `~/.config/tea/config.yml` at push time — never copy the token into the repo or the remote URL. Commit per feature. ## 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 (second return value flags when that +1 applied). `JCA.partners_for(card)` aggregates over `G.jokers.cards`; total ≥ `JCA.config.threshold` ⇒ recommended. The generic +1 counts toward the total once per candidate, not per owned joker — since tag/pair scores are even and the threshold is 4, the generic bonus alone can never trigger a recommendation, so a pulsing card always has a nameable partner. Jokers whose multiplier the generic rule misreads opt out with `{no_generic = true}` in `synergies.lua` (Joker Stencil: its ×Mult grows with *empty* slots, so "+Mult jokers feed it" is backwards). - 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 and the partner highlight (hovering a joker juices its owned partners ~0.9s; partner set cached on the card as `jca_hl`, dropped when hover ends). All hook bodies are pcall-wrapped so a scoring bug can never crash a run — keep it that way. - Sell advisor: `JCA.weakest_link()` (full board + ≥3 jokers + a strict minimum required, else nil) flags the lowest-total owned joker with a red verdict row; suppressed by learning mode, toggled by `config.sell_advisor`. - Public API for other mods: `JCA.register(key, gives, wants, opts)`, `JCA.register_pair(a, b, blurb)` (sets `JCA._pairs_dirty` so the Combos tab re-sorts), `JCA.register_clash(a, b, warning)`. Unknown tags are dropped with a log line, never an error. - 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 (plus a text-only Progress tab with discovery completion bars) 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. Cards must be `start_materialize`d as they are emplaced (`card_row_node`), exactly as every vanilla collection page does — that call *is* the appear animation, and without it cards pop in with no animation on tab open and page switch. Only the first card of a page build plays the sound (vanilla's `i>1 or j>1` silence flag); tab defs call `begin_page()` so the counter spans the page, not one row. 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. Themes (every `theme_info` tag) persist in `config.themes_found` once two DIFFERENT jokers connect the tag (giver + wanter; a joker carrying both sides needs a second joker). One toast per emplacement, famous pair outranking theme. Badge/progress render in `theme_tab_def`; toast names come from `JCA.theme_names` (filled after THEME_TABS, used only at gameplay time). - 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, DNA) only `give`; pure payoffs only `want`. - A tag needs **both sides to exist, on at least two different jokers**: something must give it, something must want it, or it is dead data that can never change a score — and if it is also a theme, an achievement nobody can earn. `lua test.lua` enforces both directions; run it after every `synergies.lua` edit. - 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 never affect scores. Known traps are warning-only data: `CLASHES` (pairwise, red "Clashes -" tooltip line vs owned jokers) and `CAUTIONS` (per-joker, buy-area "Caution:" line) in `synergies.lua`, same ~34-char blurb budget as `PAIRS`. Learning mode keeps warnings visible. - 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.