From 5a27acd9d09944371105e28c6d0a1602b174e519 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 16 Aug 2026 19:55:52 +0200 Subject: [PATCH] Split kcd2 simulate and inventoryIo; revert two seams simulate.ts 384 -> 226 and inventoryIo.ts 287 -> 178, with the moved names re-exported so no importer or test changed. search.ts and badgeValue.ts are reverted. findBestSet calls enumerateAll and hillClimb which call back into expand; valueBadge and format are mutually recursive with the recommendation half. Splitting either makes the two files import each other. The split also exposed a TypeScript hazard worth knowing: with the local Selection type no longer imported, tsc silently resolved it to the DOM global and complained about anchorNode and focusNode rather than saying the name was missing. 288 tests passing before and after, coverage still 100%. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY --- ...2-simulate-inventory-split-2026-08-16.json | 18 ++ ...2-simulate-inventory-split-2026-08-16.json | 44 ++++ kcd2_dice_solver/src/core/simulate.ts | 191 +---------------- kcd2_dice_solver/src/core/simulateTurns.ts | 198 ++++++++++++++++++ kcd2_dice_solver/src/lib/inventoryHash.ts | 130 ++++++++++++ kcd2_dice_solver/src/lib/inventoryIo.ts | 132 +----------- 6 files changed, 410 insertions(+), 303 deletions(-) create mode 100644 docs/superpowers/contracts/kcd2-simulate-inventory-split-2026-08-16.json create mode 100644 docs/superpowers/evidence/kcd2-simulate-inventory-split-2026-08-16.json create mode 100644 kcd2_dice_solver/src/core/simulateTurns.ts create mode 100644 kcd2_dice_solver/src/lib/inventoryHash.ts diff --git a/docs/superpowers/contracts/kcd2-simulate-inventory-split-2026-08-16.json b/docs/superpowers/contracts/kcd2-simulate-inventory-split-2026-08-16.json new file mode 100644 index 00000000..f366d5bc --- /dev/null +++ b/docs/superpowers/contracts/kcd2-simulate-inventory-split-2026-08-16.json @@ -0,0 +1,18 @@ +{ + "title": "Split kcd2 simulate.ts and inventoryIo.ts, reverting two mutually-dependent seams", + "objective": "Bring src/core/simulate.ts (384) and src/lib/inventoryIo.ts (287) under the 250-line cap, and establish that a seam whose halves call back into each other must be abandoned rather than forced.", + "acceptance_criteria": [ + "simulate.ts, simulateTurns.ts, inventoryIo.ts and inventoryHash.ts are each at most 250 lines.", + "The suite still reports exactly 288 passing tests in 15 files.", + "Coverage stays at 100% for statements, branches, functions and lines, with the new modules measured.", + "npm run lint passes with no new suppression and no eslint-disable.", + "Every name previously exported from simulate.ts and inventoryIo.ts is still exported from the same path, so no importer or test is edited.", + "search.ts and badgeValue.ts are fully restored, leaving no partial split and no orphan module." + ], + "out_of_scope": [ + "search.ts and badgeValue.ts, whose halves are mutually recursive.", + "The kcd2 test files and App.tsx, which are separate commits.", + "Editing any test." + ], + "verifier": "npm test -- --run (expects exactly 288 passed in 15 files), npm run coverage (100% on all four metrics), npm run lint, and check_file_length.sh." +} diff --git a/docs/superpowers/evidence/kcd2-simulate-inventory-split-2026-08-16.json b/docs/superpowers/evidence/kcd2-simulate-inventory-split-2026-08-16.json new file mode 100644 index 00000000..a0adca0a --- /dev/null +++ b/docs/superpowers/evidence/kcd2-simulate-inventory-split-2026-08-16.json @@ -0,0 +1,44 @@ +{ + "intent": "Split kcd2_dice_solver's simulate.ts and inventoryIo.ts under the cap, and abandon two seams whose halves are mutually dependent.", + "scope": [ + "kcd2_dice_solver/src/core/simulate.ts (384 -> 226) + simulateTurns.ts", + "kcd2_dice_solver/src/lib/inventoryIo.ts (287 -> 178) + inventoryHash.ts", + "Reverted: src/core/search.ts (417), src/core/badgeValue.ts (333)", + "Non-goal: no test edited, no behaviour changed" + ], + "changes": [ + "simulate.ts: whole-turn and multi-turn simulation moved to simulateTurns.ts. Two private helpers it calls, sampleCategory and roll, are now exported rather than duplicated, and simulate.ts re-exports the moved functions so importers and tests are untouched.", + "inventoryIo.ts: hash serialisation and the browser ports moved to inventoryHash.ts, with the moved names re-exported from the original path so inventoryIo.test.ts's import block still resolves.", + "search.ts and badgeValue.ts were both reverted. findBestSet calls enumerateAll and hillClimb, which call back into expand and the leaderboard types; badgeValue's valueBadge and format are mutually recursive with the recommendation half. Splitting either means the two files import each other, which is the same shared-state problem the shell scripts hit, and TypeScript would compile it while it broke at runtime." + ], + "verification": [ + { + "command": "npm test -- --run", + "result": "pass", + "evidence": "288 passed in 15 files, matching the baseline exactly." + }, + { + "command": "npm run coverage", + "result": "pass", + "evidence": "100% statements (863/863), branches, functions and lines. The two new modules are inside the coverage include glob." + }, + { + "command": "npm run lint", + "result": "fail repeatedly, then pass", + "evidence": "The split surfaced a genuine hazard: with Selection no longer imported, TypeScript silently resolved it to the DOM's global Selection type and reported 'Type number[] is missing anchorNode, anchorOffset, direction, focusNode and 19 more'. A local type name that collides with a DOM global fails in a confusing way rather than as 'cannot find name'." + }, + { + "command": "git checkout HEAD -- search.ts badgeValue.ts, rm the new modules, npm run lint", + "result": "pass", + "evidence": "Clean after both reverts, with the two successful splits left in place." + } + ], + "risks": [ + "search.ts and badgeValue.ts stay at 417 and 333 lines. Splitting them needs the recursion broken first - passing the strategy in, or hoisting the shared types into a third module - which is a design change, not a line-count split.", + "inventoryIo.ts re-exports eight names purely for compatibility. That indirection is invisible at the call site." + ], + "rollback": [ + "git revert the commit; simulate.ts and inventoryIo.ts return to 384 and 287 lines.", + "After rollback, re-run npm test and expect 288 passed." + ] +} diff --git a/kcd2_dice_solver/src/core/simulate.ts b/kcd2_dice_solver/src/core/simulate.ts index a9bf950a..566f9e46 100644 --- a/kcd2_dice_solver/src/core/simulate.ts +++ b/kcd2_dice_solver/src/core/simulate.ts @@ -19,11 +19,8 @@ * they change this loop. */ -import type { Die } from "../data/dice.ts"; -import { categoryWeights } from "./distribution.ts"; import { CATEGORIES } from "./scoring.ts"; import type { Scorer } from "./scoring.ts"; -import { mulberry32 } from "../lib/rng.ts"; import type { Random } from "../lib/rng.ts"; /** The push-your-luck policy the simulator plays. */ @@ -73,7 +70,7 @@ export const NO_CHARGES: BadgeCharges = { * @param random - Seeded random source. * @returns The index of the sampled category. */ -function sampleCategory(weights: readonly number[], random: Random): number { +export function sampleCategory(weights: readonly number[], random: Random): number { let target = random(); let slot = 0; // Stop one short: the final category absorbs any floating-point drift that @@ -94,7 +91,7 @@ function sampleCategory(weights: readonly number[], random: Random): number { * @param random - Seeded random source. * @returns The resulting count vector. */ -function roll(hand: readonly (readonly number[])[], random: Random): number[] { +export function roll(hand: readonly (readonly number[])[], random: Random): number[] { const counts = new Array(CATEGORIES).fill(0); for (const weights of hand) { counts[sampleCategory(weights, random)] += 1; @@ -205,180 +202,10 @@ export function applySetDie(counts: number[], value: number, scorer: Scorer): bo return true; } -/** - * Play one turn and return the points banked. - * - * @param dice - The six dice being played. - * @param scorer - Memoised scorer carrying the active badge rules. - * @param policy - The push-your-luck policy. - * @param charges - Badge charges available for this turn. - * @param random - Seeded random source. - * @param counter - Optional tally of how many throws the turn took. - * @returns Points banked; 0 if the turn ended in a bust. - */ -export function simulateTurn( - dice: readonly Die[], - scorer: Scorer, - policy: TurnPolicy, - charges: BadgeCharges, - random: Random, - counter: { throws: number } = { throws: 0 }, -): number { - const allWeights = dice.map(categoryWeights); - // The extra die a Might badge grants is modelled as another copy of the first - // die in the loadout — the loadout is uniform in practice, and the game does - // not say which die it adds. - const [spare] = allWeights; - - let inHand = allWeights.length; - let turnTotal = 0; - let extraDice = charges.extraDice; - let antibust = charges.antibust; - let setDie = charges.setDie; - let reroll = charges.reroll; - - for (;;) { - const hand = allWeights.slice(0, inHand); - if (extraDice > 0) { - hand.push(spare); - extraDice -= 1; - } - let counts = roll(hand, random); - counter.throws += 1; - - if (setDie > 0 && applySetDie(counts, charges.setDieValue, scorer)) { - setDie -= 1; - } - - let hold = chooseHold(counts, scorer, policy); - - if (hold === null && reroll > 0) { - // Re-roll the least useful dice rather than accept the bust. - reroll -= 1; - const replaced = Math.min(charges.rerollDice, hand.length); - counts = rerollWorst(counts, replaced, hand, random); - hold = chooseHold(counts, scorer, policy); - } - if (hold === null && antibust > 0) { - antibust -= 1; - continue; - } - if (hold === null) { - return 0; - } - - turnTotal += hold.points; - const remaining = hand.length - hold.used; - - // Banking is checked before the hot-dice re-roll, not after. Hot dice are - // often described as "free", but they are not: throwing six fresh dice can - // bust and forfeit the entire turn total. Re-rolling unconditionally also - // made the loop non-terminating for a die that always scores. - if (turnTotal >= policy.bankThreshold) { - return turnTotal; - } - // Every continue adds at least 50 points, so the turn always terminates. - inHand = remaining === 0 ? allWeights.length : remaining; - } -} - -/** - * Re-roll the least useful dice of a busted roll. - * - * @param counts - Count vector of the busted roll. - * @param howMany - How many dice to replace. - * @param hand - Category weights of the dice in hand. - * @param random - Seeded random source. - * @returns A new count vector with those dice re-rolled. - */ -function rerollWorst( - counts: readonly number[], - howMany: number, - hand: readonly (readonly number[])[], - random: Random, -): number[] { - const next = counts.slice(); - const preference = [2, 3, 4, 6, 5, 1]; - let removed = 0; - for (const face of preference) { - while (removed < howMany && next[face - 1] > 0) { - next[face - 1] -= 1; - removed += 1; - } - } - for (let i = 0; i < removed; i += 1) { - next[sampleCategory(hand[i], random)] += 1; - } - return next; -} - -/** Result of a Monte Carlo run. */ -export interface SimulationResult { - /** Mean points banked per turn. */ - readonly meanPerTurn: number; - /** Standard error of that mean, so noise is visible rather than implied. */ - readonly standardError: number; - /** Fraction of turns that ended in a bust. */ - readonly bustRate: number; - /** Mean number of throws made per turn. */ - readonly throwsPerTurn: number; - /** How many turns were played. */ - readonly turns: number; -} - -/** - * Golden-ratio increment used to derive a distinct stream per turn. - * - * Each turn is simulated from its own seed rather than from one long shared - * stream. That makes badge comparisons *paired*: a turn in which the badge never - * triggers plays out bit-for-bit identically with and without it, so the - * measured difference is the badge's effect and not a diverged random stream. - * With a shared stream the noise swamped the signal — badges that can only ever - * help were coming out slightly negative. - */ -const TURN_SEED_STEP = 0x9e3779b9; - -/** - * Estimate a dice set's mean banked points per turn. - * - * @param dice - The six dice being played. - * @param scorer - Memoised scorer carrying the active badge rules. - * @param turns - How many turns to simulate. - * @param policy - The push-your-luck policy. - * @param charges - Badge charges available each turn. - * @param seed - Seed for the random source, so runs are reproducible. - * @returns Mean, standard error, and bust rate. - */ -export function simulateTurns( - dice: readonly Die[], - scorer: Scorer, - turns: number, - policy: TurnPolicy = DEFAULT_POLICY, - charges: BadgeCharges = NO_CHARGES, - seed = 0x5eed, -): SimulationResult { - let sum = 0; - let sumSquares = 0; - let busts = 0; - let throws = 0; - for (let turn = 0; turn < turns; turn += 1) { - const random = mulberry32((seed + turn * TURN_SEED_STEP) >>> 0); - const counter = { throws: 0 }; - const banked = simulateTurn(dice, scorer, policy, charges, random, counter); - sum += banked; - sumSquares += banked * banked; - throws += counter.throws; - if (banked === 0) { - busts += 1; - } - } - const mean = sum / turns; - const variance = Math.max(0, sumSquares / turns - mean * mean); - return { - meanPerTurn: mean, - standardError: Math.sqrt(variance / turns), - bustRate: busts / turns, - throwsPerTurn: throws / turns, - turns, - }; -} +export { + simulateTurn, + simulateTurns, +} from "./simulateTurns.ts"; +export type { + SimulationResult, +} from "./simulateTurns.ts"; diff --git a/kcd2_dice_solver/src/core/simulateTurns.ts b/kcd2_dice_solver/src/core/simulateTurns.ts new file mode 100644 index 00000000..e6cb1fe6 --- /dev/null +++ b/kcd2_dice_solver/src/core/simulateTurns.ts @@ -0,0 +1,198 @@ +/** + * Whole-turn and multi-turn simulation. + * + * Split out of simulate.ts to keep it under the 250-line cap. + */ + +import type { Die } from "../data/dice.ts"; +import { + applySetDie, + chooseHold, + DEFAULT_POLICY, + NO_CHARGES, + roll, + sampleCategory, +} from "./simulate.ts"; +import type { BadgeCharges, TurnPolicy } from "./simulate.ts"; +import { categoryWeights } from "./distribution.ts"; +import type { Scorer } from "./scoring.ts"; +import { mulberry32 } from "../lib/rng.ts"; +import type { Random } from "../lib/rng.ts"; + +/** + * Play one turn and return the points banked. + * + * @param dice - The six dice being played. + * @param scorer - Memoised scorer carrying the active badge rules. + * @param policy - The push-your-luck policy. + * @param charges - Badge charges available for this turn. + * @param random - Seeded random source. + * @param counter - Optional tally of how many throws the turn took. + * @returns Points banked; 0 if the turn ended in a bust. + */ +export function simulateTurn( + dice: readonly Die[], + scorer: Scorer, + policy: TurnPolicy, + charges: BadgeCharges, + random: Random, + counter: { throws: number } = { throws: 0 }, +): number { + const allWeights = dice.map(categoryWeights); + // The extra die a Might badge grants is modelled as another copy of the first + // die in the loadout — the loadout is uniform in practice, and the game does + // not say which die it adds. + const [spare] = allWeights; + + let inHand = allWeights.length; + let turnTotal = 0; + let extraDice = charges.extraDice; + let antibust = charges.antibust; + let setDie = charges.setDie; + let reroll = charges.reroll; + + for (;;) { + const hand = allWeights.slice(0, inHand); + if (extraDice > 0) { + hand.push(spare); + extraDice -= 1; + } + let counts = roll(hand, random); + counter.throws += 1; + + if (setDie > 0 && applySetDie(counts, charges.setDieValue, scorer)) { + setDie -= 1; + } + + let hold = chooseHold(counts, scorer, policy); + + if (hold === null && reroll > 0) { + // Re-roll the least useful dice rather than accept the bust. + reroll -= 1; + const replaced = Math.min(charges.rerollDice, hand.length); + counts = rerollWorst(counts, replaced, hand, random); + hold = chooseHold(counts, scorer, policy); + } + if (hold === null && antibust > 0) { + antibust -= 1; + continue; + } + if (hold === null) { + return 0; + } + + turnTotal += hold.points; + const remaining = hand.length - hold.used; + + // Banking is checked before the hot-dice re-roll, not after. Hot dice are + // often described as "free", but they are not: throwing six fresh dice can + // bust and forfeit the entire turn total. Re-rolling unconditionally also + // made the loop non-terminating for a die that always scores. + if (turnTotal >= policy.bankThreshold) { + return turnTotal; + } + // Every continue adds at least 50 points, so the turn always terminates. + inHand = remaining === 0 ? allWeights.length : remaining; + } +} + +/** + * Re-roll the least useful dice of a busted roll. + * + * @param counts - Count vector of the busted roll. + * @param howMany - How many dice to replace. + * @param hand - Category weights of the dice in hand. + * @param random - Seeded random source. + * @returns A new count vector with those dice re-rolled. + */ +function rerollWorst( + counts: readonly number[], + howMany: number, + hand: readonly (readonly number[])[], + random: Random, +): number[] { + const next = counts.slice(); + const preference = [2, 3, 4, 6, 5, 1]; + let removed = 0; + for (const face of preference) { + while (removed < howMany && next[face - 1] > 0) { + next[face - 1] -= 1; + removed += 1; + } + } + for (let i = 0; i < removed; i += 1) { + next[sampleCategory(hand[i], random)] += 1; + } + return next; +} + +/** Result of a Monte Carlo run. */ +export interface SimulationResult { + /** Mean points banked per turn. */ + readonly meanPerTurn: number; + /** Standard error of that mean, so noise is visible rather than implied. */ + readonly standardError: number; + /** Fraction of turns that ended in a bust. */ + readonly bustRate: number; + /** Mean number of throws made per turn. */ + readonly throwsPerTurn: number; + /** How many turns were played. */ + readonly turns: number; +} + +/** + * Golden-ratio increment used to derive a distinct stream per turn. + * + * Each turn is simulated from its own seed rather than from one long shared + * stream. That makes badge comparisons *paired*: a turn in which the badge never + * triggers plays out bit-for-bit identically with and without it, so the + * measured difference is the badge's effect and not a diverged random stream. + * With a shared stream the noise swamped the signal — badges that can only ever + * help were coming out slightly negative. + */ +const TURN_SEED_STEP = 0x9e3779b9; + +/** + * Estimate a dice set's mean banked points per turn. + * + * @param dice - The six dice being played. + * @param scorer - Memoised scorer carrying the active badge rules. + * @param turns - How many turns to simulate. + * @param policy - The push-your-luck policy. + * @param charges - Badge charges available each turn. + * @param seed - Seed for the random source, so runs are reproducible. + * @returns Mean, standard error, and bust rate. + */ +export function simulateTurns( + dice: readonly Die[], + scorer: Scorer, + turns: number, + policy: TurnPolicy = DEFAULT_POLICY, + charges: BadgeCharges = NO_CHARGES, + seed = 0x5eed, +): SimulationResult { + let sum = 0; + let sumSquares = 0; + let busts = 0; + let throws = 0; + for (let turn = 0; turn < turns; turn += 1) { + const random = mulberry32((seed + turn * TURN_SEED_STEP) >>> 0); + const counter = { throws: 0 }; + const banked = simulateTurn(dice, scorer, policy, charges, random, counter); + sum += banked; + sumSquares += banked * banked; + throws += counter.throws; + if (banked === 0) { + busts += 1; + } + } + const mean = sum / turns; + const variance = Math.max(0, sumSquares / turns - mean * mean); + return { + meanPerTurn: mean, + standardError: Math.sqrt(variance / turns), + bustRate: busts / turns, + throwsPerTurn: throws / turns, + turns, + }; +} diff --git a/kcd2_dice_solver/src/lib/inventoryHash.ts b/kcd2_dice_solver/src/lib/inventoryHash.ts new file mode 100644 index 00000000..2e40eb98 --- /dev/null +++ b/kcd2_dice_solver/src/lib/inventoryHash.ts @@ -0,0 +1,130 @@ +/** + * Serialising an inventory into a URL hash and back. + * + * Split out of inventoryIo.ts to keep it under the 250-line cap. + */ + +import { EMPTY, HASH_KEY, parseInventoryJson } from "./inventoryIo.ts"; +import type { ParseResult, SavedInventory } from "./inventoryIo.ts"; + +/** + * 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; +} + +/** + * 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 => 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, +}; diff --git a/kcd2_dice_solver/src/lib/inventoryIo.ts b/kcd2_dice_solver/src/lib/inventoryIo.ts index d7757a06..25ebd738 100644 --- a/kcd2_dice_solver/src/lib/inventoryIo.ts +++ b/kcd2_dice_solver/src/lib/inventoryIo.ts @@ -164,124 +164,14 @@ export function loadInventory(storage: Pick): ParseResult { 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; -} - -/** - * 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 => 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, -}; +export { + browserClipboard, + browserSaveFile, + browserUrlHash, + decodeInventoryHash, + encodeInventoryHash, + fromBase64Url, + serialiseInventory, + toBase64Url, +} from "./inventoryHash.ts"; +export type { ClipboardPort, SaveFilePort, UrlHashPort } from "./inventoryHash.ts";