testsAndMisc/docs/superpowers/evidence/kcd2-dice-inventory-io-2026-07-26.json
Krzysztof kuhy Rudnicki 9d7751cebb
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
kcd2 dice solver: serve it at dice.kuhy.duckdns.org, fit all 43 dice, move inventories between devices
The solver only ran behind ./run.sh, had one media query in the whole
stylesheet, and kept the inventory in a 46vh scroll box that showed maybe a
third of the 43 dice. Setting it up on the PC did nothing for the phone.

Deployment
  dice.kuhy.duckdns.org, behind the existing gitea-caddy edge. DuckDNS already
  wildcards *.kuhy.duckdns.org (checked against 1.1.1.1 and 8.8.8.8), so no
  dashboard step. deploy_build.sh builds to dist.staging and rsyncs into dist/
  with --delete-after --delay-updates; plain --delete removes stale assets
  before writing the new ones, which 404s a client mid-load. A post-commit hook
  starts a MemorySwapMax=0 systemd user unit, so a rebuild can neither slow a
  commit nor thrash zram.

  Three traps found by driving the live URL in a browser rather than curling it:
  a site address of "127.0.0.1:8089" reads as a HOSTNAME, so Caddy enabled
  auto-HTTPS and started an HTTP->HTTPS redirect listener that bound *:80 next
  to the edge -- SO_REUSEPORT would then split inbound HTTP and intermittently
  break ACME for every domain here. The same address also made a Host matcher,
  so the edge's forwarded Host matched nothing and served an empty 200 while the
  local health check passed. And watching dist/'s own inode could never detect a
  detached bind mount: rsync into an existing directory never changes it. Each
  now has a guard that asserts positively rather than failing open.

Dice list
  All 43 on screen at every width, no inner scrollbar. Column count and size
  tier are chosen together from a container query on the panel -- not the
  viewport, which mispredicts by 2x at the point the columns split. Two
  invariants govern it: the list fits above the fold, and the name column stays
  wide enough to tell Painter's die B, G and R apart. The left column holds the
  grid alone, so it is sized to fill the height it owns: one --die-row value
  drives the type, buttons and bar strip, giving a 52px row and 44px buttons on
  a 1296px-tall screen and 20px rows on a phone.

  Three safe-design rules are broken inside .die-list deliberately: sub-16px
  body text, square icon buttons, sub-8px intra-cell gaps. 43 items with a name,
  a chart and a counter in one viewport is arithmetically incompatible with 16px.

Import / export
  A file, the clipboard, or a link carrying the inventory in the URL fragment.
  One validator behind all four entry points including localStorage; it keeps
  what it can and reports what it dropped, so a die added in a later game patch
  costs you that entry, not the inventory. A link never silently overwrites what
  is saved here -- it offers Keep or Discard, and only a link that actually
  carries dice is applied at all, because one encoding a count of zero parsed
  cleanly, blanked the screen, disabled Clear and left Keep writing the emptiness
  over the visitor's own loadout.

session_autopsy
  parse.py (551) and report.py (504) were over the repo's 500-line limit, so
  test_file_length has been failing on main since 5ffd035. Extracted TurnTracker
  and the two candidate-cell helpers. Report output is byte-identical on real
  data apart from its timestamp, checked against HEAD in a worktree.

Verified: 288 TS tests and 911 Python tests, 100% coverage both, lint and
pre-commit clean, 8 viewports measured on the live HTTPS site, solver confirmed
through the real module worker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 20:01:21 +02:00

70 lines
6.6 KiB
JSON

{
"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."
]
}