The 114-line private moves() method was most of the file: it enumerates every
legal step from a position -- straights, badge formations, n-of-a-kind, lone
1s and 5s, and the two joker rules -- while Scorer just maximises over it and
caches the answers.
Extracted it to scoringMoves.ts (203) as a free function over ScoringConfig,
taking combinationValue with it (its only caller, and the home of the
Emperor/Tyche triple-only rule) plus the tripleBase/ofAKindValue helpers.
scoring.ts (172) keeps Scorer, its two caches and its two recursions.
moves() was private, so no consumer saw the seam; only scoring.test.ts moved,
for the two value helpers, import path only. Coverage totals are unchanged at
863/375/181/810 -- the generator's branches are all still exercised.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
search.ts held three separable concerns. Extracted two:
searchGroups.ts (109) the vocabulary -- InventoryEntry, DiceGroup,
Selection -- plus groupInventory, countCandidates
and expand
searchStrategies.ts (232) Leaderboard and both strategies: exhaustive
enumerateAll, and hillClimb with its greedy and
random seeds
search.ts (100) keeps findBestSet, which picks between the strategies.
Import sites were rewritten to name the owning module rather than leaving a
re-export barrel in search.ts, so two test files changed -- import paths only,
no assertion touched. DiceGroup and Selection had to become exported to cross
the module boundary.
Function bodies moved verbatim: 288 tests still pass and coverage totals are
identical at 863/375/181/810, not merely still 100%.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
Nine new modules from four over-cap test files. Every kcd2 test file is
now under 250 lines; only four source files remain.
App.test.tsx ended up with no describe blocks and vitest failed with
"No test suite found" - a split that empties a file has to delete it.
The shared `response`/`ordinary` fixtures went to src/test/fixtures.ts,
which vite.config.ts excludes from coverage; beside the source they
would have counted as production code and broken the 100% gate.
288 tests passing before and after, now across 24 files instead of 15,
coverage still 100% on all four metrics.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
simulate.ts 384 -> 226 and inventoryIo.ts 287 -> 178, with the moved
names re-exported so no importer or test changed.
search.ts and badgeValue.ts are reverted. findBestSet calls enumerateAll
and hillClimb which call back into expand; valueBadge and format are
mutually recursive with the recommendation half. Splitting either makes
the two files import each other.
The split also exposed a TypeScript hazard worth knowing: with the local
Selection type no longer imported, tsc silently resolved it to the DOM
global and complained about anchorNode and focusNode rather than saying
the name was missing.
288 tests passing before and after, coverage still 100%.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
dice.ts 415 -> 93 and badges.ts 369 -> 111, with the literal tables in
six new modules. Every previously exported name is still exported from
the same path, so no importer or test changed.
Two import cycles on the way, both of which tsc and eslint called clean
and which only showed up as a runtime TypeError when 11 test files
failed with "Cannot read properties of undefined (reading tin)".
HEADSTART_POINTS now has its own module so both halves of the badge
table can use it at init time.
288 tests passing before and after, coverage still 100% on all four
metrics with the new modules measured.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
The solver only ran behind ./run.sh, had one media query in the whole
stylesheet, and kept the inventory in a 46vh scroll box that showed maybe a
third of the 43 dice. Setting it up on the PC did nothing for the phone.
Deployment
dice.kuhy.duckdns.org, behind the existing gitea-caddy edge. DuckDNS already
wildcards *.kuhy.duckdns.org (checked against 1.1.1.1 and 8.8.8.8), so no
dashboard step. deploy_build.sh builds to dist.staging and rsyncs into dist/
with --delete-after --delay-updates; plain --delete removes stale assets
before writing the new ones, which 404s a client mid-load. A post-commit hook
starts a MemorySwapMax=0 systemd user unit, so a rebuild can neither slow a
commit nor thrash zram.
Three traps found by driving the live URL in a browser rather than curling it:
a site address of "127.0.0.1:8089" reads as a HOSTNAME, so Caddy enabled
auto-HTTPS and started an HTTP->HTTPS redirect listener that bound *:80 next
to the edge -- SO_REUSEPORT would then split inbound HTTP and intermittently
break ACME for every domain here. The same address also made a Host matcher,
so the edge's forwarded Host matched nothing and served an empty 200 while the
local health check passed. And watching dist/'s own inode could never detect a
detached bind mount: rsync into an existing directory never changes it. Each
now has a guard that asserts positively rather than failing open.
Dice list
All 43 on screen at every width, no inner scrollbar. Column count and size
tier are chosen together from a container query on the panel -- not the
viewport, which mispredicts by 2x at the point the columns split. Two
invariants govern it: the list fits above the fold, and the name column stays
wide enough to tell Painter's die B, G and R apart. The left column holds the
grid alone, so it is sized to fill the height it owns: one --die-row value
drives the type, buttons and bar strip, giving a 52px row and 44px buttons on
a 1296px-tall screen and 20px rows on a phone.
Three safe-design rules are broken inside .die-list deliberately: sub-16px
body text, square icon buttons, sub-8px intra-cell gaps. 43 items with a name,
a chart and a counter in one viewport is arithmetically incompatible with 16px.
Import / export
A file, the clipboard, or a link carrying the inventory in the URL fragment.
One validator behind all four entry points including localStorage; it keeps
what it can and reports what it dropped, so a die added in a later game patch
costs you that entry, not the inventory. A link never silently overwrites what
is saved here -- it offers Keep or Discard, and only a link that actually
carries dice is applied at all, because one encoding a count of zero parsed
cleanly, blanked the screen, disabled Clear and left Keep writing the emptiness
over the visitor's own loadout.
session_autopsy
parse.py (551) and report.py (504) were over the repo's 500-line limit, so
test_file_length has been failing on main since 5ffd035. Extracted TurnTracker
and the two candidate-cell helpers. Report output is byte-identical on real
data apart from its timestamp, checked against HEAD in a worktree.
Verified: 288 TS tests and 911 Python tests, 100% coverage both, lint and
pre-commit clean, 8 viewports measured on the live HTTPS site, solver confirmed
through the real module worker.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mapping was marked UNVERIFIED because no published source states which pip
the grinning face covers. The player, who owns the game, confirmed it is the one
— the same face the Devil's head die replaces. So the die carries no plain single
pip at all, which is exactly why picking the joker alone counts as a 1.
Comment and README only; wildcardFaces was already [1]. The uniform side weights
stay flagged as the one remaining assumption, since nothing publishes them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The wiki's dice-effect row for Balatro's die is `{{Dice effect|d|d|d|d|d|d}}`
— six *unfilled placeholder* cells that render as "d%". A previous pass read
those six `d`s as six wildcard faces, which made the die resolve every throw in
the player's favour: EV 8000, bust 0%, and the solver forced it into every
loadout it could. Only one face is the joker (Jimbo's grinning face, visible on
the item icon), and no source publishes the die's side weights at all.
The two joker faces also do not score alike, and the scorer was treating them as
one thing. Per the wiki, a Devil's head "matches any combination but never
scores on its own", while Balatro's joker counts as a 1 when picked alone. So
there are now two substitute slots: a Devil's head is spent inside the
combination it completes rather than resolved to a plain face, which is what
stops it being held as a lone 1. Hands with no substitute keep the original
single-pass code path.
Balatro's die: EV 8000 -> 771.2, bust 0% -> 3.09%; a lone devil's head scores
100 -> 0 and is no longer a legal hold. The two dice now tie exactly on a single
throw of six and differ only mid-turn (813.7 vs 808.3 points per turn), which is
where "may be held alone" actually pays.
Which pip the joker replaces is unpublished; the 1 is assumed, matching the
Devil's head die the game compares it to, and marked UNVERIFIED in the data.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes every violation in DESIGN_AUDIT_TODO.md's kcd2_dice_solver section:
15px root font-size (root cause of both the sub-16px body-text floor and
the off-grid rem spacing scale), 2 hardcoded tier colors bypassing the
custom-property system (also separates their brightness from --accent/
--good, and removes an accidental cool tint from tier-silver), the
largest text widening tracking instead of tightening it, near-invisible
border contrast (~1.4:1 -> >=3:1 against both bg and panel), inconsistent
ad-hoc border-radii collapsed into a 2-step scale with nested elements
using the smaller radius, and two off-ratio button paddings corrected to
an exact 2x.
Verified: lint clean, 210/210 tests at 100% coverage, production build,
re-verified identical in an isolated worktree at HEAD (narrowly staged
around this monorepo's unrelated dirty state), and pixel-checked via a
real `vite preview` production build through a headless-Chrome screenshot.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Vite/React/TS dice solver had no git history yet. Commit it alongside
a run.sh that ensures Node/pnpm are present, installs deps, and starts
the dev server with a single command.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>