Files
JokerComboAdvisor/CLAUDE.md
T
funman300 bf0672a438 Correct the push-auth note: libsecret, not the tea token
Pushes authenticate through the global libsecret credential helper. The
tea login has auth_method oauth and no token at all, so it cannot create
repos or call the API - it was never what authenticated pushes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 12:11:49 -07:00

13 KiB
Raw Blame History

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:

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 ~/.local/share/Steam/steamapps/common/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-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 CardAreas (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_materialized 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. 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 individualjoker_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:

~/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.

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.