kcd2 dice solver: serve it at dice.kuhy.duckdns.org, fit all 43 dice, move inventories between devices
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

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>
This commit is contained in:
Krzysztof Rudnicki 2026-07-26 20:01:21 +02:00
parent 5ffd035c21
commit 9d7751cebb
23 changed files with 2784 additions and 312 deletions

View File

@ -0,0 +1,25 @@
{
"title": "Serve the KCD2 dice solver at dice.kuhy.duckdns.org, fit all 43 dice on any screen, and move inventories between devices",
"objective": "The solver only ran behind ./run.sh, had a single media query in the whole stylesheet, and kept its 43-dice inventory in a 46vh scroll box. Make it reachable at a URL without a terminal, usable on a phone, able to show every die at once at any viewport, and able to carry an inventory between devices so the list is filled in once rather than per browser. Succeeds when the live HTTPS URL renders all 43 dice above the fold on the measured phone viewport and on a 2560px desktop, the solver returns a recommendation through the real module worker, and an inventory set up on one device can be reproduced on another via a file, the clipboard, or a link.",
"acceptance_criteria": [
"https://dice.kuhy.duckdns.org/ returns 200 over HTTP/2 with a valid Let's Encrypt certificate, and the four pre-existing sites on the shared gitea-caddy edge still return 200",
"The serving container listens on 127.0.0.1:8089 only — never :80/:443 (which would contend with the edge via SO_REUSEPORT and break ACME) and never a wildcard address (which host networking would expose to the LAN); setup asserts this positively and refuses to finish otherwise",
"All 43 dice render with no inner scrollbar and the list ends above the fold at 375x667, 423x845 (the measured device), 768x1024, 1366x768, 1000x800, 1920x1080, 2560x1296 and 2560x1440",
"Painter's die B, G and R stay distinguishable — never truncated to a common prefix — at every one of those viewports",
"The dice grid fills the height it owns rather than banding at the top: row height, type and buttons scale together, reaching 44px touch targets on a tall desktop",
"An inventory round-trips through all three transports (JSON file, clipboard, URL fragment) and through localStorage, all via one validator that keeps valid entries and reports what it discarded",
"A shared link never silently overwrites a saved inventory: it offers Keep or Discard, is only applied when it actually carries dice or badges, and its fragment is cleared so a reload cannot resurrect it",
"Committing to main with changes under kcd2_dice_solver/ republishes the site, without slowing the commit or risking an OOM, and the publish detects a detached bind mount and re-attaches it",
"100% statement/branch/function/line coverage on the TypeScript suite and the Python suite, zero lint suppressions added, pre-commit clean",
"python_pkg/tests/test_file_length.py passes — parse.py and report.py brought under the 500-line limit with byte-identical report output on real data"
],
"out_of_scope": [
"The solver mathematics, the dice/badge data, and the scoring model — untouched",
"run.sh, which remains the development entry point",
"The repo colour palette: the app's existing :root tokens are reused and its divergence from the frozen unified-design-system tokens is left as a separate decision",
"Reaching 44px touch targets at phone width — arithmetically incompatible with 43 dice on one screen, and explicitly traded away",
"Rebuilding from the committed tree rather than the working tree; the working-tree hazard is documented instead",
"The LAN-exposed *:8088 listener on the older personal-website container, noted during review but a separate change"
],
"verifier": "bash ~/.claude/scripts/finish.sh gate (pre-commit + full suites); pnpm run lint && pnpm exec vitest run --coverage (288 tests, 100%); python3 -m pytest python_pkg (911 tests, 100%); headless Chrome via CDP measuring 8 viewports and driving the solver on the live HTTPS URL; curl + openssl s_client for status and certificate; setup_kcd2_dice_solver.sh status; worktree diff of session_autopsy report output against HEAD"
}

View File

@ -0,0 +1,69 @@
{
"intent": "Let a KCD2 dice-solver inventory move between devices — download/load a .json file, copy or paste the JSON, and share a link that carries the inventory in its URL fragment — and deploy the result at https://dice.kuhy.duckdns.org/.",
"scope": [
"kcd2_dice_solver/src/lib/inventoryIo.ts — one validator plus the clipboard/save-file/url-hash ports",
"kcd2_dice_solver/src/components/InventoryIo.tsx — the collapsible import/export panel",
"kcd2_dice_solver/src/App.tsx — shared-link arrival, deferred persistence, dropped-payload notices",
"kcd2_dice_solver/deploy_build.sh — the single build path used by both setup and the post-commit hook",
"linux_configuration/scripts/single_use/features/setup_kcd2_dice_solver.sh — serves dist/ behind the shared gitea-caddy edge",
"Non-goal: no change to the solver core, the dice/badge data, or the scoring model"
],
"changes": [
"Moved inventory persistence out of App.tsx into src/lib/inventoryIo.ts, so localStorage, a pasted file, the clipboard and a shared link all funnel through one validator that sanitises rather than rejects (unknown ids, bad counts and duplicates are dropped and reported, not thrown away silently).",
"Added import/export UI: download .json, copy JSON, copy share link, load a file, and paste-to-import, with clipboard and download injected as ports because navigator.clipboard and URL.createObjectURL are absent under jsdom.",
"Shared links encode base64url of the JSON rather than a positional scheme, because DICE is sorted alphabetically and a die added in a later patch would silently redefine every previously-shared link.",
"A link-loaded inventory is NOT written to storage on arrival; persistence is deferred until the visitor presses Keep or edits anything, so a shared link cannot silently overwrite a saved loadout.",
"FIX (found in review, was a real defect): a damaged fragment such as #i=@@@ still parses to a non-null result holding an EMPTY inventory, so `fromLink?.inventory ?? loadInventory(store)` never fell through — the visitor's own saved dice vanished from the page with no explanation. Extracted isEmptyInventory/isUsable into inventoryIo.ts as the single definition of 'worth applying', used now by both the paste box and the link path; App.tsx applies a link only when usable and otherwise falls back to storage.",
"Surfaced ParseResult.dropped on the link path, which was previously computed and discarded: a partially-usable link now shows 'Ignored: Unknown die ...' and an unusable one shows 'That link carried nothing usable, so this is what is saved here.'",
"Added a .notice.bad style (warning accent, same shape as .notice) for the damaged/incomplete case.",
"deploy_build.sh builds into dist.staging/ and rsyncs into dist/, and asserts the dist/ inode is unchanged: the serving container bind-mounts dist/ at /srv, so any rm -rf + mv would silently detach the mount and serve stale files forever with no error."
],
"verification": [
{
"command": "pnpm exec vitest run --coverage",
"result": "pass",
"evidence": "15 files, 280 tests passed. Coverage 100%: statements 856/856, branches 373/373, functions 179/179, lines 804/804. Includes 2 new regression tests that fail against the pre-fix code."
},
{
"command": "regression check — new tests fail before the fix",
"result": "pass",
"evidence": "Before the fix, 'keeps the saved inventory, and says so, when the link is damaged' failed with the rendered DOM showing '0 dice' plus the 'Loaded from a shared link' banner, proving the saved lucky:1 inventory was discarded. After the fix both new tests pass."
},
{
"command": "pnpm exec tsc --noEmit; pnpm exec eslint src",
"result": "pass",
"evidence": "TSC=0 ESLINT=0 (exit codes asserted directly; an earlier ${PIPESTATUS[0]} check was a bash-ism under zsh and asserted nothing)."
},
{
"command": "./deploy_build.sh",
"result": "pass",
"evidence": "Published 4 files to dist (inode 87041124, unchanged before and after rsync, so the container bind mount is still attached)."
},
{
"command": "setup_kcd2_dice_solver.sh status",
"result": "pass",
"evidence": "All 13 checks OK: container running, listening only on 8089, Caddy snippet present, DNS resolves, external https OK, post-commit rebuild hook and unit installed."
},
{
"command": "curl https://dice.kuhy.duckdns.org/",
"result": "pass",
"evidence": "HTTP 200 public and 200 on 127.0.0.1:8089; served bundle is assets/index-CjacUXQ5.js, byte-identical to the hash just built, confirming the live site serves this change and not a stale build."
},
{
"command": "grep -hoP '^\\s*id:\\s*\"[^\"]*\"' src/data/*.ts | grep -P '[^\\x00-\\x7F]'",
"result": "pass",
"evidence": "No matches across all 77 dice/badge ids, so btoa() in toBase64Url cannot throw on current data."
}
],
"risks": [
"NOT verified in a real browser: headless chromium is blocked by this environment's permission layer, so the fragment round-trip was verified only in jsdom (with the real solver and real modules, not stubs) plus HTTP/bundle-hash checks against the live site. Browser-specific APIs on this path — btoa/atob, URLSearchParams, history.replaceState — are standard, but a human should open the share link once to confirm.",
"toBase64Url uses btoa, which throws on any character above U+00FF. All 77 current ids are ASCII and the validator only ever admits known ids, so this is unreachable today; a future die added with a non-ASCII id would white-screen the page at render, since shareUrl is computed during render.",
"A shared link is applied over the local inventory on arrival (by design — following a link is deliberate). It is not persisted until Keep or an edit, so a reload recovers the saved loadout.",
"The inner Caddyfile needs auto_https off and an explicit http:// scheme: a bare 127.0.0.1:8089 address reads as a hostname, so Caddy would serve TLS on 8089 and start an HTTP->HTTPS redirect listener on :80 that, under host networking, would contend with the shared edge and could break ACME for every domain on this host. This is encoded in setup_kcd2_dice_solver.sh, not only in the generated file."
],
"rollback": [
"git revert the commit, then run kcd2_dice_solver/deploy_build.sh to republish the previous bundle into the existing dist/ inode.",
"Validate: setup_kcd2_dice_solver.sh status reports all checks OK, and curl -o /dev/null -w '%{http_code}' https://dice.kuhy.duckdns.org/ returns 200.",
"Saved inventories survive a rollback: STORAGE_KEY is unchanged and the older loadInventory tolerates the same JSON shape."
]
}

View File

@ -0,0 +1,85 @@
{
"intent": "Make the KCD2 dice solver reachable without a terminal (live at https://dice.kuhy.duckdns.org/), usable on a phone, showing all 43 dice on one screen at every width, and able to move an inventory between devices via JSON file, clipboard, or share link.",
"scope": [
"kcd2_dice_solver/src/index.css \u2014 container-query dice grid + full mobile responsiveness",
"kcd2_dice_solver/src/lib/inventoryIo.ts (new) \u2014 one validator behind localStorage, file, paste and link",
"kcd2_dice_solver/src/components/InventoryIo.tsx (new) \u2014 import/export panel",
"kcd2_dice_solver/src/App.tsx \u2014 share-link precedence, deferred persistence, Keep banner",
"kcd2_dice_solver/src/components/{DieRow,QuantityStepper,DiceList}.tsx \u2014 compact cell",
"kcd2_dice_solver/src/data/dice.ts \u2014 MAX_PER_DIE moved here so lib/ need not import a component",
"kcd2_dice_solver/deploy_build.sh (new) \u2014 inode-preserving publish",
"linux_configuration/scripts/single_use/features/setup_kcd2_dice_solver.sh (new) \u2014 deployment",
"Non-goal: changing the solver mathematics, the palette, or run.sh's role as the dev entry point"
],
"changes": [
"Dice list is a CSS grid whose column count and size tier are chosen jointly from a container query on section.inventory, with an explicit two-column floor so the row count is capped at 22 at every width. Removed max-height:46vh/overflow-y. All 43 dice now fit above the fold with no inner scrollbar at 375x667, 423x845, 768x1024, 1366x768, 1000x800 and 1920x1080.",
"Compact tier is the baseline, not a phone-only fallback: below a ~256px cell the comfortable tier's wider bar strip and third button cost 64px, which buys fewer name characters than the smaller font loses. A min-height:900px rule prefers two wide columns over three narrow ones when the screen can hold 22 rows.",
"Removed the type-a-number input from QuantityStepper (28px of a ~155px cell; max is 6) in favour of an <output> readout; dropped the per-face numbered labels; rescaled the face bars x1.6 -> x3 for the shorter strip.",
"New src/lib/inventoryIo.ts holds STORAGE_KEY, SavedInventory, a sanitise-and-report parseInventory, base64url hash codec, and three injectable ports (clipboard, file download, URL) so the jsdom-hostile browser APIs stay testable without untestable optional chaining.",
"Share links never silently overwrite a saved inventory: persistence is deferred behind one boolean until Keep is pressed or anything is edited, and the fragment is cleared on mount.",
"deploy_build.sh builds into dist.staging and rsync --delete's into dist/, asserting the inode is unchanged \u2014 the serving container bind-mounts dist/ and a bind mount follows the inode.",
"setup_kcd2_dice_solver.sh deploys behind the existing gitea-caddy edge, installs a MemorySwapMax=0 systemd user unit plus a post-commit hook for auto-rebuild, and skips the nftables regeneration when 80/443 are already accepted.",
"Restructured the two-column layout on kuhy's feedback: the left column now holds the dice grid alone. The header, subtitle and search/count/Clear toolbar moved into a new section.controls on the right; BadgePicker is wrapped in a <details> collapsed by default; the 'scroll the wheel over its counter' hint was deleted outright. Stacked below 1000px the side column becomes display:contents so its children can be ordered independently \u2014 controls, then dice, then results. With the left column no longer carrying a header the two-wide-column rule's height threshold dropped from 900px to 640px, so a 1366x768 laptop now gets 13px type in two columns instead of 11px in three.",
"Sized the dice grid to FILL the height it owns rather than sit in a band at the top: --die-row is clamp(24px, (100dvh - 9rem)/22, 56px) and the type, buttons and bar strip are all fractions of it. A 2560x1296 screen goes from a 24px row / 13px type / 22px buttons using 526px of ~1300px, to a 52px row / 20px type / 44px buttons using 1194px. Type caps at 20px while the row runs to 56px \u2014 beyond that names truncate, whereas a taller row is pure click-target gain. .app max-width raised 1560px -> 2000px so a 2560px monitor is not 40% margin."
],
"verification": [
{
"command": "pnpm exec vitest run --coverage",
"result": "pass",
"evidence": "280 tests, 15 files. Statements 100% (856/856), Branches 100% (373/373), Functions 100% (179/179), Lines 100% (804/804). Exit 0 against the repo's fail_under=100 thresholds."
},
{
"command": "pnpm run lint",
"result": "pass",
"evidence": "tsc --noEmit && eslint src (strictTypeChecked + stylisticTypeChecked) \u2014 no output, exit 0. No lint suppressions added."
},
{
"command": "Headless Chrome 138 via CDP: measure layout at 6 viewports (dev server, production preview, and the live HTTPS site)",
"result": "pass",
"evidence": "Every viewport: 43 rows rendered, innerScroll=false, list ends above the fold, no horizontal page scroll, and Painter's die B/G/R never clipped. Phone 423x845 (measured from adb: 1080px / (density 408/160)) -> 2 cols, list 460px ending at 556px of 845."
},
{
"command": "Headless Chrome: functional drive of the PRODUCTION bundle and then the live site",
"result": "pass",
"evidence": "Six clicks -> '6 dice'; solver (real module worker, which jsdom cannot host) returned loadout ['6x Weighted die'] EV 2,675. Share link round-trip: loads, Keep banner shown, fragment cleared, localStorage untouched until Keep, then written. Console clean."
},
{
"command": "curl -sI https://dice.kuhy.duckdns.org/ + openssl s_client",
"result": "pass",
"evidence": "HTTP/2 200, Let's Encrypt cert CN=dice.kuhy.duckdns.org valid to 2026-10-24. kuhy/gitea/kuhy-sync all still 200. Listener is 127.0.0.1:8089 only; LAN IP 192.168.1.43:8089 refused."
},
{
"command": "systemctl --user start kcd2-dice-build.service (auto-rebuild proof)",
"result": "pass",
"evidence": "Unit finished, 440.2M memory peak against MemoryMax=3G. dist inode 87041124 before and after. Live site then served the new asset hashes (index-CjacUXQ5.js, index-7hcb7ih-.css)."
},
{
"command": "shellcheck deploy_build.sh setup_kcd2_dice_solver.sh",
"result": "pass",
"evidence": "Only informational SC1091 (external source), SC2015 (the repo's existing status-line idiom) and SC2317."
},
{
"command": "Headless Chrome: re-measure all 6 viewports after the layout restructure, then re-drive the live site",
"result": "pass",
"evidence": "All viewports still fit with no inner scroll and no clipped Painter's names. 1366x768 improved from 3 cols/11px ending at 514px to 2 cols/13px ending at 567px; 1920x1080 list now ends at 567px (was 727px). Live site re-verified: 43 rows, solver EV 2,675, console clean. 283 tests, 100% coverage, lint clean."
},
{
"command": "Headless Chrome: measure 8 viewports incl. 2560x1296 and 2560x1440, on dev then the live site",
"result": "pass",
"evidence": "All 8 fit with no inner scroll and zero clipped names. 2560x1296: font 20px, row 52px, buttons 44px, list 1194px, 61px slack. 2560x1440: row 56px, list 1274px. 1920x1080: font 17px, row 43px. Phone and stacked layouts unchanged. Live site re-verified: 43 rows, solver EV 2,675, console clean. 283 tests, 100% coverage, lint clean."
}
],
"risks": [
"Two bugs were found and fixed only because the live site was driven in a browser rather than curl'd: (1) a site address of '127.0.0.1:8089' made Caddy treat it as a hostname, enable automatic HTTPS and start an HTTP->HTTPS redirect listener that bound *:80 alongside the public edge \u2014 SO_REUSEPORT would then split inbound HTTP and intermittently break ACME for every domain on the host; (2) the same address created a Host matcher, so the edge's forwarded 'Host: dice.kuhy.duckdns.org' matched no route and served an empty 200 while the local health check passed. Both are now guarded: the health check sends the real Host header and requires a non-empty body, and setup asserts the container binds neither :80/:443 nor a wildcard address.",
"The auto-rebuild builds the working tree, not the commit, so an unrelated uncommitted edit present at rebuild time ships to the live site. Stated in the setup report and the README rather than fixed; a detached worktree would decouple them at the cost of a worktree to manage.",
".git/hooks/post-commit is not version-controlled and is lost on a fresh clone. 'setup_kcd2_dice_solver.sh status' reports it and re-running setup restores it.",
"Sub-44px tap targets on phone (16px stepper buttons) are a deliberate, user-accepted trade for fitting 43 dice. Mitigated: the whole cell is an add target and '+' is hidden on narrow layouts because the cell supersedes it.",
"Three safe-design-rules are knowingly broken inside .die-list only: body text below 16px (11-13px names), square icon buttons rather than 2:1 padding, and sub-8px intra-cell gaps. 43 items with a name, a chart and a counter in one viewport is arithmetically incompatible with 16px.",
"The height scaling is scoped to @media (min-width: 1001px): applying it to the stacked layout overflowed the fold by ~100px at 768x1024 and 1000x800, because there the title and search box sit above the grid and the viewport height is not the grid's to claim. Caught by measurement, not by inspection."
],
"rollback": [
"App: git revert the commit; run.sh and the dev server are untouched, and the old localStorage key/shape is unchanged so saved inventories survive a revert.",
"Deployment: 'docker rm -f kcd2-dice && rm ~/gitea/sites/dice.caddy' then 'docker exec gitea-caddy caddy validate --config /etc/caddy/Caddyfile && docker exec gitea-caddy caddy reload --config /etc/caddy/Caddyfile'. Also 'rm ~/.config/systemd/user/kcd2-dice-build.service ~/testsAndMisc/.git/hooks/post-commit && systemctl --user daemon-reload'.",
"After rollback validate: the other three sites (kuhy, gitea.kuhy, kuhy-sync) still return 200, and 'sudo ss -ltnp | grep -E :(80|443)' shows only the gitea-caddy pid."
]
}

View File

@ -1,4 +1,5 @@
node_modules
dist
dist.staging
coverage
*.tsbuildinfo

View File

@ -5,9 +5,12 @@ _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
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)
@ -25,6 +28,76 @@ Three ways, all wired to the same state:
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

107
kcd2_dice_solver/deploy_build.sh Executable file
View File

@ -0,0 +1,107 @@
#!/bin/bash
#
# deploy_build.sh — build the static bundle that dice.kuhy.duckdns.org serves.
#
# The single build path, used by both setup_kcd2_dice_solver.sh and the
# post-commit auto-rebuild, so the live site can never be produced two
# different ways.
#
# Why it does not just run `vite build`:
#
# The serving container bind-mounts dist/ at /srv. A bind mount follows the
# INODE, so anything that replaces the directory (rm -rf dist && mv new dist)
# silently detaches the mount until the container is restarted — the site then
# serves the old files forever with no error anywhere. And a plain `vite build`
# empties dist/ before writing, so the site 404s for the length of the build.
#
# Building into dist.staging/ and rsyncing into dist/ avoids both, but only
# with --delete-after --delay-updates: plain --delete removes stale files
# BEFORE writing the new ones, so a client that fetched index.html moments
# earlier can 404 on the very asset it is asking for.
#
# Preserving the inode is necessary but not sufficient, so the publish is
# followed by a check that the container is still mounted on this directory —
# see assert_mount_live for why that cannot be done by watching the inode here.
#
# Usage:
# deploy_build.sh Build and publish into dist/.
set -euo pipefail
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
readonly SCRIPT_DIR
readonly STAGING="${SCRIPT_DIR}/dist.staging"
readonly LIVE="${SCRIPT_DIR}/dist"
# Must match setup_kcd2_dice_solver.sh's DICE_CONTAINER.
readonly CONTAINER="kcd2-dice"
die() {
echo "deploy_build: $1" >&2
exit 1
}
# nvm-installed toolchains are not on PATH for systemd/git-hook invocations.
ensure_toolchain() {
if [[ -s ${NVM_DIR:-$HOME/.nvm}/nvm.sh ]]; then
# shellcheck disable=SC1091 # sourced at runtime, path varies by machine
source "${NVM_DIR:-$HOME/.nvm}/nvm.sh"
fi
command -v node >/dev/null || die "node not found."
command -v pnpm >/dev/null || die "pnpm not found."
command -v rsync >/dev/null || die "rsync not found."
}
# Confirm the serving container is still mounted on THIS directory.
#
# Watching dist/'s own inode across the rsync cannot detect a detached mount and
# never could: rsync into an existing directory never changes it, and if dist/
# had already been removed (it is gitignored, so `git clean -xdff` removes it)
# the mkdir above recreates it at a fresh inode before anything is measured.
# Either way the check passes while the container serves the pre-deletion
# snapshot forever, with no error anywhere. The only thing that actually
# answers the question is what the CONTAINER sees.
assert_mount_live() {
local running host_inode container_inode
command -v docker >/dev/null 2>&1 || return 0
running="$(docker ps --format '{{.Names}}' 2>/dev/null || true)"
grep -qx "$CONTAINER" <<<"$running" || return 0
host_inode="$(stat -c %i "$LIVE")"
container_inode="$(docker exec "$CONTAINER" stat -c %i /srv 2>/dev/null || true)"
if [[ -z $container_inode ]]; then
echo "deploy_build: could not read /srv inode from ${CONTAINER}; skipping mount check." >&2
return 0
fi
if [[ $host_inode != "$container_inode" ]]; then
echo "deploy_build: dist/ was replaced — ${CONTAINER} is serving a detached copy." >&2
echo "deploy_build: restarting ${CONTAINER} to re-attach the bind mount…" >&2
docker restart "$CONTAINER" >/dev/null
container_inode="$(docker exec "$CONTAINER" stat -c %i /srv 2>/dev/null || true)"
[[ $host_inode == "$container_inode" ]] ||
die "${CONTAINER} still not mounted on ${LIVE} after a restart."
echo "deploy_build: ${CONTAINER} re-attached." >&2
fi
}
main() {
ensure_toolchain
cd "$SCRIPT_DIR"
echo "Installing dependencies…"
pnpm install --frozen-lockfile
echo "Building into dist.staging…"
pnpm exec tsc -b
pnpm exec vite build --outDir dist.staging --emptyOutDir
# Fail before touching the live directory, never after.
[[ -f "${STAGING}/index.html" ]] || die "build produced no index.html."
mkdir -p "$LIVE"
rsync -a --delete-after --delay-updates "${STAGING}/" "${LIVE}/"
assert_mount_live
echo "Published $(find "$LIVE" -type f | wc -l) files to ${LIVE} (inode $(stat -c %i "$LIVE"))."
}
main "$@"

View File

@ -10,8 +10,9 @@
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { App, STORAGE_KEY, loadInventory } from "./App.tsx";
import type { SavedInventory } from "./App.tsx";
import { App } from "./App.tsx";
import { STORAGE_KEY, encodeInventoryHash, toBase64Url } from "./lib/inventoryIo.ts";
import type { SavedInventory, UrlHashPort } from "./lib/inventoryIo.ts";
import { solve } from "./core/solve.ts";
import type { SolverPort } from "./hooks/useSolver.ts";
import type { WorkerReply, WorkerRequest } from "./worker/solver.worker.ts";
@ -70,35 +71,34 @@ function readSaved(storage: Pick<Storage, "getItem">): SavedInventory {
return JSON.parse(storage.getItem(STORAGE_KEY) ?? "{}") as SavedInventory;
}
const renderApp = (initial?: string) =>
render(<App createPort={createInlinePort} storage={memoryStorage(initial)} />);
/**
* A URL port that reports a fragment without touching the real location.
*
* @param hash - The fragment the page was "opened with".
* @returns The port plus a record of whether the app cleared the fragment.
*/
function stubUrl(hash = ""): UrlHashPort & { cleared: () => number } {
let clears = 0;
return {
read: () => hash,
clear: () => {
clears += 1;
},
base: () => "https://dice.example/",
cleared: () => clears,
};
}
describe("loadInventory", () => {
it("starts empty when nothing is saved", () => {
expect(loadInventory(memoryStorage())).toEqual({ diceCounts: {}, badgeIds: [] });
});
it("restores a saved inventory", () => {
const saved = JSON.stringify({ diceCounts: { weighted: 3 }, badgeIds: ["tin_might"] });
expect(loadInventory(memoryStorage(saved))).toEqual({
diceCounts: { weighted: 3 },
badgeIds: ["tin_might"],
});
});
it("survives a corrupt entry rather than taking the page down with it", () => {
expect(loadInventory(memoryStorage("{not json"))).toEqual({ diceCounts: {}, badgeIds: [] });
});
it("survives an entry of the wrong shape", () => {
expect(loadInventory(memoryStorage("42"))).toEqual({ diceCounts: {}, badgeIds: [] });
expect(loadInventory(memoryStorage("null"))).toEqual({ diceCounts: {}, badgeIds: [] });
expect(loadInventory(memoryStorage('{"diceCounts":"nope","badgeIds":"nope"}'))).toEqual({
diceCounts: {},
badgeIds: [],
});
});
});
const renderApp = (initial?: string, urlHash: UrlHashPort = stubUrl()) =>
render(
<App
createPort={createInlinePort}
storage={memoryStorage(initial)}
urlHash={urlHash}
clipboard={{ writeText: () => Promise.resolve() }}
saveFile={() => undefined}
/>,
);
describe("App", () => {
it("asks for dice before anything is entered", () => {
@ -140,11 +140,11 @@ describe("App", () => {
for (let i = 0; i < 6; i += 1) {
fireEvent.wheel(stepper, { deltaY: -120 });
}
expect(screen.getByLabelText("How many Weighted die")).toHaveValue(6);
expect(screen.getByLabelText("How many Weighted die")).toHaveTextContent("6");
expect(screen.getByText("6 dice")).toBeInTheDocument();
fireEvent.wheel(stepper, { deltaY: 120 });
expect(screen.getByLabelText("How many Weighted die")).toHaveValue(5);
expect(screen.getByLabelText("How many Weighted die")).toHaveTextContent("5");
expect(screen.getByText("5 dice")).toBeInTheDocument();
});
@ -162,7 +162,7 @@ describe("App", () => {
fireEvent.click(add);
}
});
expect(screen.getByLabelText("How many Weighted die")).toHaveValue(4);
expect(screen.getByLabelText("How many Weighted die")).toHaveTextContent("4");
const stepper = screen.getByLabelText("How many Weighted die").parentElement;
if (!stepper) {
@ -173,7 +173,7 @@ describe("App", () => {
fireEvent.wheel(stepper, { deltaY: -120 });
}
});
expect(screen.getByLabelText("How many Weighted die")).toHaveValue(6);
expect(screen.getByLabelText("How many Weighted die")).toHaveTextContent("6");
expect(screen.getByText("6 dice")).toBeInTheDocument();
});
@ -296,3 +296,217 @@ describe("App", () => {
});
});
});
describe("shared links", () => {
const shared = encodeInventoryHash({ diceCounts: { weighted: 3 }, badgeIds: [] });
it("prefers a link over whatever this browser had saved", () => {
const mine = JSON.stringify({ diceCounts: { lucky: 1 }, badgeIds: [] });
renderApp(mine, stubUrl(shared));
expect(screen.getByText("3 dice")).toBeInTheDocument();
});
it("drops the fragment so a reload does not resurrect the link", () => {
const url = stubUrl(shared);
renderApp(undefined, url);
expect(url.cleared()).toBe(1);
});
it("leaves the URL alone when there was no link", () => {
const url = stubUrl();
renderApp(undefined, url);
expect(url.cleared()).toBe(0);
});
it("does not overwrite the saved inventory until the visitor commits", () => {
// Silently clobbering somebody's own loadout with a link they opened is
// unrecoverable, so nothing is written until they say so.
const storage = memoryStorage(JSON.stringify({ diceCounts: { lucky: 1 }, badgeIds: [] }));
render(
<App
createPort={createInlinePort}
storage={storage}
urlHash={stubUrl(shared)}
clipboard={{ writeText: () => Promise.resolve() }}
saveFile={() => undefined}
/>,
);
expect(screen.getByText("3 dice")).toBeInTheDocument();
expect(readSaved(storage).diceCounts).toEqual({ lucky: 1 });
});
it("persists once Keep is pressed", async () => {
const storage = memoryStorage();
render(
<App
createPort={createInlinePort}
storage={storage}
urlHash={stubUrl(shared)}
clipboard={{ writeText: () => Promise.resolve() }}
saveFile={() => undefined}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Keep" }));
expect(readSaved(storage).diceCounts).toEqual({ weighted: 3 });
expect(screen.queryByRole("button", { name: "Keep" })).not.toBeInTheDocument();
});
it("persists as soon as the visitor edits anything, banner and all", async () => {
const storage = memoryStorage();
render(
<App
createPort={createInlinePort}
storage={storage}
urlHash={stubUrl(shared)}
clipboard={{ writeText: () => Promise.resolve() }}
saveFile={() => undefined}
/>,
);
await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei");
await userEvent.click(screen.getByLabelText("Remove one Weighted die"));
expect(readSaved(storage).diceCounts).toEqual({ weighted: 2 });
expect(screen.queryByRole("button", { name: "Keep" })).not.toBeInTheDocument();
});
it("shows no banner on an ordinary visit", () => {
renderApp();
expect(screen.queryByRole("button", { name: "Keep" })).not.toBeInTheDocument();
});
it("falls through to storage when the fragment carries no inventory", () => {
const mine = JSON.stringify({ diceCounts: { lucky: 1 }, badgeIds: [] });
renderApp(mine, stubUrl("#nothing=here"));
expect(screen.getByText("1 die")).toBeInTheDocument();
});
it("keeps the saved inventory when the link is damaged", () => {
const mine = JSON.stringify({ diceCounts: { lucky: 1 }, badgeIds: [] });
// "@" is not a base64 character, so decoding throws rather than yielding
// an inventory — the case a truncated or mangled URL actually produces.
renderApp(mine, stubUrl("#i=@@@"));
expect(screen.getByText("1 die")).toBeInTheDocument();
// Nothing was taken from the link, so there is nothing to accept or refuse.
expect(screen.queryByRole("button", { name: "Keep" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Discard" })).not.toBeInTheDocument();
});
it("reports what a shared link had to drop", () => {
const link = `#i=${toBase64Url(
JSON.stringify({ diceCounts: { weighted: 2, nosuchdie: 1 }, badgeIds: [] }),
)}`;
renderApp(undefined, stubUrl(link));
expect(screen.getByText("2 dice")).toBeInTheDocument();
expect(screen.getByText(/Unknown die/)).toBeInTheDocument();
});
it("replaces the whole inventory on import", async () => {
const storage = memoryStorage(JSON.stringify({ diceCounts: { lucky: 2 }, badgeIds: [] }));
render(
<App createPort={createInlinePort} storage={storage} urlHash={stubUrl()} />,
);
fireEvent.change(screen.getByLabelText("Inventory JSON"), {
target: { value: '{"diceCounts":{"weighted":4},"badgeIds":[]}' },
});
await userEvent.click(screen.getByRole("button", { name: "Import pasted" }));
expect(screen.getByText("4 dice")).toBeInTheDocument();
expect(readSaved(storage).diceCounts).toEqual({ weighted: 4 });
});
});
describe("layout", () => {
it("gives the left column to the dice and nothing else", () => {
renderApp();
const inventory = document.querySelector("section.inventory");
if (!inventory) {
throw new Error("no inventory section");
}
// The search box, the title and the badges all moved to the right column so
// the 43-row grid gets the whole vertical budget.
expect(inventory.querySelector(".die-list")).toBeInTheDocument();
expect(inventory.querySelector(".toolbar")).toBeNull();
expect(inventory.querySelector("h1")).toBeNull();
expect(inventory.querySelector(".badges")).toBeNull();
});
it("keeps the badges collapsed until asked for", () => {
renderApp();
const badges = document.querySelector("details.badges");
expect(badges).toBeInTheDocument();
expect(badges).not.toHaveAttribute("open");
// Still reachable — collapsed, not removed.
expect(within(badges as HTMLElement).getByText("Badges")).toBeInTheDocument();
});
it("no longer tells touch users to scroll a mouse wheel", () => {
renderApp();
expect(screen.queryByText(/scroll the wheel/i)).not.toBeInTheDocument();
});
});
describe("shared links cannot destroy what is already saved", () => {
it("ignores a link that carries no dice or badges", () => {
// A count of zero parses cleanly, so "did the parser complain?" is the
// wrong question — this used to blank the inventory on screen, disable
// Clear, and leave Keep as the only enabled control, writing the emptiness
// over the visitor's own dice.
const mine = JSON.stringify({ diceCounts: { lucky: 4 }, badgeIds: [] });
const empty = encodeInventoryHash({ diceCounts: { weighted: 0 }, badgeIds: [] });
const storage = memoryStorage(mine);
render(
<App createPort={createInlinePort} storage={storage} urlHash={stubUrl(empty)} />,
);
expect(screen.getByText("4 dice")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Keep" })).not.toBeInTheDocument();
expect(readSaved(storage).diceCounts).toEqual({ lucky: 4 });
});
it("restores the visitor's own inventory when a link is discarded", async () => {
const mine = JSON.stringify({ diceCounts: { lucky: 4 }, badgeIds: ["tin_might"] });
const link = encodeInventoryHash({ diceCounts: { weighted: 3 }, badgeIds: [] });
const storage = memoryStorage(mine);
render(
<App createPort={createInlinePort} storage={storage} urlHash={stubUrl(link)} />,
);
expect(screen.getByText("3 dice")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Discard" }));
expect(screen.getByText("4 dice")).toBeInTheDocument();
expect(readSaved(storage)).toEqual({
diceCounts: { lucky: 4 },
badgeIds: ["tin_might"],
});
});
it("clears a fragment even when it decoded to nothing", () => {
// A fragment left in the bar is read again on the next reload, where it
// would land on top of whatever has been edited since.
for (const hash of ["#i=@@@", "#other=1", "#i="]) {
const url = stubUrl(hash);
renderApp(undefined, url);
expect(url.cleared()).toBe(1);
}
});
});
describe("a saved inventory that no longer parses cleanly", () => {
const stale = JSON.stringify({
diceCounts: { lucky: 4, future_patch_die: 6 },
badgeIds: [],
});
it("is not silently truncated in storage on load", () => {
const storage = memoryStorage(stale);
render(<App createPort={createInlinePort} storage={storage} urlHash={stubUrl()} />);
// Still the original text: nothing was written over it on first paint.
expect(readSaved(storage).diceCounts).toEqual({ lucky: 4, future_patch_die: 6 });
});
it("says what it could not read, and only commits once accepted", async () => {
const storage = memoryStorage(stale);
render(<App createPort={createInlinePort} storage={storage} urlHash={stubUrl()} />);
expect(screen.getByText(/Unknown die “future_patch_die”/)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Keep" }));
expect(readSaved(storage).diceCounts).toEqual({ lucky: 4 });
});
});

View File

@ -9,56 +9,42 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import type { JSX } from "react";
import { BadgePicker } from "./components/BadgePicker.tsx";
import { DiceList } from "./components/DiceList.tsx";
import { InventoryIo } from "./components/InventoryIo.tsx";
import { ResultPanel } from "./components/ResultPanel.tsx";
import type { CountUpdater } from "./components/QuantityStepper.tsx";
import { SET_SIZE } from "./core/search.ts";
import type { SolveRequest } from "./core/solve.ts";
import { useSolver } from "./hooks/useSolver.ts";
import type { SolverPort } from "./hooks/useSolver.ts";
/** localStorage key for the saved inventory. */
export const STORAGE_KEY = "kcd2-dice-solver.inventory";
/** What gets persisted between visits. */
export interface SavedInventory {
readonly diceCounts: Record<string, number>;
readonly badgeIds: string[];
}
const EMPTY: SavedInventory = { diceCounts: {}, badgeIds: [] };
/**
* Read the saved inventory, tolerating anything unparsable.
*
* @param storage - Where to read from; injectable for tests.
* @returns The saved inventory, or an empty one.
*/
export function loadInventory(storage: Pick<Storage, "getItem">): SavedInventory {
const raw = storage.getItem(STORAGE_KEY);
if (raw === null) {
return EMPTY;
}
try {
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null) {
return EMPTY;
}
const { diceCounts, badgeIds } = parsed as Partial<SavedInventory>;
return {
diceCounts: typeof diceCounts === "object" ? { ...diceCounts } : {},
badgeIds: Array.isArray(badgeIds) ? [...badgeIds] : [],
};
} catch {
// A corrupt entry should cost the saved inventory, not the whole page.
return EMPTY;
}
}
import {
EMPTY,
STORAGE_KEY,
browserClipboard,
browserSaveFile,
browserUrlHash,
decodeInventoryHash,
encodeInventoryHash,
isEmptyInventory,
loadInventory,
} from "./lib/inventoryIo.ts";
import type {
ClipboardPort,
SavedInventory,
SaveFilePort,
UrlHashPort,
} from "./lib/inventoryIo.ts";
export interface AppProps {
/** Overridden in tests to avoid depending on jsdom worker support. */
readonly createPort?: () => SolverPort;
/** Overridden in tests to isolate persistence. */
readonly storage?: Pick<Storage, "getItem" | "setItem">;
/** Overridden in tests; jsdom has no navigator.clipboard. */
readonly clipboard?: ClipboardPort;
/** Overridden in tests; jsdom has no URL.createObjectURL. */
readonly saveFile?: SaveFilePort;
/** Overridden in tests to keep location/history changes from leaking. */
readonly urlHash?: UrlHashPort;
}
/**
@ -67,16 +53,73 @@ export interface AppProps {
* @param props - Optional injection points for tests.
* @returns The app element.
*/
export function App({ createPort, storage }: AppProps = {}): JSX.Element {
export function App({
createPort,
storage,
clipboard = browserClipboard,
saveFile = browserSaveFile,
urlHash = browserUrlHash,
}: AppProps = {}): JSX.Element {
const store = storage ?? window.localStorage;
const [query, setQuery] = useState("");
const [saved, setSaved] = useState<SavedInventory>(() => loadInventory(store));
// What this browser already had. Kept around so a shared link can be turned
// down without losing it.
const [stored] = useState(() => loadInventory(store));
// A link beats whatever is in this browser: following one is a deliberate act
// in a way that "opening the page again" is not.
const [fromLink] = useState(() => decodeInventoryHash(urlHash.read()));
/*
* ...but only a link that actually carried dice or badges. Testing "did the
* parser complain?" is not the same question: a link encoding a count of zero
* parses perfectly and yields an EMPTY inventory, which would then replace the
* visitor's own dice on screen, disable Clear (nothing to clear), and leave
* Keep as the sole enabled control — writing that emptiness over their saved
* loadout. Requiring the link to actually carry something removes the case.
*/
const linkApplied = fromLink !== null && !isEmptyInventory(fromLink.inventory);
const [saved, setSaved] = useState<SavedInventory>(() =>
linkApplied ? fromLink.inventory : stored.inventory,
);
// Whatever was discarded on the way in, from whichever source was used. The
// parser sanitises rather than rejects, so without surfacing this a die added
// in a later game patch — or a URL a chat client truncated — would vanish in
// complete silence.
const dropped = linkApplied ? fromLink.dropped : stored.dropped;
/*
* Nothing is written to storage until the visitor accepts it. Silently
* overwriting a saved loadout — with somebody else's dice, or with a
* sanitised copy of their own that just lost an entry — is unrecoverable, and
* a confirm() dialog is both untestable and hostile. Persistence is deferred
* until they do something that implies consent: press Keep, or simply edit
* anything. One boolean, and the banner clears itself the moment they engage.
*/
const [persist, setPersist] = useState(!linkApplied && dropped.length === 0);
useEffect(() => {
store.setItem(STORAGE_KEY, JSON.stringify(saved));
}, [saved, store]);
if (persist) {
store.setItem(STORAGE_KEY, JSON.stringify(saved));
}
}, [saved, store, persist]);
useEffect(() => {
/*
* Clear ANY fragment, not just one that decoded. A fragment left in the bar
* is a loaded gun: it is read once, at mount, so a link arriving in an
* already-open tab is a same-document navigation that changes nothing now —
* but survives to be applied over edited state on the next reload.
*/
if (urlHash.read() !== "") {
urlHash.clear();
}
}, [urlHash]);
const setDieCount = useCallback((id: string, update: CountUpdater): void => {
setPersist(true);
setSaved((previous) => {
// Applied to the previous state, not to a value captured at render time,
// so a burst of clicks or wheel ticks accumulates instead of collapsing.
@ -94,6 +137,7 @@ export function App({ createPort, storage }: AppProps = {}): JSX.Element {
}, []);
const toggleBadge = useCallback((id: string, owned: boolean): void => {
setPersist(true);
setSaved((previous) => ({
...previous,
badgeIds: owned
@ -102,6 +146,11 @@ export function App({ createPort, storage }: AppProps = {}): JSX.Element {
}));
}, []);
const importInventory = useCallback((inventory: SavedInventory): void => {
setPersist(true);
setSaved(inventory);
}, []);
const total = Object.values(saved.diceCounts).reduce((sum, count) => sum + count, 0);
const request = useMemo<SolveRequest | null>(
@ -117,55 +166,111 @@ export function App({ createPort, storage }: AppProps = {}): JSX.Element {
return (
<div className="app">
<header>
<h1>KCD2 Dice Solver</h1>
<p className="subtitle">
Pick what you own; get the best six dice and a badge for each tier.
</p>
</header>
{/*
* The left column is the dice list and nothing else. Everything that is
* not "which dice do I own" — the title, the search box, the badges, the
* results — lives on the right, where there is horizontal room going
* spare, rather than eating the vertical budget the 43-row grid needs.
*/}
<div className="columns">
<section className="inventory">
<div className="toolbar">
<input
className="search"
type="search"
placeholder="Search dice and badges…"
aria-label="Search dice and badges"
value={query}
onChange={(event) => {
setQuery(event.target.value);
}}
/>
<span className="count">
{total} {total === 1 ? "die" : "dice"}
</span>
<button
type="button"
className="clear"
disabled={total === 0 && saved.badgeIds.length === 0}
onClick={() => {
setSaved(EMPTY);
}}
>
Clear
</button>
</div>
<p className="hint">
Click a die to add one, or scroll the wheel over its counter.
</p>
<DiceList query={query} counts={saved.diceCounts} onChange={setDieCount} />
<h2>Badges</h2>
<BadgePicker query={query} owned={ownedBadges} onToggle={toggleBadge} />
</section>
<section className="results">
<ResultPanel result={result} error={error} solving={solving} />
<div className="side">
<section className="controls">
<header>
<h1>KCD2 Dice Solver</h1>
<p className="subtitle">
Pick what you own; get the best six dice and a badge for each tier.
</p>
</header>
<footer className="caveats">
<div className="toolbar">
<input
className="search"
type="search"
placeholder="Search dice and badges…"
aria-label="Search dice and badges"
value={query}
onChange={(event) => {
setQuery(event.target.value);
}}
/>
<span className="count">
{total} {total === 1 ? "die" : "dice"}
</span>
<button
type="button"
className="clear"
disabled={total === 0 && saved.badgeIds.length === 0}
onClick={() => {
setPersist(true);
setSaved(EMPTY);
}}
>
Clear
</button>
</div>
{!persist && (
<p className="notice" role="status">
{linkApplied
? "Loaded from a shared link — not saved on this device yet."
: "Some saved entries could not be read — not saved over yet."}{" "}
<button
type="button"
className="clear"
onClick={() => {
setPersist(true);
}}
>
Keep
</button>{" "}
{/* Without this, turning down a link means pressing Clear —
which writes an empty inventory over dice the visitor never
even saw, with no undo. */}
{linkApplied && (
<button
type="button"
className="clear"
onClick={() => {
setPersist(true);
setSaved(stored.inventory);
}}
>
Discard
</button>
)}
</p>
)}
{dropped.length > 0 && (
<p className="notice bad" role="status">
{`Ignored: ${dropped.join(" ")}`}
</p>
)}
</section>
<section className="results">
<ResultPanel result={result} error={error} solving={solving} />
{/* Collapsed by default: badges matter far less than the dice, and
open they cost more vertical space than everything else here. */}
<details className="badges">
<summary>Badges</summary>
<BadgePicker query={query} owned={ownedBadges} onToggle={toggleBadge} />
</details>
<InventoryIo
inventory={saved}
onImport={importInventory}
clipboard={clipboard}
saveFile={saveFile}
shareUrl={urlHash.base() + encodeInventoryHash(saved)}
/>
<footer className="caveats">
<h3>What these numbers are</h3>
<ul>
<li>
@ -192,8 +297,9 @@ export function App({ createPort, storage }: AppProps = {}): JSX.Element {
UNVERIFIED in the source.
</li>
</ul>
</footer>
</section>
</footer>
</section>
</div>
</div>
</div>
);

View File

@ -28,7 +28,9 @@ export function DiceList({ query, counts, onChange }: DiceListProps): JSX.Elemen
}
return (
<ul className="die-list">
// `role="list"` is not redundant: the list-style:none needed for the grid
// layout makes Safari/VoiceOver drop list semantics entirely.
<ul className="die-list" role="list">
{matches.map(({ item, indices }) => (
<DieRow
key={item.id}

View File

@ -3,13 +3,11 @@
*/
import type { JSX } from "react";
import { MAX_PER_DIE } from "../data/dice.ts";
import type { Die } from "../data/dice.ts";
import { QuantityStepper } from "./QuantityStepper.tsx";
import type { CountUpdater } from "./QuantityStepper.tsx";
/** Most of any single die the game lets you carry into one match. */
export const MAX_PER_DIE = 6;
export interface DieRowProps {
readonly die: Die;
readonly count: number;
@ -62,12 +60,21 @@ export function highlightName(name: string, highlight: readonly number[]): JSX.E
*/
export function DieRow({ die, count, highlight, onChange }: DieRowProps): JSX.Element {
const isWild = die.wildcardFaces.length > 0;
// One tooltip for the whole strip rather than one per bar. The per-face
// numbered labels that used to sit under the bars are gone: they cost a line
// of vertical space in every one of 43 rows, and the strip is now 11-14px
// tall, where a 0.55rem label would not have been legible anyway.
const facesTitle = die.weights
.map((weight, face) => `${face + 1}: ${(weight * 100).toFixed(1)}%`)
.join(" ");
return (
<li className={count > 0 ? "die-row owned" : "die-row"}>
<button
type="button"
className="die-main"
title={die.description}
// The name is ellipsised in the narrow cells, so it goes in the tooltip
// too — otherwise a fuzzy match landing past the cut is unrecoverable.
title={`${die.name} — ${die.description}`}
// The stepper's "+" already owns "Add one <die>"; this control needs a
// distinct accessible name so the two are not ambiguous.
aria-label={die.name}
@ -76,15 +83,19 @@ export function DieRow({ die, count, highlight, onChange }: DieRowProps): JSX.El
}}
>
<span className="die-name">{highlightName(die.name, highlight)}</span>
<span className="die-faces">
<span className="die-faces" title={facesTitle}>
{die.weights.map((weight, face) => (
<span
className="die-face"
key={face}
title={`${face + 1}: ${(weight * 100).toFixed(1)}%`}
>
<span className="die-face-bar" style={{ height: `${weight * 100 * 1.6}%` }} />
<span className="die-face-label">{face + 1}</span>
<span className="die-face" key={face}>
{/*
* x3, not the old x1.6: that factor was tuned for a 26px strip.
* At 11-14px a uniform 1/6 face rendered under 4px and every die
* looked flat. At x3 a uniform face fills half the strip and a
* face weighted a third or more saturates.
*/}
<span
className="die-face-bar"
style={{ height: `${Math.min(100, weight * 100 * 3)}%` }}
/>
</span>
))}
</span>

View File

@ -0,0 +1,161 @@
/**
* Moving an inventory between devices: a file, the clipboard, or a link.
*
* Collapsed by default and placed below the badges, because the dice grid above
* it spends the whole of a phone screen and a summary line up there would cost
* a row of dice.
*/
import { useState } from "react";
import type { ChangeEvent, JSX } from "react";
import {
EXPORT_FILENAME,
isUsable,
parseInventoryJson,
serialiseInventory,
} from "../lib/inventoryIo.ts";
import type { ClipboardPort, SavedInventory, SaveFilePort } from "../lib/inventoryIo.ts";
export interface InventoryIoProps {
readonly inventory: SavedInventory;
readonly onImport: (inventory: SavedInventory) => void;
readonly clipboard: ClipboardPort;
readonly saveFile: SaveFilePort;
/** Absolute URL carrying this inventory in its fragment. */
readonly shareUrl: string;
}
interface Status {
readonly ok: boolean;
readonly message: string;
}
/**
* Import and export controls for the saved inventory.
*
* @param props - The current inventory, an import handler, and the injectable
* clipboard and download ports.
* @returns The collapsible panel.
*/
export function InventoryIo({
inventory,
onImport,
clipboard,
saveFile,
shareUrl,
}: InventoryIoProps): JSX.Element {
const [text, setText] = useState("");
const [status, setStatus] = useState<Status | null>(null);
const json = serialiseInventory(inventory);
const apply = (raw: string): void => {
const result = parseInventoryJson(raw);
const { inventory: parsed, dropped } = result;
if (!isUsable(result)) {
setStatus({ ok: false, message: `Nothing imported. ${dropped.join(" ")}` });
return;
}
onImport(parsed);
setStatus({
ok: true,
message: dropped.length === 0 ? "Imported." : `Imported. Ignored: ${dropped.join(" ")}`,
});
};
const copy = (value: string, what: string): void => {
void clipboard.writeText(value).then(
() => {
setStatus({ ok: true, message: `${what} copied.` });
},
() => {
// Insecure origin, or a browser that refuses without a user gesture.
// Put the payload in the box so it can still be copied by hand.
setText(value);
setStatus({ ok: false, message: "Copy failed — select the text below and copy it." });
},
);
};
const onFile = (event: ChangeEvent<HTMLInputElement>): void => {
const { files } = event.target;
if (files === null || files.length === 0) {
return;
}
void files[0].text().then(apply, () => {
setStatus({ ok: false, message: "Could not read that file." });
});
};
return (
<details className="io">
<summary>Import / export</summary>
<div className="io-row">
<button
type="button"
className="clear"
onClick={() => {
saveFile(EXPORT_FILENAME, json);
setStatus({ ok: true, message: `Saved ${EXPORT_FILENAME}.` });
}}
>
Download .json
</button>
<button
type="button"
className="clear"
onClick={() => {
copy(json, "JSON");
}}
>
Copy JSON
</button>
<button
type="button"
className="clear"
onClick={() => {
copy(shareUrl, "Link");
}}
>
Copy link
</button>
</div>
<label className="io-file">
Load a .json file
<input type="file" accept="application/json" onChange={onFile} />
</label>
<textarea
className="io-text"
rows={4}
placeholder="…or paste an exported inventory here"
aria-label="Inventory JSON"
value={text}
onChange={(event) => {
setText(event.target.value);
}}
/>
<div className="io-row">
<button
type="button"
className="clear"
disabled={text.trim() === ""}
onClick={() => {
apply(text);
}}
>
Import pasted
</button>
</div>
{status !== null && (
<p className={status.ok ? "io-status" : "io-status bad"} role="status">
{status.message}
</p>
)}
</details>
);
}

View File

@ -1,8 +1,11 @@
/**
* The quantity control for one inventory row.
*
* Three ways to change a count, because all three were asked for: the −/+
* buttons, typing a number, and scrolling the mouse wheel over the control.
* Two ways to change a count: the −/+ buttons and the mouse wheel. There used
* to be a third — typing into a number input — but the inventory list now packs
* 43 dice into one screen, and the input cost 28px of a ~155px cell at phone
* width. With a maximum of six, three taps of "+" covers the whole range, so
* the field bought nothing that justified the space.
*/
import type { JSX } from "react";
@ -26,6 +29,10 @@ export interface QuantityStepperProps {
/**
* Clamp a count into the allowed range, treating anything unparsable as zero.
*
* The `Number.isFinite` guard is defensive since the number input was removed —
* every caller now passes `previous + delta`, which cannot be NaN. It is kept
* because the inventory importer also clamps values that came from a file.
*
* @param value - Proposed count.
* @param max - Largest allowed count.
* @returns The clamped whole number.
@ -65,7 +72,7 @@ export function QuantityStepper({
>
<button
type="button"
className="stepper-button"
className="stepper-button stepper-remove"
aria-label={`Remove one ${label}`}
disabled={value === 0}
onClick={() => {
@ -74,22 +81,18 @@ export function QuantityStepper({
>
−
</button>
<input
className="stepper-value"
type="number"
min={0}
max={max}
value={value}
aria-label={`How many ${label}`}
onChange={(event) => {
// Typing is the one case that really is an absolute value.
const typed = Number(event.target.value);
onChange(() => clampCount(typed, max));
}}
/>
{/*
* `output` rather than a span: it has an implicit `status` role and is a
* nameable element, so `aria-label` actually resolves for screen readers
* (on a bare span most of them ignore it). It stays a direct child of
* `.stepper` so the wheel handler still covers it.
*/}
<output className="stepper-value" aria-label={`How many ${label}`}>
{value}
</output>
<button
type="button"
className="stepper-button"
className="stepper-button stepper-add"
aria-label={`Add one ${label}`}
disabled={value >= max}
onClick={() => {

View File

@ -3,14 +3,18 @@
* scrolling the wheel over its counter, and fuzzy-searching by name.
*/
import { fireEvent, render, screen, within } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { MAX_PER_DIE } from "../data/dice.ts";
import type { Die } from "../data/dice.ts";
import type { SolveResponse } from "../core/solve.ts";
import type { SavedInventory } from "../lib/inventoryIo.ts";
import { BadgePicker } from "./BadgePicker.tsx";
import { DiceList } from "./DiceList.tsx";
import { DieRow, MAX_PER_DIE, highlightName } from "./DieRow.tsx";
import { DieRow, highlightName } from "./DieRow.tsx";
import { InventoryIo } from "./InventoryIo.tsx";
import type { InventoryIoProps } from "./InventoryIo.tsx";
import { QuantityStepper, clampCount } from "./QuantityStepper.tsx";
import { ResultPanel, summariseDice } from "./ResultPanel.tsx";
@ -115,11 +119,13 @@ describe("QuantityStepper", () => {
expect(total).toBe(6);
});
it("clamps a typed value", async () => {
const onChange = vi.fn();
render(<QuantityStepper label="Ordinary die" value={0} max={6} onChange={onChange} />);
await userEvent.type(screen.getByLabelText("How many Ordinary die"), "9");
expect(applied(onChange, 0)).toBe(6);
it("shows the count as a readout inside the stepper", () => {
// The wheel handler lives on `.stepper`, so the readout must stay a direct
// child of it — App.test.tsx reaches the wheel target via parentElement.
render(<QuantityStepper label="Ordinary die" value={3} max={6} onChange={vi.fn()} />);
const readout = screen.getByLabelText("How many Ordinary die");
expect(readout).toHaveTextContent("3");
expect(readout.parentElement).toHaveClass("stepper");
});
});
@ -199,7 +205,7 @@ describe("DiceList", () => {
it("passes the owned count through to the row", () => {
render(<DiceList query="wei" counts={{ weighted: 4 }} onChange={vi.fn()} />);
expect(screen.getByLabelText("How many Weighted die")).toHaveValue(4);
expect(screen.getByLabelText("How many Weighted die")).toHaveTextContent("4");
});
it("reports which die changed", async () => {
@ -389,3 +395,138 @@ describe("ResultPanel", () => {
expect(container.querySelector(".result")).toHaveClass("stale");
});
});
describe("InventoryIo", () => {
const inventory: SavedInventory = {
diceCounts: { weighted: 2 },
badgeIds: ["tin_might"],
};
/**
* Render the panel with every port stubbed, and open it.
*
* @param overrides - Ports or handlers to replace.
* @returns The stubs the caller will want to assert on.
*/
function setup(overrides: Partial<InventoryIoProps> = {}) {
const onImport = vi.fn();
const saveFile = vi.fn();
const clipboard = { writeText: vi.fn<(text: string) => Promise<void>>().mockResolvedValue() };
render(
<InventoryIo
inventory={inventory}
onImport={onImport}
clipboard={clipboard}
saveFile={saveFile}
shareUrl="https://dice.example/#i=abc"
{...overrides}
/>,
);
return { onImport, saveFile, clipboard };
}
it("starts collapsed so it costs no vertical space", () => {
setup();
expect(screen.getByText("Import / export").closest("details")).not.toHaveAttribute("open");
});
it("downloads the inventory as parseable JSON", async () => {
const { saveFile } = setup();
await userEvent.click(screen.getByRole("button", { name: "Download .json" }));
expect(saveFile).toHaveBeenCalledTimes(1);
const [filename, body] = saveFile.mock.calls[0] as [string, string];
expect(filename).toBe("kcd2-dice.json");
expect(JSON.parse(body)).toEqual(inventory);
expect(screen.getByRole("status")).toHaveTextContent("Saved kcd2-dice.json.");
});
it("copies the JSON to the clipboard", async () => {
const { clipboard } = setup();
await userEvent.click(screen.getByRole("button", { name: "Copy JSON" }));
expect(JSON.parse(clipboard.writeText.mock.calls[0][0])).toEqual(inventory);
await screen.findByText("JSON copied.");
});
it("copies the share link", async () => {
const { clipboard } = setup();
await userEvent.click(screen.getByRole("button", { name: "Copy link" }));
expect(clipboard.writeText).toHaveBeenCalledWith("https://dice.example/#i=abc");
await screen.findByText("Link copied.");
});
it("falls back to the textarea when the clipboard refuses", async () => {
// Insecure origins reject; the payload must still be reachable by hand.
const clipboard = {
writeText: vi.fn<(text: string) => Promise<void>>().mockRejectedValue(new Error("denied")),
};
setup({ clipboard });
await userEvent.click(screen.getByRole("button", { name: "Copy JSON" }));
await screen.findByText(/Copy failed/);
const box = screen.getByLabelText<HTMLTextAreaElement>("Inventory JSON");
expect(JSON.parse(box.value)).toEqual(inventory);
});
it("imports a pasted inventory", async () => {
const { onImport } = setup();
// fireEvent rather than userEvent.type: the latter treats "{" and "[" as
// key-descriptor syntax, which mangles JSON.
fireEvent.change(screen.getByLabelText("Inventory JSON"), {
target: { value: JSON.stringify({ diceCounts: { lucky: 3 }, badgeIds: [] }) },
});
await userEvent.click(screen.getByRole("button", { name: "Import pasted" }));
expect(onImport).toHaveBeenCalledWith({ diceCounts: { lucky: 3 }, badgeIds: [] });
expect(screen.getByRole("status")).toHaveTextContent("Imported.");
});
it("refuses an unusable payload without calling onImport", async () => {
const { onImport } = setup();
fireEvent.change(screen.getByLabelText("Inventory JSON"), { target: { value: "{not json" } });
await userEvent.click(screen.getByRole("button", { name: "Import pasted" }));
expect(onImport).not.toHaveBeenCalled();
expect(screen.getByRole("status")).toHaveTextContent("Nothing imported. Not valid JSON.");
});
it("imports what it can and says what it ignored", async () => {
const { onImport } = setup();
fireEvent.change(screen.getByLabelText("Inventory JSON"), {
target: { value: '{"diceCounts":{"lucky":1,"nope":2},"badgeIds":[]}' },
});
await userEvent.click(screen.getByRole("button", { name: "Import pasted" }));
expect(onImport).toHaveBeenCalledWith({ diceCounts: { lucky: 1 }, badgeIds: [] });
expect(screen.getByRole("status")).toHaveTextContent("Ignored: Unknown die “nope”.");
});
it("keeps the import button disabled until something is pasted", () => {
setup();
expect(screen.getByRole("button", { name: "Import pasted" })).toBeDisabled();
});
it("imports an uploaded file", async () => {
const { onImport } = setup();
const file = new File([JSON.stringify({ diceCounts: { lucky: 5 }, badgeIds: [] })], "inv.json", {
type: "application/json",
});
await userEvent.upload(screen.getByLabelText(/Load a .json file/), file);
await waitFor(() => {
expect(onImport).toHaveBeenCalledWith({ diceCounts: { lucky: 5 }, badgeIds: [] });
});
});
it("does nothing when the file picker is dismissed", () => {
const { onImport } = setup();
const input = screen.getByLabelText(/Load a .json file/);
fireEvent.change(input, { target: { files: [] } });
expect(onImport).not.toHaveBeenCalled();
fireEvent.change(input, { target: { files: null } });
expect(onImport).not.toHaveBeenCalled();
});
it("reports a file it cannot read", async () => {
const { onImport } = setup();
const unreadable = new File(["x"], "bad.json", { type: "application/json" });
vi.spyOn(unreadable, "text").mockRejectedValue(new Error("I/O"));
await userEvent.upload(screen.getByLabelText(/Load a .json file/), unreadable);
await screen.findByText("Could not read that file.");
expect(onImport).not.toHaveBeenCalled();
});
});

View File

@ -42,6 +42,16 @@ describe("dice data", () => {
expect(new Set(DICE.map((die) => die.name)).size).toBe(DICE.length);
});
it("uses only plain-ASCII ids", () => {
// Shared inventories are base64-encoded into the URL fragment, and `btoa`
// throws on any code point above 255. Ids are the only strings that reach
// it, so guarding them here removes the need for a runtime try/catch that
// could never be exercised.
for (const die of DICE) {
expect(die.id).toMatch(/^[a-z0-9_]+$/);
}
});
it("indexes every die by id", () => {
expect(DICE_BY_ID.size).toBe(DICE.length);
expect(DICE_BY_ID.get("weighted")?.name).toBe("Weighted die");
@ -87,6 +97,13 @@ describe("dice data", () => {
});
describe("badge data", () => {
it("uses only plain-ASCII ids", () => {
// Same reason as the dice: badge ids ride along in the shared-link payload.
for (const badge of BADGES) {
expect(badge.id).toMatch(/^[a-z0-9_]+$/);
}
});
it("has all 33 badges from the wiki", () => {
expect(BADGES).toHaveLength(33);
});

View File

@ -404,3 +404,12 @@ export const DICE: readonly Die[] = RAW_DICE.map((raw) => ({
export const DICE_BY_ID: ReadonlyMap<string, Die> = new Map(
DICE.map((die) => [die.id, die]),
);
/**
* Most of any single die the game lets you carry into one match.
*
* Lives here rather than in the row component because it is a rule about the
* dice, and both the inventory validator and the UI need it — the validator
* must not import a component to get at it.
*/
export const MAX_PER_DIE = 6;

View File

@ -28,15 +28,20 @@ body {
margin: 0;
}
/* Wide enough that a 2560px monitor is not 40% empty margin, but still capped:
past ~2000px the caveats column turns into unreadably long lines. The 1560px
floor of the old value mattered because the panel is 1.2/2.2 of the content
width and had to clear the 772px container query that unlocks the larger
type. */
.app {
max-width: 1400px;
max-width: 2000px;
margin: 0 auto;
padding: 1.5rem;
padding: clamp(0.75rem, 3vw, 1.5rem);
}
header h1 {
margin: 0;
font-size: 1.5rem;
font-size: clamp(1.15rem, 4vw, 1.5rem);
color: var(--accent);
letter-spacing: -0.01em;
}
@ -53,27 +58,90 @@ header h1 {
align-items: start;
}
.side {
display: flex;
flex-direction: column;
gap: 1.5rem;
min-width: 0;
}
@media (max-width: 1000px) {
.columns {
grid-template-columns: 1fr;
}
/* Stacked, the side column's own children become the grid items, so they can
be ordered independently: title and search first, then the dice, then the
results. Without this the whole right column would land either entirely
above the dice (burying them) or entirely below (hiding the search box). */
.side {
display: contents;
}
.controls {
order: -1;
}
section.inventory {
order: 0;
}
.results {
order: 1;
/* Sticky in a stacked layout pins the panel over the page and puts the
caveats below it out of reach. */
position: static;
}
}
/* The subtitle is the block that costs the most vertical space for the least
value on a phone. */
@media (max-width: 600px) {
.subtitle {
display: none;
}
header {
margin-bottom: 0.75rem;
}
}
section.inventory,
section.controls,
.result,
.caveats {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius-md);
padding: 1rem;
padding: clamp(0.75rem, 2vw, 1rem);
}
/* The dice grid sizes itself against THIS panel, not the viewport: at a 1000px
viewport the panel is ~918px wide, and one pixel later the .columns split
engages and it drops to ~472px. A viewport media query would be wrong by
about a factor of two exactly where it matters. */
section.inventory {
container-type: inline-size;
container-name: inv;
}
/* Not sticky any more: with the dice list alone on the left, the right column
is now the taller of the two, so there is nothing for it to stick against. */
.results {
display: flex;
flex-direction: column;
gap: 1rem;
position: sticky;
top: 1rem;
min-width: 0;
}
/* The two collapsible panels sit as siblings of .result, so they get the same
container treatment rather than looking like loose text between panels. */
.badges,
.io {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius-md);
padding: clamp(0.75rem, 2vw, 1rem);
}
/* --- toolbar ---------------------------------------------------------- */
@ -82,16 +150,19 @@ section.inventory,
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.search {
flex: 1;
padding: 0.5rem 1rem;
flex: 1 1 8rem;
min-width: 0;
padding: 0.4rem 0.6rem;
font-size: 0.875rem;
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
color: var(--text);
font: inherit;
font-family: inherit;
}
.search:focus {
@ -101,7 +172,8 @@ section.inventory,
.count {
color: var(--muted);
min-width: 5rem;
min-width: 4rem;
font-size: 0.8125rem;
text-align: right;
}
@ -111,9 +183,31 @@ section.inventory,
border: 1px solid var(--line);
border-radius: var(--radius-sm);
color: var(--text);
font: inherit;
font-family: inherit;
cursor: pointer;
padding: 0.5rem 1rem;
}
.clear {
padding: 0.4rem 0.7rem;
font-size: 0.8125rem;
}
/* Above phone width the toolbar has room to breathe again. */
@media (min-width: 601px) {
.search {
padding: 0.5rem 1rem;
font-size: 1rem;
}
.count {
min-width: 5rem;
font-size: 1rem;
}
.clear {
padding: 0.5rem 1rem;
font-size: 1rem;
}
}
.clear:disabled,
@ -127,10 +221,29 @@ section.inventory,
border-color: var(--accent);
}
.hint {
section.controls header {
margin-bottom: 0.75rem;
}
section.controls h1 {
margin-bottom: 0.15rem;
}
.subtitle {
margin: 0 0 0.75rem;
}
/* Badges are a secondary concern next to the dice and, open, they are the
tallest thing on the page — so they stay shut until asked for. */
.badges > summary {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
font-size: 1rem;
margin: 0.6rem 0;
}
.badges[open] > summary {
margin-bottom: 0.5rem;
}
/* --- dice list -------------------------------------------------------- */
@ -145,41 +258,184 @@ section.inventory,
padding: 0;
}
/*
* All 43 dice on one screen, at every width, with no inner scrollbar.
*
* The column count is explicit (never `auto-fill`) because `auto-fill` cannot
* express "never fewer than two columns": below its track minimum it collapses
* to one column and 43 rows, roughly 1200px tall. A hard floor of two columns
* caps the row count at 22 at every width, which is what makes the total height
* bounded by construction rather than by luck.
*
* The baseline IS the compact tier. That looks backwards until you count the
* fixed cost per cell: comfortable spends 115px on the bar strip, the gaps and
* a three-button stepper; compact spends 51px. Below a cell width of ~256px
* that 64px difference buys more name characters than the larger font loses, so
* compact is the more readable tier on narrow layouts, not merely the smaller
* one.
*
* `minmax(0, 1fr)` rather than `1fr` so the ellipsised name can shrink below
* its min-content width instead of forcing the track wider.
*/
.die-list {
max-height: 46vh;
overflow-y: auto;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
/* --die-row drives everything else: the type, the buttons and the bar strip
are all fractions of it, so one number scales the whole row. */
--die-row: 20px;
--die-font: 0.6875rem;
--die-bar-w: 2px;
--die-bar-h: 11px;
--die-btn: 16px;
--die-pad-x: 3px;
--die-gap: 2px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 2px;
}
/* The whole cell is already an add button, so "+" is the first thing to go
when horizontal space is scarce; dropping it reclaims 24px for the name. */
.die-list .stepper-add,
.die-list .die-tag {
display: none;
}
@container inv (min-width: 472px) {
.die-list {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@container inv (min-width: 772px) {
.die-list {
--die-font: 0.8125rem;
--die-bar-w: 5px;
--die-bar-h: 14px;
--die-btn: 22px;
--die-pad-x: 4px;
--die-gap: 6px;
}
.die-list .stepper-add {
display: inline-flex;
}
.die-list .die-tag {
display: inline;
}
}
@container inv (min-width: 1140px) {
.die-list {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
/*
* On a screen tall enough to hold all 22 rows of a two-column list, spend the
* space on wider cells rather than more columns. Three columns has to buy its
* shorter list with a narrower cell, which truncates the longer die names; two
* columns at the same container width fits every name in full AND affords the
* larger type. Must come last so it wins over the column rules above.
*
* 640px: 22 rows at a ~24px pitch is 526px, plus the panel and page padding.
* The threshold is this low only because the left column holds nothing but the
* grid — no title, no toolbar — so nearly the whole viewport height is its to
* spend.
*/
@media (min-height: 640px) {
@container inv (min-width: 514px) and (max-width: 1139px) {
.die-list {
--die-font: 0.8125rem;
--die-bar-w: 5px;
--die-bar-h: 14px;
--die-btn: 22px;
--die-row: 24px;
--die-pad-x: 4px;
--die-gap: 6px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.die-list .stepper-add {
display: inline-flex;
}
.die-list .die-tag {
display: inline;
}
}
}
/*
* Side by side, the dice column holds nothing but the grid — so give the grid
* the height instead of leaving it in a band at the top with 700px of dead
* space under it. 22 rows share whatever is left after the page and panel
* padding, which on a 1296px-tall screen is a 52px row and 20px type rather
* than a 24px row and 13px type.
*
* Deliberately NOT applied to the stacked layout: below 1001px the title and
* the search box sit above the grid, so the height is not the grid's to take —
* assuming otherwise overflowed the fold by ~100px at 768x1024 and 1000x800.
*
* The type caps at 20px while the row goes on to 56px: past that the names stop
* fitting the cell and start truncating, whereas a taller row is pure gain — a
* bigger click target, which is the point. The -/+ buttons reach 44px, the
* accessibility minimum this layout could not afford at phone width. 24px is
* the shortest row that still holds 13px text.
*/
@media (min-width: 1001px) and (min-height: 640px) {
@container inv (min-width: 514px) and (max-width: 1139px) {
.die-list {
--die-row: clamp(24px, calc((100dvh - 9rem) / 22), 56px);
--die-font: clamp(0.8125rem, calc(var(--die-row) * 0.4), 1.25rem);
--die-btn: clamp(22px, calc(var(--die-row) * 0.85), 44px);
--die-bar-h: clamp(14px, calc(var(--die-row) * 0.55), 28px);
--die-bar-w: clamp(5px, calc(var(--die-row) * 0.17), 10px);
--die-pad-x: 0.5rem;
--die-gap: 0.75rem;
}
}
}
/*
* No per-row divider and no border around the list. A hairline under each cell
* would sit right next to the 2px column gap, stacking two hard divides; and
* painting the gaps with a line colour would render the one-to-three empty
* trailing grid cells (43 never divides evenly into 2, 3 or 4) as solid blocks.
* Cells are transparent chips instead, and an owned one is marked by fill plus
* an accent edge.
*/
.die-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.5rem;
border-bottom: 1px solid var(--line);
}
.die-row:last-child {
border-bottom: none;
gap: 0;
padding: 0;
border-radius: var(--radius-sm);
min-width: 0;
}
.die-row.owned {
background: var(--panel-2);
box-shadow: inset 2px 0 0 var(--accent);
}
.die-main {
flex: 1;
display: flex;
align-items: center;
gap: 0.75rem;
gap: var(--die-gap);
background: none;
border: none;
color: inherit;
font: inherit;
font-family: inherit;
font-size: var(--die-font);
text-align: left;
cursor: pointer;
padding: 0.35rem;
padding: 2px var(--die-pad-x);
/* The row's height is set, not merely implied by its contents — that is what
lets the list be sized to fill the viewport. */
min-height: var(--die-row);
border-radius: var(--radius-sm);
min-width: 0;
}
@ -190,6 +446,7 @@ section.inventory,
.die-name {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@ -203,15 +460,15 @@ section.inventory,
.die-faces {
display: flex;
gap: 2px;
gap: 1px;
align-items: flex-end;
height: 26px;
height: var(--die-bar-h);
flex: none;
}
.die-face {
width: 12px;
width: var(--die-bar-w);
height: 100%;
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-end;
@ -225,22 +482,13 @@ section.inventory,
max-height: 100%;
}
.die-face-label {
position: absolute;
bottom: -0.9rem;
left: 0;
right: 0;
text-align: center;
font-size: 0.55rem;
color: var(--muted);
}
.die-tag {
font-size: 0.7rem;
color: var(--bg);
background: var(--accent);
border-radius: var(--radius-sm);
padding: 0 0.3rem;
flex: none;
}
/* --- stepper ---------------------------------------------------------- */
@ -248,18 +496,30 @@ section.inventory,
.stepper {
display: flex;
align-items: center;
gap: 0.25rem;
gap: 1px;
flex: none;
}
.stepper-button {
padding: 0;
width: var(--die-btn, 22px);
height: var(--die-btn, 22px);
line-height: 1;
font-size: calc(var(--die-font, 0.8125rem) + 1px);
display: inline-flex;
align-items: center;
justify-content: center;
}
/* A readout, not a field — the number input it replaced is gone. */
.stepper-value {
width: 3rem;
padding: 0.35rem;
min-width: calc(var(--die-btn, 22px) - 3px);
text-align: center;
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
font-size: var(--die-font, 0.8125rem);
color: var(--text);
font: inherit;
background: none;
border: none;
padding: 0;
}
/* --- badges ----------------------------------------------------------- */
@ -289,8 +549,13 @@ section.inventory,
cursor: pointer;
}
/* `column-width column-count` shorthand: at most three columns, but only as
many as fit a 14rem track — so it self-adjusts to one column on a phone
instead of always splitting in two. Multi-column is right here, unlike in the
dice grid, because the tier sections vary in height and have no height
budget to keep. */
.badge-picker {
columns: 2;
columns: 14rem 3;
column-gap: 1.5rem;
}
@ -298,6 +563,80 @@ section.inventory,
break-inside: avoid;
}
/* --- import / export -------------------------------------------------- */
.io > summary {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.io-row {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin: 0.5rem 0;
}
.io-file {
display: block;
font-size: 0.8rem;
color: var(--muted);
margin: 0.5rem 0;
}
.io-file input {
display: block;
margin-top: 0.25rem;
font-size: 0.8rem;
color: var(--text);
max-width: 100%;
}
.io-text {
display: block;
width: 100%;
padding: 0.4rem 0.6rem;
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
color: var(--text);
font-family: inherit;
font-size: 0.8rem;
resize: vertical;
}
.io-status {
margin: 0.5rem 0 0;
font-size: 0.8rem;
color: var(--good);
}
.io-status.bad {
color: var(--bad);
}
/* Arrival banner: shown until a link-loaded inventory is kept or edited.
The .bad variant below reports what a link lost and stays for the visit. */
.notice {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin: 0.6rem 0 0;
padding: 0.5rem 0.6rem;
background: var(--panel-2);
border-radius: var(--radius-sm);
box-shadow: inset 2px 0 0 var(--accent);
color: var(--muted);
font-size: 0.8rem;
}
/* Something arrived damaged or incomplete — same shape, warning accent. */
.notice.bad {
box-shadow: inset 2px 0 0 var(--bad);
}
/* --- results ---------------------------------------------------------- */
.result h2,
@ -329,9 +668,11 @@ section.inventory,
min-width: 2rem;
}
/* 8rem, not 9: two 128px tiles plus the gap still fit a ~295px panel, so the
stats stay side by side on a phone rather than stacking into four rows. */
.stats {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
gap: 0.5rem;
margin: 0.75rem 0;
}
@ -351,7 +692,7 @@ section.inventory,
.stats dd {
margin: 0.15rem 0 0;
font-size: 1.25rem;
font-size: clamp(1rem, 3.5vw, 1.25rem);
color: var(--good);
}
@ -369,13 +710,20 @@ section.inventory,
.empty {
margin: 0;
padding: 1rem 0;
padding: 0.75rem 0;
}
.alt-ev {
color: var(--accent);
display: inline-block;
min-width: 4rem;
min-width: 3.5rem;
}
/* Long die names must wrap rather than push the results panel wider than the
phone viewport. */
.loadout-name,
.caveats li {
overflow-wrap: anywhere;
}
.alternatives li,

View File

@ -0,0 +1,302 @@
/**
* Tests for inventory persistence and transport.
*
* The validator is the security boundary of the import features: everything a
* user can paste, upload, or follow a link to lands here first. So the cases
* below lean on malformed input rather than the happy path.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import {
EMPTY,
EXPORT_FILENAME,
STORAGE_KEY,
browserClipboard,
browserSaveFile,
browserUrlHash,
decodeInventoryHash,
encodeInventoryHash,
fromBase64Url,
loadInventory,
parseInventory,
parseInventoryJson,
serialiseInventory,
toBase64Url,
} from "./inventoryIo.ts";
import type { SavedInventory } from "./inventoryIo.ts";
/**
* A storage stub holding one entry.
*
* @param initial - What the key already contains, if anything.
* @returns A minimal Storage.
*/
function memoryStorage(initial?: string): Pick<Storage, "getItem"> {
return { getItem: (key: string) => (key === STORAGE_KEY ? (initial ?? null) : null) };
}
describe("parseInventory", () => {
it("keeps a clean payload intact", () => {
const input = { diceCounts: { weighted: 3 }, badgeIds: ["tin_might"] };
expect(parseInventory(input)).toEqual({
inventory: { diceCounts: { weighted: 3 }, badgeIds: ["tin_might"] },
dropped: [],
});
});
it("rejects anything that is not an object outright", () => {
// Nothing salvageable, so this is the one case that fails whole.
for (const value of [42, null, "nope", undefined]) {
expect(parseInventory(value)).toEqual({ inventory: EMPTY, dropped: ["Not an inventory."] });
}
});
it("notes a missing or mistyped diceCounts", () => {
const result = parseInventory({ diceCounts: "nope", badgeIds: [] });
expect(result.inventory.diceCounts).toEqual({});
expect(result.dropped).toContain("No dice in this file.");
});
it("notes a null diceCounts", () => {
const result = parseInventory({ diceCounts: null, badgeIds: [] });
expect(result.dropped).toContain("No dice in this file.");
});
it("notes a missing or mistyped badgeIds", () => {
const result = parseInventory({ diceCounts: {}, badgeIds: "nope" });
expect(result.inventory.badgeIds).toEqual([]);
expect(result.dropped).toContain("No badges in this file.");
});
it("drops a die it does not recognise", () => {
const result = parseInventory({ diceCounts: { not_a_die: 2 }, badgeIds: [] });
expect(result.inventory.diceCounts).toEqual({});
expect(result.dropped).toContain("Unknown die “not_a_die”.");
});
it("drops a non-integer count", () => {
const result = parseInventory({ diceCounts: { weighted: 1.5 }, badgeIds: [] });
expect(result.inventory.diceCounts).toEqual({});
expect(result.dropped).toContain("Invalid count for “weighted”.");
});
it("drops a count that is not a number at all", () => {
const result = parseInventory({ diceCounts: { weighted: "three" }, badgeIds: [] });
expect(result.dropped).toContain("Invalid count for “weighted”.");
});
it("drops a negative count", () => {
const result = parseInventory({ diceCounts: { weighted: -1 }, badgeIds: [] });
expect(result.dropped).toContain("Invalid count for “weighted”.");
});
it("drops a count above the per-die maximum", () => {
const result = parseInventory({ diceCounts: { weighted: 7 }, badgeIds: [] });
expect(result.inventory.diceCounts).toEqual({});
expect(result.dropped).toContain("Count for “weighted” exceeds 6.");
});
it("drops a zero count without complaining about it", () => {
// The app never writes zeros, so a zero is tidy-up, not corruption.
const result = parseInventory({ diceCounts: { weighted: 0 }, badgeIds: [] });
expect(result.inventory.diceCounts).toEqual({});
expect(result.dropped).toEqual([]);
});
it("drops a badge it does not recognise", () => {
const result = parseInventory({ diceCounts: {}, badgeIds: ["not_a_badge"] });
expect(result.inventory.badgeIds).toEqual([]);
expect(result.dropped).toContain("Unknown badge “not_a_badge”.");
});
it("drops a badge id that is not a string", () => {
const result = parseInventory({ diceCounts: {}, badgeIds: [7] });
expect(result.dropped).toContain("Unknown badge “7”.");
});
it("dedupes a repeated badge", () => {
const result = parseInventory({ diceCounts: {}, badgeIds: ["tin_might", "tin_might"] });
expect(result.inventory.badgeIds).toEqual(["tin_might"]);
expect(result.dropped).toContain("Duplicate badge “tin_might”.");
});
it("keeps the valid entries alongside the invalid ones", () => {
// This is the whole reason for sanitising instead of rejecting: one bad id
// must not cost you the rest of an inventory you just pasted.
const result = parseInventory({
diceCounts: { weighted: 2, not_a_die: 1, lucky: 99 },
badgeIds: ["tin_might", "not_a_badge"],
});
expect(result.inventory.diceCounts).toEqual({ weighted: 2 });
expect(result.inventory.badgeIds).toEqual(["tin_might"]);
expect(result.dropped).toHaveLength(3);
});
});
describe("parseInventoryJson", () => {
it("reports unparsable JSON", () => {
expect(parseInventoryJson("{not json")).toEqual({
inventory: EMPTY,
dropped: ["Not valid JSON."],
});
});
it("parses valid JSON through the validator", () => {
const result = parseInventoryJson('{"diceCounts":{"weighted":1},"badgeIds":[]}');
expect(result.inventory.diceCounts).toEqual({ weighted: 1 });
});
});
describe("loadInventory", () => {
it("starts empty when nothing is saved", () => {
expect(loadInventory(memoryStorage()).inventory).toEqual(EMPTY);
});
it("restores a saved inventory", () => {
const saved = JSON.stringify({ diceCounts: { weighted: 3 }, badgeIds: ["tin_might"] });
expect(loadInventory(memoryStorage(saved)).inventory).toEqual({
diceCounts: { weighted: 3 },
badgeIds: ["tin_might"],
});
});
it("survives a corrupt entry rather than taking the page down with it", () => {
expect(loadInventory(memoryStorage("{not json")).inventory).toEqual(EMPTY);
});
it("survives an entry of the wrong shape", () => {
expect(loadInventory(memoryStorage("42")).inventory).toEqual(EMPTY);
expect(loadInventory(memoryStorage("null")).inventory).toEqual(EMPTY);
expect(
loadInventory(memoryStorage('{"diceCounts":"nope","badgeIds":"nope"}')).inventory,
).toEqual(EMPTY);
});
});
describe("serialiseInventory", () => {
it("round-trips through the parser", () => {
const inventory: SavedInventory = {
diceCounts: { weighted: 4 },
badgeIds: ["tin_might"],
};
expect(parseInventoryJson(serialiseInventory(inventory)).inventory).toEqual(inventory);
});
});
describe("base64url", () => {
it("round-trips ASCII", () => {
expect(fromBase64Url(toBase64Url("hello world"))).toBe("hello world");
});
it("really produces URL-safe characters, not merely claims to", () => {
// Standard base64 of these bytes contains both '+' and '/', so this
// exercises the substitutions rather than trivially covering the line.
const raw = "ÿï¿";
const encoded = toBase64Url(raw);
expect(encoded).not.toMatch(/[+/=]/);
expect(encoded).toMatch(/[-_]/);
expect(fromBase64Url(encoded)).toBe(raw);
});
it("round-trips every padding length", () => {
for (const text of ["abc", "abcd", "abcde"]) {
expect(fromBase64Url(toBase64Url(text))).toBe(text);
}
});
});
describe("inventory hash", () => {
const inventory: SavedInventory = {
diceCounts: { weighted: 2 },
badgeIds: ["tin_might"],
};
it("round-trips an inventory", () => {
const result = decodeInventoryHash(encodeInventoryHash(inventory));
expect(result?.inventory).toEqual(inventory);
expect(result?.dropped).toEqual([]);
});
it("decodes with or without the leading hash", () => {
const hash = encodeInventoryHash(inventory);
expect(decodeInventoryHash(hash.slice(1))?.inventory).toEqual(inventory);
});
it("returns null when there is no fragment", () => {
expect(decodeInventoryHash("")).toBeNull();
});
it("returns null when the fragment carries something else", () => {
expect(decodeInventoryHash("#other=1")).toBeNull();
});
it("returns null for an empty inventory parameter", () => {
expect(decodeInventoryHash("#i=")).toBeNull();
});
it("reports a damaged link rather than throwing", () => {
// Lone '!' is not valid base64, so atob throws — and it must be caught in
// the same place a JSON failure would be.
expect(decodeInventoryHash("#i=!!!")).toEqual({
inventory: EMPTY,
dropped: ["This link is damaged."],
});
});
it("reports a link that decodes but is not an inventory", () => {
const result = decodeInventoryHash(`#i=${toBase64Url("42")}`);
expect(result?.dropped).toEqual(["Not an inventory."]);
});
});
describe("browser ports", () => {
afterEach(() => {
vi.restoreAllMocks();
window.location.hash = "";
});
it("browserClipboard writes through navigator.clipboard", async () => {
const writeText = vi.fn<(text: string) => Promise<void>>().mockResolvedValue();
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
await browserClipboard.writeText("payload");
expect(writeText).toHaveBeenCalledWith("payload");
});
it("browserSaveFile builds, clicks, and revokes an object URL", () => {
// jsdom has no createObjectURL, but the properties are assignable — so the
// real implementation can be exercised rather than mocked away wholesale.
const createObjectURL = vi.fn(() => "blob:kcd2");
const revokeObjectURL = vi.fn();
Object.defineProperty(URL, "createObjectURL", { value: createObjectURL, configurable: true });
Object.defineProperty(URL, "revokeObjectURL", { value: revokeObjectURL, configurable: true });
const click = vi
.spyOn(HTMLAnchorElement.prototype, "click")
.mockImplementation(function mockClick(this: HTMLAnchorElement) {
expect(this.download).toBe(EXPORT_FILENAME);
expect(this.getAttribute("href")).toBe("blob:kcd2");
});
browserSaveFile(EXPORT_FILENAME, "{}");
expect(createObjectURL).toHaveBeenCalledTimes(1);
expect(click).toHaveBeenCalledTimes(1);
expect(revokeObjectURL).toHaveBeenCalledWith("blob:kcd2");
});
it("browserUrlHash reads the fragment", () => {
window.location.hash = "#i=abc";
expect(browserUrlHash.read()).toBe("#i=abc");
});
it("browserUrlHash clears the fragment without reloading", () => {
window.location.hash = "#i=abc";
browserUrlHash.clear();
expect(window.location.hash).toBe("");
});
it("browserUrlHash builds a base URL with no fragment", () => {
window.location.hash = "#i=abc";
expect(browserUrlHash.base()).toBe(window.location.origin + window.location.pathname);
expect(browserUrlHash.base()).not.toContain("#");
});
});

View File

@ -0,0 +1,287 @@
/**
* Reading and writing an inventory: localStorage, a JSON file, the clipboard,
* and a shareable link.
*
* All four entry points funnel through one validator. That is the point of the
* module — a payload typed by hand, exported from an older build, or pasted
* from a link gets exactly the same treatment as one the app wrote itself.
*
* The three `browser*` ports at the bottom exist because `navigator.clipboard`,
* `URL.createObjectURL` and `location`/`history` are either absent or leaky
* under jsdom. Injecting them keeps the components testable without sprinkling
* optional chaining through code paths that would then never be exercised.
*/
import { BADGES_BY_ID } from "../data/badges.ts";
import { DICE_BY_ID, MAX_PER_DIE } from "../data/dice.ts";
/** localStorage key for the saved inventory. */
export const STORAGE_KEY = "kcd2-dice-solver.inventory";
/** Fragment parameter carrying a shared inventory. */
export const HASH_KEY = "i";
/** Filename offered when exporting. */
export const EXPORT_FILENAME = "kcd2-dice.json";
/** What gets persisted between visits. */
export interface SavedInventory {
readonly diceCounts: Record<string, number>;
readonly badgeIds: string[];
}
/** An inventory with nothing in it. */
export const EMPTY: SavedInventory = { diceCounts: {}, badgeIds: [] };
/** A validated inventory plus a note for anything that had to be discarded. */
export interface ParseResult {
readonly inventory: SavedInventory;
/** Human-readable notes; empty when the payload was clean. */
readonly dropped: readonly string[];
}
/**
* Whether an inventory holds nothing at all.
*
* @param inventory - The inventory to test.
* @returns True when it has neither dice nor badges.
*/
export function isEmptyInventory(inventory: SavedInventory): boolean {
return Object.keys(inventory.diceCounts).length === 0 && inventory.badgeIds.length === 0;
}
/**
* Whether a parse result carried anything worth applying.
*
* An empty result WITH complaints means the payload was unusable — a damaged
* link, a truncated file, JSON that was never an inventory. An empty result
* WITHOUT complaints is a legitimately empty inventory, which is a real thing
* to import. Both callers (the paste box and the shared-link path) need exactly
* this distinction, so it lives here rather than being spelled out twice.
*
* @param result - The result of parsing an untrusted payload.
* @returns True when the inventory should be applied.
*/
export function isUsable({ inventory, dropped }: ParseResult): boolean {
return !isEmptyInventory(inventory) || dropped.length === 0;
}
/**
* Validate anything claiming to be an inventory, keeping what is usable.
*
* Sanitise rather than reject: a single unknown id — a die added in a later
* game patch, a hand-edited file, a link from an older build — should not throw
* away an inventory somebody just pasted. What it must not do is discard things
* silently, so everything dropped is reported and the UI shows it. Only
* structural failure (not an object at all) is fatal, because then there is
* nothing left to keep.
*
* @param value - Untrusted parsed JSON.
* @returns The usable inventory and notes on whatever was discarded.
*/
export function parseInventory(value: unknown): ParseResult {
const dropped: string[] = [];
if (typeof value !== "object" || value === null) {
return { inventory: EMPTY, dropped: ["Not an inventory."] };
}
// Typed as unknown rather than Partial<SavedInventory>: the payload is
// untrusted, and claiming its fields already have their declared types would
// make the guards below look redundant to the type checker when they are the
// only thing standing between a hand-edited file and the solver.
const { diceCounts, badgeIds } = value as Record<string, unknown>;
const counts: Record<string, number> = {};
if (typeof diceCounts !== "object" || diceCounts === null) {
dropped.push("No dice in this file.");
} else {
for (const [id, count] of Object.entries(diceCounts as Record<string, unknown>)) {
if (!DICE_BY_ID.has(id)) {
dropped.push(`Unknown die “${id}”.`);
} else if (typeof count !== "number" || !Number.isInteger(count) || count < 0) {
dropped.push(`Invalid count for “${id}”.`);
} else if (count > MAX_PER_DIE) {
dropped.push(`Count for “${id}” exceeds ${String(MAX_PER_DIE)}.`);
} else if (count > 0) {
// Zero is not an error — the app already omits empty entries.
counts[id] = count;
}
}
}
const badges: string[] = [];
if (!Array.isArray(badgeIds)) {
dropped.push("No badges in this file.");
} else {
for (const id of badgeIds as unknown[]) {
if (typeof id !== "string" || !BADGES_BY_ID.has(id)) {
dropped.push(`Unknown badge “${String(id)}”.`);
} else if (badges.includes(id)) {
dropped.push(`Duplicate badge “${id}”.`);
} else {
badges.push(id);
}
}
}
return { inventory: { diceCounts: counts, badgeIds: badges }, dropped };
}
/**
* Parse an inventory out of a JSON string.
*
* @param raw - Untrusted JSON text.
* @returns The usable inventory and notes on whatever was discarded.
*/
export function parseInventoryJson(raw: string): ParseResult {
let value: unknown;
try {
value = JSON.parse(raw);
} catch {
return { inventory: EMPTY, dropped: ["Not valid JSON."] };
}
return parseInventory(value);
}
/**
* Read the saved inventory, tolerating anything unparsable.
*
* Returns the full ParseResult, not just the inventory: this path sanitises
* exactly like the file, paste and link paths, so it has to be able to say what
* it discarded. Swallowing `dropped` here made storage the one entry point of
* the four that silently deleted entries — and it deleted them on first paint,
* before anyone could object.
*
* @param storage - Where to read from; injectable for tests.
* @returns The saved inventory and notes on anything discarded.
*/
export function loadInventory(storage: Pick<Storage, "getItem">): ParseResult {
const raw = storage.getItem(STORAGE_KEY);
if (raw === null) {
return { inventory: EMPTY, dropped: [] };
}
// A corrupt entry costs the saved inventory, not the whole page.
return parseInventoryJson(raw);
}
/**
* Render an inventory as the JSON we hand to the user.
*
* @param inventory - The inventory to write out.
* @returns Indented JSON.
*/
export function serialiseInventory(inventory: SavedInventory): string {
return JSON.stringify(inventory, null, 2);
}
/**
* Encode text as base64url (URL-safe, unpadded).
*
* @param text - ASCII text to encode.
* @returns The base64url form.
*/
export function toBase64Url(text: string): string {
return btoa(text).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
/**
* Decode base64url text.
*
* @param encoded - base64url, with or without padding.
* @returns The decoded text.
*/
export function fromBase64Url(encoded: string): string {
const standard = encoded.replace(/-/g, "+").replace(/_/g, "/");
const padding = (4 - (standard.length % 4)) % 4;
return atob(standard + "=".repeat(padding));
}
/**
* Encode an inventory as a URL fragment.
*
* base64url of the JSON, deliberately, rather than a compact positional scheme
* keyed on the index of each die. `DICE` is sorted alphabetically, so any die
* added in a future patch is all but guaranteed to land mid-array — which would
* silently turn every previously-shared link into a *different* inventory.
* A longer URL is a much better failure mode than quietly wrong data.
*
* @param inventory - The inventory to share.
* @returns A fragment beginning with `#`.
*/
export function encodeInventoryHash(inventory: SavedInventory): string {
return `#${HASH_KEY}=${toBase64Url(JSON.stringify(inventory))}`;
}
/**
* Decode an inventory from a URL fragment.
*
* @param hash - The fragment, with or without its leading `#`.
* @returns The parse result, or null when the fragment carries no inventory.
*/
export function decodeInventoryHash(hash: string): ParseResult | null {
const params = new URLSearchParams(hash.startsWith("#") ? hash.slice(1) : hash);
const encoded = params.get(HASH_KEY);
if (encoded === null || encoded === "") {
return null;
}
let json: string;
try {
// atob belongs inside the try with the parse: a malformed fragment throws
// here, not in JSON.parse, and both mean the same thing to the caller.
json = fromBase64Url(encoded);
} catch {
return { inventory: EMPTY, dropped: ["This link is damaged."] };
}
return parseInventoryJson(json);
}
/** Writing text to the system clipboard. */
export interface ClipboardPort {
writeText: (text: string) => Promise<void>;
}
/**
* The real clipboard.
*
* No optional chaining on purpose: on a non-secure origin or under jsdom this
* throws or rejects, and the caller's catch is the failure path. Guarding with
* `?.` would add a branch that no test could reach.
*/
export const browserClipboard: ClipboardPort = {
writeText: (text: string): Promise<void> => navigator.clipboard.writeText(text),
};
/** Offering text to the user as a file download. */
export type SaveFilePort = (filename: string, text: string) => void;
/** The real download, via a synthetic anchor. */
export const browserSaveFile: SaveFilePort = (filename: string, text: string): void => {
const url = URL.createObjectURL(new Blob([text], { type: "application/json" }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
};
/** Reading, clearing, and building on the page's URL. */
export interface UrlHashPort {
read: () => string;
clear: () => void;
base: () => string;
}
/**
* The real URL.
*
* One port with three methods rather than three separate props: under
* `exactOptionalPropertyTypes` every optional prop owes a "falls back to the
* real thing" test, so bundling them costs one test instead of three.
*/
export const browserUrlHash: UrlHashPort = {
read: (): string => window.location.hash,
clear: (): void => {
window.history.replaceState(null, "", window.location.pathname + window.location.search);
},
base: (): string => window.location.origin + window.location.pathname,
};

View File

@ -0,0 +1,484 @@
#!/bin/bash
#
# setup_kcd2_dice_solver.sh — KCD2 dice solver at dice.kuhy.duckdns.org
#
# Serves the solver's static build behind the existing shared Caddy edge
# (gitea-caddy), and wires up an auto-rebuild so committing to main republishes
# the site.
#
# No DuckDNS work is needed: DuckDNS wildcards *.kuhy.duckdns.org, verified
# against 1.1.1.1 and 8.8.8.8 (an unregistered zzz-nope-9x.kuhy.duckdns.org
# resolves to the same address). Adding the Caddy snippet is enough to get a
# certificate.
#
# What it does (idempotent — safe to re-run):
# - Installs build/runtime deps.
# - Builds dist/ via the repo's own deploy_build.sh.
# - Serves dist/ read-only from a loopback caddy container on 127.0.0.1:8089.
# - Adds a reverse-proxy snippet for dice.kuhy.duckdns.org to the shared edge.
# - Installs a memory-capped systemd user unit plus a post-commit git hook so
# commits to main that touch kcd2_dice_solver/ republish the site.
# - Opens the firewall via setup_wireguard_ssh.sh (already open in practice).
#
# Reboot survival: the container uses restart: unless-stopped and docker.service
# is enabled — no systemd unit is needed for serving.
#
# Usage:
# setup_kcd2_dice_solver.sh [setup] Build + deploy (default).
# setup_kcd2_dice_solver.sh status Self-diagnose the deployment.
# setup_kcd2_dice_solver.sh help Show this help.
#
# Prerequisite: setup_gitea.sh must have been run at least once (the gitea-caddy
# edge must exist). Run as your normal user, not root.
set -euo pipefail
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
readonly SCRIPT_DIR
# shellcheck source=../../lib/common.sh
source "${SCRIPT_DIR}/../../lib/common.sh"
# --- Configuration ----------------------------------------------------------
readonly DICE_DOMAIN="dice.kuhy.duckdns.org"
readonly DICE_PORT="8089"
readonly REPO_ROOT="${HOME}/testsAndMisc"
readonly DICE_SRC="${REPO_ROOT}/kcd2_dice_solver"
readonly DICE_BUILD="${DICE_SRC}/deploy_build.sh"
readonly DICE_DATA_DIR="${HOME}/kcd2-dice-serve"
readonly DICE_COMPOSE="${DICE_DATA_DIR}/docker-compose.yml"
readonly DICE_INNER_CADDY="${DICE_DATA_DIR}/Caddyfile"
# Shared edge owned by setup_gitea.sh.
readonly GITEA_DATA_DIR="${HOME}/gitea"
readonly CADDYFILE="${GITEA_DATA_DIR}/Caddyfile"
readonly SITES_DIR="${GITEA_DATA_DIR}/sites"
readonly DICE_SNIPPET="${SITES_DIR}/dice.caddy"
readonly CADDY_CONTAINER="gitea-caddy"
readonly DICE_CONTAINER="kcd2-dice"
# Auto-rebuild.
readonly UNIT_DIR="${HOME}/.config/systemd/user"
readonly UNIT_NAME="kcd2-dice-build.service"
readonly UNIT_FILE="${UNIT_DIR}/${UNIT_NAME}"
readonly HOOK_FILE="${REPO_ROOT}/.git/hooks/post-commit"
# Sibling script (single source of truth for the firewall).
readonly WG_SCRIPT="${SCRIPT_DIR}/setup_wireguard_ssh.sh"
die() {
log_error "$1"
exit 1
}
usage() {
grep -E '^#( |$)' "$0" | sed -E 's/^# ?//'
exit 0
}
# --- Phase 1: preflight -----------------------------------------------------
preflight() {
[[ ${EUID} -ne 0 ]] || die "Run as your normal user, not root."
[[ -d $DICE_SRC ]] || die "Solver source not found at ${DICE_SRC}."
[[ -x $DICE_BUILD ]] || die "Build script ${DICE_BUILD} is missing or not executable."
install_missing_pacman_packages nodejs pnpm docker docker-compose rsync curl
if ! is_service_active docker; then
log_info "Starting and enabling docker.service…"
sudo systemctl enable --now docker
fi
[[ -f $CADDYFILE ]] ||
die "Shared Caddyfile ${CADDYFILE} missing — run setup_gitea.sh first."
if ! docker ps --format '{{.Names}}' | grep -qx "$CADDY_CONTAINER"; then
die "Container ${CADDY_CONTAINER} is not running — run setup_gitea.sh first."
fi
grep -qE '^[[:space:]]*import[[:space:]]+/etc/caddy/sites' "$CADDYFILE" ||
die "Root Caddyfile does not import sites/ — run setup_personal_website.sh first."
log_ok "Preflight checks passed."
}
# --- Phase 2: build ---------------------------------------------------------
build_solver() {
log_info "Building the solver bundle…"
bash "$DICE_BUILD"
[[ -f "${DICE_SRC}/dist/index.html" ]] ||
die "Build did not produce ${DICE_SRC}/dist/index.html."
log_ok "Solver built to ${DICE_SRC}/dist."
}
# --- Phase 3: the static-serve stack ----------------------------------------
write_serve_stack() {
ensure_dir "$DICE_DATA_DIR"
cat >"$DICE_COMPOSE" <<EOF
# Managed by setup_kcd2_dice_solver.sh — do not edit by hand.
services:
${DICE_CONTAINER}:
image: caddy:2.8
container_name: ${DICE_CONTAINER}
restart: unless-stopped
# host networking is REQUIRED, not a style choice: the nftables forward
# chain is policy drop with no accept rules, so a bridge-networked
# container has no outbound egress at all.
network_mode: host
volumes:
- ${DICE_SRC}/dist:/srv:ro
- ./Caddyfile:/etc/caddy/Caddyfile:ro
EOF
cat >"$DICE_INNER_CADDY" <<EOF
# Managed by setup_kcd2_dice_solver.sh — do not edit by hand.
# Static file server on loopback; fronted by ${CADDY_CONTAINER} on the public
# domain.
#
# admin off is REQUIRED: this container shares the host network namespace with
# ${CADDY_CONTAINER}, so without it both would listen on the admin API (:2019)
# and a 'caddy reload' aimed at the edge could be applied here instead — which
# would make this process grab 80/443 and run its own ACME.
#
# auto_https off and the explicit http:// scheme are EQUALLY required, and this
# was found the hard way. A site address of "127.0.0.1:${DICE_PORT}" reads as a
# HOSTNAME, so Caddy turns on automatic HTTPS for it: it issues a local
# certificate, serves TLS on ${DICE_PORT} (so the plain-HTTP health check fails)
# and — the real damage — starts an HTTP->HTTPS redirect listener on :80. Under
# host networking that binds *:80 next to the edge, and SO_REUSEPORT then splits
# inbound HTTP between the two, which would intermittently break ACME
# challenges for every domain on this host.
#
# The site address is port-only and the loopback restriction is done with
# "bind". These are two different things and using the wrong one breaks in a way
# that looks like it works:
#
# A site address of "http://127.0.0.1:${DICE_PORT}" sets a host MATCHER of
# 127.0.0.1. ${CADDY_CONTAINER} forwards the original "Host:
# ${DICE_DOMAIN}" header, which then matches no route, and Caddy answers 200
# with an empty body. A local health check passes (curl sends Host:
# 127.0.0.1) while every real request serves a blank page.
#
# ":${DICE_PORT}" matches any Host, and "bind 127.0.0.1" restricts the
# listener itself — which the address never did. Without bind, host
# networking would expose this unfronted server to the whole LAN.
{
admin off
auto_https off
}
:${DICE_PORT} {
bind 127.0.0.1
root * /srv
# Single-page bundle: unknown paths should serve the app, not 404.
try_files {path} /index.html
file_server
encode gzip
}
EOF
log_ok "Wrote ${DICE_COMPOSE} and ${DICE_INNER_CADDY}."
}
# --- Phase 4: front it on the public domain ---------------------------------
write_snippet() {
cat >"$DICE_SNIPPET" <<EOF
# Managed by setup_kcd2_dice_solver.sh — do not edit by hand.
${DICE_DOMAIN} {
reverse_proxy 127.0.0.1:${DICE_PORT}
}
EOF
log_ok "Wrote ${DICE_SNIPPET}."
}
# --- Phase 5: start ---------------------------------------------------------
# Sends the Host header the edge will actually forward, and requires a
# non-empty body. A bare "curl http://127.0.0.1:PORT/" sends Host: 127.0.0.1
# and returns an empty 200 against a host-matcher misconfiguration — it passes
# while every real request serves a blank page. Ask the question the edge asks.
wait_for_site() {
local _ size
for _ in $(seq 1 30); do
size="$(curl -s -o /dev/null -w '%{size_download}' \
-H "Host: ${DICE_DOMAIN}" "http://127.0.0.1:${DICE_PORT}/" 2>/dev/null || echo 0)"
if [[ ${size:-0} -gt 0 ]]; then
return 0
fi
sleep 1
done
return 1
}
# This container shares the host network namespace with the public edge, so a
# misconfiguration here does not fail locally — it quietly steals a share of
# :80 or :443 from every other site on the box. Gate on it rather than trust
# the config to stay correct.
assert_no_edge_conflict() {
local pid listeners
pid="$(docker inspect -f '{{.State.Pid}}' "$DICE_CONTAINER" 2>/dev/null || echo "")"
[[ -n $pid && $pid != "0" ]] || die "Could not read the ${DICE_CONTAINER} PID."
listeners="$(sudo -n ss -ltnp 2>/dev/null | grep "pid=${pid}," || true)"
# Demand positive evidence FIRST. Asserting only the absence of :80/:443
# fails open: if `ss` returns nothing — a stale PID after a restart, or
# sudo -n unavailable — every "is it bad?" test misses and the guard happily
# reports success without having looked at anything.
if ! grep -qE '(^|[[:space:]])127\.0\.0\.1:'"${DICE_PORT}"'([[:space:]]|$)' <<<"$listeners"; then
docker stop "$DICE_CONTAINER" >/dev/null 2>&1 || true
die "Could not confirm ${DICE_CONTAINER} listens on 127.0.0.1:${DICE_PORT}. Container stopped."
fi
if grep -qE ':(80|443)\b' <<<"$listeners"; then
docker stop "$DICE_CONTAINER" >/dev/null 2>&1 || true
die "${DICE_CONTAINER} bound :80/:443 — it would contend with ${CADDY_CONTAINER}. Container stopped."
fi
# Must be on loopback, not 0.0.0.0/*: host networking would otherwise expose
# the unfronted static server to the whole LAN.
if grep -qE '(\*|0\.0\.0\.0):'"${DICE_PORT}"'\b' <<<"$listeners"; then
docker stop "$DICE_CONTAINER" >/dev/null 2>&1 || true
die "${DICE_CONTAINER} bound ${DICE_PORT} on all interfaces, not loopback. Container stopped."
fi
log_ok "${DICE_CONTAINER} listens on loopback:${DICE_PORT} only — no contention, not LAN-exposed."
}
start_site() {
log_info "Starting the ${DICE_CONTAINER} container…"
docker compose -f "$DICE_COMPOSE" up -d
if wait_for_site; then
log_ok "Solver answering on http://127.0.0.1:${DICE_PORT}/."
else
docker stop "$DICE_CONTAINER" >/dev/null 2>&1 || true
die "Solver did not become reachable on 127.0.0.1:${DICE_PORT} (container stopped)."
fi
assert_no_edge_conflict
}
# --- Phase 6: reload the edge -----------------------------------------------
reload_caddy() {
if docker exec "$CADDY_CONTAINER" caddy validate --config /etc/caddy/Caddyfile >/dev/null 2>&1; then
docker exec "$CADDY_CONTAINER" caddy reload --config /etc/caddy/Caddyfile
log_ok "Reloaded ${CADDY_CONTAINER}."
else
die "Caddy config invalid — aborting reload. Check ${SITES_DIR}/*.caddy."
fi
}
# --- Phase 7: auto-rebuild on commit ----------------------------------------
# The build runs in a memory-capped systemd user unit rather than inside the
# git hook. MemorySwapMax=0 is required, not cosmetic: this box has ~4 GB of
# zram (swap held IN ram), so a memory-limited cgroup without it thrashes zram
# instead of dying cleanly, and freezes the machine. A git hook running tsc and
# vite is precisely the scenario that costs.
install_autorebuild() {
ensure_dir "$UNIT_DIR"
cat >"$UNIT_FILE" <<EOF
# Managed by setup_kcd2_dice_solver.sh — do not edit by hand.
[Unit]
Description=Rebuild the KCD2 dice solver static bundle
[Service]
Type=oneshot
WorkingDirectory=${DICE_SRC}
ExecStart=${DICE_BUILD}
MemoryMax=3G
MemorySwapMax=0
EOF
systemctl --user daemon-reload
log_ok "Installed ${UNIT_FILE}."
[[ -d "${REPO_ROOT}/.git" ]] || die "${REPO_ROOT} is not a git repository."
cat >"$HOOK_FILE" <<EOF
#!/bin/bash
# Managed by setup_kcd2_dice_solver.sh — do not edit by hand.
# Republishes https://${DICE_DOMAIN}/ when main gains solver changes.
[[ "\$(git rev-parse --abbrev-ref HEAD)" == main ]] || exit 0
git diff --name-only HEAD~1 HEAD -- kcd2_dice_solver/ | grep -q . || exit 0
# --no-block so the commit is never slowed, || true so systemd can never fail it.
systemctl --user start --no-block ${UNIT_NAME} || true
EOF
chmod +x "$HOOK_FILE"
log_ok "Installed ${HOOK_FILE}."
}
# --- Phase 8: firewall ------------------------------------------------------
# setup_wireguard_ssh.sh owns /etc/nftables.conf and regenerates it from
# scratch — `flush ruleset` and all — on every run. That is fine on a quiet
# machine and rude on a busy one: it momentarily drops the whole ruleset while
# games, VPN links and long-lived connections are relying on it. Since the only
# thing this deployment needs is 80/443 already being accepted, check first and
# only pay for the regeneration when the rule is genuinely missing.
# Exact token membership rather than a regex over the rule text: a loose
# pattern would also match "8080, 4433" and wrongly conclude the ports are open.
web_ports_open() {
local line ports
while read -r line; do
# Strip everything but digits and commas, then test for whole tokens.
ports=",${line//[^0-9,]/},"
if [[ $ports == *,80,* && $ports == *,443,* ]]; then
return 0
fi
done < <(sudo -n nft list ruleset 2>/dev/null | grep -oE 'tcp dport \{[^}]*\}')
return 1
}
ensure_firewall() {
if web_ports_open; then
log_ok "Ports 80/443 already accepted — leaving the ruleset untouched."
return 0
fi
log_info "Opening ports 80/443 via setup_wireguard_ssh.sh…"
sudo bash "$WG_SCRIPT" allow-web
}
# --- Phase 9: report --------------------------------------------------------
print_report() {
cat <<EOF
============================================================================
KCD2 dice solver deployed.
============================================================================
Solver : https://${DICE_DOMAIN}/
Auto-rebuild: committing to main with changes under kcd2_dice_solver/ starts
${UNIT_NAME}, which rebuilds dist/ in place.
Watch it: journalctl --user -u ${UNIT_NAME} -f
Force it: systemctl --user start ${UNIT_NAME}
NOTE: the rebuild builds the WORKING TREE, not the commit. An unrelated
uncommitted edit that happens to be present when it runs will ship to the live
site. Commit or set aside unrelated work before triggering a rebuild.
Acceptance test (do this on your phone):
Turn Wi-Fi OFF (use cellular) and open https://${DICE_DOMAIN}/ —
all 43 dice should be on one screen and tappable.
============================================================================
EOF
}
setup_cmd() {
print_setup_header "KCD2 dice solver setup"
preflight
build_solver
write_serve_stack
# The snippet goes into the SHARED sites/ directory, so it is written only
# after the backend is up and verified. Writing it first means a failure in
# start_site leaves an orphan behind that the next `caddy reload` from any
# other service picks up — the edge then serves this domain as a 502 and
# starts ACME retries for a host with no backend.
start_site
write_snippet
reload_caddy
install_autorebuild
ensure_firewall
print_report
log_ok "KCD2 dice solver setup complete."
}
# --- status: self-diagnose --------------------------------------------------
status_line() {
if [[ $1 -eq 0 ]]; then
log_ok "$2"
else
log_warn "$2"
fi
}
check_http() {
# Prints 0 to stdout if the URL returns 200 with a non-empty body, else 1.
# The body-size check matters: a host-matcher misconfiguration answers 200
# with nothing in it, which a status-only check would call healthy.
#
# Certificates are NOT skipped for https URLs — `curl -k` would report an
# expired or failed-ACME cert as a healthy site, which is precisely the kind
# of green-while-broken this command exists to catch. The loopback check runs
# over plain http, so it needs no exception.
local url="$1" out code size
out="$(curl -s -o /dev/null -w '%{http_code} %{size_download}' --max-time 10 \
${2:+-H "Host: $2"} "$url" 2>/dev/null || echo "000 0")"
read -r code size <<<"$out"
[[ $code == "200" && ${size:-0} -gt 0 ]] && echo 0 || echo 1
}
status_cmd() {
print_setup_header "KCD2 dice solver status"
has_cmd node && status_line 0 "node present" || status_line 1 "node missing"
has_cmd pnpm && status_line 0 "pnpm present" || status_line 1 "pnpm missing"
[[ -f "${DICE_SRC}/dist/index.html" ]] &&
status_line 0 "build present (dist/index.html)" ||
status_line 1 "build missing — run setup"
if docker ps --format '{{.Names}}' | grep -qx "$DICE_CONTAINER"; then
status_line 0 "${DICE_CONTAINER} container running"
local pid listeners
pid="$(docker inspect -f '{{.State.Pid}}' "$DICE_CONTAINER" 2>/dev/null || echo "")"
listeners="$(sudo -n ss -ltnp 2>/dev/null | grep "pid=${pid}," || true)"
if grep -qE ':(80|443)\b' <<<"$listeners"; then
status_line 1 "${DICE_CONTAINER} is CONTENDING for :80/:443 with ${CADDY_CONTAINER}"
else
status_line 0 "${DICE_CONTAINER} listens only on ${DICE_PORT}"
fi
else
status_line 1 "${DICE_CONTAINER} container not running"
fi
status_line "$(check_http "http://127.0.0.1:${DICE_PORT}/" "$DICE_DOMAIN")" \
"local static server (127.0.0.1:${DICE_PORT}, Host: ${DICE_DOMAIN})"
[[ -f $DICE_SNIPPET ]] &&
status_line 0 "Caddy snippet present" ||
status_line 1 "Caddy snippet missing"
getent hosts "$DICE_DOMAIN" >/dev/null &&
status_line 0 "${DICE_DOMAIN} resolves" ||
status_line 1 "${DICE_DOMAIN} does not resolve"
status_line "$(check_http "https://${DICE_DOMAIN}/")" \
"external https://${DICE_DOMAIN}/"
# The hook lives in .git/hooks, which is not version-controlled, so it is
# lost on a fresh clone and must be checked rather than assumed. Spelled as
# an explicit if: with two conditions, A && B || C really would misfire.
if [[ -x $HOOK_FILE ]] && grep -q "$UNIT_NAME" "$HOOK_FILE" 2>/dev/null; then
status_line 0 "post-commit auto-rebuild hook installed"
else
status_line 1 "post-commit hook missing — re-run setup"
fi
[[ -f $UNIT_FILE ]] &&
status_line 0 "${UNIT_NAME} installed" ||
status_line 1 "${UNIT_NAME} missing — re-run setup"
if systemctl --user is-failed --quiet "$UNIT_NAME"; then
status_line 1 "last auto-rebuild FAILED — journalctl --user -u ${UNIT_NAME}"
else
status_line 0 "no failed auto-rebuild recorded"
fi
# "not failed" also covers "never ran", so check the artefact rather than the
# unit: if any source file is newer than the published bundle, the hook is
# not firing and the site is quietly serving an old build.
local newest_src
newest_src="$(find "${DICE_SRC}/src" "${DICE_SRC}/index.html" -type f -newer \
"${DICE_SRC}/dist/index.html" -print -quit 2>/dev/null || true)"
if [[ -n $newest_src ]]; then
status_line 1 "published bundle is STALE (e.g. ${newest_src#"${DICE_SRC}/"}) — run: systemctl --user start ${UNIT_NAME}"
else
status_line 0 "published bundle is up to date with src/"
fi
}
main() {
local cmd="${1:-setup}"
case "$cmd" in
setup)
setup_cmd
;;
status)
status_cmd
;;
help | -h | --help)
usage
;;
*)
log_error "Unknown command: $cmd"
usage
exit 1
;;
esac
}
main "$@"

View File

@ -7,7 +7,6 @@ parse result outlives the accumulator fields it updates.
from __future__ import annotations
from collections import Counter
from dataclasses import dataclass, field
from datetime import datetime, timezone
import json
import re
@ -23,7 +22,7 @@ from python_pkg.session_autopsy.records import (
TurnEfficiency,
)
from python_pkg.session_autopsy.signatures import command_signature, normalize_signature
from python_pkg.session_autopsy.turns import LINT_TEST, POLL, VCS_CHECK, classify_turn
from python_pkg.session_autopsy.turn_tracker import TurnTracker
if TYPE_CHECKING:
from pathlib import Path
@ -86,69 +85,6 @@ _GENERIC_ERROR_SIGS = frozenset(
)
@dataclass
class _OpenTurn:
"""The API turn currently being accumulated, keyed by requestId."""
request_id: str | None = None
tools: int = 0
commands: list[str] = field(default_factory=list)
class _TurnTracker:
"""Running turn-efficiency totals plus the turn currently open.
The two belong together: the open turn only exists to be folded into the
totals, and keeping them as one object is also what keeps ``_Accumulator``
inside its instance-attribute budget.
"""
def __init__(self) -> None:
"""Start with empty totals and no open turn."""
self.totals = TurnEfficiency()
self._open = _OpenTurn()
def begin(self, request_id: object) -> None:
"""Open a new turn if this line belongs to a different API response.
Args:
request_id: The line's ``requestId`` (or message-id fallback).
"""
if request_id != self._open.request_id:
self.flush()
self._open.request_id = request_id if isinstance(request_id, str) else None
def add_tool(self) -> None:
"""Count one tool_use block in the open turn."""
self._open.tools += 1
def add_command(self, command: str) -> None:
"""Record one Bash command issued in the open turn.
Args:
command: The raw Bash tool input.
"""
self._open.commands.append(command)
def flush(self) -> None:
"""Fold the open turn into the totals and start a fresh one."""
open_turn = self._open
self._open = _OpenTurn()
if open_turn.tools <= 0:
return
self.totals.api_turns += 1
self.totals.tool_calls += open_turn.tools
if open_turn.tools > 1:
self.totals.batched_turns += 1
label = classify_turn(open_turn.commands)
if label == POLL:
self.totals.poll_turns += 1
elif label == LINT_TEST:
self.totals.lint_test_turns += 1
elif label == VCS_CHECK:
self.totals.vcs_check_turns += 1
class _Accumulator:
"""Mutable per-session state fed one transcript line at a time."""
@ -168,7 +104,7 @@ class _Accumulator:
# One API response can span several transcript lines, so batching has to be
# grouped by requestId — counting per line would report every turn as
# single-tool and hide the real (much lower) batching rate.
self._turns = _TurnTracker()
self._turns = TurnTracker()
@property
def turns(self) -> TurnEfficiency:

View File

@ -5,6 +5,7 @@ from __future__ import annotations
import json
from typing import TYPE_CHECKING
from python_pkg.session_autopsy.report_cells import action_cell, verdict_label
from python_pkg.session_autopsy.stats import parse_timestamp
if TYPE_CHECKING:
@ -340,35 +341,6 @@ def _turn_efficiency(records: list[SessionRecord]) -> str:
)
def _action_cell(cand: Candidate, handled: set[str]) -> str:
"""The table's action column for one candidate.
Args:
cand: The candidate.
handled: Ids with a recorded verdict.
Returns:
The suggested action, or a pointer to where its verdict lives.
"""
if cand.id in handled:
return "already handled — see Reviewed/scoreboard"
return cand.action
def _verdict_label(entry: dict[str, object]) -> str:
"""Human label for a reviewed entry's verdict.
Args:
entry: A compiled.json entry.
Returns:
``"dropped (workflow removed)"`` or ``"keep LLM"``.
"""
if entry.get("verdict") == "dropped":
return "dropped (workflow removed)"
return "keep LLM"
def _candidate_table(candidates: list[Candidate], handled: set[str]) -> str:
"""Render the ranked candidate table.
@ -386,7 +358,7 @@ def _candidate_table(candidates: list[Candidate], handled: set[str]) -> str:
f"| {rank} | {cand.id} | {cand.kind} | "
f"{cand.occurrences}x / {cand.sessions} sessions "
f"| {fmt_tokens(cand.est_weekly_savings)}/wk | "
f"{_action_cell(cand, handled)} |"
f"{action_cell(cand, handled)} |"
for rank, cand in enumerate(candidates[:TABLE_TOP], start=1)
]
overflow = ""
@ -443,7 +415,7 @@ def _reviewed_keep_llm(compiled: list[dict[str, object]]) -> str:
return "## Reviewed — keeping LLM\n\nnone reviewed yet\n"
lines = [
f"- {entry.get('candidate_id')} — "
f"reviewed {entry.get('compiled_at')} — {_verdict_label(entry)}"
f"reviewed {entry.get('compiled_at')} — {verdict_label(entry)}"
for entry in kept
]
return "## Reviewed — keeping LLM\n\n" + "\n".join(lines) + "\n"

View File

@ -0,0 +1,43 @@
"""Cell labels for the candidate table in REPORT.md.
Split out of ``report.py`` to keep it inside the repo's 500-line-per-file
limit. Both functions are pure label logic with no formatting dependencies,
which is why these two came out and the rest of the table rendering did not:
the detail block needs ``fmt_tokens`` and would make the import circular.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from python_pkg.session_autopsy.detectors import Candidate
def action_cell(cand: Candidate, handled: set[str]) -> str:
"""The table's action column for one candidate.
Args:
cand: The candidate.
handled: Ids with a recorded verdict.
Returns:
The suggested action, or a pointer to where its verdict lives.
"""
if cand.id in handled:
return "already handled — see Reviewed/scoreboard"
return cand.action
def verdict_label(entry: dict[str, object]) -> str:
"""Human label for a reviewed entry's verdict.
Args:
entry: A compiled.json entry.
Returns:
``"dropped (workflow removed)"`` or ``"keep LLM"``.
"""
if entry.get("verdict") == "dropped":
return "dropped (workflow removed)"
return "keep LLM"

View File

@ -0,0 +1,76 @@
"""Turn-efficiency accounting for a single transcript.
Split out of ``parse.py``: the tracker is self-contained — it knows only about
turn totals and the classification of the commands in a turn — and lifting it
out keeps the parser inside the repo's 500-line-per-file limit.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from python_pkg.session_autopsy.records import TurnEfficiency
from python_pkg.session_autopsy.turns import LINT_TEST, POLL, VCS_CHECK, classify_turn
@dataclass
class OpenTurn:
"""The API turn currently being accumulated, keyed by requestId."""
request_id: str | None = None
tools: int = 0
commands: list[str] = field(default_factory=list)
class TurnTracker:
"""Running turn-efficiency totals plus the turn currently open.
The two belong together: the open turn only exists to be folded into the
totals, and keeping them as one object is also what keeps the parser's
accumulator inside its instance-attribute budget.
"""
def __init__(self) -> None:
"""Start with empty totals and no open turn."""
self.totals = TurnEfficiency()
self._open = OpenTurn()
def begin(self, request_id: object) -> None:
"""Open a new turn if this line belongs to a different API response.
Args:
request_id: The line's ``requestId`` (or message-id fallback).
"""
if request_id != self._open.request_id:
self.flush()
self._open.request_id = request_id if isinstance(request_id, str) else None
def add_tool(self) -> None:
"""Count one tool_use block in the open turn."""
self._open.tools += 1
def add_command(self, command: str) -> None:
"""Record one Bash command issued in the open turn.
Args:
command: The raw Bash tool input.
"""
self._open.commands.append(command)
def flush(self) -> None:
"""Fold the open turn into the totals and start a fresh one."""
open_turn = self._open
self._open = OpenTurn()
if open_turn.tools <= 0:
return
self.totals.api_turns += 1
self.totals.tool_calls += open_turn.tools
if open_turn.tools > 1:
self.totals.batched_turns += 1
label = classify_turn(open_turn.commands)
if label == POLL:
self.totals.poll_turns += 1
elif label == LINT_TEST:
self.totals.lint_test_turns += 1
elif label == VCS_CHECK:
self.totals.vcs_check_turns += 1