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";