testsAndMisc/kcd2_dice_solver/README.md
Krzysztof kuhy Rudnicki 9d7751cebb
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
kcd2 dice solver: serve it at dice.kuhy.duckdns.org, fit all 43 dice, move inventories between devices
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>
2026-07-26 20:01:21 +02:00

163 lines
8.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# KCD2 Dice Solver
Pick which six dice and which badge to bring to a game of dice in
_Kingdom Come: Deliverance II_. Enter what you own; the solver evaluates every
loadout your inventory allows and reports the best one with the numbers behind
it.
**Live at <https://dice.kuhy.duckdns.org/>** — no terminal needed, and usable on
a phone. See [Deployment](#deployment) for how it gets there.
```bash
pnpm install
pnpm dev # http://localhost:5174 — for working on it
pnpm test # full suite
pnpm coverage # 100% statements / branches / functions / lines
pnpm lint # tsc --noEmit + eslint (strictTypeChecked)
pnpm build
```
## Entering an inventory
Three ways, all wired to the same state:
- **Click** a die's row to add one.
- **Scroll** the mouse wheel over its counter to step it up or down.
- **Search** by name — a fuzzy match, so `wei` finds _Weighted die_ and
`paintb` finds _Painter's die B_. The same box filters the badge list.
The inventory is saved to `localStorage`, so it survives a reload.
All 43 dice are on screen at once at every width, phone included — there is no
inner scroll box. The column count and the type size are picked together from a
container query on the inventory panel (not the viewport: at a 1000px viewport
the panel is ~918px wide and one pixel later it drops to ~472px). Two rules
govern the choice: the list must fit above the fold, and the name column must
stay wide enough to tell `Painter's die B`, `G` and `R` apart, which is the
tightest constraint in the layout.
**The left column holds the dice grid and nothing else.** The title, the search
box and the badges all live on the right, where horizontal room is going spare,
rather than spending the vertical budget the grid needs. Badges are collapsed by
default — open, they are the tallest thing on the page. Stacked on a phone the
order becomes title/search, then the dice, then the results.
Because the grid owns that column outright, it is sized to **fill** the height
rather than sit in a band at the top: the 22 rows divide up whatever the
viewport has left, so the row height, the type and the −/+ buttons all scale
together off one `--die-row` value. A 1296px-tall screen gets a 52px row, 20px
type and 44px buttons — the accessibility minimum this layout cannot afford at
phone width, where the same rows are 20px. The type stops growing at 20px while
the row keeps going to 56px: past that the names start truncating, whereas a
taller row is pure gain. The scaling is deliberately **not** applied to the
stacked layout, where the title and search box sit above the grid and the height
is not the grid's to take.
## Moving an inventory between devices
`Import / export`, below the badges, offers three routes — set your dice up once
and carry them anywhere:
- **Download `.json`** and load it back with the file picker.
- **Copy JSON** to the clipboard, paste it into the box on the other device.
(If the clipboard is unavailable — an insecure origin, say — the payload is
put in the box so it can still be copied by hand.)
- **Copy link**: the inventory is base64url-encoded into the URL fragment.
Opening that link elsewhere loads it. A realistic inventory is a ~150–260
character URL.
Everything imported goes through one validator (`src/lib/inventoryIo.ts`), which
**keeps what it can and tells you what it dropped** rather than rejecting a
whole payload over one bad entry — a die added in a future game patch, or a link
a chat client truncated, should not cost you the rest of your inventory.
A link never silently overwrites what is already saved here. It loads, shows a
banner, and is only written to `localStorage` once you press **Keep** or edit
something. The fragment is cleared on arrival so a later reload does not
resurrect it.
## Deployment
`linux_configuration/scripts/single_use/features/setup_kcd2_dice_solver.sh`
builds `dist/` and serves it read-only from a loopback-bound `caddy` container
on `127.0.0.1:8089`, fronted by the shared `gitea-caddy` edge. It is idempotent;
`setup_kcd2_dice_solver.sh status` self-diagnoses.
Committing to `main` with changes under `kcd2_dice_solver/` republishes the site
via a `post-commit` hook that starts `kcd2-dice-build.service`. Two details that
are load-bearing rather than incidental:
- `deploy_build.sh` builds into `dist.staging/` and `rsync --delete`s into
`dist/`. The container bind-mounts `dist/`, and a bind mount follows the
**inode** — replacing the directory would detach the mount silently, and a
plain `vite build` would empty it mid-request. The script asserts the inode is
unchanged.
- The build unit sets `MemorySwapMax=0`. This box has zram (swap held in RAM),
where a memory-capped cgroup without it thrashes instead of dying cleanly.
The rebuild builds the **working tree**, not the commit — an unrelated
uncommitted edit present at rebuild time will ship.
## How the recommendation is computed
**Scoring** (`src/core/scoring.ts`) is the wiki's table: 100 per one, 50 per
five, `1000` for three ones and `100 × face` otherwise, doubled for each die
beyond three, and 500/750/1500 for the three straights. A roll scores the best
_partition_ of its dice, which is a small memoised search.
**Expected value** (`src/core/evaluate.ts`) is exact, not sampled. Scoring
depends only on the multiset of faces, so the six dice are convolved into a
distribution over count vectors — at most 924 of them, against 6⁶ = 46,656
ordered outcomes. Those vectors stay packed as integers end to end, so the
scorer's memo is keyed on them directly. `evaluate.test.ts` checks the result
against a naive full enumeration that shares none of that machinery.
**The search** (`src/core/search.ts`) evaluates whole sets, because a Farkle
set's value is not the sum of its dice — ranking dice individually and taking
the top six is simply wrong. Dice with identical distributions are pooled
first (nine of the game's dice are plain uniform dice under different names).
Small inventories are enumerated exhaustively and reported as _provably
optimal_; larger ones use a multi-start steepest-ascent local search and are
reported as _not proven_.
**Turn value** (`src/core/simulate.ts`) is a Monte Carlo simulation, and unlike
the above it depends on how you play. The policy is stated explicitly in that
file and its knobs are exposed rather than buried: hold the subset maximising
`points + 60 × dice-left`, bank at 300.
**Badges** (`src/core/badgeValue.ts`) split in two. The five that change the
scoring table are valued on the exact EV delta after re-running the whole dice
search with the rule on — so equipping one can change which dice you should
bring. The rest are simulated one charge at a time, with each turn drawing from
its own seeded stream so the comparison is paired and the measured difference is
the badge rather than a diverged random sequence.
Nothing uses `Math.random`; every random source is a seeded `mulberry32`, so
every number here is reproducible.
## What the numbers are not
- **Balatro's die** publishes no face probabilities: the wiki's dice-effect row
for it is six unfilled `d` placeholders, and Inara does not list the die at
all. Exactly **one** of its faces is the joker (Jimbo's grinning face), so it
is modelled as a fair die whose 1 is replaced by that joker — the same face the
Devil's head die replaces, which is why holding it alone counts as a 1. Only
the uniform weights are an assumption; the joker's face is not.
- **The two joker faces score differently**, and the scorer keeps them apart. A
Devil's head "matches any combination but never scores on its own", so it can
never be the lone 1 or 5 that lets you hold a die; Balatro's joker can. Both
are assumed able to fill in for any face of a combination, including one made
entirely of jokers — the wiki's examples only ever pair a joker with natural
dice, so that part is an assumption.
- **The three "Advantage" formations** (Carpenter's Cut, Executioner's Gallows,
Priest's Eye) and the **Headstart** point leads have no published values. The
constants used are marked `UNVERIFIED` in `src/data/badges.ts`. Correct them
from the game's help screen if you have it open.
- **Badge point-per-game figures** are estimates for ranking badges against each
other, not a prediction of a scoreline.
- The wiki and [Inara](https://inara.cz/kingdom-come-2/items-dice/) **disagree**
on about six dice (Ci/Fer/Lu, Painted, Trinity, Holy Trinity, Devil's head).
The [fandom wiki](https://kingdom-come-deliverance.fandom.com/wiki/Dice/KCD2)
is treated as canonical; the Inara figures sit in a comment beside each
conflicting entry.