319f79bc5d
Hovering a joker that is a fusion component now names the fusion it feeds,
its cost with discounts applied, and what is still missing. In the shop the
card is counted as if already bought, so it answers the buy-side question
("Buy to fuse into Diamond Bard ($12 to fuse)") instead of restating what
you already own.
The line worth having is the last one. Fusing CONSUMES its components --
fuse_card calls ingredient:remove() -- so every combo those jokers held with
the rest of the board dies with them, and the FUSE button cannot tell you
that. "Fusing drops combos with: Smeared" is computed from the surviving
board, and a component being spent is not counted as a loss.
Three things about their code shaped this:
* Card:get_card_fusion() would have answered most of it, but it drives its
price flicker with math.randomseed(love.timer.getTime() * 8). An advisor
reseeding Lua's RNG on every hover has no business doing that, so this
reads FusionJokers.fusions directly and mirrors their discount arithmetic
(flat then percentage, per-result and universal, floored, min $1).
* Recipes take repeated components -- their own debug fusion needs 3x Joker
-- so components are counted by quantity, not presence.
* Affordability is judged only on plain numbers: Fusion Jokers uses to_big,
and Talisman turns G.GAME.dollars into an object. Guessing wrong about
someone's money is worse than staying quiet, so the money line just
does not appear.
Ownership is board membership, not card.area, matching copy_source and
dagger_victim -- the area pointer answers differently for the same card
depending on who built it.
Verified against the shipped recipe table: all 15 resolve at the right cost,
and all 30 components are jokers the database already knows. Not installed
means silence, like every other integration.
918 tests pass on 5.4 and LuaJIT (31 new), and 18/18 in the real game.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
320 lines
20 KiB
Markdown
320 lines
20 KiB
Markdown
# 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 integrations.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`.
|
||
|
||
Then the audit, after any `synergies.lua` change:
|
||
|
||
```sh
|
||
lua audit.lua --write # refreshes AUDIT.md; exits 1 on an unreviewed joker
|
||
```
|
||
|
||
`test.lua` is the **gate** — it fails on what is broken. `audit.lua` is the **map** —
|
||
it reports what is merely suspicious, chiefly the ~32 jokers the advisor is silent
|
||
about (they give nothing but Mult/Chips/xMult and want nothing, so they can only ever
|
||
earn the generic +1). Silence is the *right* answer for many of them, but it must be a
|
||
decision rather than an accident, so `audit.lua` carries a `VERDICTS` table recording
|
||
what we concluded about each one and why — including the traps. **Read `AUDIT.md`
|
||
before re-deriving any of this.** A newly-silent joker lands in "never looked at" and
|
||
fails the audit until it gets a verdict.
|
||
|
||
The verdicts also hold the leads not yet chased (Ceremonial Dagger's positional
|
||
destroy, Erosion vs the card-adders, Burglar + Ramen/Green Joker, Chaos + Flash Card).
|
||
They are marked `candidate` because they are **unverified** — check the dump before
|
||
acting on one.
|
||
|
||
## 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`.
|
||
The link used to point at the repo's pre-rename home (`~/Documents/Balatro Mod`);
|
||
when that path died the loader silently skipped the mod — **if the game boots
|
||
with no JCA in the log at all, check this symlink first.** A disabled-in-BMM
|
||
mod looks the same way (SMODS drops a `.lovelyignore` file into the mod folder
|
||
to disable it — never create one at the repo root).
|
||
- Game: Steam/Proton, installed in the `/mnt/games/Steam` library (not the
|
||
default one — its save prefix is
|
||
`/mnt/games/Steam/steamapps/compatdata/2379780/pfx/.../Roaming/Balatro`).
|
||
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). `git push` just works: it authenticates through the global libsecret
|
||
credential helper (`credential.helper=/usr/lib/git-core/git-credential-libsecret`).
|
||
Never copy a token into the repo or the remote URL. Commit per feature.
|
||
- The `tea` CLI is installed and logged in as `funman300`, but its stored login has
|
||
`auth_method: oauth` and **no token**, so `tea` cannot create repos or call the
|
||
API — it is not the thing that authenticates pushes, despite what this file used
|
||
to say. Creating a new remote repo needs the web UI (or a token added to `tea`).
|
||
|
||
## 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-scoring owned joker with a
|
||
red verdict row; suppressed by learning mode, toggled by
|
||
`config.sell_advisor`. Its ranking subtracts `CLASH_PENALTY` (odd, so it
|
||
always breaks a tie) per clash with an owned joker — the joker eating
|
||
another one's payoff is the one you want to cut. This is the **only**
|
||
place a clash changes a number; `JCA.score` stays pure, because a trap
|
||
must never make a card look like a combo.
|
||
- Copy advisor: `JCA.copy_source(card)` → `target, status, chain`. Reports what
|
||
an owned Blueprint/Brainstorm is *actually* copying, from the live board order
|
||
(Blueprint takes the joker to its right, Brainstorm the leftmost). Copiers
|
||
**chain** — `SMODS.blueprint_effect` recurses (`utils.lua:2269`) and
|
||
Blueprint's own `blueprint_compat` is true, so Blueprint→Blueprint→Baron
|
||
really gives two Barons; the chain is walked to the joker actually copied,
|
||
capped at the board size like vanilla, and a copier ring yields nothing.
|
||
`status` is `ok`/`none`/`incompatible`/`debuffed`. Test `blueprint_compat`
|
||
for **truth**, not `~= false` — vanilla does (`utils.lua:2254`), so a modded
|
||
joker that omits the flag is *not* copyable. A debuffed target copies as
|
||
nothing. Do not rebuild the neighbour lookup with the `a and b or c` idiom:
|
||
for a rightmost Blueprint the right-hand slot is nil and the idiom falls
|
||
through to the leftmost joker, confidently reporting the wrong card.
|
||
Shop copiers get the same treatment *before* purchase via
|
||
`JCA.copy_source_if_bought(key)`: a purchase always lands in the
|
||
**rightmost** slot (`buy_from_shop` → `G.jokers:emplace`,
|
||
`button_callbacks.lua:2279`; emplace appends, `cardarea.lua:53`), so a
|
||
bought Blueprint starts dead (buy-area tooltip names the joker to slot it
|
||
against) while a bought Brainstorm copies the leftmost joker immediately
|
||
(tooltip says which, chaining through a leftmost copier like copy_source).
|
||
- `JCA.is_eternal(card)` reads `card.ability.eternal` — the flag vanilla itself
|
||
uses for the badge (`card.lua:1141`). **Never call `SMODS.is_eternal()` from
|
||
an advisor path**: it runs a full `SMODS.calculate_context` per call
|
||
(`utils.lua:3079`), far too heavy for a hover, and outside a live run it
|
||
reported *every* joker as Eternal, silently disabling the sell advisor.
|
||
Eternal jokers cannot be sold at all (`Card:can_sell_card`, `card.lua:1993`),
|
||
so `weakest_link` never names one — advice you are not allowed to take is
|
||
worse than none — though they still count toward the best score.
|
||
- 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/Traps 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). The Traps tab (clash duos, then caution jokers; one
|
||
pager spans both page kinds) is deliberately an **open book**: no
|
||
discovery gating and no Progress entry, because a warning locked behind
|
||
fielding the bad pair would reward exactly what it warns against.
|
||
`register_clash` keeps `JCA.clash_list` in sync (and sets
|
||
`JCA._traps_dirty`) so third-party clashes appear on the tab; blurbs are
|
||
read back through `clash_blurb` at build time so updates always win.
|
||
- 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`).
|
||
- `integrations.lua` — cross-mod integrations, loaded at the very end of
|
||
`main.lua` (pcall-wrapped; counts land in `JCA.integrations`). Every
|
||
integration gates on its target's global — a missing mod is silence — and
|
||
every target loads before us (JokerDisplay priority -1e9, Fusion Jokers
|
||
-10000, TMJ -1000, ours 0):
|
||
- **JokerDisplay**: appends a `(2 combos)` counter element to each known
|
||
joker's `reminder_text` (created when absent) and wraps the definition's
|
||
`calc_function` to fill `card.joker_display_values.jca_combos` from
|
||
`partners_for`. The wrapper pcalls the original calc and blanks the text
|
||
when `config.jd_combos` is off, so the toggle works mid-run both ways.
|
||
Definitions are marked `jca_combo_counter` against double patching.
|
||
- **Too Many Jokers**: registers a `TMJ.SEARCH_FIELD_FUNCS` entry (its
|
||
api.md's documented hook) returning the joker's `TAG_LABEL`s on both
|
||
sides, so the collection search finds jokers by 'money', 'Hearts',
|
||
'held-card retriggers'... Unknown centers return nil, never an error.
|
||
- **Fusion Jokers**: installs `JCA.fusion_plans(card)`, which reports every
|
||
fusion a joker is a component of — result, discounted cost, what is still
|
||
missing, and **what fusing would cost you in combos**. That last part is
|
||
the reason the integration exists: `fuse_card` calls `ingredient:remove()`
|
||
on the components, so their partnerships with the rest of the board die,
|
||
and nothing but this advisor knows what those were. Rendered in
|
||
`tooltip_rows` (max 2 plans), ready fusions sorted first.
|
||
Read the recipe table (`FusionJokers.fusions`, an array with the
|
||
`ingredience[component][result]` reverse index hung off the same table) —
|
||
**never call `Card:get_card_fusion()`**: it drives its price flicker with
|
||
`math.randomseed(love.timer.getTime() * 8)`, so an advisor calling it
|
||
would reseed Lua's RNG on every hover. Cost mirrors their arithmetic
|
||
(flat then percentage discount, per-result and universal, floored, min $1)
|
||
and affordability is only judged on plain numbers, because Talisman turns
|
||
`G.GAME.dollars` into a big-number object. Components are counted by
|
||
**quantity**, not presence — their own debug recipe needs 3× Joker.
|
||
The smoke scenario asserts the JokerDisplay patch against the real mod.
|
||
|
||
## 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 cluster tag needs all three `TAG_TEXT` phrasings, not just `both`: `both` is
|
||
only true when *both* jokers sit on both sides of the tag, and `JCA.explain`
|
||
requires that before using it. Four Fingers does not "reward Straights" — it
|
||
makes them reachable. The suite enforces the three phrasings.
|
||
- 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.
|
||
**Verify every new trap against the dump before adding it** — a warning that
|
||
is merely plausible is worse than none. The mod once shipped a `j_smeared` +
|
||
`j_flower_pot` clash that was exactly backwards: Flower Pot fills its four
|
||
suit slots with a greedy `if/elseif` chain (`card.lua:4256`) and Smeared makes
|
||
Hearts answer for Diamonds, so any two red + two black cards fill the pot. It
|
||
is one of the best combos in the game, and the mod was warning players off it.
|
||
Most traps hinge on the scoring-pipeline order in `functions/state_events.lua`
|
||
(`before` → per-card `individual` → `joker_main`): Vampire strips enhancements
|
||
in `before`, so every enhancement payoff that reads them in `individual` —
|
||
Golden Ticket, Glass, Stone, Driver's License — is looking at cards it has
|
||
already eaten.
|
||
- The UI hooks used to be the untested surface. They are now covered in the real
|
||
game by the smoke harness (see below). If a tooltip regression is reported,
|
||
run it, then suspect the `aut.info` entry format and check the lovely log.
|
||
|
||
## In-game smoke tests
|
||
|
||
`lua test.lua` stubs the game, so it cannot tell you that vanilla accepted the
|
||
tooltip format or that a card is actually animating. The smoke harness runs the
|
||
real engine — real `Card` objects, the real `Card:generate_UIBox_ability_table`
|
||
pipeline, a real overlay `UIBox` — with no clicking:
|
||
|
||
```sh
|
||
~/Documents/BalatroSmoke/run.sh JokerComboAdvisor
|
||
```
|
||
|
||
It boots the game, runs `scenarios/JokerComboAdvisor.lua`, quits, and prints a
|
||
pass/fail report (non-zero exit on failure). Close Balatro first; it needs a clean
|
||
boot. Scenarios live in the harness repo, not here. If `~/Documents/BalatroSmoke`
|
||
is missing, clone it: `git clone https://git.aleshym.co/funman300/BalatroSmoke.git
|
||
~/Documents/BalatroSmoke` and symlink it into the Mods folder like any mod.
|
||
|
||
It covers the surfaces this repo's tests cannot reach: the hover tooltip naming a
|
||
partner, the clash and caution lines, discovery on emplace, and — the reason it
|
||
exists — that catalog cards really do `start_materialize` on tab build *and* on
|
||
page switch (asserted via `card.dissolve > 0` in the live UIBox).
|
||
|
||
It is also the only place the cross-mod integrations meet the real thing: the
|
||
JokerDisplay counter against vanilla definitions, and the Fusion Jokers advice
|
||
against the **real** recipe table (`test.lua` can only drive `JCA.fusion_plans`
|
||
against a stub). Both scenarios no-op with a pass when their mod is absent, so
|
||
the harness stays green on a clean install. Note a fake run starts at **$0** —
|
||
a fusion affordability assertion has to set `G.GAME.dollars` itself.
|
||
|
||
The harness suppresses every save path and verifies from outside that no discovery
|
||
state leaked, so running it can never spoil the catalog. Do not "fix" that by
|
||
reversing writes after the fact — a crashed scenario would skip the reversal.
|