a2d16ae12f
New integrations.lua, loaded pcall-wrapped at the end of main.lua; every integration gates on its target's global so a missing mod is silence. Both targets load before us by priority. JokerDisplay: each known joker's display definition gains a live '(2 combos)' counter in JD's own parenthetical reminder style -- the same partner count the hover tooltip names -- by appending a dynamic element to reminder_text (created when absent) and wrapping calc_function to fill card.joker_display_values. The wrapper pcalls the original calc and blanks the text when the new jd_combos config toggle is off, so it flips mid-run in both directions. Too Many Jokers: registers the SEARCH_FIELD_FUNCS hook its api.md documents, indexing every joker under its tag labels on both sides, so the collection search finds jokers by 'money', 'Hearts', 'held-card retriggers'. Unknown centers stay nil. The smoke scenario now asserts the JokerDisplay patch against the real mod: 13/13 in-game. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
283 lines
18 KiB
Markdown
283 lines
18 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.
|
||
- `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 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`).
|
||
- `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
|
||
both targets load before us (JokerDisplay priority -1e9, 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.
|
||
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).
|
||
|
||
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.
|