{"format":1,"verifier":"src/games/verify.ts","versions":{"peeks-originals-1":"src/games/math.ts","peeks-dice-1":"src/lib/games/dice-rules.ts","peeks-plinko-2":"src/lib/games/plinko-2.ts","peeks-plinko-normal-1":"src/lib/games/plinko-normal.ts","peeks-limbo-1":"src/lib/games/limbo-rules.ts","peeks-keno-1":"src/lib/games/keno-rules.ts","peeks-wheel-1":"src/lib/games/wheel-rules.ts","peeks-roulette-1":"src/lib/games/roulette-rules.ts","peeks-mines-1":"src/lib/games/mines-rules.ts","peeks-tower-1":"src/lib/games/tower-rules.ts","peeks-blackjack-1":"src/lib/games/blackjack-rules.ts","peeks-coinflip-2":"src/lib/games/coinflip-rules.ts","peeks-moles-2":"src/lib/games/moles-rules.ts","peeks-baccarat-1":"src/lib/games/baccarat-rules.ts","peeks-hilo-1":"src/lib/games/hilo-rules.ts","peeks-rps-1":"src/lib/games/rps-rules.ts","peeks-war-1":"src/lib/games/war-rules.ts","peeks-chicken-1":"src/lib/games/chicken-rules.ts","peeks-plinko-3":"src/lib/games/plinko-2.ts","peeks-dice-2":"src/lib/games/dice-rules.ts","peeks-limbo-2":"src/lib/games/limbo-rules.ts","peeks-keno-2":"src/lib/games/keno-rules.ts","peeks-wheel-2":"src/lib/games/wheel-rules.ts","peeks-roulette-2":"src/lib/games/roulette-rules.ts","peeks-mines-2":"src/lib/games/mines-rules.ts","peeks-tower-2":"src/lib/games/tower-rules.ts","peeks-blackjack-2":"src/lib/games/blackjack-rules.ts","peeks-baccarat-2":"src/lib/games/baccarat-rules.ts","peeks-hilo-2":"src/lib/games/hilo-rules.ts","peeks-war-2":"src/lib/games/war-rules.ts","peeks-rps-2":"src/lib/games/rps-rules.ts","peeks-chicken-2":"src/lib/games/chicken-rules.ts","peeks-coinflip-3":"src/lib/games/coinflip-rules.ts","peeks-moles-3":"src/lib/games/moles-rules.ts"},"files":{"src/games/math.ts":{"source":"import { actBlackjack, blackjackAdditionalStake, dealBlackjack } from \"./blackjack\";\nimport { validatePlinkoRows } from \"./plinko-rules\";\nimport {\n  actMoles,\n  assertMolesStake,\n  evaluateMoles,\n  molesPreview,\n  validateMolesParameters,\n} from \"./moles\";\nimport {\n  COINFLIP_RULES,\n  assertCoinflipStake,\n  validateCoinflipParameters,\n  type CoinflipSide,\n} from \"./coinflip\";\nimport { validateRouletteBets, rouletteTableOutcome, rouletteTableReturns } from \"./roulette\";\nimport {\n  DICE_RANGE_RULES,\n  diceWinningTickets,\n  diceWins,\n  validateDiceParameters,\n  type DiceRange,\n} from \"./dice\";\nimport {\n  TABLE_GAME_SLUGS,\n  isTableGame,\n  validateTableParameters,\n  evaluateTableBet,\n  actOnTableGame,\n  maximumTablePayoutMinor,\n  theoreticalTableRtp,\n  type TableGameSlug,\n} from \"./table-math\";\n\nexport const GAME_SLUGS = [\n  \"dice\",\n  \"plinko\",\n  \"limbo\",\n  \"keno\",\n  \"wheel\",\n  \"roulette\",\n  \"mines\",\n  \"tower\",\n  \"blackjack\",\n  \"coinflip\",\n  \"moles\",\n  ...TABLE_GAME_SLUGS,\n] as const;\nexport type GameSlug = (typeof GAME_SLUGS)[number];\nexport const RULES_VERSION = \"peeks-originals-1\";\nexport const MAX_PAYOUT_MINOR = 100_000_000n;\nexport type Parameters = Record<string, unknown>;\nexport type Ratio = { numerator: bigint; denominator: bigint };\nexport type RandomSource = { integer(max: number): number };\nexport type Resolution = {\n  game: GameSlug;\n  status: \"WON\" | \"LOST\" | \"ACTIVE\";\n  payoutMinor: string;\n  multiplier: string;\n  result: Record<string, unknown>;\n  secret?: Record<string, unknown>;\n  rulesVersion: string;\n  additionalStakeMinor?: string;\n};\nconst invalid = (message = \"Invalid game parameters\") => new RangeError(message);\nexport function isGameSlug(value: unknown): value is GameSlug {\n  return typeof value === \"string\" && (GAME_SLUGS as readonly string[]).includes(value);\n}\nfunction integer(value: unknown, min: number, max: number): number {\n  if (typeof value !== \"number\" || !Number.isSafeInteger(value) || value < min || value > max)\n    throw invalid();\n  return value;\n}\nfunction risk(value: unknown) {\n  if (![\"low\", \"medium\", \"high\"].includes(value as string)) throw invalid();\n  return value as \"low\" | \"medium\" | \"high\";\n}\nexport function validateParameters(game: GameSlug, input: unknown = {}): Parameters {\n  if (!isGameSlug(game) || !input || typeof input !== \"object\" || Array.isArray(input))\n    throw invalid();\n  const p = input as Parameters;\n  if (isTableGame(game)) return validateTableParameters(game, input);\n  if (game === \"coinflip\") return validateCoinflipParameters(input);\n  if (game === \"moles\") return validateMolesParameters(input);\n  const fields: Record<Exclude<GameSlug, TableGameSlug | \"coinflip\" | \"moles\">, string[]> = {\n    dice: [\"chance\", \"side\", \"mode\", \"ranges\"],\n    plinko: [\"rows\", \"risk\"],\n    limbo: [\"target\"],\n    keno: [\"selected\", \"risk\"],\n    wheel: [\"risk\"],\n    roulette: [\"selection\", \"bets\"],\n    mines: [\"mines\"],\n    tower: [\"risk\"],\n    blackjack: [],\n  };\n  if (Object.keys(p).some((key) => !fields[game].includes(key))) throw invalid();\n  if (game === \"blackjack\") return {};\n  if (game === \"dice\") {\n    if (\"mode\" in p || \"ranges\" in p) return validateDiceParameters(p);\n    const side = p.side ?? \"under\";\n    if (side !== \"under\" && side !== \"over\") throw invalid();\n    return { chance: integer(p.chance ?? 50, 2, 98), side };\n  }\n  if (game === \"plinko\") {\n    return { rows: validatePlinkoRows(p.rows ?? 16), risk: risk(p.risk ?? \"medium\") };\n  }\n  if (game === \"limbo\") {\n    const target = p.target ?? 2;\n    if (\n      typeof target !== \"number\" ||\n      !Number.isFinite(target) ||\n      target < 1.01 ||\n      target > 1000 ||\n      Math.abs(target * 100 - Math.round(target * 100)) > 1e-7\n    )\n      throw invalid();\n    return { target };\n  }\n  if (game === \"keno\") {\n    if (\n      !Array.isArray(p.selected) ||\n      !p.selected.length ||\n      p.selected.length > 10 ||\n      new Set(p.selected).size !== p.selected.length\n    )\n      throw invalid(\"Pick 1–10 different numbers\");\n    return {\n      selected: p.selected.map((value) => integer(value, 1, 40)).sort((a, b) => a - b),\n      risk: risk(p.risk ?? \"medium\"),\n    };\n  }\n  if (game === \"roulette\") {\n    if (\"bets\" in p) {\n      if (\"selection\" in p) throw invalid(\"Choose a chip layout or a legacy selection, not both\");\n      return { bets: validateRouletteBets(p.bets) };\n    }\n    const selection = p.selection ?? \"red\";\n    if (typeof selection === \"number\") integer(selection, 0, 36);\n    else if (![\"red\", \"black\", \"even\", \"odd\", \"low\", \"high\"].includes(selection as string))\n      throw invalid();\n    return { selection };\n  }\n  if (game === \"mines\") return { mines: integer(p.mines ?? 3, 1, 24) };\n  return { risk: risk(p.risk ?? \"medium\") };\n}\nexport function choose(n: number, k: number): bigint {\n  if (k < 0 || k > n) return 0n;\n  let value = 1n;\n  for (let i = 1; i <= Math.min(k, n - k); i++) value = (value * BigInt(n - i + 1)) / BigInt(i);\n  return value;\n}\nexport function ratio(numerator: bigint, denominator: bigint): Ratio {\n  if (numerator < 0n || denominator <= 0n) throw invalid();\n  return { numerator, denominator };\n}\nexport function ratioText(value: Ratio, digits = 4): string {\n  const scale = 10n ** BigInt(digits);\n  const fixed = (value.numerator * scale) / value.denominator;\n  return `${fixed / scale}.${(fixed % scale).toString().padStart(digits, \"0\")}`;\n}\nexport function ratioWire(value: Ratio) {\n  return {\n    numerator: value.numerator.toString(),\n    denominator: value.denominator.toString(),\n    value: ratioText(value, 8),\n  };\n}\nexport function pay(stake: bigint, multiplier: Ratio): bigint {\n  if (stake <= 0n) throw invalid(\"Stake must be positive\");\n  const amount = (stake * multiplier.numerator) / multiplier.denominator;\n  return amount > MAX_PAYOUT_MINOR ? MAX_PAYOUT_MINOR : amount;\n}\nfunction normalizedSchedule(weights: bigint[], probabilities: bigint[], total: bigint): Ratio[] {\n  const expectation = weights.reduce(\n    (sum, weight, index) => sum + weight * probabilities[index],\n    0n,\n  );\n  return weights.map((weight) => ratio(weight * 99n * total, expectation * 100n));\n}\nexport function plinkoSchedule(rows: number, selectedRisk: string): Ratio[] {\n  validatePlinkoRows(rows);\n  risk(selectedRisk);\n  const exponent = selectedRisk === \"high\" ? 7n : selectedRisk === \"medium\" ? 4n : 2n;\n  const weights = Array.from({ length: rows + 1 }, (_, index) =>\n    rows % 2 === 0\n      ? 1n + BigInt(Math.abs(index - rows / 2)) ** exponent\n      : 2n ** exponent + BigInt(Math.abs(2 * index - rows)) ** exponent,\n  );\n  return normalizedSchedule(\n    weights,\n    weights.map((_, index) => choose(rows, index)),\n    2n ** BigInt(rows),\n  );\n}\nexport function kenoSchedule(picks: number, selectedRisk: string): Ratio[] {\n  integer(picks, 1, 10);\n  risk(selectedRisk);\n  const threshold =\n    selectedRisk === \"low\"\n      ? 1\n      : selectedRisk === \"medium\"\n        ? Math.max(1, Math.ceil(picks / 3))\n        : Math.max(1, Math.ceil(picks / 2));\n  const weights = Array.from({ length: picks + 1 }, (_, hits) =>\n    hits < threshold ? 0n : BigInt(hits - threshold + 1) ** BigInt(selectedRisk === \"high\" ? 4 : 2),\n  );\n  return normalizedSchedule(\n    weights,\n    weights.map((_, hits) => choose(10, hits) * choose(30, picks - hits)),\n    choose(40, picks),\n  );\n}\nexport function wheelSchedule(selectedRisk: string): Ratio[] {\n  risk(selectedRisk);\n  const weights = Array.from({ length: 20 }, (_, index) =>\n    selectedRisk === \"high\"\n      ? index === 0\n        ? 20n\n        : 0n\n      : selectedRisk === \"medium\"\n        ? index % 5 === 0\n          ? 5n\n          : 0n\n        : index % 2 === 0\n          ? 2n\n          : 0n,\n  );\n  return normalizedSchedule(\n    weights,\n    weights.map(() => 1n),\n    20n,\n  );\n}\nexport const ROULETTE_RED = [1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36];\nexport const ROULETTE_ORDER = [\n  0, 32, 15, 19, 4, 21, 2, 25, 17, 34, 6, 27, 13, 36, 11, 30, 8, 23, 10, 5, 24, 16, 33, 1, 20, 14,\n  31, 9, 22, 18, 29, 7, 28, 12, 35, 3, 26,\n];\nexport function rouletteWins(roll: number, selection: unknown): boolean {\n  if (typeof selection === \"number\") return selection === roll;\n  if (!roll) return false;\n  return selection === \"red\"\n    ? ROULETTE_RED.includes(roll)\n    : selection === \"black\"\n      ? !ROULETTE_RED.includes(roll)\n      : selection === \"even\"\n        ? roll % 2 === 0\n        : selection === \"odd\"\n          ? roll % 2 === 1\n          : selection === \"low\"\n            ? roll <= 18\n            : roll >= 19;\n}\nexport function interactiveMultiplier(\n  game: \"mines\" | \"tower\",\n  parameters: Parameters,\n  revealed: number,\n): Ratio {\n  if (revealed === 0) return ratio(1n, 1n);\n  if (game === \"mines\")\n    return ratio(\n      99n * choose(25, revealed),\n      100n * choose(25 - (parameters.mines as number), revealed),\n    );\n  const safe = parameters.risk === \"high\" ? 1n : parameters.risk === \"low\" ? 3n : 2n,\n    columns = parameters.risk === \"low\" ? 4n : 3n;\n  return ratio(99n * columns ** BigInt(revealed), 100n * safe ** BigInt(revealed));\n}\nexport function maximumPayoutMinor(input: {\n  game: GameSlug;\n  stakeMinor: bigint;\n  parameters: unknown;\n}): bigint {\n  const p = validateParameters(input.game, input.parameters),\n    stake = input.stakeMinor;\n  if (stake <= 0n) throw invalid();\n  if (input.game === \"coinflip\") return assertCoinflipStake(stake, p).winningPayoutMinor;\n  if (input.game === \"moles\") return assertMolesStake(stake, p).winningPayoutMinor;\n  if (input.game === \"roulette\" && p.bets)\n    return rouletteTableReturns(stake, p.bets, MAX_PAYOUT_MINOR).maximumPayoutMinor;\n  if (input.game === \"blackjack\") return pay(stake, ratio(16n, 1n));\n  if (isTableGame(input.game)) return maximumTablePayoutMinor({ ...input, game: input.game });\n  const candidates =\n    input.game === \"plinko\"\n      ? plinkoSchedule(p.rows as number, p.risk as string)\n      : input.game === \"keno\"\n        ? kenoSchedule((p.selected as number[]).length, p.risk as string)\n        : input.game === \"wheel\"\n          ? wheelSchedule(p.risk as string)\n          : [\n              input.game === \"dice\"\n                ? p.ranges\n                  ? ratio(9900n, BigInt(diceWinningTickets(p.ranges as DiceRange[])))\n                  : ratio(99n, BigInt(p.chance as number))\n                : input.game === \"limbo\"\n                  ? ratio(BigInt(Math.round((p.target as number) * 100)), 100n)\n                  : input.game === \"roulette\"\n                    ? ratio(typeof p.selection === \"number\" ? 36n : 2n, 1n)\n                    : interactiveMultiplier(\n                        input.game as \"mines\" | \"tower\",\n                        p,\n                        input.game === \"mines\" ? 25 - (p.mines as number) : 9,\n                      ),\n            ];\n  return candidates.reduce((max, value) => {\n    const amount = pay(stake, value);\n    return amount > max ? amount : max;\n  }, stake);\n}\nexport function theoreticalRtp(\n  game: GameSlug,\n  stake: bigint,\n  parameters: unknown,\n  reveals = 1,\n): Ratio {\n  const p = validateParameters(game, parameters);\n  if (stake <= 0n) throw invalid();\n  if (game === \"coinflip\") return assertCoinflipStake(stake, p).rtp;\n  if (game === \"moles\") {\n    const terms = assertMolesStake(stake, p);\n    return molesPreview(stake, p, terms.parameters.mode === \"auto\" ? terms.horizon : reveals).rtp;\n  }\n  if (isTableGame(game)) return theoreticalTableRtp(game, stake, parameters, reveals);\n  if (game === \"blackjack\") throw invalid(\"Blackjack return depends on player strategy\");\n  if (game === \"roulette\" && p.bets) {\n    const value = rouletteTableReturns(stake, p.bets, MAX_PAYOUT_MINOR);\n    return ratio(value.numerator, value.denominator);\n  }\n  if (game === \"dice\" && p.ranges) {\n    const winning = BigInt(diceWinningTickets(p.ranges as DiceRange[]));\n    return ratio(pay(stake, ratio(9900n, winning)) * winning, stake * 10000n);\n  }\n  if (game === \"dice\")\n    return ratio(\n      pay(stake, ratio(99n, BigInt(p.chance as number))) * BigInt(p.chance as number),\n      stake * 100n,\n    );\n  if (game === \"limbo\") {\n    const target = BigInt(Math.round((p.target as number) * 100)),\n      winning = (9900n * 1_000_000n) / target;\n    return ratio(pay(stake, ratio(target, 100n)) * winning, stake * 100_000_000n);\n  }\n  if (game === \"roulette\")\n    return ratio(\n      pay(stake, ratio(typeof p.selection === \"number\" ? 36n : 2n, 1n)) *\n        (typeof p.selection === \"number\" ? 1n : 18n),\n      stake * 37n,\n    );\n  if (game === \"mines\")\n    return ratio(\n      pay(stake, interactiveMultiplier(game, p, reveals)) *\n        choose(25 - (p.mines as number), reveals),\n      stake * choose(25, reveals),\n    );\n  if (game === \"tower\")\n    return ratio(\n      pay(stake, interactiveMultiplier(game, p, reveals)) *\n        (p.risk === \"high\" ? 1n : p.risk === \"low\" ? 3n : 2n) ** BigInt(reveals),\n      stake * (p.risk === \"low\" ? 4n : 3n) ** BigInt(reveals),\n    );\n  const table =\n    game === \"plinko\"\n      ? plinkoSchedule(p.rows as number, p.risk as string)\n      : game === \"keno\"\n        ? kenoSchedule((p.selected as number[]).length, p.risk as string)\n        : wheelSchedule(p.risk as string);\n  const probabilities = table.map((_, i) =>\n    game === \"plinko\"\n      ? choose(p.rows as number, i)\n      : game === \"keno\"\n        ? choose(10, i) * choose(30, (p.selected as number[]).length - i)\n        : 1n,\n  );\n  return ratio(\n    table.reduce((sum, value, i) => sum + pay(stake, value) * probabilities[i], 0n),\n    stake *\n      (game === \"plinko\"\n        ? 2n ** BigInt(p.rows as number)\n        : game === \"keno\"\n          ? choose(40, (p.selected as number[]).length)\n          : 20n),\n  );\n}\nfunction sample(size: number, count: number, random: RandomSource): number[] {\n  const items = Array.from({ length: size }, (_, index) => index);\n  for (let i = size - 1; i > 0; i--) {\n    const j = random.integer(i + 1);\n    [items[i], items[j]] = [items[j], items[i]];\n  }\n  return items.slice(0, count);\n}\nfunction resolved(\n  game: GameSlug,\n  stake: bigint,\n  payout: bigint,\n  result: Parameters,\n  status?: Resolution[\"status\"],\n  secret?: Parameters,\n): Resolution {\n  return {\n    game,\n    status: status ?? (payout > 0n ? \"WON\" : \"LOST\"),\n    payoutMinor: payout.toString(),\n    multiplier: ratioText(ratio(payout, stake)),\n    result,\n    ...(secret ? { secret } : {}),\n    rulesVersion: RULES_VERSION,\n  };\n}\nexport function evaluateBet(\n  input: { game: GameSlug; stakeMinor: bigint; parameters: unknown },\n  random: RandomSource,\n): Resolution {\n  const { game, stakeMinor: stake } = input,\n    p = validateParameters(game, input.parameters);\n  if (stake <= 0n) throw invalid();\n  if (game === \"moles\") return evaluateMoles(stake, p, random);\n  if (game === \"blackjack\") return dealBlackjack(stake, random);\n  if (isTableGame(game)) return evaluateTableBet({ ...input, game }, random);\n  const common = {\n    parameters: p,\n    theoreticalRtp: ratioWire(theoreticalRtp(game, stake, p)),\n    maxPayoutMinor: MAX_PAYOUT_MINOR.toString(),\n  };\n  if (game === \"coinflip\") {\n    const terms = assertCoinflipStake(stake, p),\n      parameters = terms.parameters,\n      flips: CoinflipSide[] = Array.from({ length: terms.coins }, () =>\n        random.integer(2) === 0 ? \"heads\" : \"tails\",\n      ),\n      hits = flips.reduce(\n        (sum, flip, index) =>\n          sum +\n          Number(\n            flip === (parameters.mode === \"classic\" ? parameters.sides[index] : parameters.side),\n          ),\n        0,\n      ),\n      won = hits >= terms.requiredHits;\n    return resolved(game, stake, won ? terms.winningPayoutMinor : 0n, {\n      ...common,\n      coinflipRules: COINFLIP_RULES,\n      flips,\n      hits,\n      requiredHits: terms.requiredHits,\n      won,\n      winningOutcomes: terms.winningOutcomes.toString(),\n      totalOutcomes: terms.totalOutcomes.toString(),\n      nominalMultiplier: ratioWire(terms.nominalMultiplier),\n    });\n  }\n  if (game === \"dice\") {\n    const roll = random.integer(10_000);\n    if (p.ranges) {\n      const ranges = p.ranges as DiceRange[],\n        winningTickets = diceWinningTickets(ranges);\n      const multiplier = ratio(9900n, BigInt(winningTickets));\n      return resolved(game, stake, diceWins(roll, ranges) ? pay(stake, multiplier) : 0n, {\n        ...common,\n        roll,\n        rangeRules: DICE_RANGE_RULES,\n        winningTickets,\n        nominalMultiplier: ratioWire(multiplier),\n      });\n    }\n    const win =\n      p.side === \"under\"\n        ? roll < (p.chance as number) * 100\n        : roll >= (100 - (p.chance as number)) * 100;\n    return resolved(game, stake, win ? pay(stake, ratio(99n, BigInt(p.chance as number))) : 0n, {\n      ...common,\n      roll,\n      threshold: p.side === \"under\" ? p.chance : 100 - (p.chance as number),\n      nominalMultiplier: ratioWire(ratio(99n, BigInt(p.chance as number))),\n    });\n  }\n  if (game === \"limbo\") {\n    const word = random.integer(100_000_000),\n      hundredths = Math.min(100_000, Math.max(100, Math.floor(9_900_000_000 / (word + 1)))),\n      target = Math.round((p.target as number) * 100);\n    return resolved(\n      game,\n      stake,\n      hundredths >= target ? pay(stake, ratio(BigInt(target), 100n)) : 0n,\n      { ...common, hundredths, target },\n    );\n  }\n  if (game === \"plinko\") {\n    const path = Array.from({ length: p.rows as number }, () => random.integer(2)),\n      bucket = path.reduce((sum, bit) => sum + bit, 0),\n      table = plinkoSchedule(p.rows as number, p.risk as string);\n    return resolved(game, stake, pay(stake, table[bucket]), {\n      ...common,\n      path,\n      bucket,\n      table: table.map(ratioWire),\n    });\n  }\n  if (game === \"keno\") {\n    const drawn = sample(40, 10, random).map((value) => value + 1),\n      selected = p.selected as number[],\n      hits = selected.filter((value) => drawn.includes(value)).length,\n      table = kenoSchedule(selected.length, p.risk as string);\n    return resolved(game, stake, pay(stake, table[hits]), {\n      ...common,\n      drawn,\n      hits,\n      table: table.map(ratioWire),\n    });\n  }\n  if (game === \"wheel\") {\n    const segment = random.integer(20),\n      table = wheelSchedule(p.risk as string);\n    return resolved(game, stake, pay(stake, table[segment]), {\n      ...common,\n      segment,\n      table: table.map(ratioWire),\n    });\n  }\n  if (game === \"roulette\") {\n    const roll = random.integer(37);\n    if (p.bets) {\n      const outcome = rouletteTableOutcome(stake, p.bets, roll, MAX_PAYOUT_MINOR);\n      return resolved(game, stake, outcome.payoutMinor, {\n        ...common,\n        roll,\n        color: roll === 0 ? \"green\" : ROULETTE_RED.includes(roll) ? \"red\" : \"black\",\n        settlements: outcome.settlements,\n        grossBeforeCapMinor: outcome.grossMinor.toString(),\n        capped: outcome.grossMinor > outcome.payoutMinor,\n      });\n    }\n    return resolved(\n      game,\n      stake,\n      rouletteWins(roll, p.selection)\n        ? pay(stake, ratio(typeof p.selection === \"number\" ? 36n : 2n, 1n))\n        : 0n,\n      {\n        ...common,\n        roll,\n        color: roll === 0 ? \"green\" : ROULETTE_RED.includes(roll) ? \"red\" : \"black\",\n      },\n    );\n  }\n  const hazards =\n    game === \"mines\"\n      ? sample(25, p.mines as number, random).sort((a, b) => a - b)\n      : Array.from({ length: 9 }, () =>\n          sample(p.risk === \"low\" ? 4 : 3, p.risk === \"high\" ? 2 : 1, random),\n        );\n  return resolved(\n    game,\n    stake,\n    0n,\n    {\n      ...common,\n      revealed: [],\n      decisions: [],\n      potentialPayoutMinor: stake.toString(),\n      nextMultiplier: ratioWire(interactiveMultiplier(game, p, 1)),\n    },\n    \"ACTIVE\",\n    { parameters: p, hazards, revealed: [], decisions: [] },\n  );\n}\nexport type GameAction = \"reveal\" | \"cashout\" | \"hit\" | \"stand\" | \"double\" | \"split\";\nexport type GameDecision = { action: GameAction; selection?: number; expectedStep?: number };\nexport function additionalStakeMinor(input: {\n  game: GameSlug;\n  secret: Parameters;\n  action: GameAction;\n}): bigint {\n  return input.game === \"blackjack\" ? blackjackAdditionalStake(input.secret, input.action) : 0n;\n}\nexport function actOnGame(input: {\n  game: GameSlug;\n  stakeMinor: bigint;\n  secret: Parameters;\n  action: GameAction;\n  selection?: number;\n  expectedStep?: number;\n}): Resolution {\n  const { game, stakeMinor: stake, secret, action } = input;\n  if (game === \"moles\") return actMoles(input);\n  if (input.expectedStep !== undefined) throw invalid(\"This game does not use Moles steps\");\n  if (isTableGame(game)) {\n    if (action !== \"reveal\" && action !== \"cashout\") throw invalid();\n    return actOnTableGame({ ...input, game, action });\n  }\n  if (game === \"blackjack\") {\n    if (stake <= 0n) throw invalid();\n    return actBlackjack(stake, secret, action);\n  }\n  if (\n    (game !== \"mines\" && game !== \"tower\") ||\n    !secret ||\n    stake <= 0n ||\n    !Array.isArray(secret.revealed) ||\n    !Array.isArray(secret.hazards)\n  )\n    throw invalid(\"Invalid active game\");\n  const p = validateParameters(game, secret.parameters),\n    revealed = [...secret.revealed] as number[],\n    hazards = secret.hazards as number[] | number[][];\n  const maxReveals = game === \"mines\" ? 25 - (p.mines as number) : 9;\n  if (action !== \"reveal\" && action !== \"cashout\") throw invalid();\n  let lost = false;\n  if (action === \"reveal\") {\n    const selection = integer(input.selection, 0, game === \"mines\" ? 24 : p.risk === \"low\" ? 3 : 2);\n    if (revealed.length >= maxReveals || (game === \"mines\" && revealed.includes(selection)))\n      throw invalid(\"Choose an unrevealed tile\");\n    lost =\n      game === \"mines\"\n        ? (hazards as number[]).includes(selection)\n        : (hazards as number[][])[revealed.length].includes(selection);\n    revealed.push(selection);\n  }\n  const complete = lost || action === \"cashout\" || revealed.length === maxReveals;\n  const potential = lost ? 0n : pay(stake, interactiveMultiplier(game, p, revealed.length));\n  const result = {\n    parameters: p,\n    revealed,\n    decisions: [\n      ...(Array.isArray(secret.decisions) ? secret.decisions : []),\n      {\n        action,\n        ...(input.selection === undefined ? {} : { selection: input.selection }),\n      },\n    ],\n    potentialPayoutMinor: potential.toString(),\n    theoreticalRtp: ratioWire(theoreticalRtp(game, stake, p, revealed.length)),\n    maxPayoutMinor: MAX_PAYOUT_MINOR.toString(),\n    ...(complete\n      ? { hazards }\n      : {\n          nextMultiplier: ratioWire(interactiveMultiplier(game, p, revealed.length + 1)),\n        }),\n    ...(lost ? { hit: input.selection } : {}),\n    ...(action === \"cashout\" && !revealed.length ? { refunded: true } : {}),\n  };\n  return resolved(\n    game,\n    stake,\n    complete ? potential : 0n,\n    result,\n    complete ? (lost ? \"LOST\" : \"WON\") : \"ACTIVE\",\n    complete ? undefined : { ...secret, revealed, decisions: result.decisions },\n  );\n}\n","sha256":"a58e3b469890336753d92760eeec52befc261ad83251ea9ce7abfddc2c5da794"},"src/games/blackjack.ts":{"source":"import {\n  MAX_PAYOUT_MINOR,\n  RULES_VERSION,\n  ratio,\n  ratioText,\n  type Parameters,\n  type RandomSource,\n  type Resolution,\n} from \"./math\";\n\nexport type BlackjackAction = \"hit\" | \"stand\" | \"double\" | \"split\" | \"cashout\";\nexport type BlackjackHand = {\n  cards: number[];\n  stakeMinor: string;\n  stood: boolean;\n  fromSplit: boolean;\n  splitAces: boolean;\n};\nexport type BlackjackSecret = {\n  shoe: number[];\n  cursor: number;\n  dealer: number[];\n  hands: BlackjackHand[];\n  activeHand: number;\n  originalStakeMinor: string;\n  decisions?: { action: BlackjackAction }[];\n};\nexport const BLACKJACK_RULES = Object.freeze({\n  decks: 6,\n  dealerStandsSoft17: true,\n  naturalPayout: \"3:2\",\n  maxHands: 4,\n  doubleAfterSplit: true,\n  splitEqualRanks: true,\n  splitAcesOneCard: true,\n  insurance: false,\n  surrender: false,\n});\nexport function cardRank(card: number) {\n  return (card % 13) + 1;\n}\nexport function handValue(cards: number[]) {\n  let total = 0,\n    aces = 0;\n  for (const card of cards) {\n    const rank = cardRank(card);\n    total += rank === 1 ? 11 : Math.min(10, rank);\n    if (rank === 1) aces++;\n  }\n  while (total > 21 && aces > 0) {\n    total -= 10;\n    aces--;\n  }\n  return { total, soft: aces > 0, bust: total > 21 };\n}\nfunction natural(hand: BlackjackHand) {\n  return !hand.fromSplit && hand.cards.length === 2 && handValue(hand.cards).total === 21;\n}\nfunction draw(secret: BlackjackSecret) {\n  if (secret.cursor >= secret.shoe.length) throw new RangeError(\"The committed shoe is exhausted\");\n  return secret.shoe[secret.cursor++];\n}\nfunction currentHand(secret: BlackjackSecret) {\n  const hand = secret.hands[secret.activeHand];\n  if (!hand || hand.stood || handValue(hand.cards).bust)\n    throw new RangeError(\"No active Blackjack hand\");\n  return hand;\n}\nexport function blackjackAdditionalStake(secret: Parameters, action: string): bigint {\n  if (action !== \"double\" && action !== \"split\") return 0n;\n  const state = secret as unknown as BlackjackSecret,\n    hand = currentHand(state);\n  if (hand.cards.length !== 2 || hand.splitAces)\n    throw new RangeError(\"This action requires two initial cards\");\n  if (\n    action === \"split\" &&\n    (state.hands.length >= 4 || cardRank(hand.cards[0]) !== cardRank(hand.cards[1]))\n  )\n    throw new RangeError(\"Only equal ranks can split, up to four hands\");\n  return BigInt(hand.stakeMinor);\n}\nfunction publicResult(secret: BlackjackSecret, terminal: boolean, payouts?: bigint[]) {\n  const hand = secret.hands[secret.activeHand],\n    value = hand ? handValue(hand.cards) : null;\n  return {\n    parameters: {},\n    rules: BLACKJACK_RULES,\n    initialStakeMinor: secret.originalStakeMinor,\n    decisions: secret.decisions ?? [],\n    rtpType: \"strategy-dependent\",\n    dealer: terminal ? secret.dealer : [secret.dealer[0], null],\n    dealerTotal: terminal ? handValue(secret.dealer).total : handValue([secret.dealer[0]]).total,\n    hands: secret.hands.map((hand, index) => ({\n      cards: hand.cards,\n      stakeMinor: hand.stakeMinor,\n      total: handValue(hand.cards).total,\n      soft: handValue(hand.cards).soft,\n      bust: handValue(hand.cards).bust,\n      stood: hand.stood,\n      natural: natural(hand),\n      ...(payouts ? { payoutMinor: payouts[index].toString() } : {}),\n    })),\n    activeHand: terminal ? null : secret.activeHand,\n    actions:\n      terminal || !hand\n        ? []\n        : [\n            \"hit\",\n            \"stand\",\n            ...(hand.cards.length === 2 && !hand.splitAces ? [\"double\"] : []),\n            ...(hand.cards.length === 2 &&\n            !hand.splitAces &&\n            secret.hands.length < 4 &&\n            cardRank(hand.cards[0]) === cardRank(hand.cards[1])\n              ? [\"split\"]\n              : []),\n          ],\n    nextTotal: value?.total,\n    maxPayoutMinor: MAX_PAYOUT_MINOR.toString(),\n  };\n}\nfunction resolution(\n  secret: BlackjackSecret,\n  totalStake: bigint,\n  terminal: boolean,\n  extra = 0n,\n): Resolution {\n  const dealer = handValue(secret.dealer),\n    dealerNatural = secret.dealer.length === 2 && dealer.total === 21;\n  const payouts = secret.hands.map((hand) => {\n    const value = handValue(hand.cards),\n      stake = BigInt(hand.stakeMinor);\n    if (value.bust) return 0n;\n    if (natural(hand)) return dealerNatural ? stake : (stake * 5n) / 2n;\n    if (dealerNatural) return 0n;\n    return dealer.bust || value.total > dealer.total\n      ? stake * 2n\n      : value.total === dealer.total\n        ? stake\n        : 0n;\n  });\n  const uncapped = terminal ? payouts.reduce((sum, value) => sum + value, 0n) : 0n,\n    paid = uncapped > MAX_PAYOUT_MINOR ? MAX_PAYOUT_MINOR : uncapped;\n  return {\n    game: \"blackjack\",\n    status: terminal ? (paid > 0n ? \"WON\" : \"LOST\") : \"ACTIVE\",\n    payoutMinor: paid.toString(),\n    multiplier: ratioText(ratio(paid, totalStake)),\n    result: {\n      ...publicResult(secret, terminal, terminal ? payouts : undefined),\n      ...(terminal && uncapped > paid\n        ? { uncappedPayoutMinor: uncapped.toString(), capped: true }\n        : {}),\n    },\n    ...(terminal ? {} : { secret: secret as unknown as Parameters }),\n    additionalStakeMinor: extra.toString(),\n    rulesVersion: RULES_VERSION,\n  };\n}\nfunction finishDealer(secret: BlackjackSecret) {\n  if (secret.hands.some((hand) => !handValue(hand.cards).bust && !natural(hand))) {\n    while (handValue(secret.dealer).total < 17) secret.dealer.push(draw(secret));\n  }\n}\nfunction advance(secret: BlackjackSecret) {\n  while (\n    secret.activeHand < secret.hands.length &&\n    (secret.hands[secret.activeHand].stood ||\n      handValue(secret.hands[secret.activeHand].cards).total >= 21)\n  ) {\n    secret.hands[secret.activeHand].stood = true;\n    secret.activeHand++;\n  }\n  return secret.activeHand >= secret.hands.length;\n}\nexport function dealBlackjack(stake: bigint, random: RandomSource): Resolution {\n  const shoe = Array.from({ length: 312 }, (_, index) => index % 52);\n  for (let i = shoe.length - 1; i > 0; i--) {\n    const j = random.integer(i + 1);\n    [shoe[i], shoe[j]] = [shoe[j], shoe[i]];\n  }\n  const secret: BlackjackSecret = {\n    shoe,\n    cursor: 4,\n    dealer: [shoe[1], shoe[3]],\n    hands: [\n      {\n        cards: [shoe[0], shoe[2]],\n        stakeMinor: stake.toString(),\n        stood: false,\n        fromSplit: false,\n        splitAces: false,\n      },\n    ],\n    activeHand: 0,\n    originalStakeMinor: stake.toString(),\n  };\n  const terminal = handValue(secret.dealer).total === 21 || natural(secret.hands[0]);\n  return resolution(secret, stake, terminal);\n}\nexport function actBlackjack(\n  totalStake: bigint,\n  inputSecret: Parameters,\n  action: string,\n): Resolution {\n  if (![\"hit\", \"stand\", \"double\", \"split\", \"cashout\"].includes(action))\n    throw new RangeError(\"Invalid Blackjack action\");\n  const secret = structuredClone(inputSecret) as unknown as BlackjackSecret,\n    hand = currentHand(secret),\n    extra = blackjackAdditionalStake(inputSecret, action);\n  secret.decisions = [...(secret.decisions ?? []), { action: action as BlackjackAction }];\n  if (action === \"hit\") hand.cards.push(draw(secret));\n  if (action === \"stand\") hand.stood = true;\n  if (action === \"double\") {\n    hand.stakeMinor = (BigInt(hand.stakeMinor) * 2n).toString();\n    hand.cards.push(draw(secret));\n    hand.stood = true;\n  }\n  if (action === \"split\") {\n    const first = hand.cards[0],\n      second = hand.cards[1],\n      aces = cardRank(first) === 1;\n    hand.cards = [first, draw(secret)];\n    hand.fromSplit = true;\n    hand.splitAces = aces;\n    hand.stood = aces;\n    const next = {\n      cards: [second, draw(secret)],\n      stakeMinor: hand.stakeMinor,\n      stood: aces,\n      fromSplit: true,\n      splitAces: aces,\n    };\n    secret.hands.splice(secret.activeHand + 1, 0, next);\n  }\n  if (action === \"cashout\") for (const entry of secret.hands) entry.stood = true;\n  const terminal = advance(secret);\n  if (terminal) finishDealer(secret);\n  return resolution(secret, totalStake + extra, terminal, extra);\n}\n","sha256":"a331867b11a34564571495844d89da09c26ce0386aeea4b6654587078c7aabdf"},"src/games/plinko-rules.ts":{"source":"export const PLINKO_ROWS = [8, 9, 10, 11, 12, 13, 14, 15, 16] as const;\n\nexport function validatePlinkoRows(value: unknown): number {\n  if (typeof value !== \"number\" || !Number.isInteger(value) || value < 8 || value > 16)\n    throw new RangeError(\"Choose a whole row count from 8 to 16\");\n  return value;\n}\n","sha256":"633869c2e2784526de58cfe7a0359ebbf70e9666c631dfed3cb3f8d076daeef7"},"src/games/moles.ts":{"source":"import type { GameAction, RandomSource, Resolution } from \"./math\";\n\nexport const MOLES_RULES = \"peeks-moles-1\";\nexport const MOLES_HOLES = 7;\nexport const MOLES_MAX_STAGES = 9;\nexport type MolesParameters =\n  | { mode: \"manual\"; moles: number }\n  | { mode: \"auto\"; moles: number; picks: number[] };\nexport type MolesRatio = { numerator: bigint; denominator: bigint };\nexport type MolesWireRatio = { numerator: string; denominator: string; value: string };\nexport type MolesStage = { step: number; selection: number; moleHoles: number[]; hit: boolean };\nexport type MolesDecision = {\n  action: \"reveal\" | \"cashout\";\n  selection?: number;\n  expectedStep: number;\n};\nexport type MolesResult = {\n  molesRules: typeof MOLES_RULES;\n  parameters: MolesParameters;\n  step: number;\n  hits: number;\n  stages: MolesStage[];\n  decisions: MolesDecision[];\n  potentialPayoutMinor: string;\n  nominalMultiplier: MolesWireRatio;\n  theoreticalRtp: MolesWireRatio;\n  maxPayoutMinor: string;\n  nextMultiplier?: MolesWireRatio;\n  boards?: number[][];\n  refunded?: true;\n};\ntype MolesState = {\n  molesRules: typeof MOLES_RULES;\n  parameters: Extract<MolesParameters, { mode: \"manual\" }>;\n  boards: number[][];\n  stages: MolesStage[];\n  decisions: MolesDecision[];\n};\n\nfunction integer(value: unknown, min: number, max: number): number {\n  if (typeof value !== \"number\" || !Number.isSafeInteger(value) || value < min || value > max)\n    throw new RangeError(\"Invalid Moles selection\");\n  return value;\n}\n\nexport function validateMolesParameters(input: unknown): MolesParameters {\n  if (!input || typeof input !== \"object\" || Array.isArray(input))\n    throw new RangeError(\"Invalid Moles parameters\");\n  const value = input as Record<string, unknown>,\n    keys = Object.keys(value),\n    moles = integer(value.moles, 1, 6);\n  if (\n    value.mode === \"manual\" &&\n    keys.length === 2 &&\n    keys.includes(\"mode\") &&\n    keys.includes(\"moles\")\n  )\n    return { mode: \"manual\", moles };\n  if (\n    value.mode !== \"auto\" ||\n    keys.length !== 3 ||\n    ![\"mode\", \"moles\", \"picks\"].every((key) => keys.includes(key)) ||\n    !Array.isArray(value.picks)\n  )\n    throw new RangeError(\"Choose manual Moles or an automatic sequence\");\n  integer(value.picks.length, 1, MOLES_MAX_STAGES);\n  return { mode: \"auto\", moles, picks: Array.from(value.picks, (pick) => integer(pick, 0, 6)) };\n}\n\nexport function molesMultiplier(moles: number, stage: number): MolesRatio {\n  integer(moles, 1, 6);\n  integer(stage, 0, MOLES_MAX_STAGES);\n  return stage === 0\n    ? { numerator: 1n, denominator: 1n }\n    : {\n        numerator: 99n * 7n ** BigInt(stage),\n        denominator: 100n * BigInt(moles) ** BigInt(stage),\n      };\n}\n\nexport function molesTerms(input: unknown) {\n  const parameters = validateMolesParameters(input),\n    horizon = parameters.mode === \"manual\" ? MOLES_MAX_STAGES : parameters.picks.length,\n    nominalMultiplier = molesMultiplier(parameters.moles, horizon),\n    bound =\n      ((100_000_000n + 1n) * nominalMultiplier.denominator - 1n) / nominalMultiplier.numerator;\n  return {\n    parameters,\n    horizon,\n    nominalMultiplier,\n    winningOutcomes: BigInt(parameters.moles) ** BigInt(horizon),\n    totalOutcomes: 7n ** BigInt(horizon),\n    maxStakeMinor: bound < 1_000_000n ? bound : 1_000_000n,\n  };\n}\n\nexport function molesPreview(stakeMinor: bigint, input: unknown, requestedStage?: number) {\n  if (typeof stakeMinor !== \"bigint\" || stakeMinor <= 0n)\n    throw new RangeError(\"Choose a positive Moles stake\");\n  const terms = molesTerms(input),\n    stage = integer(requestedStage ?? terms.horizon, 0, MOLES_MAX_STAGES),\n    nominalMultiplier = molesMultiplier(terms.parameters.moles, stage),\n    winningPayoutMinor = (stakeMinor * nominalMultiplier.numerator) / nominalMultiplier.denominator,\n    winningOutcomes = BigInt(terms.parameters.moles) ** BigInt(stage),\n    totalOutcomes = 7n ** BigInt(stage),\n    rtp = {\n      numerator: winningOutcomes * winningPayoutMinor,\n      denominator: totalOutcomes * stakeMinor,\n    };\n  return {\n    ...terms,\n    stage,\n    nominalMultiplier,\n    winningOutcomes,\n    totalOutcomes,\n    winningPayoutMinor,\n    rtp,\n    admissible: stakeMinor <= terms.maxStakeMinor,\n  };\n}\n\nexport function assertMolesStake(stakeMinor: bigint, parameters: unknown) {\n  const preview = molesPreview(stakeMinor, parameters);\n  if (!preview.admissible)\n    throw new RangeError(\n      `Maximum Moles bet for these settings is ${preview.maxStakeMinor / 100n}.${(preview.maxStakeMinor % 100n).toString().padStart(2, \"0\")} PC`,\n    );\n  return preview;\n}\n\nfunction text(ratio: MolesRatio, digits = 4) {\n  const scale = 10n ** BigInt(digits),\n    value = (ratio.numerator * scale) / ratio.denominator;\n  return `${value / scale}.${(value % scale).toString().padStart(digits, \"0\")}`;\n}\n\nfunction wire(ratio: MolesRatio): MolesWireRatio {\n  return {\n    numerator: ratio.numerator.toString(),\n    denominator: ratio.denominator.toString(),\n    value: text(ratio, 8),\n  };\n}\n\nfunction outcome(\n  stake: bigint,\n  parameters: MolesParameters,\n  boards: number[][],\n  stages: MolesStage[],\n  decisions: MolesDecision[],\n  status: Resolution[\"status\"],\n  refunded = false,\n): Resolution {\n  const maximum = assertMolesStake(stake, parameters),\n    step = stages.length,\n    hits = stages.filter((stage) => stage.hit).length,\n    quote = molesPreview(stake, parameters, step),\n    payout = status === \"WON\" ? quote.winningPayoutMinor : 0n,\n    rtp = parameters.mode === \"auto\" ? maximum.rtp : quote.rtp,\n    result: MolesResult = {\n      molesRules: MOLES_RULES,\n      parameters,\n      step,\n      hits,\n      stages: stages.map((stage) => ({ ...stage, moleHoles: [...stage.moleHoles] })),\n      decisions: decisions.map((decision) => ({ ...decision })),\n      potentialPayoutMinor: (status === \"ACTIVE\" ? quote.winningPayoutMinor : payout).toString(),\n      nominalMultiplier: wire(quote.nominalMultiplier),\n      theoreticalRtp: wire(rtp),\n      maxPayoutMinor: maximum.winningPayoutMinor.toString(),\n      ...(status === \"ACTIVE\"\n        ? { nextMultiplier: wire(molesMultiplier(parameters.moles, step + 1)) }\n        : { boards: boards.map((board) => [...board]) }),\n      ...(refunded ? { refunded: true as const } : {}),\n    };\n  return {\n    game: \"moles\",\n    status,\n    payoutMinor: payout.toString(),\n    multiplier: text({ numerator: payout, denominator: stake }),\n    result,\n    rulesVersion: \"peeks-originals-1\",\n    ...(status === \"ACTIVE\"\n      ? { secret: { molesRules: MOLES_RULES, parameters, boards, stages, decisions } }\n      : {}),\n  };\n}\n\nexport function evaluateMoles(stake: bigint, input: unknown, random: RandomSource): Resolution {\n  const { parameters } = assertMolesStake(stake, input),\n    boards = Array.from({ length: MOLES_MAX_STAGES }, () => {\n      const holes = Array.from({ length: MOLES_HOLES }, (_, index) => index);\n      for (let i = holes.length - 1; i > 0; i--) {\n        const j = random.integer(i + 1);\n        [holes[i], holes[j]] = [holes[j], holes[i]];\n      }\n      return holes.slice(0, parameters.moles).sort((a, b) => a - b);\n    });\n  if (parameters.mode === \"manual\") return outcome(stake, parameters, boards, [], [], \"ACTIVE\");\n  const stages: MolesStage[] = [];\n  for (const selection of parameters.picks) {\n    const step = stages.length,\n      moleHoles = boards[step],\n      hit = moleHoles.includes(selection);\n    stages.push({ step, selection, moleHoles: [...moleHoles], hit });\n    if (!hit) return outcome(stake, parameters, boards, stages, [], \"LOST\");\n  }\n  return outcome(stake, parameters, boards, stages, [], \"WON\");\n}\n\nfunction state(input: Record<string, unknown>): MolesState {\n  const parameters = validateMolesParameters(input.parameters);\n  if (\n    input.molesRules !== MOLES_RULES ||\n    parameters.mode !== \"manual\" ||\n    !Array.isArray(input.boards) ||\n    input.boards.length !== MOLES_MAX_STAGES ||\n    !Array.isArray(input.stages) ||\n    input.stages.length >= MOLES_MAX_STAGES ||\n    !Array.isArray(input.decisions) ||\n    input.decisions.length !== input.stages.length\n  )\n    throw new RangeError(\"Invalid Moles state\");\n  const boards = input.boards.map((board) => {\n    if (!Array.isArray(board) || board.length !== parameters.moles)\n      throw new RangeError(\"Invalid Moles board\");\n    const holes = board.map((hole) => integer(hole, 0, 6));\n    if (holes.some((hole, index) => index > 0 && holes[index - 1] >= hole))\n      throw new RangeError(\"Invalid Moles board\");\n    return holes;\n  });\n  const decisions: MolesDecision[] = [],\n    stages = input.stages.map((item, step) => {\n      const selection = integer(item?.selection, 0, 6),\n        board = boards[step],\n        decision = (input.decisions as unknown[])[step] as MolesDecision;\n      if (\n        item.step !== step ||\n        item.hit !== true ||\n        !board.includes(selection) ||\n        !Array.isArray(item.moleHoles) ||\n        item.moleHoles.length !== board.length ||\n        item.moleHoles.some((hole: unknown, index: number) => hole !== board[index]) ||\n        !decision ||\n        decision.action !== \"reveal\" ||\n        decision.expectedStep !== step ||\n        decision.selection !== selection\n      )\n        throw new RangeError(\"Invalid Moles transcript\");\n      decisions.push({ action: \"reveal\", selection, expectedStep: step });\n      return { step, selection, moleHoles: [...board], hit: true };\n    });\n  return { molesRules: MOLES_RULES, parameters, boards, stages, decisions };\n}\n\nexport function actMoles(input: {\n  stakeMinor: bigint;\n  secret: Record<string, unknown>;\n  action: GameAction;\n  selection?: number;\n  expectedStep?: number;\n}): Resolution {\n  const current = state(input.secret),\n    step = current.stages.length;\n  assertMolesStake(input.stakeMinor, current.parameters);\n  if (integer(input.expectedStep, 0, 8) !== step) throw new RangeError(\"Moles stage has changed\");\n  if (input.action !== \"reveal\" && input.action !== \"cashout\")\n    throw new RangeError(\"Choose a Moles hole or cash out\");\n  if (input.action === \"cashout\") {\n    if (input.selection !== undefined) throw new RangeError(\"Cashout has no hole selection\");\n    current.decisions.push({ action: \"cashout\", expectedStep: step });\n    return outcome(\n      input.stakeMinor,\n      current.parameters,\n      current.boards,\n      current.stages,\n      current.decisions,\n      \"WON\",\n      step === 0,\n    );\n  }\n  const selection = integer(input.selection, 0, 6),\n    moleHoles = current.boards[step],\n    hit = moleHoles.includes(selection);\n  current.stages.push({ step, selection, moleHoles: [...moleHoles], hit });\n  current.decisions.push({ action: \"reveal\", selection, expectedStep: step });\n  return outcome(\n    input.stakeMinor,\n    current.parameters,\n    current.boards,\n    current.stages,\n    current.decisions,\n    !hit ? \"LOST\" : step + 1 === MOLES_MAX_STAGES ? \"WON\" : \"ACTIVE\",\n  );\n}\n","sha256":"5aa4af6ed837dcb5272cd94c3b6528ffe7781a665d115b90c491fd9e740f9b37"},"src/games/coinflip.ts":{"source":"export const COINFLIP_RULES = \"peeks-coinflip-1\";\nexport type CoinflipSide = \"heads\" | \"tails\";\nexport type CoinflipParameters =\n  | { mode: \"classic\"; sides: CoinflipSide[] }\n  | { mode: \"target\"; coins: number; side: CoinflipSide; target: number };\nexport type CoinflipRatio = { numerator: bigint; denominator: bigint };\nconst maximumStake = 1_000_000n;\nconst maximumPayout = 100_000_000n;\n\nfunction count(value: unknown): number {\n  if (typeof value !== \"number\" || !Number.isSafeInteger(value) || value < 1 || value > 20)\n    throw new RangeError(\"Choose 1–20 coins\");\n  return value;\n}\n\nfunction side(value: unknown): CoinflipSide {\n  if (value !== \"heads\" && value !== \"tails\") throw new RangeError(\"Choose heads or tails\");\n  return value;\n}\n\nfunction winningOutcomes(coins: number, target: number): bigint {\n  let combinations = 1n,\n    winning = 0n;\n  for (let hits = 0; hits <= coins; hits++) {\n    if (hits >= target) winning += combinations;\n    if (hits < coins) combinations = (combinations * BigInt(coins - hits)) / BigInt(hits + 1);\n  }\n  return winning;\n}\n\nexport function coinflipMinTarget(coins: number): number {\n  count(coins);\n  const total = 2n ** BigInt(coins);\n  for (let target = 1; target <= coins; target++)\n    if (100n * winningOutcomes(coins, target) <= 85n * total) return target;\n  return coins;\n}\n\nexport function validateCoinflipParameters(input: unknown): CoinflipParameters {\n  if (!input || typeof input !== \"object\" || Array.isArray(input))\n    throw new RangeError(\"Invalid Coinflip parameters\");\n  const value = input as Record<string, unknown>,\n    keys = Object.keys(value);\n  if (value.mode === \"classic\") {\n    if (\n      keys.length !== 2 ||\n      !keys.includes(\"mode\") ||\n      !keys.includes(\"sides\") ||\n      !Array.isArray(value.sides)\n    )\n      throw new RangeError(\"Classic Coinflip requires only mode and sides\");\n    count(value.sides.length);\n    return { mode: \"classic\", sides: Array.from(value.sides, side) };\n  }\n  if (\n    value.mode !== \"target\" ||\n    keys.length !== 4 ||\n    ![\"mode\", \"coins\", \"side\", \"target\"].every((key) => keys.includes(key))\n  )\n    throw new RangeError(\"Target Coinflip requires mode, coins, side and target\");\n  const coins = count(value.coins),\n    target = count(value.target),\n    minimum = coinflipMinTarget(coins);\n  if (target < minimum || target > coins)\n    throw new RangeError(`Choose a target from ${minimum} to ${coins}`);\n  return { mode: \"target\", coins, side: side(value.side), target };\n}\n\nexport function coinflipTerms(input: unknown) {\n  const parameters = validateCoinflipParameters(input),\n    coins = parameters.mode === \"classic\" ? parameters.sides.length : parameters.coins,\n    requiredHits = parameters.mode === \"classic\" ? coins : parameters.target,\n    winning = parameters.mode === \"classic\" ? 1n : winningOutcomes(coins, requiredHits),\n    total = 2n ** BigInt(coins),\n    nominalMultiplier: CoinflipRatio = { numerator: 99n * total, denominator: 100n * winning },\n    payoutBound =\n      ((maximumPayout + 1n) * nominalMultiplier.denominator - 1n) / nominalMultiplier.numerator;\n  return {\n    parameters,\n    coins,\n    requiredHits,\n    winningOutcomes: winning,\n    totalOutcomes: total,\n    nominalMultiplier,\n    maxStakeMinor: payoutBound < maximumStake ? payoutBound : maximumStake,\n  };\n}\n\nexport function coinflipPreview(stakeMinor: bigint, parameters: unknown) {\n  if (typeof stakeMinor !== \"bigint\" || stakeMinor <= 0n)\n    throw new RangeError(\"Choose a positive Coinflip stake\");\n  const terms = coinflipTerms(parameters),\n    winningPayoutMinor =\n      (stakeMinor * terms.nominalMultiplier.numerator) / terms.nominalMultiplier.denominator,\n    rtp: CoinflipRatio = {\n      numerator: terms.winningOutcomes * winningPayoutMinor,\n      denominator: terms.totalOutcomes * stakeMinor,\n    };\n  return {\n    ...terms,\n    winningPayoutMinor,\n    rtp,\n    admissible: stakeMinor <= terms.maxStakeMinor,\n  };\n}\n\nexport function assertCoinflipStake(stakeMinor: bigint, parameters: unknown) {\n  const preview = coinflipPreview(stakeMinor, parameters);\n  if (!preview.admissible)\n    throw new RangeError(\n      `Maximum Coinflip bet for these settings is ${preview.maxStakeMinor / 100n}.${(preview.maxStakeMinor % 100n).toString().padStart(2, \"0\")} PC`,\n    );\n  return preview;\n}\n","sha256":"1a5d344baf4200c41fdc18bc31ab795011eb5cfe5f50532026fbe213dee5a1b7"},"src/games/roulette.ts":{"source":"export type RouletteBetType =\n  | \"straight\"\n  | \"split\"\n  | \"street\"\n  | \"corner\"\n  | \"sixline\"\n  | \"dozen\"\n  | \"column\"\n  | \"outside\";\n\nexport type RoulettePosition = Readonly<{\n  id: string;\n  type: RouletteBetType;\n  label: string;\n  pockets: readonly number[];\n  factor: number;\n}>;\nexport type RouletteChip = { id: string; amountMinor: string };\nexport const EUROPEAN_RED = Object.freeze([\n  1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36,\n]);\n\nconst catalog: RoulettePosition[] = [];\nconst add = (type: RouletteBetType, pockets: number[], label: string, name = pockets.join(\"-\")) => {\n  catalog.push(\n    Object.freeze({\n      id: `${type}:${name}`,\n      type,\n      label,\n      pockets: Object.freeze(pockets),\n      factor: 36 / pockets.length,\n    }),\n  );\n};\nfor (let pocket = 0; pocket <= 36; pocket++) add(\"straight\", [pocket], `Number ${pocket}`);\nfor (let row = 0; row < 12; row++) {\n  const first = row * 3 + 1;\n  const street = [first, first + 1, first + 2];\n  add(\"street\", street, `Street ${first}–${first + 2}`);\n  for (let column = 0; column < 3; column++) {\n    const pocket = first + column;\n    if (column < 2) add(\"split\", [pocket, pocket + 1], `Split ${pocket} / ${pocket + 1}`);\n    if (row < 11) {\n      add(\"split\", [pocket, pocket + 3], `Split ${pocket} / ${pocket + 3}`);\n      if (column < 2)\n        add(\n          \"corner\",\n          [pocket, pocket + 1, pocket + 3, pocket + 4],\n          `Corner ${pocket} / ${pocket + 1} / ${pocket + 3} / ${pocket + 4}`,\n        );\n    }\n  }\n  if (row < 11)\n    add(\n      \"sixline\",\n      Array.from({ length: 6 }, (_, i) => first + i),\n      `Six line ${first}–${first + 5}`,\n    );\n}\nfor (let pocket = 1; pocket <= 3; pocket++) add(\"split\", [0, pocket], `Split 0 / ${pocket}`);\nadd(\"street\", [0, 1, 2], \"Trio 0 / 1 / 2\");\nadd(\"street\", [0, 2, 3], \"Trio 0 / 2 / 3\");\nadd(\"corner\", [0, 1, 2, 3], \"First four 0 / 1 / 2 / 3\");\nfor (let group = 1; group <= 3; group++) {\n  const start = (group - 1) * 12 + 1;\n  add(\n    \"dozen\",\n    Array.from({ length: 12 }, (_, i) => start + i),\n    `Dozen ${start}–${start + 11}`,\n    String(group),\n  );\n  add(\n    \"column\",\n    Array.from({ length: 12 }, (_, i) => group + i * 3),\n    `Column ${group}`,\n    String(group),\n  );\n}\nconst numbers = Array.from({ length: 36 }, (_, i) => i + 1);\nadd(\"outside\", [...EUROPEAN_RED], \"Red\", \"red\");\nadd(\n  \"outside\",\n  numbers.filter((n) => !EUROPEAN_RED.includes(n)),\n  \"Black\",\n  \"black\",\n);\nadd(\n  \"outside\",\n  numbers.filter((n) => n % 2 === 0),\n  \"Even\",\n  \"even\",\n);\nadd(\n  \"outside\",\n  numbers.filter((n) => n % 2 === 1),\n  \"Odd\",\n  \"odd\",\n);\nadd(\"outside\", numbers.slice(0, 18), \"1 to 18\", \"low\");\nadd(\"outside\", numbers.slice(18), \"19 to 36\", \"high\");\n\nexport const ROULETTE_POSITIONS: readonly RoulettePosition[] = Object.freeze(catalog);\nconst byId = new Map(ROULETTE_POSITIONS.map((position) => [position.id, position]));\nexport function roulettePosition(id: string): RoulettePosition | undefined {\n  return byId.get(id);\n}\n\nexport function validateRouletteBets(input: unknown): RouletteChip[] {\n  if (!Array.isArray(input) || input.length < 1 || input.length > ROULETTE_POSITIONS.length)\n    throw new RangeError(\"Place at least one chip on a valid Roulette position\");\n  const ids = new Set<string>();\n  return input\n    .map((value: unknown) => {\n      if (!value || typeof value !== \"object\" || Array.isArray(value))\n        throw new RangeError(\"Invalid Roulette chip\");\n      const chip = value as Record<string, unknown>;\n      if (\n        Object.keys(chip).some((key) => key !== \"id\" && key !== \"amountMinor\") ||\n        typeof chip.id !== \"string\" ||\n        !byId.has(chip.id) ||\n        ids.has(chip.id) ||\n        typeof chip.amountMinor !== \"string\" ||\n        !/^[1-9]\\d{0,29}$/.test(chip.amountMinor)\n      )\n        throw new RangeError(\"Invalid or duplicate Roulette position\");\n      ids.add(chip.id);\n      return { id: chip.id, amountMinor: chip.amountMinor };\n    })\n    .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));\n}\n\nexport function rouletteStakeMinor(bets: readonly RouletteChip[]): bigint {\n  return bets.reduce((sum, bet) => sum + BigInt(bet.amountMinor), 0n);\n}\n\nfunction checkedLayout(stake: bigint, input: unknown, cap: bigint) {\n  const bets = validateRouletteBets(input);\n  if (stake <= 0n || cap <= 0n || rouletteStakeMinor(bets) !== stake)\n    throw new RangeError(\"Roulette chips must equal the total wager\");\n  return bets.map((bet) => ({ ...bet, position: byId.get(bet.id)! }));\n}\n\nexport function rouletteTableOutcome(stake: bigint, input: unknown, roll: number, cap: bigint) {\n  const layout = checkedLayout(stake, input, cap);\n  if (!Number.isInteger(roll) || roll < 0 || roll > 36)\n    throw new RangeError(\"Invalid Roulette pocket\");\n  const settlements = layout.map(({ position, ...bet }) => ({\n    ...bet,\n    won: position.pockets.includes(roll),\n    grossMinor: (position.pockets.includes(roll)\n      ? BigInt(bet.amountMinor) * BigInt(position.factor)\n      : 0n\n    ).toString(),\n  }));\n  const grossMinor = settlements.reduce((sum, bet) => sum + BigInt(bet.grossMinor), 0n);\n  return { payoutMinor: grossMinor > cap ? cap : grossMinor, grossMinor, settlements };\n}\n\nexport function rouletteTableReturns(stake: bigint, input: unknown, cap: bigint) {\n  const layout = checkedLayout(stake, input, cap);\n  let numerator = 0n,\n    maximumPayoutMinor = stake;\n  for (let roll = 0; roll < 37; roll++) {\n    const gross = layout.reduce(\n      (sum, bet) =>\n        sum +\n        (bet.position.pockets.includes(roll)\n          ? BigInt(bet.amountMinor) * BigInt(bet.position.factor)\n          : 0n),\n      0n,\n    );\n    const payout = gross > cap ? cap : gross;\n    numerator += payout;\n    if (payout > maximumPayoutMinor) maximumPayoutMinor = payout;\n  }\n  return { numerator, denominator: stake * 37n, maximumPayoutMinor };\n}\n","sha256":"4a1ccc2cf9ef13c8df9b48429ea9eb6aea11459d8b40489588e8be9cbbbf4f00"},"src/games/dice.ts":{"source":"export const DICE_MODES = [\"under\", \"over\", \"between\", \"outside\", \"two_ranges\"] as const;\nexport type DiceMode = (typeof DICE_MODES)[number];\nexport type DiceRange = [number, number];\nexport type DiceParameters = { mode: DiceMode; ranges: DiceRange[] };\nexport const DICE_TICKETS = 10_000;\nexport const DICE_RANGE_RULES = \"peeks-dice-ranges-1\";\n\nexport function diceWinningTickets(ranges: readonly DiceRange[]): number {\n  return ranges.reduce((total, [low, high]) => total + high - low + 1, 0);\n}\n\nexport function validateDiceParameters(input: unknown): DiceParameters {\n  if (!input || typeof input !== \"object\" || Array.isArray(input))\n    throw new RangeError(\"Invalid Dice ranges\");\n  const value = input as Record<string, unknown>;\n  if (\n    Object.keys(value).some((key) => key !== \"mode\" && key !== \"ranges\") ||\n    !DICE_MODES.includes(value.mode as DiceMode)\n  )\n    throw new RangeError(\"Choose a valid Dice mode\");\n  const mode = value.mode as DiceMode;\n  const count = mode === \"outside\" || mode === \"two_ranges\" ? 2 : 1;\n  if (!Array.isArray(value.ranges) || value.ranges.length !== count)\n    throw new RangeError(\n      `${mode === \"two_ranges\" ? \"Two ranges\" : mode} needs ${count} winning ${count === 1 ? \"range\" : \"ranges\"}`,\n    );\n  const ranges = value.ranges.map((range: unknown) => {\n    if (\n      !Array.isArray(range) ||\n      range.length !== 2 ||\n      !range.every(\n        (ticket) =>\n          typeof ticket === \"number\" &&\n          Number.isSafeInteger(ticket) &&\n          ticket >= 0 &&\n          ticket < DICE_TICKETS,\n      ) ||\n      range[0] > range[1]\n    )\n      throw new RangeError(\"Each range must have ordered endpoints from 0.00 to 99.99\");\n    return [...range] as DiceRange;\n  });\n  if (ranges.length === 2 && ranges[0][1] >= ranges[1][0])\n    throw new RangeError(\"Winning ranges must be ordered and cannot overlap\");\n  if ((mode === \"under\" || mode === \"outside\") && ranges[0][0] !== 0)\n    throw new RangeError(\"This mode must start at 0.00\");\n  if ((mode === \"over\" || mode === \"outside\") && ranges.at(-1)![1] !== DICE_TICKETS - 1)\n    throw new RangeError(\"This mode must end at 99.99\");\n  const winning = diceWinningTickets(ranges);\n  if (winning < count || winning >= DICE_TICKETS)\n    throw new RangeError(\"Keep at least one losing outcome\");\n  return { mode, ranges };\n}\n\nexport function diceWins(ticket: number, ranges: readonly DiceRange[]): boolean {\n  return ranges.some(([low, high]) => ticket >= low && ticket <= high);\n}\n\nexport function diceSelectionForTickets(requested: number, mode: DiceMode) {\n  if (!Number.isSafeInteger(requested) || !DICE_MODES.includes(mode))\n    throw new RangeError(\"Invalid Dice chance\");\n  const minimum = mode === \"outside\" || mode === \"two_ranges\" ? 2 : 1;\n  const winningTickets = Math.max(minimum, Math.min(DICE_TICKETS - 1, requested));\n  let ranges: DiceRange[];\n  if (mode === \"under\") ranges = [[0, winningTickets - 1]];\n  else if (mode === \"over\") ranges = [[DICE_TICKETS - winningTickets, DICE_TICKETS - 1]];\n  else if (mode === \"between\") {\n    const low = Math.floor((DICE_TICKETS - winningTickets) / 2);\n    ranges = [[low, low + winningTickets - 1]];\n  } else {\n    const left = Math.floor(winningTickets / 2),\n      right = winningTickets - left;\n    if (mode === \"outside\")\n      ranges = [\n        [0, left - 1],\n        [DICE_TICKETS - right, DICE_TICKETS - 1],\n      ];\n    else {\n      const low = Math.floor((DICE_TICKETS / 2 - left) / 2);\n      const high = DICE_TICKETS / 2 + Math.floor((DICE_TICKETS / 2 - right) / 2);\n      ranges = [\n        [low, low + left - 1],\n        [high, high + right - 1],\n      ];\n    }\n  }\n  return {\n    parameters: validateDiceParameters({ mode, ranges }),\n    winningTickets,\n    adjusted: winningTickets !== requested,\n  };\n}\n\nexport function diceDisplayParameters(input: Record<string, unknown>): DiceParameters {\n  if (\"ranges\" in input || \"mode\" in input) return validateDiceParameters(input);\n  if (\n    !Number.isInteger(input.chance) ||\n    Number(input.chance) < 2 ||\n    Number(input.chance) > 98 ||\n    ![\"under\", \"over\"].includes(input.side as string)\n  )\n    throw new RangeError(\"Invalid legacy Dice parameters\");\n  return diceSelectionForTickets(Number(input.chance) * 100, input.side as DiceMode).parameters;\n}\n\nexport function diceDecimalTicket(value: string): number | null {\n  if (!/^\\d{1,3}(?:\\.\\d{0,2})?$/.test(value)) return null;\n  const [whole, fraction = \"\"] = value.split(\".\");\n  return Number(whole) * 100 + Number(fraction.padEnd(2, \"0\"));\n}\n\nexport function diceMultiplierTickets(value: string): number | null {\n  if (!/^\\d{1,5}(?:\\.\\d{0,6})?$/.test(value)) return null;\n  const [whole, fraction = \"\"] = value.split(\".\");\n  const scale = 10n ** BigInt(fraction.length);\n  const decimal = BigInt(whole) * scale + BigInt(fraction || \"0\");\n  return decimal > 0n ? Number((9900n * scale) / decimal) : null;\n}\n\nexport function diceTicketText(ticket: number) {\n  return `${Math.floor(ticket / 100)}.${(ticket % 100).toString().padStart(2, \"0\")}`;\n}\n","sha256":"9974eec60bd6299c44c515796db894e8de46ad60f48a5cec263ddccb395f9e5b"},"src/games/table-math.ts":{"source":"import type { Parameters, RandomSource, Ratio, Resolution } from \"./math\";\n\nexport const TABLE_GAME_SLUGS = [\"baccarat\", \"hilo\", \"rps\", \"war\", \"chicken\"] as const;\nexport type TableGameSlug = (typeof TABLE_GAME_SLUGS)[number];\nexport type TableCard = { rank: number; suit: number };\nexport type TableDecision = { action: \"reveal\" | \"cashout\"; selection?: number };\nexport const TABLE_MAX_PAYOUT_MINOR = 100_000_000n;\nexport const TABLE_MAX_STEPS = 24;\nexport const BACCARAT_COUNTS = Object.freeze({\n  player: 2_230_518_282_592_256n,\n  banker: 2_292_252_566_437_888n,\n  tie: 475_627_426_473_216n,\n  total: 4_998_398_275_503_360n,\n});\nexport const RPS_CHOICES = [\"rock\", \"paper\", \"scissors\"] as const;\n\nconst invalid = (message = \"Invalid game parameters\") => new RangeError(message);\nconst cap = (value: bigint) => (value > TABLE_MAX_PAYOUT_MINOR ? TABLE_MAX_PAYOUT_MINOR : value);\nfunction gcd(a: bigint, b: bigint): bigint {\n  while (b) [a, b] = [b, a % b];\n  return a;\n}\nfunction fraction(numerator: bigint, denominator: bigint): Ratio {\n  if (numerator < 0n || denominator <= 0n) throw invalid();\n  const divisor = gcd(numerator, denominator);\n  return { numerator: numerator / divisor, denominator: denominator / divisor };\n}\nfunction times(a: Ratio, b: Ratio) {\n  return fraction(a.numerator * b.numerator, a.denominator * b.denominator);\n}\nfunction text(value: Ratio, digits = 4) {\n  const scale = 10n ** BigInt(digits),\n    fixed = (value.numerator * scale) / value.denominator;\n  return `${fixed / scale}.${(fixed % scale).toString().padStart(digits, \"0\")}`;\n}\nfunction wire(value: Ratio) {\n  return {\n    numerator: value.numerator.toString(),\n    denominator: value.denominator.toString(),\n    value: text(value, 8),\n  };\n}\nfunction pay(stake: bigint, value: Ratio) {\n  if (stake <= 0n) throw invalid(\"Stake must be positive\");\n  return cap((stake * value.numerator) / value.denominator);\n}\nconst card = (value: number): TableCard => ({\n  rank: (value % 13) + 1,\n  suit: Math.floor(value / 13) % 4,\n});\nfunction integer(value: unknown, low: number, high: number): number {\n  if (typeof value !== \"number\" || !Number.isSafeInteger(value) || value < low || value > high)\n    throw invalid();\n  return value;\n}\nfunction isCard(value: unknown): value is TableCard {\n  const c = value as TableCard | undefined;\n  return (\n    !!c &&\n    Number.isInteger(c.rank) &&\n    c.rank >= 1 &&\n    c.rank <= 13 &&\n    Number.isInteger(c.suit) &&\n    c.suit >= 0 &&\n    c.suit < 4\n  );\n}\nexport function isTableGame(value: unknown): value is TableGameSlug {\n  return typeof value === \"string\" && (TABLE_GAME_SLUGS as readonly string[]).includes(value);\n}\n\nexport function validateTableParameters(game: TableGameSlug, input: unknown = {}): Parameters {\n  if (!isTableGame(game) || !input || typeof input !== \"object\" || Array.isArray(input))\n    throw invalid();\n  const p = input as Parameters;\n  const allowed =\n    game === \"baccarat\"\n      ? [\"selection\"]\n      : game === \"rps\"\n        ? [\"choice\"]\n        : game === \"chicken\"\n          ? [\"risk\"]\n          : [];\n  if (Object.keys(p).some((key) => !allowed.includes(key))) throw invalid();\n  if (game === \"baccarat\") {\n    const selection = p.selection ?? \"banker\";\n    if (![\"player\", \"banker\", \"tie\"].includes(selection as string)) throw invalid();\n    return { selection };\n  }\n  if (game === \"rps\") {\n    const choice = p.choice ?? \"rock\";\n    if (!(RPS_CHOICES as readonly unknown[]).includes(choice)) throw invalid();\n    return { choice };\n  }\n  if (game === \"chicken\") {\n    const risk = p.risk ?? \"medium\";\n    if (![\"low\", \"medium\", \"high\"].includes(risk as string)) throw invalid();\n    return { risk };\n  }\n  return {};\n}\n\nexport function baccaratBankerDraws(total: number, playerThird: number | null): boolean {\n  integer(total, 0, 7);\n  if (playerThird === null) return total <= 5;\n  integer(playerThird, 0, 9);\n  return (\n    total <= 2 ||\n    (total === 3 && playerThird !== 8) ||\n    (total === 4 && playerThird >= 2 && playerThird <= 7) ||\n    (total === 5 && playerThird >= 4 && playerThird <= 7) ||\n    (total === 6 && (playerThird === 6 || playerThird === 7))\n  );\n}\n\nexport function baccaratFromCards(cards: readonly TableCard[]) {\n  if (cards.length < 6 || cards.slice(0, 6).some((value) => !isCard(value)))\n    throw invalid(\"Six valid cards are required\");\n  const point = (value: TableCard) => (value.rank >= 10 ? 0 : value.rank);\n  const player = [cards[0], cards[2]],\n    banker = [cards[1], cards[3]];\n  let playerTotal = (point(player[0]) + point(player[1])) % 10,\n    bankerTotal = (point(banker[0]) + point(banker[1])) % 10,\n    cursor = 4;\n  const natural = playerTotal >= 8 || bankerTotal >= 8;\n  if (!natural) {\n    const playerThird = playerTotal <= 5 ? cards[cursor++] : null;\n    if (playerThird) {\n      player.push(playerThird);\n      playerTotal = (playerTotal + point(playerThird)) % 10;\n    }\n    if (baccaratBankerDraws(bankerTotal, playerThird ? point(playerThird) : null)) {\n      const third = cards[cursor++];\n      banker.push(third);\n      bankerTotal = (bankerTotal + point(third)) % 10;\n    }\n  }\n  const winner =\n    playerTotal === bankerTotal ? \"tie\" : playerTotal > bankerTotal ? \"player\" : \"banker\";\n  return { player, banker, playerTotal, bankerTotal, natural, winner, cardsUsed: cursor };\n}\n\nexport function baccaratDeal(random: RandomSource) {\n  const shoe = Array.from({ length: 416 }, (_, index) => index),\n    cards: TableCard[] = [];\n  for (let i = 0; i < 6; i++) {\n    const selected = random.integer(shoe.length),\n      [value] = shoe.splice(selected, 1);\n    cards.push(card(value));\n  }\n  return baccaratFromCards(cards);\n}\n\nexport function chickenChance(risk: unknown): Ratio {\n  if (risk === \"low\") return fraction(24n, 25n);\n  if (risk === \"medium\") return fraction(9n, 10n);\n  if (risk === \"high\") return fraction(3n, 4n);\n  throw invalid();\n}\nexport function chickenMultiplier(risk: unknown, steps: number): Ratio {\n  integer(steps, 0, TABLE_MAX_STEPS);\n  if (!steps) return fraction(1n, 1n);\n  const chance = chickenChance(risk);\n  return fraction(\n    99n * chance.denominator ** BigInt(steps),\n    100n * chance.numerator ** BigInt(steps),\n  );\n}\n\nexport function hiloWinningRanks(rank: number, selection: number) {\n  integer(rank, 1, 13);\n  integer(selection, 0, 1);\n  return selection === 0 ? rank - 1 : 13 - rank;\n}\nexport function hiloMultiplier(\n  current: Ratio,\n  rank: number,\n  selection: number,\n  first: boolean,\n): Ratio {\n  const wins = hiloWinningRanks(rank, selection);\n  if (!wins) throw invalid(\"That direction cannot win from this card\");\n  return times(current, fraction(first ? 99n * 12n : 12n, BigInt(wins) * (first ? 100n : 1n)));\n}\n\nexport function theoreticalTableRtp(\n  game: TableGameSlug,\n  stake: bigint,\n  input: unknown,\n  reveals = 1,\n): Ratio {\n  const p = validateTableParameters(game, input);\n  if (stake <= 0n) throw invalid(\"Stake must be positive\");\n  if (game === \"baccarat\") {\n    const { player, banker, tie, total } = BACCARAT_COUNTS;\n    const expected =\n      p.selection === \"tie\"\n        ? pay(stake, fraction(9n, 1n)) * tie\n        : p.selection === \"player\"\n          ? pay(stake, fraction(2n, 1n)) * player + stake * tie\n          : pay(stake, fraction(39n, 20n)) * banker + stake * tie;\n    return fraction(expected, stake * total);\n  }\n  if (game === \"rps\") return fraction(pay(stake, fraction(197n, 100n)) + stake, stake * 3n);\n  if (game === \"war\") return fraction(pay(stake, fraction(1187n, 600n)) * 6n + stake, stake * 13n);\n  if (game === \"hilo\")\n    throw invalid(\"Hilo return depends on choices, stopping, rounding and the payout cap\");\n  integer(reveals, 0, TABLE_MAX_STEPS);\n  const chance = chickenChance(p.risk);\n  return fraction(\n    pay(stake, chickenMultiplier(p.risk, reveals)) * chance.numerator ** BigInt(reveals),\n    stake * chance.denominator ** BigInt(reveals),\n  );\n}\n\nexport function maximumTablePayoutMinor(input: {\n  game: TableGameSlug;\n  stakeMinor: bigint;\n  parameters: unknown;\n}): bigint {\n  const p = validateTableParameters(input.game, input.parameters),\n    stake = input.stakeMinor;\n  if (stake <= 0n || stake > TABLE_MAX_PAYOUT_MINOR) throw invalid(\"Invalid stake\");\n  if (input.game === \"hilo\") return TABLE_MAX_PAYOUT_MINOR;\n  const maximum =\n    input.game === \"baccarat\"\n      ? fraction(\n          p.selection === \"tie\" ? 9n : p.selection === \"player\" ? 2n : 39n,\n          p.selection === \"banker\" ? 20n : 1n,\n        )\n      : input.game === \"rps\"\n        ? fraction(197n, 100n)\n        : input.game === \"war\"\n          ? fraction(1187n, 600n)\n          : chickenMultiplier(p.risk, TABLE_MAX_STEPS);\n  const value = pay(stake, maximum);\n  return value < stake ? stake : value;\n}\n\ntype InteractiveState = {\n  kind: \"hilo\" | \"chicken\";\n  parameters: Parameters;\n  revealed: number[];\n  decisions: TableDecision[];\n  multiplier: { numerator: string; denominator: string };\n  cards?: TableCard[];\n  hazards?: boolean[];\n};\nfunction stateRatio(state: InteractiveState) {\n  return fraction(BigInt(state.multiplier.numerator), BigInt(state.multiplier.denominator));\n}\nfunction outcome(\n  game: TableGameSlug,\n  stake: bigint,\n  payout: bigint,\n  result: Parameters,\n  status: Resolution[\"status\"] = payout > 0n ? \"WON\" : \"LOST\",\n  secret?: Parameters,\n): Resolution {\n  return {\n    game,\n    status,\n    payoutMinor: payout.toString(),\n    multiplier: text(fraction(payout, stake)),\n    result,\n    rulesVersion: \"peeks-originals-1\",\n    ...(secret ? { secret } : {}),\n  };\n}\nfunction common(game: TableGameSlug, stake: bigint, p: Parameters) {\n  return {\n    parameters: p,\n    maxPayoutMinor: TABLE_MAX_PAYOUT_MINOR.toString(),\n    ...(game === \"hilo\"\n      ? { returnBasis: \"99% before rounding and payout cap; choice dependent at this stake\" }\n      : { theoreticalRtp: wire(theoreticalTableRtp(game, stake, p)) }),\n  };\n}\nfunction publicInteractive(\n  state: InteractiveState,\n  stake: bigint,\n  settled: boolean,\n  lost = false,\n  refunded = false,\n): Parameters {\n  const steps = state.revealed.length,\n    result: Parameters = {\n      ...common(state.kind, stake, state.parameters),\n      revealed: [...state.revealed],\n      decisions: state.decisions.map((value) => ({ ...value })),\n      steps,\n      maximumSteps: TABLE_MAX_STEPS,\n      potentialPayoutMinor: lost ? \"0\" : pay(stake, stateRatio(state)).toString(),\n      ...(refunded ? { refunded: true } : {}),\n    };\n  if (state.kind === \"chicken\") {\n    result.safeChance = wire(chickenChance(state.parameters.risk));\n    result.theoreticalRtp = wire(theoreticalTableRtp(\"chicken\", stake, state.parameters, steps));\n    if (settled) result.hazards = state.hazards;\n    if (!settled) result.nextMultiplier = wire(chickenMultiplier(state.parameters.risk, steps + 1));\n  } else {\n    const current = state.cards![steps];\n    result.currentCard = current;\n    result.cards = state.cards!.slice(0, steps + 1);\n    result.nextChoices = [0, 1].map((selection) => {\n      const count = hiloWinningRanks(current.rank, selection);\n      return {\n        selection,\n        winningRanks: count,\n        totalRanks: 12,\n        multiplier: count\n          ? wire(hiloMultiplier(stateRatio(state), current.rank, selection, steps === 0))\n          : null,\n      };\n    });\n    if (settled) result.committedCards = state.cards;\n  }\n  return result;\n}\n\nexport function evaluateTableBet(\n  input: { game: TableGameSlug; stakeMinor: bigint; parameters: unknown },\n  random: RandomSource,\n): Resolution {\n  const { game, stakeMinor: stake } = input,\n    p = validateTableParameters(game, input.parameters);\n  if (stake <= 0n || stake > TABLE_MAX_PAYOUT_MINOR) throw invalid(\"Invalid stake\");\n  if (game === \"baccarat\") {\n    const deal = baccaratDeal(random),\n      pushed = deal.winner === \"tie\" && p.selection !== \"tie\";\n    const payout = pushed\n      ? stake\n      : deal.winner === p.selection\n        ? pay(\n            stake,\n            fraction(\n              p.selection === \"banker\" ? 39n : p.selection === \"tie\" ? 9n : 2n,\n              p.selection === \"banker\" ? 20n : 1n,\n            ),\n          )\n        : 0n;\n    return outcome(game, stake, payout, {\n      ...common(game, stake, p),\n      ...deal,\n      pushed,\n      decks: 8,\n      freshShoeEachRound: true,\n    });\n  }\n  if (game === \"rps\") {\n    const house = random.integer(3),\n      player = RPS_CHOICES.indexOf(p.choice as (typeof RPS_CHOICES)[number]),\n      pushed = house === player,\n      won = (player - house + 3) % 3 === 1;\n    return outcome(game, stake, pushed ? stake : won ? pay(stake, fraction(197n, 100n)) : 0n, {\n      ...common(game, stake, p),\n      playerChoice: p.choice,\n      houseChoice: RPS_CHOICES[house],\n      pushed,\n    });\n  }\n  if (game === \"war\") {\n    const player = card(random.integer(52)),\n      house = card(random.integer(52)),\n      rank = (value: TableCard) => (value.rank === 1 ? 14 : value.rank),\n      pushed = player.rank === house.rank;\n    return outcome(\n      game,\n      stake,\n      pushed ? stake : rank(player) > rank(house) ? pay(stake, fraction(1187n, 600n)) : 0n,\n      { ...common(game, stake, p), player, house, pushed, aceHigh: true, replacement: true },\n    );\n  }\n  const state: InteractiveState = {\n    kind: game,\n    parameters: p,\n    revealed: [],\n    decisions: [],\n    multiplier: { numerator: \"1\", denominator: \"1\" },\n  };\n  if (game === \"chicken\") {\n    const chance = chickenChance(p.risk);\n    state.hazards = Array.from(\n      { length: TABLE_MAX_STEPS },\n      () => random.integer(Number(chance.denominator)) >= Number(chance.numerator),\n    );\n  } else {\n    state.cards = [card(random.integer(52))];\n    for (let i = 0; i < TABLE_MAX_STEPS; i++) {\n      const next = random.integer(48),\n        compressedRank = (next % 12) + 1,\n        current = state.cards[i].rank;\n      state.cards.push({\n        rank: compressedRank >= current ? compressedRank + 1 : compressedRank,\n        suit: Math.floor(next / 12),\n      });\n    }\n  }\n  return outcome(\n    game,\n    stake,\n    0n,\n    publicInteractive(state, stake, false),\n    \"ACTIVE\",\n    state as unknown as Parameters,\n  );\n}\n\nfunction validateState(game: \"hilo\" | \"chicken\", secret: Parameters): InteractiveState {\n  const state = secret as unknown as InteractiveState;\n  if (\n    !state ||\n    state.kind !== game ||\n    !Array.isArray(state.revealed) ||\n    state.revealed.length >= TABLE_MAX_STEPS ||\n    state.revealed.some((value) => !Number.isSafeInteger(value)) ||\n    !Array.isArray(state.decisions) ||\n    state.decisions.length !== state.revealed.length ||\n    !state.multiplier ||\n    !/^\\d{1,80}$/.test(state.multiplier.numerator) ||\n    !/^[1-9]\\d{0,79}$/.test(state.multiplier.denominator)\n  )\n    throw invalid(\"Invalid active game\");\n  validateTableParameters(game, state.parameters);\n  if (\n    game === \"hilo\" &&\n    (!Array.isArray(state.cards) ||\n      state.cards.length !== TABLE_MAX_STEPS + 1 ||\n      state.cards.some((value) => !isCard(value)) ||\n      state.cards.some((value, index) => index > 0 && value.rank === state.cards![index - 1].rank))\n  )\n    throw invalid(\"Invalid committed cards\");\n  if (\n    game === \"chicken\" &&\n    (!Array.isArray(state.hazards) ||\n      state.hazards.length !== TABLE_MAX_STEPS ||\n      state.hazards.some((value) => typeof value !== \"boolean\"))\n  )\n    throw invalid(\"Invalid committed path\");\n  stateRatio(state);\n  return state;\n}\n\nexport function actOnTableGame(input: {\n  game: TableGameSlug;\n  stakeMinor: bigint;\n  secret: Parameters;\n  action: \"reveal\" | \"cashout\";\n  selection?: number;\n}): Resolution {\n  const { game, stakeMinor: stake, action } = input;\n  if (\n    (game !== \"hilo\" && game !== \"chicken\") ||\n    stake <= 0n ||\n    (action !== \"reveal\" && action !== \"cashout\")\n  )\n    throw invalid(\"Invalid game action\");\n  const current = validateState(game, input.secret),\n    state: InteractiveState = {\n      ...current,\n      revealed: [...current.revealed],\n      decisions: current.decisions.map((value) => ({ ...value })),\n      multiplier: { ...current.multiplier },\n    };\n  if (action === \"cashout\") {\n    state.decisions.push({ action });\n    return outcome(\n      game,\n      stake,\n      pay(stake, stateRatio(state)),\n      publicInteractive(state, stake, true, false, !state.revealed.length),\n      \"WON\",\n    );\n  }\n  const step = state.revealed.length;\n  let lost: boolean, multiplier: Ratio;\n  if (game === \"hilo\") {\n    const selection = integer(input.selection, 0, 1),\n      prior = state.cards![step].rank,\n      next = state.cards![step + 1].rank;\n    multiplier = hiloMultiplier(stateRatio(state), prior, selection, step === 0);\n    lost = selection === 0 ? next > prior : next < prior;\n    state.decisions.push({ action, selection });\n    state.revealed.push(next);\n  } else {\n    if (input.selection !== undefined) integer(input.selection, 0, TABLE_MAX_STEPS - 1);\n    lost = state.hazards![step];\n    multiplier = chickenMultiplier(state.parameters.risk, step + 1);\n    state.decisions.push({\n      action,\n      ...(input.selection === undefined ? {} : { selection: input.selection }),\n    });\n    state.revealed.push(step);\n  }\n  state.multiplier = {\n    numerator: multiplier.numerator.toString(),\n    denominator: multiplier.denominator.toString(),\n  };\n  const complete =\n    lost ||\n    state.revealed.length === TABLE_MAX_STEPS ||\n    pay(stake, multiplier) === TABLE_MAX_PAYOUT_MINOR;\n  return outcome(\n    game,\n    stake,\n    complete && !lost ? pay(stake, multiplier) : 0n,\n    publicInteractive(state, stake, complete, lost),\n    complete ? (lost ? \"LOST\" : \"WON\") : \"ACTIVE\",\n    complete ? undefined : (state as unknown as Parameters),\n  );\n}\n","sha256":"cec179958f11a44bc082e4693c0c74a77f787bc5a10cb389b8becacd051ba883"},"src/lib/games/dice-rules.ts":{"source":"import { ratio, ratioText, ratioWire, type Resolution } from \"@/games/math\";\nimport type { Entropy, GameRules, Limits } from \"./rules\";\nimport {\n  admitPayoutLimit,\n  admitStakeRange,\n  gameLimits,\n  maxWinLimits,\n  maximumPayoutMessage,\n} from \"./limits\";\nimport { admitMaxWinStake, selectionMaxBetMinor } from \"./max-win\";\n\nexport const DICE_RULES_VERSION = \"peeks-dice-1\" as const;\nexport const DICE_2_RULES_VERSION = \"peeks-dice-2\" as const;\nexport const DICE_RULES_VERSIONS = Object.freeze([\n  DICE_RULES_VERSION,\n  DICE_2_RULES_VERSION,\n] as const);\nexport type DiceRulesVersion = (typeof DICE_RULES_VERSIONS)[number];\nexport const DICE_SETTLEMENT_POLICY = \"peeks-pc-floor-1\" as const;\nexport const DICE_LIMITS = Object.freeze({\n  houseEdge: 1,\n  maxBarPosition: 98,\n  maxMultiplier: 9900,\n  maxWinChance: 98,\n  minBarPosition: 2,\n  minMultiplier: 1.0102,\n  minWinChance: 0.01,\n});\nexport const DICE_OUTCOMES = 10_000;\nexport const DICE_PROCESSING_DELAYS = Object.freeze({ standard: 500, instant: 0 } as const);\nexport const DICE_MAX_STAKE_MINOR = 1_000_000n;\nexport const DICE_GAME_LIMITS = gameLimits(\"dice\");\nexport const DICE_MAX_PAYOUT_MINOR = DICE_GAME_LIMITS.maxPayoutMinor;\nexport const DICE_2_GAME_LIMITS = maxWinLimits(\"dice\");\nexport const DICE_HASH = \"SHA-256\" as const;\nexport const DICE_WORDS_PER_DIGEST = 8;\nexport const DICE_MAX_DIGESTS = 1024;\nexport const DICE_UNBIASED_LIMIT = 0x1_0000_0000 - (0x1_0000_0000 % DICE_OUTCOMES);\nexport const DICE_MULTIPLIER_DECIMALS = 4;\nexport const DICE_MULTIPLIER_SCALE = 10n ** BigInt(DICE_MULTIPLIER_DECIMALS);\nexport const DICE_SPAM_GUARD_MS = 150;\nexport const DICE_RATE_LIMIT = 60_000 / DICE_SPAM_GUARD_MS;\nexport const DICE_MAX_PAYOUT_REACHED = maximumPayoutMessage(DICE_MAX_PAYOUT_MINOR);\n\nexport type DiceAnimationDuration =\n  (typeof DICE_PROCESSING_DELAYS)[keyof typeof DICE_PROCESSING_DELAYS];\nexport type DiceParameters = {\n  animationDurationMs: DiceAnimationDuration;\n  over: boolean;\n  roll: number;\n};\nexport type DiceRatio = Readonly<{ numerator: bigint; denominator: bigint }>;\nexport type DiceQuote = Readonly<{\n  parameters: DiceParameters;\n  rollConvert: number;\n  winningOutcomes: number;\n  winChanceText: string;\n  multiplier: DiceRatio;\n  multiplierText: string;\n}>;\nexport type DiceDraw = Readonly<{\n  rollResult: number;\n  cursor: number;\n  wordIndex: number;\n  rejectedWords: number;\n}>;\nexport type DiceStakeTerms = Readonly<{\n  quote: DiceQuote;\n  maxStakeMinor: bigint;\n  maxPayoutMinor: bigint;\n}>;\n\nconst invalid = (message = \"Invalid Dice parameters\") => new RangeError(message);\n\nexport function isDiceRulesVersion(value: unknown): value is DiceRulesVersion {\n  return (DICE_RULES_VERSIONS as readonly unknown[]).includes(value);\n}\n\nexport function diceLimits(version: DiceRulesVersion = DICE_RULES_VERSION): Limits {\n  if (!isDiceRulesVersion(version)) throw invalid(\"Invalid Dice rules\");\n  return version === DICE_RULES_VERSION ? DICE_GAME_LIMITS : DICE_2_GAME_LIMITS;\n}\nconst positionMessage = `Position results in invalid win chance. Must be between ${DICE_LIMITS.minWinChance}% and ${DICE_LIMITS.maxWinChance}%`;\nconst keys: readonly string[] = Object.freeze([\"animationDurationMs\", \"over\", \"roll\"]);\nconst hundred = DICE_OUTCOMES / 100;\nconst minimumRollUnder = Math.round(DICE_LIMITS.minWinChance * hundred);\nconst maximumRollUnder = Math.round(DICE_LIMITS.maxWinChance * hundred);\nconst minimumRollOver = DICE_OUTCOMES - maximumRollUnder;\nconst maximumRollOver = DICE_OUTCOMES - minimumRollUnder;\nconst edge = 100 - DICE_LIMITS.houseEdge;\n\nfunction reduce(numerator: bigint, denominator: bigint): DiceRatio {\n  let a = numerator,\n    b = denominator;\n  while (b) [a, b] = [b, a % b];\n  return Object.freeze({ numerator: numerator / a, denominator: denominator / a });\n}\n\nfunction hundredthsText(value: number): string {\n  return `${Math.floor(value / 100)}.${(value % 100).toString().padStart(2, \"0\")}`;\n}\n\nfunction scaledText(value: bigint): string {\n  const scale = DICE_MULTIPLIER_SCALE;\n  return `${value / scale}.${(value % scale).toString().padStart(DICE_MULTIPLIER_DECIMALS, \"0\")}`;\n}\n\nfunction stake(value: unknown): bigint {\n  if (typeof value !== \"bigint\" || value <= 0n) throw invalid(\"Invalid Dice stake\");\n  if (value > DICE_MAX_STAKE_MINOR) throw invalid(\"Maximum stake is $10,000.00\");\n  return value;\n}\n\nfunction hundredths(value: unknown): number {\n  if (typeof value !== \"number\" || !Number.isFinite(value)) throw invalid();\n  const scaled = Math.round(value * 100);\n  if (scaled / 100 !== value) throw invalid();\n  return scaled;\n}\n\nexport function validateDiceParameters(input: unknown): DiceParameters {\n  if (!input || typeof input !== \"object\" || Array.isArray(input)) throw invalid();\n  const present = Reflect.ownKeys(input);\n  if (\n    present.length !== keys.length ||\n    !present.every(\n      (key) =>\n        typeof key === \"string\" &&\n        keys.includes(key) &&\n        Object.prototype.propertyIsEnumerable.call(input, key),\n    )\n  )\n    throw invalid();\n  const { animationDurationMs, over, roll } = input as Record<string, unknown>;\n  if (typeof over !== \"boolean\") throw invalid();\n  if (\n    animationDurationMs !== DICE_PROCESSING_DELAYS.standard &&\n    animationDurationMs !== DICE_PROCESSING_DELAYS.instant\n  )\n    throw invalid();\n  const target = hundredths(roll);\n  if (\n    over\n      ? target < minimumRollOver || target > maximumRollOver\n      : target < minimumRollUnder || target > maximumRollUnder\n  )\n    throw invalid(positionMessage);\n  return { animationDurationMs, over, roll: target / 100 };\n}\n\nexport function diceMultiplierScaled(winningOutcomes: number): bigint {\n  if (\n    !Number.isSafeInteger(winningOutcomes) ||\n    winningOutcomes < minimumRollUnder ||\n    winningOutcomes > maximumRollUnder\n  )\n    throw invalid(positionMessage);\n  return BigInt(\n    (edge / (winningOutcomes / hundred)).toFixed(DICE_MULTIPLIER_DECIMALS).replace(\".\", \"\"),\n  );\n}\n\nexport function diceMultiplierText(winningOutcomes: number): string {\n  return scaledText(diceMultiplierScaled(winningOutcomes));\n}\n\nexport function diceQuote(input: unknown): DiceQuote {\n  const parameters = validateDiceParameters(input),\n    rollConvert = Math.round(parameters.roll * 100),\n    winningOutcomes = parameters.over ? DICE_OUTCOMES - rollConvert : rollConvert,\n    scaled = diceMultiplierScaled(winningOutcomes);\n  return Object.freeze({\n    parameters,\n    rollConvert,\n    winningOutcomes,\n    winChanceText: hundredthsText(winningOutcomes),\n    multiplier: Object.freeze({ numerator: scaled, denominator: DICE_MULTIPLIER_SCALE }),\n    multiplierText: scaledText(scaled),\n  });\n}\n\nexport function diceRollText(rollResult: number): string {\n  if (!Number.isSafeInteger(rollResult) || rollResult < 0 || rollResult >= DICE_OUTCOMES)\n    throw invalid(\"Invalid Dice roll result\");\n  return hundredthsText(rollResult);\n}\n\nexport function diceWins(rollResult: number, input: unknown): boolean {\n  diceRollText(rollResult);\n  const quote = diceQuote(input);\n  return quote.parameters.over ? rollResult >= quote.rollConvert : rollResult < quote.rollConvert;\n}\n\nexport function diceNominalRtp(input: unknown): DiceRatio {\n  const quote = diceQuote(input);\n  return reduce(\n    quote.multiplier.numerator * BigInt(quote.winningOutcomes),\n    quote.multiplier.denominator * BigInt(DICE_OUTCOMES),\n  );\n}\n\nfunction grossWinMinor(stakeMinor: bigint, quote: DiceQuote): bigint {\n  return (stakeMinor * quote.multiplier.numerator) / quote.multiplier.denominator;\n}\n\nexport function diceStakeTerms(\n  input: unknown,\n  version: DiceRulesVersion = DICE_RULES_VERSION,\n): DiceStakeTerms {\n  const quote = diceQuote(input),\n    limits = diceLimits(version);\n  if (version !== DICE_RULES_VERSION)\n    return Object.freeze({\n      quote,\n      maxStakeMinor: selectionMaxBetMinor(limits, quote.multiplier),\n      maxPayoutMinor: limits.maxPayoutMinor,\n    });\n  const bound = (DICE_MAX_PAYOUT_MINOR * quote.multiplier.denominator) / quote.multiplier.numerator;\n  return Object.freeze({\n    quote,\n    maxStakeMinor: bound < DICE_MAX_STAKE_MINOR ? bound : DICE_MAX_STAKE_MINOR,\n    maxPayoutMinor: DICE_MAX_PAYOUT_MINOR,\n  });\n}\n\nexport function diceIsOverLimit(\n  stakeMinor: bigint,\n  input: unknown,\n  version: DiceRulesVersion = DICE_RULES_VERSION,\n): boolean {\n  const quote = diceQuote(input);\n  return (\n    stake(stakeMinor) * quote.multiplier.numerator >\n    diceLimits(version).maxPayoutMinor * quote.multiplier.denominator\n  );\n}\n\nexport function assertDicePayoutLimit(stakeMinor: bigint, input: unknown): void {\n  if (diceIsOverLimit(stakeMinor, input)) throw invalid(DICE_MAX_PAYOUT_REACHED);\n}\n\nexport function admitDiceStake(\n  stakeMinor: bigint,\n  input: unknown,\n  version: DiceRulesVersion = DICE_RULES_VERSION,\n): void {\n  if (version !== DICE_RULES_VERSION) {\n    admitMaxWinStake(diceLimits(version), stakeMinor, diceQuote(input).multiplier);\n    return;\n  }\n  admitStakeRange(DICE_GAME_LIMITS, stakeMinor);\n  admitPayoutLimit(DICE_GAME_LIMITS, stakeMinor, diceQuote(input).multiplier);\n}\n\nexport function diceMaximumPayoutMinor(\n  stakeMinor: bigint,\n  input: unknown,\n  version: DiceRulesVersion = DICE_RULES_VERSION,\n): bigint {\n  const gross = grossWinMinor(stake(stakeMinor), diceQuote(input)),\n    cap = diceLimits(version).maxPayoutMinor;\n  return gross > cap ? cap : gross;\n}\n\nexport function dicePcRtp(\n  stakeMinor: bigint,\n  input: unknown,\n  version: DiceRulesVersion = DICE_RULES_VERSION,\n): DiceRatio {\n  const quote = diceQuote(input);\n  return reduce(\n    diceMaximumPayoutMinor(stakeMinor, quote.parameters, version) * BigInt(quote.winningOutcomes),\n    stakeMinor * BigInt(DICE_OUTCOMES),\n  );\n}\n\nfunction entropySeeds(entropy: Entropy) {\n  if (\n    !entropy ||\n    typeof entropy !== \"object\" ||\n    typeof entropy.hmac !== \"function\" ||\n    typeof entropy.serverSeed !== \"string\" ||\n    !entropy.serverSeed.trim() ||\n    typeof entropy.clientSeed !== \"string\" ||\n    !entropy.clientSeed.trim() ||\n    !Number.isSafeInteger(entropy.nonce) ||\n    entropy.nonce < 0\n  )\n    throw invalid(\"Invalid Dice fairness seed\");\n  return `${entropy.clientSeed}:${entropy.nonce}:dice`;\n}\n\nexport function diceMessage(entropy: Entropy, cursor: number): string {\n  if (!Number.isSafeInteger(cursor) || cursor < 0) throw invalid(\"Invalid Dice entropy cursor\");\n  return `${entropySeeds(entropy)}:${cursor}`;\n}\n\nexport async function diceDraw(entropy: Entropy): Promise<DiceDraw> {\n  const message = entropySeeds(entropy);\n  let rejectedWords = 0;\n  for (let cursor = 0; cursor < DICE_MAX_DIGESTS; cursor++) {\n    const digest = await entropy.hmac(DICE_HASH, entropy.serverSeed, `${message}:${cursor}`);\n    if (!(digest instanceof Uint8Array) || digest.byteLength !== 32)\n      throw invalid(\"Invalid Dice HMAC digest\");\n    for (let wordIndex = 0; wordIndex < DICE_WORDS_PER_DIGEST; wordIndex++) {\n      const offset = wordIndex * 4;\n      const word =\n        ((digest[offset] << 24) |\n          (digest[offset + 1] << 16) |\n          (digest[offset + 2] << 8) |\n          digest[offset + 3]) >>>\n        0;\n      if (word < DICE_UNBIASED_LIMIT)\n        return Object.freeze({\n          rollResult: word % DICE_OUTCOMES,\n          cursor,\n          wordIndex,\n          rejectedWords,\n        });\n      rejectedWords++;\n    }\n  }\n  throw invalid(\"Dice entropy stream exhausted\");\n}\n\nfunction draw(value: unknown): DiceDraw {\n  if (!value || typeof value !== \"object\" || Array.isArray(value))\n    throw invalid(\"Invalid Dice draw\");\n  const { rollResult, cursor, wordIndex, rejectedWords } = value as Record<string, unknown>;\n  if (\n    typeof rollResult !== \"number\" ||\n    !Number.isSafeInteger(cursor) ||\n    (cursor as number) < 0 ||\n    (cursor as number) >= DICE_MAX_DIGESTS ||\n    !Number.isSafeInteger(wordIndex) ||\n    (wordIndex as number) < 0 ||\n    (wordIndex as number) >= DICE_WORDS_PER_DIGEST ||\n    rejectedWords !== (cursor as number) * DICE_WORDS_PER_DIGEST + (wordIndex as number)\n  )\n    throw invalid(\"Invalid Dice draw\");\n  diceRollText(rollResult);\n  return Object.freeze({\n    rollResult,\n    cursor: cursor as number,\n    wordIndex: wordIndex as number,\n    rejectedWords: rejectedWords as number,\n  });\n}\n\nexport function diceDrawFromResult(result: Record<string, unknown>): DiceDraw {\n  if (!result || typeof result !== \"object\" || Array.isArray(result))\n    throw invalid(\"Invalid Dice result\");\n  const recorded = result.draw;\n  if (!recorded || typeof recorded !== \"object\" || Array.isArray(recorded))\n    throw invalid(\"Invalid Dice draw\");\n  return draw({ ...(recorded as Record<string, unknown>), rollResult: result.rollResult });\n}\n\nexport function settleDiceRoll(input: {\n  stakeMinor: bigint;\n  parameters: unknown;\n  draw: DiceDraw;\n  version?: DiceRulesVersion;\n}): Resolution {\n  const version = input.version ?? DICE_RULES_VERSION,\n    cap = diceLimits(version).maxPayoutMinor,\n    stakeMinor = stake(input.stakeMinor),\n    quote = diceQuote(input.parameters),\n    drawn = draw(input.draw),\n    win = quote.parameters.over\n      ? drawn.rollResult >= quote.rollConvert\n      : drawn.rollResult < quote.rollConvert,\n    gross = win ? grossWinMinor(stakeMinor, quote) : 0n,\n    capped = gross > cap,\n    payout = capped ? cap : gross;\n  return {\n    game: \"dice\",\n    status: payout > 0n ? \"WON\" : \"LOST\",\n    payoutMinor: payout.toString(),\n    multiplier: ratioText(ratio(payout, stakeMinor)),\n    rulesVersion: version,\n    result: {\n      parameters: { ...quote.parameters },\n      diceRules: version,\n      settlementPolicy: DICE_SETTLEMENT_POLICY,\n      roll: quote.parameters.roll,\n      rollConvert: quote.rollConvert,\n      over: quote.parameters.over,\n      rollResult: drawn.rollResult,\n      rollResultText: diceRollText(drawn.rollResult),\n      win,\n      winningOutcomes: quote.winningOutcomes,\n      winChanceText: quote.winChanceText,\n      outcomes: DICE_OUTCOMES,\n      multiplierQuote: ratioWire(quote.multiplier),\n      multiplierText: quote.multiplierText,\n      grossPayoutMinor: gross.toString(),\n      capped,\n      maxPayoutMinor: cap.toString(),\n      nominalRtp: ratioWire(diceNominalRtp(quote.parameters)),\n      theoreticalRtp: ratioWire(dicePcRtp(stakeMinor, quote.parameters, version)),\n      draw: {\n        cursor: drawn.cursor,\n        wordIndex: drawn.wordIndex,\n        rejectedWords: drawn.rejectedWords,\n      },\n      decisions: [],\n    },\n  };\n}\n\nfunction diceRules(version: DiceRulesVersion): GameRules<DiceParameters> {\n  return Object.freeze({\n    game: \"dice\",\n    version,\n    stateful: false,\n    actions: Object.freeze([]),\n    rateLimitPerMinute: DICE_RATE_LIMIT,\n    limits: diceLimits(version),\n    admitStake: (stakeMinor: bigint, parameters: DiceParameters) =>\n      admitDiceStake(stakeMinor, parameters, version),\n    validateParameters: validateDiceParameters,\n    maximumPayoutMinor: (stakeMinor: bigint, parameters: DiceParameters) =>\n      diceMaximumPayoutMinor(stakeMinor, parameters, version),\n    async resolve(input) {\n      const stakeMinor = stake(input.stakeMinor),\n        parameters = validateDiceParameters(input.parameters);\n      return settleDiceRoll({\n        stakeMinor,\n        parameters,\n        draw: await diceDraw(input.entropy),\n        version,\n      });\n    },\n    reconstruct(input) {\n      return settleDiceRoll({\n        stakeMinor: input.stakeMinor,\n        parameters: input.parameters,\n        draw: diceDrawFromResult(input.result),\n        version,\n      });\n    },\n  } satisfies GameRules<DiceParameters>);\n}\n\nexport const diceRulesModule: GameRules<DiceParameters> = diceRules(DICE_RULES_VERSION);\nexport const dice2RulesModule: GameRules<DiceParameters> = diceRules(DICE_2_RULES_VERSION);\n","sha256":"241f908f5ca5252de0682d226c54a9ecba6c36431d8961112f71439b360a3904"},"src/lib/games/rules.ts":{"source":"import type { GameSlug, Resolution } from \"@/games/math\";\n\nexport type Hash = \"SHA-256\" | \"SHA-512\";\n\nexport type Entropy = Readonly<{\n  serverSeed: string;\n  clientSeed: string;\n  nonce: number;\n  hmac(algorithm: Hash, key: string, message: string): Promise<Uint8Array>;\n  digest(algorithm: Hash, message: string): Promise<Uint8Array>;\n}>;\n\nexport type Decision = Readonly<{\n  action: string;\n  selection?: number;\n  selections?: readonly number[];\n  choice?: string;\n  expectedStep?: number;\n}>;\n\nexport type GameParameters = Record<string, unknown>;\n\nexport type Limits = Readonly<{\n  minStakeMinor: bigint;\n  maxStakeMinor: bigint;\n  maxPayoutMinor: bigint;\n}>;\n\nexport type ResolveInput<P extends GameParameters = GameParameters> = Readonly<{\n  stakeMinor: bigint;\n  parameters: P;\n  entropy: Entropy;\n}>;\n\nexport type ActInput<P extends GameParameters = GameParameters> = Readonly<{\n  stakeMinor: bigint;\n  parameters: P;\n  secret: Record<string, unknown>;\n  result: Record<string, unknown>;\n  decision: Decision;\n  entropy: Entropy;\n}>;\n\nexport type GameRules<P extends GameParameters = GameParameters> = Readonly<{\n  game: GameSlug;\n  version: string;\n  formerly?: string;\n  stateful: boolean;\n  actions: readonly string[];\n  closingActions?: readonly string[];\n  rateLimitPerMinute?: number;\n  actionRateLimitPerMinute?: number;\n  limits?: Limits;\n  admitStake?(stakeMinor: bigint, parameters: P): void;\n  validateParameters(input: unknown): P;\n  maximumPayoutMinor(stakeMinor: bigint, parameters: P): bigint;\n  maximumStakeMinor?(stakeMinor: bigint, parameters: P): bigint;\n  resolve(input: ResolveInput<P>): Promise<Resolution>;\n  act?(input: ActInput<P>): Promise<Resolution>;\n  additionalStakeMinor?(\n    input: Readonly<{ parameters: P; secret: Record<string, unknown>; decision: Decision }>,\n  ): bigint;\n  reconstruct?(\n    input: Readonly<{ stakeMinor: bigint; parameters: P; result: Record<string, unknown> }>,\n  ): Resolution;\n}>;\n","sha256":"30df42dfcad535ae415c0b4cd4d49b9aa250653af74c12cfbdca3fe86944b5f5"},"src/lib/games/limits.ts":{"source":"import { MAX_PAYOUT_MINOR, type GameSlug } from \"@/games/math\";\nimport type { Limits } from \"./rules\";\n\nexport type UsdLimit = number | Readonly<Record<string, number>>;\nexport type UsdGameLimits = Readonly<{\n  min_bet: number;\n  max_bet: UsdLimit;\n  max_payout: number;\n}>;\nexport type FrameLimits = Readonly<{\n  minMinor: bigint;\n  maxMinor: bigint;\n  maxPayoutMinor: bigint;\n  sideMaxMinor?: bigint;\n  tieMaxMinor?: bigint;\n}>;\nexport type MultiplierRatio = Readonly<{ numerator: bigint; denominator: bigint }>;\n\nexport const PLATFORM_LIMITS = Object.freeze({\n  maxStakeMinor: 1_000_000n,\n  maxRoundStakeMinor: 8_000_000n,\n  maxPayoutMinor: MAX_PAYOUT_MINOR,\n});\n\nexport const USD_GAME_LIMITS: Readonly<Record<GameSlug, UsdGameLimits>> = Object.freeze({\n  dice: Object.freeze({ min_bet: 0.1, max_bet: 12_500, max_payout: 300_000 }),\n  plinko: Object.freeze({\n    min_bet: 0.1,\n    max_bet: Object.freeze({ low: 2_000, medium: 2_000, high: 2_000, rain: 500 }),\n    max_payout: 1_000_000,\n  }),\n  limbo: Object.freeze({ min_bet: 0.1, max_bet: 12_500, max_payout: 300_000 }),\n  keno: Object.freeze({\n    min_bet: 0.1,\n    max_bet: Object.freeze({\n      classic: 4_000,\n      low: 4_000,\n      medium: 4_000,\n      high: 4_000,\n      extreme: 500,\n      maxMultiplier100: 10_000,\n    }),\n    max_payout: 2_000_000,\n  }),\n  wheel: Object.freeze({ min_bet: 0.1, max_bet: 12_500, max_payout: 500_000 }),\n  roulette: Object.freeze({ min_bet: 0.1, max_bet: 10_000, max_payout: 360_000 }),\n  mines: Object.freeze({ min_bet: 0.1, max_bet: 6_000, max_payout: 1_000_000 }),\n  tower: Object.freeze({ min_bet: 0.1, max_bet: 10_000, max_payout: 1_000_000 }),\n  blackjack: Object.freeze({\n    min_bet: 0.1,\n    max_bet: Object.freeze({ \"main-bet\": 100_000, \"side-bet\": 5_000 }),\n    max_payout: 800_000,\n  }),\n  coinflip: Object.freeze({ min_bet: 0.1, max_bet: 10_000, max_payout: 1_000_000 }),\n  moles: Object.freeze({ min_bet: 0.1, max_bet: 10_000, max_payout: 1_000_000 }),\n  baccarat: Object.freeze({\n    min_bet: 0.1,\n    max_bet: Object.freeze({\n      \"main-bet-tie\": 10_000,\n      \"main-bet-non-tie\": 50_000,\n      \"side-bet\": 3_000,\n    }),\n    max_payout: 190_000,\n  }),\n  hilo: Object.freeze({ min_bet: 0.1, max_bet: 50_000, max_payout: 500_000 }),\n  war: Object.freeze({\n    min_bet: 0.1,\n    max_bet: Object.freeze({ \"main-bet\": 50_000, \"side-bet\": 10_000 }),\n    max_payout: 500_000,\n  }),\n  rps: Object.freeze({ min_bet: 0.1, max_bet: 25_000, max_payout: 500_000 }),\n  chicken: Object.freeze({ min_bet: 0.1, max_bet: 10_000, max_payout: 1_000_000 }),\n});\n\nfunction usdMinor(value: number): bigint {\n  if (!Number.isFinite(value) || value <= 0) throw new Error(\"Invalid game limit\");\n  return BigInt(Math.round(value * 100));\n}\n\nfunction capped(value: bigint, cap: bigint): bigint {\n  return value > cap ? cap : value;\n}\n\nfunction resolved(limit: UsdLimit, key?: string): number {\n  if (typeof limit === \"number\") return limit;\n  if (key !== undefined && Object.hasOwn(limit, key) && typeof limit[key] === \"number\")\n    return limit[key];\n  const first = Object.values(limit).find((value) => typeof value === \"number\");\n  if (first === undefined) throw new Error(\"Invalid game limit\");\n  return first;\n}\n\nfunction usdLimits(game: GameSlug): UsdGameLimits {\n  const limits = USD_GAME_LIMITS[game];\n  if (!limits) throw new RangeError(\"Unknown game\");\n  return limits;\n}\n\nexport function limitKeys(game: GameSlug): readonly string[] {\n  const limit = usdLimits(game).max_bet;\n  return Object.freeze(typeof limit === \"number\" ? [] : Object.keys(limit));\n}\n\nexport function gameLimits(game: GameSlug, key?: string): Limits {\n  const usd = usdLimits(game);\n  return Object.freeze({\n    minStakeMinor: usdMinor(usd.min_bet),\n    maxStakeMinor: capped(usdMinor(resolved(usd.max_bet, key)), PLATFORM_LIMITS.maxStakeMinor),\n    maxPayoutMinor: capped(usdMinor(usd.max_payout), PLATFORM_LIMITS.maxPayoutMinor),\n  });\n}\n\nexport function maxWinLimits(game: GameSlug, key?: string): Limits {\n  return Object.freeze({\n    ...gameLimits(game, key),\n    maxPayoutMinor: PLATFORM_LIMITS.maxPayoutMinor,\n  });\n}\n\nexport function frameLimits(game: GameSlug): FrameLimits {\n  const base = maxWinLimits(game);\n  const side = limitKeys(game).includes(\"side-bet\")\n    ? gameLimits(game, \"side-bet\").maxStakeMinor\n    : undefined;\n  if (game === \"baccarat\")\n    return Object.freeze({\n      minMinor: base.minStakeMinor,\n      maxMinor: gameLimits(game, \"main-bet-non-tie\").maxStakeMinor,\n      maxPayoutMinor: base.maxPayoutMinor,\n      tieMaxMinor: gameLimits(game, \"main-bet-tie\").maxStakeMinor,\n      sideMaxMinor: side,\n    });\n  return Object.freeze({\n    minMinor: base.minStakeMinor,\n    maxMinor: base.maxStakeMinor,\n    maxPayoutMinor: base.maxPayoutMinor,\n    ...(side === undefined ? {} : { sideMaxMinor: side }),\n  });\n}\n\nexport function limitAmountText(minor: bigint): string {\n  const negative = minor < 0n;\n  const value = negative ? -minor : minor;\n  return `${negative ? \"-\" : \"\"}${(value / 100n).toLocaleString(\"en-US\")}.${(value % 100n)\n    .toString()\n    .padStart(2, \"0\")}`;\n}\n\nexport function payoutLimitText(minor: bigint): string {\n  const cents = minor % 100n;\n  return cents === 0n\n    ? (minor / 100n).toString()\n    : `${minor / 100n}.${cents.toString().padStart(2, \"0\").replace(/0$/, \"\")}`;\n}\n\nexport function minimumBetMessage(minor: bigint): string {\n  return `Minimum bet is ${limitAmountText(minor)}`;\n}\n\nexport function maximumBetMessage(minor: bigint): string {\n  return `Maximum bet is ${limitAmountText(minor)}`;\n}\n\nexport function maximumPayoutMessage(minor: bigint): string {\n  return `Maximum payout reached: ${payoutLimitText(minor)}`;\n}\n\nexport function admitStakeRange(\n  limits: Pick<Limits, \"minStakeMinor\" | \"maxStakeMinor\">,\n  stakeMinor: bigint,\n): void {\n  if (typeof stakeMinor !== \"bigint\") throw new RangeError(\"Invalid stake\");\n  if (stakeMinor < limits.minStakeMinor)\n    throw new RangeError(minimumBetMessage(limits.minStakeMinor));\n  if (stakeMinor > limits.maxStakeMinor)\n    throw new RangeError(maximumBetMessage(limits.maxStakeMinor));\n}\n\nexport function exceedsMaxPayout(\n  limits: Pick<Limits, \"maxPayoutMinor\">,\n  stakeMinor: bigint,\n  multiplier: MultiplierRatio,\n): boolean {\n  return stakeMinor * multiplier.numerator > limits.maxPayoutMinor * multiplier.denominator;\n}\n\nexport function admitPayoutLimit(\n  limits: Pick<Limits, \"maxPayoutMinor\">,\n  stakeMinor: bigint,\n  multiplier: MultiplierRatio,\n): void {\n  if (exceedsMaxPayout(limits, stakeMinor, multiplier))\n    throw new RangeError(maximumPayoutMessage(limits.maxPayoutMinor));\n}\n","sha256":"bac85dcb18f484fec33eeee23f5022668f3c06cd379f1734dff71fc383d9b65f"},"src/lib/games/max-win.ts":{"source":"import { PLATFORM_LIMITS, maximumBetMessage, minimumBetMessage } from \"./limits\";\nimport type { GameParameters, GameRules, Limits } from \"./rules\";\n\nexport type MaxWinRatio = Readonly<{ numerator: bigint; denominator: bigint }>;\nexport type MaxWinStakeLimits = Pick<Limits, \"minStakeMinor\" | \"maxStakeMinor\">;\n\nexport const MAX_WIN_MINOR: bigint = PLATFORM_LIMITS.maxPayoutMinor;\nexport const MIN_STAKE_MINOR = 10n;\nexport const MAX_WIN_MULTIPLIER: MaxWinRatio = Object.freeze({\n  numerator: MAX_WIN_MINOR / MIN_STAKE_MINOR,\n  denominator: 1n,\n});\nexport const MAX_WIN_MULTIPLIER_HUNDREDTHS = MAX_WIN_MULTIPLIER.numerator * 100n;\nexport const MAX_WIN_END = \"maxWin\" as const;\nconst MAX_BET_LEAD = \"Maximum bet for this selection is \";\n\nfunction ratio(multiplier: MaxWinRatio): MaxWinRatio {\n  if (\n    !multiplier ||\n    typeof multiplier.numerator !== \"bigint\" ||\n    typeof multiplier.denominator !== \"bigint\" ||\n    multiplier.numerator < 0n ||\n    multiplier.denominator <= 0n\n  )\n    throw new RangeError(\"Invalid multiplier\");\n  return multiplier;\n}\n\nfunction stake(stakeMinor: bigint): bigint {\n  if (typeof stakeMinor !== \"bigint\" || stakeMinor < 0n) throw new RangeError(\"Invalid stake\");\n  return stakeMinor;\n}\n\nexport function hundredthsRatio(hundredths: bigint): MaxWinRatio {\n  return ratio({ numerator: hundredths, denominator: 100n });\n}\n\nexport function dollarsText(minor: bigint): string {\n  const negative = minor < 0n,\n    magnitude = negative ? -minor : minor;\n  return `${negative ? \"-\" : \"\"}$${(magnitude / 100n).toLocaleString(\"en-US\")}.${(magnitude % 100n)\n    .toString()\n    .padStart(2, \"0\")}`;\n}\n\nexport function grossPayoutMinor(stakeMinor: bigint, multiplier: MaxWinRatio): bigint {\n  const { numerator, denominator } = ratio(multiplier);\n  return (stake(stakeMinor) * numerator) / denominator;\n}\n\nexport function exceedsMaxWin(stakeMinor: bigint, multiplier: MaxWinRatio): boolean {\n  const { numerator, denominator } = ratio(multiplier);\n  return stake(stakeMinor) * numerator > MAX_WIN_MINOR * denominator;\n}\n\nexport function withinTableCeiling(multiplier: MaxWinRatio): boolean {\n  const { numerator, denominator } = ratio(multiplier);\n  return numerator * MAX_WIN_MULTIPLIER.denominator <= MAX_WIN_MULTIPLIER.numerator * denominator;\n}\n\nexport function maxBetForMultiplier(multiplier: MaxWinRatio): bigint {\n  const { numerator, denominator } = ratio(multiplier);\n  if (numerator === 0n) throw new RangeError(\"A zero multiplier has no maximum bet\");\n  return (MAX_WIN_MINOR * denominator) / numerator;\n}\n\nexport function maxBetForPayout(stakeMinor: bigint, maxPayoutMinor: bigint): bigint {\n  if (typeof maxPayoutMinor !== \"bigint\" || maxPayoutMinor <= 0n)\n    throw new RangeError(\"Invalid maximum payout\");\n  return (stake(stakeMinor) * MAX_WIN_MINOR) / maxPayoutMinor;\n}\n\nexport function maxBetMessage(maxBetMinor: bigint): string {\n  return `${MAX_BET_LEAD}${dollarsText(maxBetMinor)}`;\n}\n\nexport function unavailableSelectionMessage(): string {\n  return `This selection can win more than the $${(MAX_WIN_MINOR / 100n).toLocaleString(\"en-US\")} max win`;\n}\n\nexport function maxWinLine(multiplier: MaxWinRatio): string {\n  const maxBet = maxBetForMultiplier(multiplier);\n  return maxBet < MIN_STAKE_MINOR ? unavailableSelectionMessage() : maxBetMessage(maxBet);\n}\n\nexport function isMaxWinRefusal(message: unknown): boolean {\n  return (\n    typeof message === \"string\" &&\n    (message.startsWith(MAX_BET_LEAD) || message === unavailableSelectionMessage())\n  );\n}\n\nexport function maxWinRefusalText<P extends GameParameters>(\n  rules: Pick<GameRules<P>, \"admitStake\">,\n  stakeMinor: bigint,\n  parameters: P | null,\n): string | null {\n  if (parameters === null || stakeMinor <= 0n || !rules.admitStake) return null;\n  try {\n    rules.admitStake(stakeMinor, parameters);\n    return null;\n  } catch (cause) {\n    return cause instanceof Error && isMaxWinRefusal(cause.message) ? cause.message : null;\n  }\n}\n\nexport function overMaxWin<P extends GameParameters>(\n  rules: Pick<GameRules<P>, \"admitStake\">,\n  stakeMinor: bigint,\n  parameters: P | null,\n): boolean {\n  return maxWinRefusalText(rules, stakeMinor, parameters) !== null;\n}\n\nexport function maxWinStopMessage(payoutMinor: bigint): string {\n  return payoutMinor >= MAX_WIN_MINOR\n    ? `Max win reached. Paid ${dollarsText(payoutMinor)} in full.`\n    : `Cashed out to stay within the $${(MAX_WIN_MINOR / 100n).toLocaleString(\"en-US\")} max win. Paid ${dollarsText(payoutMinor)} in full.`;\n}\n\nexport function stepwiseStopMessage(payoutMinor: bigint, topOfTable: boolean): string {\n  return topOfTable\n    ? `Top multiplier reached. Paid ${dollarsText(payoutMinor)} in full.`\n    : maxWinStopMessage(payoutMinor);\n}\n\nexport function maxWinRefusal(\n  limits: MaxWinStakeLimits,\n  stakeMinor: unknown,\n  maxBetMinor: bigint | null,\n): string | null {\n  if (typeof stakeMinor !== \"bigint\") return \"Invalid stake\";\n  if (stakeMinor < limits.minStakeMinor) return minimumBetMessage(limits.minStakeMinor);\n  if (maxBetMinor !== null && maxBetMinor < limits.minStakeMinor)\n    return unavailableSelectionMessage();\n  if (maxBetMinor !== null && maxBetMinor < limits.maxStakeMinor)\n    return stakeMinor > maxBetMinor ? maxBetMessage(maxBetMinor) : null;\n  return stakeMinor > limits.maxStakeMinor ? maximumBetMessage(limits.maxStakeMinor) : null;\n}\n\nexport function selectionMaxBetMinor(\n  limits: MaxWinStakeLimits,\n  topMultiplier: MaxWinRatio | null,\n): bigint {\n  if (topMultiplier === null || ratio(topMultiplier).numerator === 0n) return limits.maxStakeMinor;\n  const limit = maxBetForMultiplier(topMultiplier);\n  return limit < limits.maxStakeMinor ? limit : limits.maxStakeMinor;\n}\n\nexport function admitMaxWinStake(\n  limits: MaxWinStakeLimits,\n  stakeMinor: bigint,\n  topMultiplier: MaxWinRatio | null,\n): void {\n  const maxBet =\n    topMultiplier === null || ratio(topMultiplier).numerator === 0n\n      ? null\n      : maxBetForMultiplier(topMultiplier);\n  const refusal = maxWinRefusal(limits, stakeMinor, maxBet);\n  if (refusal !== null) throw new RangeError(refusal);\n}\n\nexport function admitMaxWinPayout(\n  limits: MaxWinStakeLimits,\n  stakeMinor: bigint,\n  maxPayoutMinor: bigint,\n): void {\n  if (typeof maxPayoutMinor !== \"bigint\" || maxPayoutMinor < 0n)\n    throw new RangeError(\"Invalid maximum payout\");\n  const maxBet =\n    typeof stakeMinor === \"bigint\" && stakeMinor > 0n && maxPayoutMinor > MAX_WIN_MINOR\n      ? maxBetForPayout(stakeMinor, maxPayoutMinor)\n      : null;\n  const refusal = maxWinRefusal(limits, stakeMinor, maxBet);\n  if (refusal !== null) throw new RangeError(refusal);\n  if (maxPayoutMinor > MAX_WIN_MINOR) throw new RangeError(maxBetMessage(maxBet!));\n}\n\nexport function cashOutBeforeStep(stakeMinor: bigint, nextMultiplier: MaxWinRatio): boolean {\n  return exceedsMaxWin(stakeMinor, nextMultiplier);\n}\n\nexport function maxWinMultiplierHundredths(stakeMinor: bigint): bigint {\n  if (stake(stakeMinor) === 0n) throw new RangeError(\"Invalid stake\");\n  return (MAX_WIN_MINOR * 100n) / stakeMinor;\n}\n","sha256":"e24bd4b6e26c24815fb37335ab90057c44f46ac357f2b10ef4316792349459c8"},"src/lib/games/plinko-2.ts":{"source":"import { ratio, ratioText, ratioWire, type Resolution } from \"@/games/math\";\nimport type { Entropy, GameRules } from \"./rules\";\nimport { PLINKO_RATE_LIMIT } from \"./plinko-normal\";\nimport {\n  PLINKO_ROWS,\n  PLINKO_NORMAL_RULES_VERSION,\n  evaluatePlinkoPath,\n  plinkoPathFromDigest,\n  plinkoRtp,\n  plinkoSchedule,\n  plinkoScheduleDecimal,\n  validatePlinkoParameters,\n  type PlinkoParameters,\n} from \"./plinko-rules\";\nimport {\n  PLINKO_RAIN_RISK,\n  plinkoRainSchedule,\n  validatePlinkoRainParameters,\n  type PlinkoRainParameters,\n} from \"./plinko-rain\";\nimport {\n  plinkoRainMaximumPayoutMinor,\n  reconstructPlinkoRain,\n  resolvePlinkoRain,\n} from \"./plinko-rain-settlement\";\nimport { PLATFORM_LIMITS, admitStakeRange, gameLimits, maxWinLimits } from \"./limits\";\nimport { admitMaxWinStake, type MaxWinRatio } from \"./max-win\";\nimport { maxWinSuccessor } from \"./max-win-successor\";\nimport { PLINKO_RAIN_CAP_UNITS } from \"./plinko-rain\";\n\nexport const PLINKO_2_RULES_VERSION = \"peeks-plinko-2\" as const;\nexport const PLINKO_3_RULES_VERSION = \"peeks-plinko-3\" as const;\nexport const PLINKO_2_SETTLEMENT_POLICY = \"peeks-pc-floor-clip-1\" as const;\nexport const PLINKO_2_ROWS = PLINKO_ROWS;\nexport const PLINKO_2_RISKS = Object.freeze([\"low\", \"medium\", \"high\", \"rain\"] as const);\nexport const PLINKO_2_GAME_LIMITS = gameLimits(\"plinko\");\nexport const PLINKO_2_MAX_PAYOUT_MINOR = PLINKO_2_GAME_LIMITS.maxPayoutMinor;\nexport type Plinko2Risk = (typeof PLINKO_2_RISKS)[number];\nexport type Plinko2Parameters = PlinkoParameters | PlinkoRainParameters;\n\nfunction rain(input: unknown): boolean {\n  return (\n    !!input &&\n    typeof input === \"object\" &&\n    !Array.isArray(input) &&\n    Object.hasOwn(input, \"risk\") &&\n    (input as Record<string, unknown>).risk === PLINKO_RAIN_RISK\n  );\n}\n\nexport function validatePlinko2Parameters(input: unknown): Plinko2Parameters {\n  return rain(input) ? validatePlinkoRainParameters(input) : validatePlinkoParameters(input);\n}\n\nexport function plinko2Schedule(input: unknown): readonly string[] {\n  const parameters = validatePlinko2Parameters(input);\n  return parameters.risk === PLINKO_RAIN_RISK\n    ? plinkoRainSchedule(parameters)\n    : plinkoScheduleDecimal(parameters);\n}\n\nexport function plinko2StakeLimits(input: unknown) {\n  return gameLimits(\"plinko\", validatePlinko2Parameters(input).risk);\n}\n\nexport function plinko2MaximumStakeMinor(input: unknown): bigint {\n  return plinko2StakeLimits(input).maxStakeMinor;\n}\n\nexport function admitPlinko2Stake(stakeMinor: bigint, input: unknown): void {\n  admitStakeRange(plinko2StakeLimits(input), stakeMinor);\n}\n\nfunction normalStake(stakeMinor: unknown): bigint {\n  if (\n    typeof stakeMinor !== \"bigint\" ||\n    stakeMinor <= 0n ||\n    stakeMinor > PLATFORM_LIMITS.maxStakeMinor\n  )\n    throw new RangeError(\"Invalid Plinko stake\");\n  return stakeMinor;\n}\n\nfunction clipped(amount: bigint): bigint {\n  return amount > PLINKO_2_MAX_PAYOUT_MINOR ? PLINKO_2_MAX_PAYOUT_MINOR : amount;\n}\n\nexport function plinko2NormalMaximumPayoutMinor(stakeMinor: bigint, input: unknown): bigint {\n  const stake = normalStake(stakeMinor);\n  const best = plinkoSchedule(validatePlinkoParameters(input)).reduce((top, value) =>\n    value.numerator * top.denominator > top.numerator * value.denominator ? value : top,\n  );\n  return clipped((stake * best.numerator) / best.denominator);\n}\n\nexport function plinko2NormalPcRtp(stakeMinor: bigint, input: unknown) {\n  const stake = normalStake(stakeMinor);\n  const parameters = validatePlinkoParameters(input);\n  let count = 1n,\n    sum = 0n;\n  for (const [bucket, value] of plinkoSchedule(parameters).entries()) {\n    sum += count * clipped((stake * value.numerator) / value.denominator);\n    if (bucket < parameters.rows)\n      count = (count * BigInt(parameters.rows - bucket)) / BigInt(bucket + 1);\n  }\n  return ratio(sum, stake * 2n ** BigInt(parameters.rows));\n}\n\nexport function settlePlinko2Path(input: {\n  stakeMinor: bigint;\n  parameters: unknown;\n  path: readonly number[];\n}): Resolution {\n  const stake = normalStake(input.stakeMinor);\n  const evaluated = evaluatePlinkoPath({\n    stakeMinor: stake,\n    parameters: input.parameters,\n    path: input.path,\n  });\n  const gross = evaluated.grossMinor.numerator / evaluated.grossMinor.denominator;\n  const capped = gross > PLINKO_2_MAX_PAYOUT_MINOR;\n  const payout = capped ? PLINKO_2_MAX_PAYOUT_MINOR : gross;\n  return {\n    game: \"plinko\",\n    status: payout > 0n ? \"WON\" : \"LOST\",\n    payoutMinor: payout.toString(),\n    multiplier: ratioText(ratio(payout, stake)),\n    rulesVersion: PLINKO_2_RULES_VERSION,\n    result: {\n      parameters: evaluated.parameters,\n      plinkoRules: PLINKO_NORMAL_RULES_VERSION,\n      settlementPolicy: PLINKO_2_SETTLEMENT_POLICY,\n      nominalRtp: ratioWire(plinkoRtp(evaluated.parameters)),\n      theoreticalRtp: ratioWire(plinko2NormalPcRtp(stake, evaluated.parameters)),\n      maxPayoutMinor: PLINKO_2_MAX_PAYOUT_MINOR.toString(),\n      grossPayoutMinor: gross.toString(),\n      capped,\n      path: evaluated.path,\n      bucket: evaluated.bucket,\n      table: plinkoSchedule(evaluated.parameters).map(ratioWire),\n    },\n  };\n}\n\nasync function normalPath(entropy: Entropy, rows: number): Promise<(0 | 1)[]> {\n  if (\n    typeof entropy?.serverSeed !== \"string\" ||\n    !entropy.serverSeed.trim() ||\n    typeof entropy.clientSeed !== \"string\" ||\n    !entropy.clientSeed.trim() ||\n    !Number.isSafeInteger(entropy.nonce) ||\n    entropy.nonce < 0\n  )\n    throw new RangeError(\"Invalid Plinko fairness seed\");\n  const digest = await entropy.hmac(\n    \"SHA-256\",\n    entropy.serverSeed.trim(),\n    `${entropy.clientSeed.trim()}:${entropy.nonce}:plinko:0`,\n  );\n  return plinkoPathFromDigest(digest, rows);\n}\n\nfunction versioned(outcome: Resolution): Resolution {\n  return { ...outcome, rulesVersion: PLINKO_2_RULES_VERSION };\n}\n\nexport const plinko2RulesModule: GameRules<Plinko2Parameters> = Object.freeze({\n  game: \"plinko\",\n  version: PLINKO_2_RULES_VERSION,\n  stateful: false,\n  actions: Object.freeze([] as string[]),\n  rateLimitPerMinute: PLINKO_RATE_LIMIT,\n  limits: PLINKO_2_GAME_LIMITS,\n  admitStake: admitPlinko2Stake,\n  validateParameters: validatePlinko2Parameters,\n  maximumPayoutMinor(stakeMinor: bigint, input: Plinko2Parameters) {\n    const parameters = validatePlinko2Parameters(input);\n    return parameters.risk === PLINKO_RAIN_RISK\n      ? plinkoRainMaximumPayoutMinor(stakeMinor, parameters)\n      : plinko2NormalMaximumPayoutMinor(stakeMinor, parameters);\n  },\n  async resolve(input) {\n    const parameters = validatePlinko2Parameters(input.parameters);\n    if (parameters.risk === PLINKO_RAIN_RISK)\n      return versioned(\n        await resolvePlinkoRain({\n          stakeMinor: input.stakeMinor,\n          parameters,\n          entropy: input.entropy,\n        }),\n      );\n    const stakeMinor = normalStake(input.stakeMinor);\n    return settlePlinko2Path({\n      stakeMinor,\n      parameters,\n      path: await normalPath(input.entropy, parameters.rows),\n    });\n  },\n  reconstruct(input) {\n    const parameters = validatePlinko2Parameters(input.parameters);\n    return parameters.risk === PLINKO_RAIN_RISK\n      ? versioned(\n          reconstructPlinkoRain({\n            stakeMinor: input.stakeMinor,\n            parameters,\n            result: input.result,\n          }),\n        )\n      : settlePlinko2Path({\n          stakeMinor: input.stakeMinor,\n          parameters,\n          path: input.result?.path as number[],\n        });\n  },\n});\n\nexport function plinko3TopMultiplier(input: unknown): MaxWinRatio {\n  const parameters = validatePlinko2Parameters(input);\n  if (parameters.risk === PLINKO_RAIN_RISK)\n    return Object.freeze({ numerator: PLINKO_RAIN_CAP_UNITS, denominator: 1n });\n  return plinkoSchedule(parameters).reduce((top, value) =>\n    value.numerator * top.denominator > top.numerator * value.denominator ? value : top,\n  );\n}\n\nexport function admitPlinko3Stake(stakeMinor: bigint, input: unknown): void {\n  admitMaxWinStake(plinko2StakeLimits(input), stakeMinor, plinko3TopMultiplier(input));\n}\n\nexport const plinko3RulesModule: GameRules<Plinko2Parameters> = maxWinSuccessor(\n  plinko2RulesModule,\n  PLINKO_3_RULES_VERSION,\n  { limits: maxWinLimits(\"plinko\"), admitStake: admitPlinko3Stake },\n);\n","sha256":"927825e096ebf2b43ed0add8e5912a59c4530eaa94624b4bc6fa1b6b5b00c34c"},"src/lib/games/plinko-normal.ts":{"source":"import type { GameRules } from \"./rules\";\nimport {\n  PLINKO_NORMAL_RULES_VERSION,\n  plinkoPathFromDigest,\n  validatePlinkoParameters,\n  type PlinkoParameters,\n} from \"./plinko-rules\";\nimport { plinkoMaximumPayoutMinor, settlePlinkoPath } from \"./plinko-settlement\";\n\nexport const PLINKO_RATE_LIMIT = 450;\n\nexport const plinkoNormalRulesModule: GameRules<PlinkoParameters> = Object.freeze({\n  game: \"plinko\",\n  version: PLINKO_NORMAL_RULES_VERSION,\n  stateful: false,\n  actions: Object.freeze([] as string[]),\n  rateLimitPerMinute: PLINKO_RATE_LIMIT,\n  validateParameters: validatePlinkoParameters,\n  maximumPayoutMinor(stakeMinor: bigint, parameters: PlinkoParameters) {\n    return plinkoMaximumPayoutMinor(stakeMinor, parameters);\n  },\n  async resolve(input) {\n    const parameters = validatePlinkoParameters(input.parameters);\n    plinkoMaximumPayoutMinor(input.stakeMinor, parameters);\n    const entropy = input.entropy;\n    if (\n      typeof entropy?.serverSeed !== \"string\" ||\n      !entropy.serverSeed.trim() ||\n      typeof entropy.clientSeed !== \"string\" ||\n      !entropy.clientSeed.trim() ||\n      !Number.isSafeInteger(entropy.nonce) ||\n      entropy.nonce < 0\n    )\n      throw new RangeError(\"Invalid Plinko fairness seed\");\n    const digest = await entropy.hmac(\n      \"SHA-256\",\n      entropy.serverSeed.trim(),\n      `${entropy.clientSeed.trim()}:${entropy.nonce}:plinko:0`,\n    );\n    return settlePlinkoPath({\n      stakeMinor: input.stakeMinor,\n      parameters,\n      path: plinkoPathFromDigest(digest, parameters.rows),\n    });\n  },\n  reconstruct(input) {\n    return settlePlinkoPath({\n      stakeMinor: input.stakeMinor,\n      parameters: input.parameters,\n      path: input.result.path as number[],\n    });\n  },\n});\n","sha256":"e87fdad2bc6f1890e5a165f0968df0ce5c5c46316f0f105614784fcc55c28e2e"},"src/lib/games/plinko-rules.ts":{"source":"export const PLINKO_NORMAL_RULES_VERSION = \"peeks-plinko-normal-1\" as const;\nexport const PLINKO_ROWS = Object.freeze([8, 9, 10, 11, 12, 13, 14, 15, 16] as const);\nexport const PLINKO_RISKS = Object.freeze([\"low\", \"medium\", \"high\"] as const);\nexport type PlinkoRisk = (typeof PLINKO_RISKS)[number];\nexport type PlinkoParameters = { rows: number; risk: PlinkoRisk };\nexport type PlinkoRatio = Readonly<{ numerator: bigint; denominator: bigint }>;\nexport type PlinkoEvaluation = {\n  game: \"plinko\";\n  rulesVersion: typeof PLINKO_NORMAL_RULES_VERSION;\n  parameters: PlinkoParameters;\n  path: (0 | 1)[];\n  bucket: number;\n  multiplier: PlinkoRatio;\n  grossMinor: PlinkoRatio;\n  nominalRtp: PlinkoRatio;\n};\n\nconst schedules: Record<PlinkoRisk, Record<number, readonly string[]>> = {\n  low: {\n    8: Object.freeze([\"5.6\", \"2.1\", \"1.1\", \"1\", \"0.5\", \"1\", \"1.1\", \"2.1\", \"5.6\"]),\n    9: Object.freeze([\"5.6\", \"2\", \"1.6\", \"1\", \"0.7\", \"0.7\", \"1\", \"1.6\", \"2\", \"5.6\"]),\n    10: Object.freeze([\"8.9\", \"3\", \"1.4\", \"1.1\", \"1\", \"0.5\", \"1\", \"1.1\", \"1.4\", \"3\", \"8.9\"]),\n    11: Object.freeze([\"8.4\", \"3\", \"1.9\", \"1.3\", \"1\", \"0.7\", \"0.7\", \"1\", \"1.3\", \"1.9\", \"3\", \"8.4\"]),\n    12: Object.freeze([\n      \"10\",\n      \"3\",\n      \"1.6\",\n      \"1.4\",\n      \"1.1\",\n      \"1\",\n      \"0.5\",\n      \"1\",\n      \"1.1\",\n      \"1.4\",\n      \"1.6\",\n      \"3\",\n      \"10\",\n    ]),\n    13: Object.freeze([\n      \"8.1\",\n      \"4\",\n      \"3\",\n      \"1.9\",\n      \"1.2\",\n      \"0.9\",\n      \"0.7\",\n      \"0.7\",\n      \"0.9\",\n      \"1.2\",\n      \"1.9\",\n      \"3\",\n      \"4\",\n      \"8.1\",\n    ]),\n    14: Object.freeze([\n      \"7.1\",\n      \"4\",\n      \"1.9\",\n      \"1.4\",\n      \"1.3\",\n      \"1.1\",\n      \"1\",\n      \"0.5\",\n      \"1\",\n      \"1.1\",\n      \"1.3\",\n      \"1.4\",\n      \"1.9\",\n      \"4\",\n      \"7.1\",\n    ]),\n    15: Object.freeze([\n      \"15\",\n      \"8\",\n      \"3\",\n      \"2\",\n      \"1.5\",\n      \"1.1\",\n      \"1\",\n      \"0.7\",\n      \"0.7\",\n      \"1\",\n      \"1.1\",\n      \"1.5\",\n      \"2\",\n      \"3\",\n      \"8\",\n      \"15\",\n    ]),\n    16: Object.freeze([\n      \"16\",\n      \"9\",\n      \"2\",\n      \"1.4\",\n      \"1.4\",\n      \"1.2\",\n      \"1.1\",\n      \"1\",\n      \"0.5\",\n      \"1\",\n      \"1.1\",\n      \"1.2\",\n      \"1.4\",\n      \"1.4\",\n      \"2\",\n      \"9\",\n      \"16\",\n    ]),\n  },\n  medium: {\n    8: Object.freeze([\"13\", \"3\", \"1.3\", \"0.7\", \"0.4\", \"0.7\", \"1.3\", \"3\", \"13\"]),\n    9: Object.freeze([\"18\", \"4\", \"1.7\", \"0.9\", \"0.5\", \"0.5\", \"0.9\", \"1.7\", \"4\", \"18\"]),\n    10: Object.freeze([\"22\", \"5\", \"2\", \"1.4\", \"0.6\", \"0.4\", \"0.6\", \"1.4\", \"2\", \"5\", \"22\"]),\n    11: Object.freeze([\"24\", \"6\", \"3\", \"1.8\", \"0.7\", \"0.5\", \"0.5\", \"0.7\", \"1.8\", \"3\", \"6\", \"24\"]),\n    12: Object.freeze([\n      \"33\",\n      \"11\",\n      \"4\",\n      \"2\",\n      \"1.1\",\n      \"0.6\",\n      \"0.3\",\n      \"0.6\",\n      \"1.1\",\n      \"2\",\n      \"4\",\n      \"11\",\n      \"33\",\n    ]),\n    13: Object.freeze([\n      \"43\",\n      \"13\",\n      \"6\",\n      \"3\",\n      \"1.3\",\n      \"0.7\",\n      \"0.4\",\n      \"0.4\",\n      \"0.7\",\n      \"1.3\",\n      \"3\",\n      \"6\",\n      \"13\",\n      \"43\",\n    ]),\n    14: Object.freeze([\n      \"58\",\n      \"15\",\n      \"7\",\n      \"4\",\n      \"1.9\",\n      \"1\",\n      \"0.5\",\n      \"0.2\",\n      \"0.5\",\n      \"1\",\n      \"1.9\",\n      \"4\",\n      \"7\",\n      \"15\",\n      \"58\",\n    ]),\n    15: Object.freeze([\n      \"88\",\n      \"18\",\n      \"11\",\n      \"5\",\n      \"3\",\n      \"1.3\",\n      \"0.5\",\n      \"0.3\",\n      \"0.3\",\n      \"0.5\",\n      \"1.3\",\n      \"3\",\n      \"5\",\n      \"11\",\n      \"18\",\n      \"88\",\n    ]),\n    16: Object.freeze([\n      \"110\",\n      \"41\",\n      \"10\",\n      \"5\",\n      \"3\",\n      \"1.5\",\n      \"1\",\n      \"0.5\",\n      \"0.3\",\n      \"0.5\",\n      \"1\",\n      \"1.5\",\n      \"3\",\n      \"5\",\n      \"10\",\n      \"41\",\n      \"110\",\n    ]),\n  },\n  high: {\n    8: Object.freeze([\"29\", \"4\", \"1.5\", \"0.3\", \"0.2\", \"0.3\", \"1.5\", \"4\", \"29\"]),\n    9: Object.freeze([\"43\", \"7\", \"2\", \"0.6\", \"0.2\", \"0.2\", \"0.6\", \"2\", \"7\", \"43\"]),\n    10: Object.freeze([\"76\", \"10\", \"3\", \"0.9\", \"0.3\", \"0.2\", \"0.3\", \"0.9\", \"3\", \"10\", \"76\"]),\n    11: Object.freeze([\n      \"120\",\n      \"14\",\n      \"5.2\",\n      \"1.4\",\n      \"0.4\",\n      \"0.2\",\n      \"0.2\",\n      \"0.4\",\n      \"1.4\",\n      \"5.2\",\n      \"14\",\n      \"120\",\n    ]),\n    12: Object.freeze([\n      \"170\",\n      \"24\",\n      \"8.1\",\n      \"2\",\n      \"0.7\",\n      \"0.2\",\n      \"0.2\",\n      \"0.2\",\n      \"0.7\",\n      \"2\",\n      \"8.1\",\n      \"24\",\n      \"170\",\n    ]),\n    13: Object.freeze([\n      \"260\",\n      \"37\",\n      \"11\",\n      \"4\",\n      \"1\",\n      \"0.2\",\n      \"0.2\",\n      \"0.2\",\n      \"0.2\",\n      \"1\",\n      \"4\",\n      \"11\",\n      \"37\",\n      \"260\",\n    ]),\n    14: Object.freeze([\n      \"420\",\n      \"56\",\n      \"18\",\n      \"5\",\n      \"1.9\",\n      \"0.3\",\n      \"0.2\",\n      \"0.2\",\n      \"0.2\",\n      \"0.3\",\n      \"1.9\",\n      \"5\",\n      \"18\",\n      \"56\",\n      \"420\",\n    ]),\n    15: Object.freeze([\n      \"620\",\n      \"83\",\n      \"27\",\n      \"8\",\n      \"3\",\n      \"0.5\",\n      \"0.2\",\n      \"0.2\",\n      \"0.2\",\n      \"0.2\",\n      \"0.5\",\n      \"3\",\n      \"8\",\n      \"27\",\n      \"83\",\n      \"620\",\n    ]),\n    16: Object.freeze([\n      \"1000\",\n      \"130\",\n      \"26\",\n      \"9\",\n      \"4\",\n      \"2\",\n      \"0.2\",\n      \"0.2\",\n      \"0.2\",\n      \"0.2\",\n      \"0.2\",\n      \"2\",\n      \"4\",\n      \"9\",\n      \"26\",\n      \"130\",\n      \"1000\",\n    ]),\n  },\n};\nfor (const risk of PLINKO_RISKS) Object.freeze(schedules[risk]);\nObject.freeze(schedules);\n\nfunction rows(value: unknown): number {\n  if (typeof value !== \"number\" || !Number.isSafeInteger(value) || value < 8 || value > 16)\n    throw new RangeError(\"Invalid Plinko row count\");\n  return value;\n}\n\nfunction ratio(numerator: bigint, denominator: bigint): PlinkoRatio {\n  let a = numerator,\n    b = denominator;\n  while (b) [a, b] = [b, a % b];\n  return Object.freeze({ numerator: numerator / a, denominator: denominator / a });\n}\n\nfunction decimal(value: string): PlinkoRatio {\n  const [whole, fraction = \"\"] = value.split(\".\");\n  const denominator = 10n ** BigInt(fraction.length);\n  return ratio(BigInt(whole) * denominator + BigInt(fraction || \"0\"), denominator);\n}\n\nexport function validatePlinkoParameters(input: unknown): PlinkoParameters {\n  if (!input || typeof input !== \"object\" || Array.isArray(input))\n    throw new RangeError(\"Invalid Plinko parameters\");\n  const value = input as Record<string, unknown>;\n  if (\n    Object.keys(value).length !== 2 ||\n    !Object.hasOwn(value, \"rows\") ||\n    !Object.hasOwn(value, \"risk\") ||\n    !PLINKO_RISKS.includes(value.risk as PlinkoRisk)\n  )\n    throw new RangeError(\"Invalid Plinko parameters\");\n  return { rows: rows(value.rows), risk: value.risk as PlinkoRisk };\n}\n\nexport function plinkoScheduleDecimal(input: unknown): readonly string[] {\n  const parameters = validatePlinkoParameters(input);\n  return schedules[parameters.risk][parameters.rows];\n}\n\nexport function plinkoSchedule(input: unknown): readonly PlinkoRatio[] {\n  return Object.freeze(plinkoScheduleDecimal(input).map(decimal));\n}\n\nexport function plinkoRtp(input: unknown): PlinkoRatio {\n  const parameters = validatePlinkoParameters(input);\n  const table = plinkoSchedule(parameters);\n  let count = 1n,\n    numerator = 0n,\n    denominator = 1n;\n  for (let bucket = 0; bucket <= parameters.rows; bucket++) {\n    const value = table[bucket];\n    numerator = numerator * value.denominator + count * value.numerator * denominator;\n    denominator *= value.denominator;\n    const reduced = ratio(numerator, denominator);\n    numerator = reduced.numerator;\n    denominator = reduced.denominator;\n    if (bucket < parameters.rows)\n      count = (count * BigInt(parameters.rows - bucket)) / BigInt(bucket + 1);\n  }\n  return ratio(numerator, denominator * 2n ** BigInt(parameters.rows));\n}\n\nexport function plinkoPathFromDigest(digest: Uint8Array, rowCount: number): (0 | 1)[] {\n  rows(rowCount);\n  if (!(digest instanceof Uint8Array) || digest.byteLength !== 32)\n    throw new RangeError(\"Invalid Plinko HMAC digest\");\n  return Array.from(\n    { length: rowCount },\n    (_, index) => ((digest[index >>> 3] >>> (7 - (index & 7))) & 1) as 0 | 1,\n  );\n}\n\nexport async function plinkoPath(input: {\n  serverSeed: string;\n  clientSeed: string;\n  nonce: number;\n  rows: number;\n}): Promise<(0 | 1)[]> {\n  const rowCount = rows(input.rows);\n  if (\n    typeof input.serverSeed !== \"string\" ||\n    !input.serverSeed.trim() ||\n    typeof input.clientSeed !== \"string\" ||\n    !input.clientSeed.trim() ||\n    !Number.isSafeInteger(input.nonce) ||\n    input.nonce < 0\n  )\n    throw new RangeError(\"Invalid Plinko fairness seed\");\n  const encoder = new TextEncoder();\n  const key = await crypto.subtle.importKey(\n    \"raw\",\n    encoder.encode(input.serverSeed.trim()),\n    { name: \"HMAC\", hash: \"SHA-256\" },\n    false,\n    [\"sign\"],\n  );\n  const digest = await crypto.subtle.sign(\n    \"HMAC\",\n    key,\n    encoder.encode(`${input.clientSeed.trim()}:${input.nonce}:plinko:0`),\n  );\n  return plinkoPathFromDigest(new Uint8Array(digest), rowCount);\n}\n\nexport function evaluatePlinkoPath(input: {\n  parameters: unknown;\n  path: readonly number[];\n  stakeMinor: bigint;\n}): PlinkoEvaluation {\n  const parameters = validatePlinkoParameters(input.parameters);\n  if (\n    !Array.isArray(input.path) ||\n    input.path.length !== parameters.rows ||\n    Array.from(input.path).some((bit) => bit !== 0 && bit !== 1)\n  )\n    throw new RangeError(\"Invalid Plinko path\");\n  if (typeof input.stakeMinor !== \"bigint\" || input.stakeMinor <= 0n)\n    throw new RangeError(\"Invalid Plinko stake\");\n  const path = Array.from(input.path) as (0 | 1)[];\n  const bucket = path.reduce<number>((sum, bit) => sum + bit, 0);\n  const multiplier = plinkoSchedule(parameters)[bucket];\n  return {\n    game: \"plinko\",\n    rulesVersion: PLINKO_NORMAL_RULES_VERSION,\n    parameters,\n    path,\n    bucket,\n    multiplier,\n    grossMinor: ratio(input.stakeMinor * multiplier.numerator, multiplier.denominator),\n    nominalRtp: plinkoRtp(parameters),\n  };\n}\n","sha256":"2131c3f6fabfc37870ed8644c8e5426741c79d599454d2c345935c97442a603f"},"src/lib/games/plinko-settlement.ts":{"source":"import { MAX_PAYOUT_MINOR, ratio, ratioText, ratioWire, type Resolution } from \"@/games/math\";\nimport {\n  PLINKO_NORMAL_RULES_VERSION,\n  evaluatePlinkoPath,\n  plinkoRtp,\n  plinkoSchedule,\n  validatePlinkoParameters,\n} from \"./plinko-rules\";\n\nexport const PLINKO_PC_POLICY = \"peeks-pc-floor-1\";\n\nexport function plinkoStakeTerms(parameters: unknown) {\n  const validated = validatePlinkoParameters(parameters);\n  const table = plinkoSchedule(validated);\n  const maximum = table.reduce((best, value) =>\n    value.numerator * best.denominator > best.numerator * value.denominator ? value : best,\n  );\n  const bound = (MAX_PAYOUT_MINOR * maximum.denominator) / maximum.numerator;\n  return {\n    parameters: validated,\n    maximumMultiplier: maximum,\n    maxStakeMinor: bound < 1_000_000n ? bound : 1_000_000n,\n  };\n}\n\nexport function plinkoMaximumPayoutMinor(stakeMinor: bigint, parameters: unknown) {\n  const terms = plinkoStakeTerms(parameters);\n  if (typeof stakeMinor !== \"bigint\" || stakeMinor <= 0n || stakeMinor > terms.maxStakeMinor)\n    throw new RangeError(\n      `Maximum Plinko bet for these settings is ${terms.maxStakeMinor / 100n}.${(terms.maxStakeMinor % 100n).toString().padStart(2, \"0\")} PC`,\n    );\n  return (stakeMinor * terms.maximumMultiplier.numerator) / terms.maximumMultiplier.denominator;\n}\n\nexport function plinkoPcRtp(stakeMinor: bigint, parameters: unknown) {\n  plinkoMaximumPayoutMinor(stakeMinor, parameters);\n  const validated = validatePlinkoParameters(parameters);\n  let count = 1n,\n    sum = 0n;\n  for (const [bucket, value] of plinkoSchedule(validated).entries()) {\n    sum += count * ((stakeMinor * value.numerator) / value.denominator);\n    if (bucket < validated.rows)\n      count = (count * BigInt(validated.rows - bucket)) / BigInt(bucket + 1);\n  }\n  return ratio(sum, stakeMinor * 2n ** BigInt(validated.rows));\n}\n\nexport function settlePlinkoPath(input: {\n  stakeMinor: bigint;\n  parameters: unknown;\n  path: readonly number[];\n}): Resolution {\n  plinkoMaximumPayoutMinor(input.stakeMinor, input.parameters);\n  const evaluated = evaluatePlinkoPath(input);\n  const payout = evaluated.grossMinor.numerator / evaluated.grossMinor.denominator;\n  return {\n    game: \"plinko\",\n    status: payout > 0n ? \"WON\" : \"LOST\",\n    payoutMinor: payout.toString(),\n    multiplier: ratioText(ratio(payout, input.stakeMinor)),\n    rulesVersion: PLINKO_NORMAL_RULES_VERSION,\n    result: {\n      parameters: evaluated.parameters,\n      plinkoRules: PLINKO_NORMAL_RULES_VERSION,\n      settlementPolicy: PLINKO_PC_POLICY,\n      nominalRtp: ratioWire(plinkoRtp(evaluated.parameters)),\n      theoreticalRtp: ratioWire(plinkoPcRtp(input.stakeMinor, evaluated.parameters)),\n      maxPayoutMinor: MAX_PAYOUT_MINOR.toString(),\n      path: evaluated.path,\n      bucket: evaluated.bucket,\n      table: plinkoSchedule(evaluated.parameters).map(ratioWire),\n    },\n  };\n}\n","sha256":"6e87ee374697beb6ca288bdf21be65b3e7706de0edd65aebd8b8ebaba5e22b81"},"src/lib/games/plinko-rain.ts":{"source":"import type { Entropy } from \"./rules\";\nimport { plinkoPathFromDigest } from \"./plinko-rules\";\nimport { gameLimits, maximumBetMessage } from \"./limits\";\n\nexport const PLINKO_RAIN_RULES_VERSION = \"peeks-plinko-rain-1\" as const;\nexport const PLINKO_RAIN_RISK = \"rain\" as const;\nexport const PLINKO_RAIN_HASH = \"SHA-256\" as const;\nexport const PLINKO_RAIN_TRIGGER = \"-1\";\nexport const PLINKO_RAIN_MAX_MULTIPLIER = \"10000\";\nexport const PLINKO_RAIN_CAP_UNITS = 10_000n;\nexport const PLINKO_RAIN_CAP_TENTHS = 100_000n;\nexport const PLINKO_RAIN_ROUND_LIMIT = 51;\nexport const PLINKO_RAIN_TICKETS = 100_000_000;\nexport const PLINKO_RAIN_TICKET_LIMIT = 0x1_0000_0000 - (0x1_0000_0000 % PLINKO_RAIN_TICKETS);\nexport const PLINKO_RAIN_BLOCKS = 64;\nexport const PLINKO_RAIN_MAX_STAKE_MINOR = gameLimits(\"plinko\", PLINKO_RAIN_RISK).maxStakeMinor;\nexport const PLINKO_RAIN_MAX_STAKE_MESSAGE = maximumBetMessage(PLINKO_RAIN_MAX_STAKE_MINOR);\n\nexport type PlinkoRainParameters = { rows: number; risk: typeof PLINKO_RAIN_RISK };\nexport type PlinkoRainSegment = Readonly<{\n  multiplier: number;\n  percent: string;\n  weight: number;\n}>;\nexport type PlinkoRainDraw = Readonly<{ path: readonly (0 | 1)[]; ticket: number | null }>;\nexport type PlinkoRainStage = Readonly<{\n  round: number;\n  path: readonly (0 | 1)[];\n  bucketIndex: number;\n  bucketMultiplier: string;\n  ticket: number | null;\n  rouletteMultiplier: string | null;\n  accumulatedMultiplier: string;\n  multiplierCapped: boolean;\n}>;\nexport type PlinkoRainFinish = \"bucket\" | \"roundLimit\";\nexport type PlinkoRainEvaluation = Readonly<{\n  parameters: Readonly<PlinkoRainParameters>;\n  stages: readonly PlinkoRainStage[];\n  finish: PlinkoRainFinish;\n  finalTenths: bigint;\n  finalMultiplier: string;\n  multiplierCapped: boolean;\n}>;\n\nconst tables: Readonly<Record<number, readonly string[]>> = Object.freeze({\n  8: Object.freeze([\"22\", \"-1\", \"0.9\", \"0.4\", \"0.2\", \"0.4\", \"0.9\", \"-1\", \"22\"]),\n  9: Object.freeze([\"30\", \"-1\", \"2.2\", \"0.7\", \"0.2\", \"0.2\", \"0.7\", \"2.2\", \"-1\", \"30\"]),\n  10: Object.freeze([\"45\", \"-1\", \"3.1\", \"1.2\", \"0.4\", \"0.2\", \"0.4\", \"1.2\", \"3.1\", \"-1\", \"45\"]),\n  11: Object.freeze([\"65\", \"10\", \"-1\", \"1.4\", \"0.5\", \"0.2\", \"0.2\", \"0.5\", \"1.4\", \"-1\", \"10\", \"65\"]),\n  12: Object.freeze([\n    \"100\",\n    \"15\",\n    \"-1\",\n    \"3.1\",\n    \"0.6\",\n    \"0.3\",\n    \"0.2\",\n    \"0.3\",\n    \"0.6\",\n    \"3.1\",\n    \"-1\",\n    \"15\",\n    \"100\",\n  ]),\n  13: Object.freeze([\n    \"175\",\n    \"25\",\n    \"4\",\n    \"-1\",\n    \"1\",\n    \"0.3\",\n    \"0.2\",\n    \"0.2\",\n    \"0.3\",\n    \"1\",\n    \"-1\",\n    \"4\",\n    \"25\",\n    \"175\",\n  ]),\n  14: Object.freeze([\n    \"250\",\n    \"35\",\n    \"11\",\n    \"-1\",\n    \"1.8\",\n    \"0.5\",\n    \"0.3\",\n    \"0.2\",\n    \"0.3\",\n    \"0.5\",\n    \"1.8\",\n    \"-1\",\n    \"11\",\n    \"35\",\n    \"250\",\n  ]),\n  15: Object.freeze([\n    \"400\",\n    \"40\",\n    \"17\",\n    \"-1\",\n    \"2.3\",\n    \"1.3\",\n    \"0.4\",\n    \"0.2\",\n    \"0.2\",\n    \"0.4\",\n    \"1.3\",\n    \"2.3\",\n    \"-1\",\n    \"17\",\n    \"40\",\n    \"400\",\n  ]),\n  16: Object.freeze([\n    \"500\",\n    \"42\",\n    \"22\",\n    \"4\",\n    \"-1\",\n    \"2\",\n    \"0.3\",\n    \"0.2\",\n    \"0.2\",\n    \"0.2\",\n    \"0.3\",\n    \"2\",\n    \"-1\",\n    \"4\",\n    \"22\",\n    \"42\",\n    \"500\",\n  ]),\n});\n\nconst segments: readonly (readonly [number, string])[] = [\n  [2, \"22.09\"],\n  [3, \"25.115\"],\n  [4, \"10\"],\n  [5, \"13\"],\n  [6, \"6\"],\n  [7, \"4\"],\n  [8, \"2.5\"],\n  [9, \"2.5\"],\n  [10, \"3.5\"],\n  [11, \"1.5\"],\n  [12, \"1.2\"],\n  [13, \"1.2\"],\n  [14, \"1\"],\n  [15, \"1.4\"],\n  [16, \"0.8\"],\n  [17, \"0.4\"],\n  [18, \"0.4\"],\n  [19, \"0.4\"],\n  [20, \"0.7\"],\n  [25, \"0.4\"],\n  [30, \"0.4\"],\n  [35, \"0.3\"],\n  [40, \"0.2\"],\n  [45, \"0.2\"],\n  [50, \"0.5\"],\n  [60, \"0.065\"],\n  [70, \"0.05\"],\n  [80, \"0.04\"],\n  [90, \"0.04\"],\n  [100, \"0.1\"],\n];\n\nfunction millionths(percent: string): number {\n  const [whole, fraction = \"\"] = percent.split(\".\");\n  return Number(whole) * 1_000_000 + Number(fraction.padEnd(6, \"0\"));\n}\n\nexport const PLINKO_RAIN_ROULETTE: readonly PlinkoRainSegment[] = Object.freeze(\n  segments.map(([multiplier, percent]) =>\n    Object.freeze({ multiplier, percent, weight: millionths(percent) }),\n  ),\n);\n\nconst invalid = (message: string) => new RangeError(message);\n\nexport function plinkoRainTenths(value: string): bigint {\n  if (typeof value !== \"string\" || !/^(0|[1-9]\\d*)(\\.\\d)?$/.test(value))\n    throw invalid(\"Invalid Plinko Rain multiplier\");\n  const [whole, fraction = \"0\"] = value.split(\".\");\n  return BigInt(whole) * 10n + BigInt(fraction);\n}\n\nexport function plinkoRainMultiplierText(tenths: bigint): string {\n  if (typeof tenths !== \"bigint\" || tenths < 0n) throw invalid(\"Invalid Plinko Rain multiplier\");\n  const fraction = tenths % 10n;\n  return fraction ? `${tenths / 10n}.${fraction}` : `${tenths / 10n}`;\n}\n\nexport function validatePlinkoRainParameters(input: unknown): PlinkoRainParameters {\n  if (!input || typeof input !== \"object\" || Array.isArray(input))\n    throw invalid(\"Invalid Plinko parameters\");\n  const value = input as Record<string, unknown>;\n  if (\n    Object.keys(value).length !== 2 ||\n    !Object.hasOwn(value, \"rows\") ||\n    !Object.hasOwn(value, \"risk\") ||\n    value.risk !== PLINKO_RAIN_RISK\n  )\n    throw invalid(\"Invalid Plinko parameters\");\n  const rows = value.rows;\n  if (typeof rows !== \"number\" || !Number.isSafeInteger(rows) || rows < 8 || rows > 16)\n    throw invalid(\"Invalid Plinko row count\");\n  return { rows, risk: PLINKO_RAIN_RISK };\n}\n\nexport function validatePlinkoRainStake(value: unknown): bigint {\n  if (typeof value !== \"bigint\" || value <= 0n) throw invalid(\"Invalid Plinko stake\");\n  if (value > PLINKO_RAIN_MAX_STAKE_MINOR) throw invalid(PLINKO_RAIN_MAX_STAKE_MESSAGE);\n  return value;\n}\n\nexport function plinkoRainSchedule(input: unknown): readonly string[] {\n  return tables[validatePlinkoRainParameters(input).rows];\n}\n\nexport function plinkoRainTriggerBuckets(input: unknown): readonly number[] {\n  return Object.freeze(\n    plinkoRainSchedule(input).flatMap((value, bucket) =>\n      value === PLINKO_RAIN_TRIGGER ? [bucket] : [],\n    ),\n  );\n}\n\nexport function plinkoRainPathMessage(clientSeed: string, nonce: number, round: number): string {\n  return `${clientSeed}:${nonce}:${round}:plinko:0`;\n}\n\nexport function plinkoRainRouletteMessage(\n  clientSeed: string,\n  nonce: number,\n  round: number,\n  block: number,\n): string {\n  return `${clientSeed}:${nonce}:${round}:rain:plinko:${block}`;\n}\n\nexport function plinkoRainTicketFromDigest(digest: Uint8Array): number | null {\n  if (!(digest instanceof Uint8Array) || digest.byteLength !== 32)\n    throw invalid(\"Invalid Plinko Rain HMAC digest\");\n  for (let offset = 0; offset < 32; offset += 4) {\n    const word =\n      ((digest[offset] << 24) |\n        (digest[offset + 1] << 16) |\n        (digest[offset + 2] << 8) |\n        digest[offset + 3]) >>>\n      0;\n    if (word < PLINKO_RAIN_TICKET_LIMIT) return word % PLINKO_RAIN_TICKETS;\n  }\n  return null;\n}\n\nexport function plinkoRainSegmentIndex(ticket: unknown): number {\n  if (\n    typeof ticket !== \"number\" ||\n    !Number.isSafeInteger(ticket) ||\n    ticket < 0 ||\n    ticket >= PLINKO_RAIN_TICKETS\n  )\n    throw invalid(\"Invalid Plinko Rain ticket\");\n  let cumulative = 0;\n  for (let index = 0; index < PLINKO_RAIN_ROULETTE.length; index++) {\n    cumulative += PLINKO_RAIN_ROULETTE[index].weight;\n    if (ticket < cumulative) return index;\n  }\n  throw invalid(\"Invalid Plinko Rain ticket\");\n}\n\nexport function plinkoRainFactor(ticket: unknown): number {\n  return PLINKO_RAIN_ROULETTE[plinkoRainSegmentIndex(ticket)].multiplier;\n}\n\nfunction seeds(entropy: Entropy) {\n  if (\n    !entropy ||\n    typeof entropy !== \"object\" ||\n    typeof entropy.hmac !== \"function\" ||\n    typeof entropy.serverSeed !== \"string\" ||\n    !entropy.serverSeed.trim() ||\n    typeof entropy.clientSeed !== \"string\" ||\n    !entropy.clientSeed.trim() ||\n    !Number.isSafeInteger(entropy.nonce) ||\n    entropy.nonce < 0\n  )\n    throw invalid(\"Invalid Plinko fairness seed\");\n  return {\n    key: entropy.serverSeed.trim(),\n    client: entropy.clientSeed.trim(),\n    nonce: entropy.nonce,\n  };\n}\n\nfunction roundOf(value: unknown): number {\n  if (\n    typeof value !== \"number\" ||\n    !Number.isSafeInteger(value) ||\n    value < 0 ||\n    value >= PLINKO_RAIN_ROUND_LIMIT\n  )\n    throw invalid(\"Invalid Plinko Rain round\");\n  return value;\n}\n\nexport async function plinkoRainPath(\n  entropy: Entropy,\n  input: unknown,\n  round: number,\n): Promise<(0 | 1)[]> {\n  const { rows } = validatePlinkoRainParameters(input),\n    seed = seeds(entropy);\n  return plinkoPathFromDigest(\n    await entropy.hmac(\n      PLINKO_RAIN_HASH,\n      seed.key,\n      plinkoRainPathMessage(seed.client, seed.nonce, roundOf(round)),\n    ),\n    rows,\n  );\n}\n\nexport async function plinkoRainTicket(entropy: Entropy, round: number): Promise<number> {\n  const seed = seeds(entropy),\n    index = roundOf(round);\n  for (let block = 0; block < PLINKO_RAIN_BLOCKS; block++) {\n    const ticket = plinkoRainTicketFromDigest(\n      await entropy.hmac(\n        PLINKO_RAIN_HASH,\n        seed.key,\n        plinkoRainRouletteMessage(seed.client, seed.nonce, index, block),\n      ),\n    );\n    if (ticket !== null) return ticket;\n  }\n  throw invalid(\"Plinko Rain entropy stream exhausted\");\n}\n\nexport async function plinkoRainDraws(\n  entropy: Entropy,\n  input: unknown,\n): Promise<readonly PlinkoRainDraw[]> {\n  const parameters = validatePlinkoRainParameters(input),\n    table = tables[parameters.rows];\n  seeds(entropy);\n  const draws: PlinkoRainDraw[] = [];\n  for (let round = 0; round < PLINKO_RAIN_ROUND_LIMIT; round++) {\n    const path = await plinkoRainPath(entropy, parameters, round),\n      bucket = path.reduce<number>((sum, bit) => sum + bit, 0);\n    if (table[bucket] !== PLINKO_RAIN_TRIGGER) {\n      draws.push(Object.freeze({ path: Object.freeze(path), ticket: null }));\n      break;\n    }\n    draws.push(\n      Object.freeze({\n        path: Object.freeze(path),\n        ticket: await plinkoRainTicket(entropy, round),\n      }),\n    );\n  }\n  return Object.freeze(draws);\n}\n\nfunction pathOf(value: unknown, rows: number): (0 | 1)[] {\n  if (\n    !Array.isArray(value) ||\n    value.length !== rows ||\n    Array.from(value).some((bit) => bit !== 0 && bit !== 1)\n  )\n    throw invalid(\"Invalid Plinko path\");\n  return Array.from(value) as (0 | 1)[];\n}\n\nexport function evaluatePlinkoRain(input: {\n  parameters: unknown;\n  draws: readonly unknown[];\n}): PlinkoRainEvaluation {\n  const parameters = validatePlinkoRainParameters(input?.parameters),\n    table = tables[parameters.rows],\n    draws = input.draws;\n  if (!Array.isArray(draws) || draws.length < 1 || draws.length > PLINKO_RAIN_ROUND_LIMIT)\n    throw invalid(\"Invalid Plinko Rain stages\");\n  const stages: PlinkoRainStage[] = [];\n  let units = 1n,\n    finalTenths = 0n,\n    capped = false,\n    finish: PlinkoRainFinish = \"bucket\";\n  for (let round = 0; round < draws.length; round++) {\n    const draw = draws[round];\n    if (!draw || typeof draw !== \"object\" || Array.isArray(draw))\n      throw invalid(\"Invalid Plinko Rain stages\");\n    const entry = draw as Record<string, unknown>,\n      path = pathOf(entry.path, parameters.rows),\n      bucketIndex = path.reduce<number>((sum, bit) => sum + bit, 0),\n      bucketMultiplier = table[bucketIndex],\n      last = round === draws.length - 1;\n    if (bucketMultiplier === PLINKO_RAIN_TRIGGER) {\n      if (last && round !== PLINKO_RAIN_ROUND_LIMIT - 1)\n        throw invalid(\"Plinko Rain stages end before the ball settles\");\n      const factor = plinkoRainFactor(entry.ticket),\n        product = units * BigInt(factor),\n        clamped = product > PLINKO_RAIN_CAP_UNITS;\n      units = clamped ? PLINKO_RAIN_CAP_UNITS : product;\n      capped ||= clamped;\n      stages.push(\n        Object.freeze({\n          round,\n          path: Object.freeze(path),\n          bucketIndex,\n          bucketMultiplier,\n          ticket: entry.ticket as number,\n          rouletteMultiplier: `${factor}`,\n          accumulatedMultiplier: units.toString(),\n          multiplierCapped: clamped,\n        }),\n      );\n      if (last) {\n        finalTenths = units * 10n;\n        finish = \"roundLimit\";\n      }\n      continue;\n    }\n    if (entry.ticket !== null) throw invalid(\"Invalid Plinko Rain ticket\");\n    if (!last) throw invalid(\"Plinko Rain stages continue after the ball settles\");\n    const product = units * plinkoRainTenths(bucketMultiplier),\n      clamped = product > PLINKO_RAIN_CAP_TENTHS;\n    finalTenths = clamped ? PLINKO_RAIN_CAP_TENTHS : product;\n    capped ||= clamped;\n    stages.push(\n      Object.freeze({\n        round,\n        path: Object.freeze(path),\n        bucketIndex,\n        bucketMultiplier,\n        ticket: null,\n        rouletteMultiplier: null,\n        accumulatedMultiplier: plinkoRainMultiplierText(finalTenths),\n        multiplierCapped: clamped,\n      }),\n    );\n  }\n  return Object.freeze({\n    parameters: Object.freeze(parameters),\n    stages: Object.freeze(stages),\n    finish,\n    finalTenths,\n    finalMultiplier: plinkoRainMultiplierText(finalTenths),\n    multiplierCapped: capped,\n  });\n}\n","sha256":"6450bf9b6a8be2241de059f174bbc0ac47fc59d691f93d7ae2d5069149cdf76a"},"src/lib/games/plinko-rain-settlement.ts":{"source":"import { MAX_PAYOUT_MINOR, ratio, ratioText, ratioWire, type Resolution } from \"@/games/math\";\nimport type { Entropy } from \"./rules\";\nimport {\n  PLINKO_RAIN_CAP_UNITS,\n  PLINKO_RAIN_MAX_MULTIPLIER,\n  PLINKO_RAIN_ROUND_LIMIT,\n  PLINKO_RAIN_RULES_VERSION,\n  evaluatePlinkoRain,\n  plinkoRainDraws,\n  plinkoRainSchedule,\n  validatePlinkoRainParameters,\n  validatePlinkoRainStake,\n  type PlinkoRainFinish,\n  type PlinkoRainParameters,\n  type PlinkoRainStage,\n} from \"./plinko-rain\";\nimport { plinkoRainPcRtp, plinkoRainRtp } from \"./plinko-rain-distribution\";\n\nexport const PLINKO_RAIN_PC_POLICY = \"peeks-pc-floor-clip-1\" as const;\n\nexport type PlinkoRainWireRatio = ReturnType<typeof ratioWire>;\nexport type PlinkoRainResult = {\n  parameters: PlinkoRainParameters;\n  plinkoRules: typeof PLINKO_RAIN_RULES_VERSION;\n  settlementPolicy: typeof PLINKO_RAIN_PC_POLICY;\n  nominalRtp: PlinkoRainWireRatio;\n  theoreticalRtp: PlinkoRainWireRatio;\n  maxPayoutMinor: string;\n  maxMultiplier: typeof PLINKO_RAIN_MAX_MULTIPLIER;\n  roundLimit: number;\n  multipliers: readonly string[];\n  stages: readonly PlinkoRainStage[];\n  finish: PlinkoRainFinish;\n  finalMultiplier: string;\n  multiplierCapped: boolean;\n  grossBeforeCapMinor: string;\n  capped: boolean;\n};\n\nfunction clipped(amount: bigint): bigint {\n  return amount > MAX_PAYOUT_MINOR ? MAX_PAYOUT_MINOR : amount;\n}\n\nexport function plinkoRainGrossMinor(stakeMinor: bigint, finalTenths: bigint): bigint {\n  if (typeof finalTenths !== \"bigint\" || finalTenths < 0n)\n    throw new RangeError(\"Invalid Plinko Rain multiplier\");\n  return (validatePlinkoRainStake(stakeMinor) * finalTenths) / 10n;\n}\n\nexport function plinkoRainPayoutMinor(stakeMinor: bigint, finalTenths: bigint): bigint {\n  return clipped(plinkoRainGrossMinor(stakeMinor, finalTenths));\n}\n\nexport function plinkoRainMaximumPayoutMinor(stakeMinor: bigint, parameters: unknown) {\n  validatePlinkoRainParameters(parameters);\n  return clipped(validatePlinkoRainStake(stakeMinor) * PLINKO_RAIN_CAP_UNITS);\n}\n\nexport function settlePlinkoRain(input: {\n  stakeMinor: bigint;\n  parameters: unknown;\n  draws: readonly unknown[];\n}): Resolution {\n  const stake = validatePlinkoRainStake(input.stakeMinor),\n    evaluation = evaluatePlinkoRain(input),\n    gross = plinkoRainGrossMinor(stake, evaluation.finalTenths),\n    payout = clipped(gross),\n    parameters = { ...evaluation.parameters },\n    result: PlinkoRainResult = {\n      parameters,\n      plinkoRules: PLINKO_RAIN_RULES_VERSION,\n      settlementPolicy: PLINKO_RAIN_PC_POLICY,\n      nominalRtp: ratioWire(plinkoRainRtp(parameters)),\n      theoreticalRtp: ratioWire(plinkoRainPcRtp(stake, parameters)),\n      maxPayoutMinor: MAX_PAYOUT_MINOR.toString(),\n      maxMultiplier: PLINKO_RAIN_MAX_MULTIPLIER,\n      roundLimit: PLINKO_RAIN_ROUND_LIMIT,\n      multipliers: [...plinkoRainSchedule(parameters)],\n      stages: evaluation.stages.map((stage) => ({ ...stage, path: [...stage.path] })),\n      finish: evaluation.finish,\n      finalMultiplier: evaluation.finalMultiplier,\n      multiplierCapped: evaluation.multiplierCapped,\n      grossBeforeCapMinor: gross.toString(),\n      capped: gross > MAX_PAYOUT_MINOR,\n    };\n  return {\n    game: \"plinko\",\n    status: payout > 0n ? \"WON\" : \"LOST\",\n    payoutMinor: payout.toString(),\n    multiplier: ratioText(ratio(payout, stake)),\n    rulesVersion: PLINKO_RAIN_RULES_VERSION,\n    result,\n  };\n}\n\nexport async function resolvePlinkoRain(input: {\n  stakeMinor: bigint;\n  parameters: unknown;\n  entropy: Entropy;\n}): Promise<Resolution> {\n  const parameters = validatePlinkoRainParameters(input.parameters);\n  validatePlinkoRainStake(input.stakeMinor);\n  return settlePlinkoRain({\n    stakeMinor: input.stakeMinor,\n    parameters,\n    draws: await plinkoRainDraws(input.entropy, parameters),\n  });\n}\n\nexport function reconstructPlinkoRain(input: {\n  stakeMinor: bigint;\n  parameters: unknown;\n  result: Record<string, unknown>;\n}): Resolution {\n  const stages = input.result?.stages;\n  if (!Array.isArray(stages) || stages.length > PLINKO_RAIN_ROUND_LIMIT)\n    throw new RangeError(\"Invalid Plinko Rain stages\");\n  return settlePlinkoRain({\n    stakeMinor: input.stakeMinor,\n    parameters: input.parameters,\n    draws: Array.from(stages, (stage) =>\n      stage && typeof stage === \"object\" && !Array.isArray(stage)\n        ? {\n            path: (stage as Record<string, unknown>).path,\n            ticket: (stage as Record<string, unknown>).ticket,\n          }\n        : stage,\n    ),\n  });\n}\n","sha256":"e4d746e053ed82accd6379e86e55850e542516c66e71d7d40691bcc95224b0a3"},"src/lib/games/plinko-rain-distribution.ts":{"source":"import { MAX_PAYOUT_MINOR, choose, type Ratio } from \"@/games/math\";\nimport {\n  PLINKO_RAIN_CAP_TENTHS,\n  PLINKO_RAIN_CAP_UNITS,\n  PLINKO_RAIN_ROULETTE,\n  PLINKO_RAIN_ROUND_LIMIT,\n  PLINKO_RAIN_TICKETS,\n  PLINKO_RAIN_TRIGGER,\n  plinkoRainSchedule,\n  plinkoRainTenths,\n  validatePlinkoRainParameters,\n  validatePlinkoRainStake,\n} from \"./plinko-rain\";\n\nexport type PlinkoRainOutcome = Readonly<{ tenths: bigint; weight: bigint }>;\nexport type PlinkoRainDistribution = Readonly<{\n  rows: number;\n  denominator: bigint;\n  triggerProbability: Readonly<Ratio>;\n  outcomes: readonly PlinkoRainOutcome[];\n}>;\n\nconst tickets = BigInt(PLINKO_RAIN_TICKETS);\nconst limit = PLINKO_RAIN_ROUND_LIMIT;\nconst distributions = new Map<number, PlinkoRainDistribution>();\nconst nominal = new Map<number, Readonly<Ratio>>();\nlet accumulators: readonly ReadonlyMap<bigint, bigint>[] | undefined;\n\nfunction reduced(numerator: bigint, denominator: bigint): Readonly<Ratio> {\n  let a = numerator,\n    b = denominator;\n  while (b) [a, b] = [b, a % b];\n  return Object.freeze({ numerator: numerator / a, denominator: denominator / a });\n}\n\nfunction capped(value: bigint, cap: bigint): bigint {\n  return value > cap ? cap : value;\n}\n\nfunction plinkoRainAccumulators(): readonly ReadonlyMap<bigint, bigint>[] {\n  if (accumulators) return accumulators;\n  const levels: Map<bigint, bigint>[] = [new Map([[1n, 1n]])];\n  for (let round = 1; round < limit; round++) {\n    const next = new Map<bigint, bigint>();\n    for (const [units, weight] of levels[round - 1])\n      for (const segment of PLINKO_RAIN_ROULETTE) {\n        const value = capped(units * BigInt(segment.multiplier), PLINKO_RAIN_CAP_UNITS);\n        next.set(value, (next.get(value) ?? 0n) + weight * BigInt(segment.weight));\n      }\n    levels.push(next);\n  }\n  accumulators = Object.freeze(levels);\n  return accumulators;\n}\n\nexport function plinkoRainAccumulatorStates(round?: number): readonly bigint[] {\n  const levels = plinkoRainAccumulators();\n  if (\n    round !== undefined &&\n    (typeof round !== \"number\" || !Number.isSafeInteger(round) || round < 0 || round >= limit)\n  )\n    throw new RangeError(\"Invalid Plinko Rain round\");\n  const states = new Set<bigint>();\n  for (const level of round === undefined ? levels : [levels[round]])\n    for (const units of level.keys()) states.add(units);\n  return Object.freeze(\n    [...states].sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)),\n  );\n}\n\nexport function plinkoRainDistribution(input: unknown): PlinkoRainDistribution {\n  const { rows } = validatePlinkoRainParameters(input),\n    cached = distributions.get(rows);\n  if (cached) return cached;\n  const levels = plinkoRainAccumulators(),\n    paths = 1n << BigInt(rows),\n    terminal = new Map<bigint, bigint>();\n  let triggers = 0n;\n  plinkoRainSchedule({ rows, risk: \"rain\" }).forEach((value, bucket) => {\n    const count = choose(rows, bucket);\n    if (value === PLINKO_RAIN_TRIGGER) triggers += count;\n    else {\n      const tenths = plinkoRainTenths(value);\n      terminal.set(tenths, (terminal.get(tenths) ?? 0n) + count);\n    }\n  });\n  const reach = new Map<bigint, bigint>();\n  for (let round = 0; round < limit; round++) {\n    const scale =\n      triggers ** BigInt(round) *\n      paths ** BigInt(limit - 1 - round) *\n      tickets ** BigInt(limit - round);\n    for (const [units, weight] of levels[round])\n      reach.set(units, (reach.get(units) ?? 0n) + scale * weight);\n  }\n  const weights = new Map<bigint, bigint>(),\n    add = (tenths: bigint, weight: bigint) =>\n      weights.set(tenths, (weights.get(tenths) ?? 0n) + weight);\n  for (const [units, weight] of reach)\n    for (const [tenths, count] of terminal)\n      add(capped(units * tenths, PLINKO_RAIN_CAP_TENTHS), count * weight);\n  const exhausted = triggers ** BigInt(limit);\n  for (const [units, weight] of levels[limit - 1])\n    for (const segment of PLINKO_RAIN_ROULETTE)\n      add(\n        capped(units * BigInt(segment.multiplier), PLINKO_RAIN_CAP_UNITS) * 10n,\n        exhausted * weight * BigInt(segment.weight),\n      );\n  const distribution: PlinkoRainDistribution = Object.freeze({\n    rows,\n    denominator: paths ** BigInt(limit) * tickets ** BigInt(limit),\n    triggerProbability: reduced(triggers, paths),\n    outcomes: Object.freeze(\n      [...weights]\n        .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))\n        .map(([tenths, weight]) => Object.freeze({ tenths, weight })),\n    ),\n  });\n  distributions.set(rows, distribution);\n  return distribution;\n}\n\nexport function plinkoRainRtp(input: unknown): Readonly<Ratio> {\n  const distribution = plinkoRainDistribution(input),\n    cached = nominal.get(distribution.rows);\n  if (cached) return cached;\n  const value = reduced(\n    distribution.outcomes.reduce((sum, outcome) => sum + outcome.weight * outcome.tenths, 0n),\n    distribution.denominator * 10n,\n  );\n  nominal.set(distribution.rows, value);\n  return value;\n}\n\nexport function plinkoRainPcRtp(stakeMinor: bigint, input: unknown): Readonly<Ratio> {\n  const stake = validatePlinkoRainStake(stakeMinor),\n    distribution = plinkoRainDistribution(input);\n  return reduced(\n    distribution.outcomes.reduce(\n      (sum, outcome) =>\n        sum + outcome.weight * capped((stake * outcome.tenths) / 10n, MAX_PAYOUT_MINOR),\n      0n,\n    ),\n    distribution.denominator * stake,\n  );\n}\n","sha256":"59fd068d0da8b5bd8bb08d8b6538b7a7058e5cca31a8ce039bcecd90de9ca010"},"src/lib/games/max-win-successor.ts":{"source":"import type { GameParameters, GameRules, Limits } from \"./rules\";\nimport { relabelRules } from \"./rules-versions\";\n\nexport function maxWinSuccessor<P extends GameParameters>(\n  rules: GameRules<P>,\n  version: string,\n  options: Readonly<{\n    limits: Limits;\n    admitStake(stakeMinor: bigint, parameters: NoInfer<P>): void;\n  }>,\n): GameRules<P> {\n  if (rules.stateful || rules.act || rules.additionalStakeMinor)\n    throw new Error(\"Only stateless rules can be carried forward\");\n  const inbound = (id: string) => (id === version ? rules.version : id),\n    outbound = (id: string) => (id === rules.version ? version : id),\n    reconstruct = rules.reconstruct;\n  return Object.freeze({\n    game: rules.game,\n    version,\n    stateful: false,\n    actions: rules.actions,\n    ...(rules.closingActions === undefined ? {} : { closingActions: rules.closingActions }),\n    ...(rules.rateLimitPerMinute === undefined\n      ? {}\n      : { rateLimitPerMinute: rules.rateLimitPerMinute }),\n    limits: options.limits,\n    admitStake: options.admitStake,\n    validateParameters: rules.validateParameters,\n    maximumPayoutMinor: rules.maximumPayoutMinor,\n    ...(rules.maximumStakeMinor === undefined\n      ? {}\n      : { maximumStakeMinor: rules.maximumStakeMinor }),\n    async resolve(input) {\n      return relabelRules(await rules.resolve(input), outbound);\n    },\n    ...(reconstruct === undefined\n      ? {}\n      : {\n          reconstruct(\n            input: Readonly<{ stakeMinor: bigint; parameters: P; result: Record<string, unknown> }>,\n          ) {\n            return relabelRules(\n              reconstruct({ ...input, result: relabelRules(input.result, inbound) }),\n              outbound,\n            );\n          },\n        }),\n  } satisfies GameRules<P>);\n}\n","sha256":"7748d36719f4a53c695a93f0add53fa21d5275fc756243057ebc94c175f66ac7"},"src/lib/games/rules-versions.ts":{"source":"export const PEEKS_RULES_PREFIX = \"peeks-\";\n\nexport const RULES_VERSION_KEYS: readonly string[] = Object.freeze([\n  \"rulesVersion\",\n  \"version\",\n  \"plinkoRules\",\n  \"diceRules\",\n  \"limboRules\",\n  \"kenoRules\",\n  \"rouletteRules\",\n  \"minesRules\",\n  \"towerRules\",\n  \"blackjackRules\",\n  \"baccaratRules\",\n  \"hiloRules\",\n  \"warRules\",\n  \"rpsRules\",\n  \"chickenRules\",\n  \"coinflipRules\",\n  \"molesRules\",\n]);\n\nconst RULES_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;\nconst OUTSIDE_ID = /^[a-z]+-[a-z0-9]+(?:-[a-z0-9]+)*$/;\n\nexport function isRulesName(value: unknown): value is string {\n  return typeof value === \"string\" && RULES_NAME.test(value);\n}\n\nexport function isPeeksRulesId(value: unknown): value is string {\n  return (\n    typeof value === \"string\" &&\n    value.startsWith(PEEKS_RULES_PREFIX) &&\n    RULES_NAME.test(value.slice(PEEKS_RULES_PREFIX.length))\n  );\n}\n\nexport function peeksRulesName(id: string): string {\n  return id.slice(PEEKS_RULES_PREFIX.length);\n}\n\nexport function outsideRulesName(id: unknown): string | undefined {\n  if (typeof id !== \"string\" || id.startsWith(PEEKS_RULES_PREFIX) || !OUTSIDE_ID.test(id))\n    return undefined;\n  return id.slice(id.indexOf(\"-\") + 1);\n}\n\nfunction plain(value: object): boolean {\n  const prototype = Object.getPrototypeOf(value);\n  return prototype === Object.prototype || prototype === null;\n}\n\nexport function relabelRules<T>(value: T, relabel: (id: string) => string): T {\n  if (!value || typeof value !== \"object\") return value;\n  if (Array.isArray(value)) {\n    let copy: unknown[] | null = null;\n    for (let index = 0; index < value.length; index++) {\n      const next = relabelRules(value[index], relabel);\n      if (next !== value[index]) {\n        copy ??= [...value];\n        copy[index] = next;\n      }\n    }\n    return (copy ?? value) as T;\n  }\n  if (!plain(value)) return value;\n  const record = value as Record<string, unknown>;\n  let copy: Record<string, unknown> | null = null;\n  for (const key of Object.keys(record)) {\n    const current = record[key];\n    const next =\n      typeof current === \"string\"\n        ? RULES_VERSION_KEYS.includes(key)\n          ? relabel(current)\n          : current\n        : relabelRules(current, relabel);\n    if (next !== current) {\n      copy ??= { ...record };\n      copy[key] = next;\n    }\n  }\n  return (copy ?? value) as T;\n}\n","sha256":"953f12da3778e097fef2164d7e50434d1e1e6ef3e4c7f266e496a3c0d4daee2b"},"src/lib/games/limbo-rules.ts":{"source":"import { ratioText, type Resolution } from \"@/games/math\";\nimport type { Entropy, GameRules, Limits } from \"./rules\";\nimport { admitPayoutLimit, admitStakeRange, gameLimits, maxWinLimits } from \"./limits\";\nimport { admitMaxWinStake } from \"./max-win\";\n\nexport const LIMBO_RULES_VERSION = \"peeks-limbo-1\" as const;\nexport const LIMBO_2_RULES_VERSION = \"peeks-limbo-2\" as const;\nexport const LIMBO_RULES_VERSIONS = Object.freeze([\n  LIMBO_RULES_VERSION,\n  LIMBO_2_RULES_VERSION,\n] as const);\nexport type LimboRulesVersion = (typeof LIMBO_RULES_VERSIONS)[number];\nexport const LIMBO_SETTLEMENT_POLICY = \"peeks-pc-floor-cap-1\" as const;\nexport const LIMBO_GAME = \"limbo\" as const;\nexport const LIMBO_HASH = \"SHA-256\" as const;\nexport const LIMBO_CURSOR = 0;\nexport const LIMBO_WORD_INDEX = 0;\nexport const LIMBO_PROCESSING_DELAYS = Object.freeze({\n  standard: 450,\n  instant: 0,\n} as const);\nexport const LIMBO_SPAM_GUARD_MS = 150;\nexport const LIMBO_RATE_LIMIT = 60_000 / LIMBO_SPAM_GUARD_MS;\nexport const LIMBO_OUTCOMES = 0x1_0000_0000n;\nexport const LIMBO_EDGE_HUNDREDTHS = 99n * LIMBO_OUTCOMES;\nexport const LIMBO_FLOOR_HUNDREDTHS = 100n;\nexport const LIMBO_MIN_TARGET_HUNDREDTHS = 101n;\nexport const LIMBO_MAX_TARGET_HUNDREDTHS = 10_000_000n;\nexport const LIMBO_MIN_TARGET = \"1.01\";\nexport const LIMBO_MAX_TARGET = \"100000.00\";\nexport const LIMBO_DEFAULT_TARGET = \"2.00\";\nexport const LIMBO_MAX_STAKE_MINOR = 1_000_000n;\nexport const LIMBO_GAME_LIMITS = gameLimits(\"limbo\");\nexport const LIMBO_MAX_PAYOUT_MINOR = LIMBO_GAME_LIMITS.maxPayoutMinor;\nexport const LIMBO_2_GAME_LIMITS = maxWinLimits(\"limbo\");\n\nexport type LimboAnimationDuration =\n  (typeof LIMBO_PROCESSING_DELAYS)[keyof typeof LIMBO_PROCESSING_DELAYS];\nexport type LimboParameters = {\n  animationDurationMs: LimboAnimationDuration;\n  targetMultiplier: string;\n};\nexport type LimboRatio = Readonly<{ numerator: bigint; denominator: bigint }>;\nexport type LimboWireRatio = Readonly<{\n  numerator: string;\n  denominator: string;\n  value: string;\n}>;\nexport type LimboDraw = Readonly<{\n  cursor: typeof LIMBO_CURSOR;\n  wordIndex: typeof LIMBO_WORD_INDEX;\n  uint32: number;\n}>;\nexport type LimboResult = {\n  parameters: LimboParameters;\n  limboRules: LimboRulesVersion;\n  settlementPolicy: typeof LIMBO_SETTLEMENT_POLICY;\n  draw: LimboDraw;\n  resultHundredths: number;\n  resultMultiplier: string;\n  targetHundredths: number;\n  targetMultiplier: string;\n  win: boolean;\n  winningOutcomes: string;\n  totalOutcomes: string;\n  winChance: LimboWireRatio;\n  nominalMultiplier: LimboWireRatio;\n  nominalRtp: LimboWireRatio;\n  theoreticalRtp: LimboWireRatio;\n  potentialPayoutMinor: string;\n  grossPayoutMinor: string;\n  maxPayoutMinor: string;\n  capped: boolean;\n  decisions: [];\n};\nexport type LimboResolution = Resolution & {\n  game: typeof LIMBO_GAME;\n  status: \"WON\" | \"LOST\";\n  rulesVersion: LimboRulesVersion;\n  result: LimboResult;\n};\n\nconst TARGET_PATTERN = /^(0|[1-9][0-9]{0,5})\\.([0-9]{2})$/;\nconst KEYS = Object.freeze([\"animationDurationMs\", \"targetMultiplier\"] as const);\nconst invalidParameters = () => new RangeError(\"Invalid Limbo parameters\");\n\nexport function isLimboRulesVersion(value: unknown): value is LimboRulesVersion {\n  return (LIMBO_RULES_VERSIONS as readonly unknown[]).includes(value);\n}\n\nexport function limboLimits(version: LimboRulesVersion = LIMBO_RULES_VERSION): Limits {\n  if (!isLimboRulesVersion(version)) throw new RangeError(\"Invalid Limbo rules\");\n  return version === LIMBO_RULES_VERSION ? LIMBO_GAME_LIMITS : LIMBO_2_GAME_LIMITS;\n}\n\nfunction reduced(numerator: bigint, denominator: bigint): LimboRatio {\n  if (numerator < 0n || denominator <= 0n) throw new RangeError(\"Invalid Limbo ratio\");\n  let a = numerator,\n    b = denominator;\n  while (b) [a, b] = [b, a % b];\n  const divisor = a || 1n;\n  return Object.freeze({ numerator: numerator / divisor, denominator: denominator / divisor });\n}\n\nfunction wire(value: LimboRatio): LimboWireRatio {\n  return Object.freeze({\n    numerator: value.numerator.toString(),\n    denominator: value.denominator.toString(),\n    value: ratioText(value, 8),\n  });\n}\n\nexport function limboTargetHundredths(value: unknown): bigint {\n  if (typeof value !== \"string\") throw invalidParameters();\n  const match = TARGET_PATTERN.exec(value);\n  if (!match) throw invalidParameters();\n  const hundredths = BigInt(match[1]) * 100n + BigInt(match[2]);\n  if (hundredths < LIMBO_MIN_TARGET_HUNDREDTHS || hundredths > LIMBO_MAX_TARGET_HUNDREDTHS)\n    throw invalidParameters();\n  return hundredths;\n}\n\nexport function validateLimboParameters(input: unknown): LimboParameters {\n  if (!input || typeof input !== \"object\" || Array.isArray(input)) throw invalidParameters();\n  const value = input as Record<string, unknown>;\n  if (Object.keys(value).length !== KEYS.length || !KEYS.every((key) => Object.hasOwn(value, key)))\n    throw invalidParameters();\n  const duration = value.animationDurationMs;\n  const targetMultiplier = value.targetMultiplier;\n  const animationDurationMs =\n    duration === LIMBO_PROCESSING_DELAYS.standard\n      ? LIMBO_PROCESSING_DELAYS.standard\n      : duration === LIMBO_PROCESSING_DELAYS.instant\n        ? LIMBO_PROCESSING_DELAYS.instant\n        : null;\n  if (animationDurationMs === null) throw invalidParameters();\n  limboTargetHundredths(targetMultiplier);\n  return { animationDurationMs, targetMultiplier: targetMultiplier as string };\n}\n\nexport function limboStake(stakeMinor: unknown): bigint {\n  if (typeof stakeMinor !== \"bigint\" || stakeMinor <= 0n)\n    throw new RangeError(\"Invalid Limbo stake\");\n  if (stakeMinor > LIMBO_MAX_STAKE_MINOR) throw new RangeError(\"Maximum stake is $10,000.00\");\n  return stakeMinor;\n}\n\nexport function limboMultiplierText(hundredths: bigint): string {\n  if (typeof hundredths !== \"bigint\" || hundredths < 0n)\n    throw new RangeError(\"Invalid Limbo multiplier\");\n  return `${hundredths / 100n}.${(hundredths % 100n).toString().padStart(2, \"0\")}`;\n}\n\nexport function limboHmacMessage(clientSeed: string, nonce: number): string {\n  return `${clientSeed}:${nonce}:limbo:${LIMBO_CURSOR}`;\n}\n\nexport function limboUint32FromDigest(digest: Uint8Array): number {\n  if (!(digest instanceof Uint8Array) || digest.byteLength !== 32)\n    throw new RangeError(\"Invalid Limbo HMAC digest\");\n  return ((digest[0] << 24) | (digest[1] << 16) | (digest[2] << 8) | digest[3]) >>> 0;\n}\n\nexport function limboResultHundredths(uint32: number): bigint {\n  if (!Number.isSafeInteger(uint32) || uint32 < 0 || uint32 > 0xffff_ffff)\n    throw new RangeError(\"Invalid Limbo draw\");\n  const hundredths = LIMBO_EDGE_HUNDREDTHS / BigInt(uint32 + 1);\n  return hundredths < LIMBO_FLOOR_HUNDREDTHS ? LIMBO_FLOOR_HUNDREDTHS : hundredths;\n}\n\nfunction target(parameters: unknown): bigint {\n  return limboTargetHundredths(validateLimboParameters(parameters).targetMultiplier);\n}\n\nexport function limboWinningOutcomes(parameters: unknown): bigint {\n  return LIMBO_EDGE_HUNDREDTHS / target(parameters);\n}\n\nexport function limboWinChance(parameters: unknown): LimboRatio {\n  return reduced(limboWinningOutcomes(parameters), LIMBO_OUTCOMES);\n}\n\nexport function limboNominalRtp(parameters: unknown): LimboRatio {\n  const hundredths = target(parameters);\n  return reduced(hundredths * (LIMBO_EDGE_HUNDREDTHS / hundredths), 100n * LIMBO_OUTCOMES);\n}\n\nexport function limboUncappedPayoutMinor(stakeMinor: bigint, parameters: unknown): bigint {\n  return (limboStake(stakeMinor) * target(parameters)) / 100n;\n}\n\nexport function limboMaximumPayoutMinor(\n  stakeMinor: bigint,\n  parameters: unknown,\n  version: LimboRulesVersion = LIMBO_RULES_VERSION,\n): bigint {\n  const gross = limboUncappedPayoutMinor(stakeMinor, parameters),\n    cap = limboLimits(version).maxPayoutMinor;\n  return gross > cap ? cap : gross;\n}\n\nexport function limboIsOverLimit(\n  stakeMinor: bigint,\n  parameters: unknown,\n  version: LimboRulesVersion = LIMBO_RULES_VERSION,\n): boolean {\n  return limboStake(stakeMinor) * target(parameters) > limboLimits(version).maxPayoutMinor * 100n;\n}\n\nexport function admitLimboStake(\n  stakeMinor: bigint,\n  parameters: unknown,\n  version: LimboRulesVersion = LIMBO_RULES_VERSION,\n): void {\n  if (version !== LIMBO_RULES_VERSION) {\n    admitMaxWinStake(limboLimits(version), stakeMinor, {\n      numerator: target(parameters),\n      denominator: 100n,\n    });\n    return;\n  }\n  admitStakeRange(LIMBO_GAME_LIMITS, stakeMinor);\n  admitPayoutLimit(LIMBO_GAME_LIMITS, stakeMinor, {\n    numerator: target(parameters),\n    denominator: 100n,\n  });\n}\n\nexport function limboPcRtp(\n  stakeMinor: bigint,\n  parameters: unknown,\n  version: LimboRulesVersion = LIMBO_RULES_VERSION,\n): LimboRatio {\n  const stake = limboStake(stakeMinor);\n  return reduced(\n    limboMaximumPayoutMinor(stake, parameters, version) * limboWinningOutcomes(parameters),\n    stake * LIMBO_OUTCOMES,\n  );\n}\n\nexport function settleLimbo(input: {\n  stakeMinor: bigint;\n  parameters: unknown;\n  uint32: number;\n  version?: LimboRulesVersion;\n}): LimboResolution {\n  const version = input.version ?? LIMBO_RULES_VERSION;\n  const cap = limboLimits(version).maxPayoutMinor;\n  const stake = limboStake(input.stakeMinor);\n  const parameters = validateLimboParameters(input.parameters);\n  const targetHundredths = limboTargetHundredths(parameters.targetMultiplier);\n  const resultHundredths = limboResultHundredths(input.uint32);\n  const win = resultHundredths >= targetHundredths;\n  const uncapped = (stake * targetHundredths) / 100n;\n  const potential = uncapped > cap ? cap : uncapped;\n  const gross = win ? uncapped : 0n;\n  const capped = gross > cap;\n  const payout = capped ? cap : gross;\n  const winning = LIMBO_EDGE_HUNDREDTHS / targetHundredths;\n  return {\n    game: LIMBO_GAME,\n    status: payout > 0n ? \"WON\" : \"LOST\",\n    payoutMinor: payout.toString(),\n    multiplier: ratioText({ numerator: payout, denominator: stake }),\n    rulesVersion: version,\n    result: {\n      parameters: {\n        animationDurationMs: parameters.animationDurationMs,\n        targetMultiplier: parameters.targetMultiplier,\n      },\n      limboRules: version,\n      settlementPolicy: LIMBO_SETTLEMENT_POLICY,\n      draw: {\n        cursor: LIMBO_CURSOR,\n        wordIndex: LIMBO_WORD_INDEX,\n        uint32: input.uint32,\n      },\n      resultHundredths: Number(resultHundredths),\n      resultMultiplier: limboMultiplierText(resultHundredths),\n      targetHundredths: Number(targetHundredths),\n      targetMultiplier: parameters.targetMultiplier,\n      win,\n      winningOutcomes: winning.toString(),\n      totalOutcomes: LIMBO_OUTCOMES.toString(),\n      winChance: wire(reduced(winning, LIMBO_OUTCOMES)),\n      nominalMultiplier: wire(reduced(targetHundredths, 100n)),\n      nominalRtp: wire(reduced(targetHundredths * winning, 100n * LIMBO_OUTCOMES)),\n      theoreticalRtp: wire(reduced(potential * winning, stake * LIMBO_OUTCOMES)),\n      potentialPayoutMinor: potential.toString(),\n      grossPayoutMinor: gross.toString(),\n      maxPayoutMinor: cap.toString(),\n      capped,\n      decisions: [],\n    },\n  };\n}\n\nfunction fairnessSeeds(entropy: Entropy) {\n  if (\n    !entropy ||\n    typeof entropy !== \"object\" ||\n    typeof entropy.hmac !== \"function\" ||\n    typeof entropy.serverSeed !== \"string\" ||\n    !entropy.serverSeed.trim() ||\n    typeof entropy.clientSeed !== \"string\" ||\n    !entropy.clientSeed.trim() ||\n    !Number.isSafeInteger(entropy.nonce) ||\n    entropy.nonce < 0\n  )\n    throw new RangeError(\"Invalid Limbo fairness seed\");\n  return entropy;\n}\n\nexport async function limboDraw(entropy: Entropy): Promise<number> {\n  const seeds = fairnessSeeds(entropy);\n  const digest = await seeds.hmac(\n    LIMBO_HASH,\n    seeds.serverSeed,\n    limboHmacMessage(seeds.clientSeed, seeds.nonce),\n  );\n  return limboUint32FromDigest(digest);\n}\n\nexport function limboRecordedDraw(result: unknown): number {\n  const draw =\n    result && typeof result === \"object\" && !Array.isArray(result)\n      ? (result as Record<string, unknown>).draw\n      : undefined;\n  if (!draw || typeof draw !== \"object\" || Array.isArray(draw))\n    throw new RangeError(\"Invalid Limbo draw\");\n  const { cursor, wordIndex, uint32 } = draw as Record<string, unknown>;\n  if (cursor !== LIMBO_CURSOR || wordIndex !== LIMBO_WORD_INDEX || typeof uint32 !== \"number\")\n    throw new RangeError(\"Invalid Limbo draw\");\n  limboResultHundredths(uint32);\n  return uint32;\n}\n\nfunction limboRules(version: LimboRulesVersion): GameRules<LimboParameters> {\n  return Object.freeze({\n    game: LIMBO_GAME,\n    version,\n    stateful: false,\n    actions: Object.freeze([]),\n    rateLimitPerMinute: LIMBO_RATE_LIMIT,\n    limits: limboLimits(version),\n    admitStake: (stakeMinor: bigint, parameters: LimboParameters) =>\n      admitLimboStake(stakeMinor, parameters, version),\n    validateParameters: validateLimboParameters,\n    maximumPayoutMinor: (stakeMinor: bigint, parameters: LimboParameters) =>\n      limboMaximumPayoutMinor(stakeMinor, parameters, version),\n    async resolve(input) {\n      const stakeMinor = limboStake(input.stakeMinor);\n      const parameters = validateLimboParameters(input.parameters);\n      const uint32 = await limboDraw(input.entropy);\n      return settleLimbo({ stakeMinor, parameters, uint32, version });\n    },\n    reconstruct(input) {\n      return settleLimbo({\n        stakeMinor: input.stakeMinor,\n        parameters: input.parameters,\n        uint32: limboRecordedDraw(input.result),\n        version,\n      });\n    },\n  } satisfies GameRules<LimboParameters>);\n}\n\nexport const limboRulesModule: GameRules<LimboParameters> = limboRules(LIMBO_RULES_VERSION);\nexport const limbo2RulesModule: GameRules<LimboParameters> = limboRules(LIMBO_2_RULES_VERSION);\n","sha256":"ec836b17c0bd7fb4b638bc6942af8cb955ce4c8709647cb0cd20b878661045b1"},"src/lib/games/keno-rules.ts":{"source":"import {\n  MAX_PAYOUT_MINOR,\n  choose,\n  ratio,\n  ratioText,\n  ratioWire,\n  type Ratio,\n  type Resolution,\n} from \"@/games/math\";\nimport type { Entropy, GameRules } from \"./rules\";\nimport { admitStakeRange, gameLimits, maxWinLimits } from \"./limits\";\nimport { admitMaxWinStake, selectionMaxBetMinor } from \"./max-win\";\nimport { maxWinSuccessor } from \"./max-win-successor\";\n\nexport const KENO_RULES_VERSION = \"peeks-keno-1\" as const;\nexport const KENO_2_RULES_VERSION = \"peeks-keno-2\" as const;\nexport const KENO_RULES_VERSIONS = Object.freeze([\n  KENO_RULES_VERSION,\n  KENO_2_RULES_VERSION,\n] as const);\nexport type KenoRulesVersion = (typeof KENO_RULES_VERSIONS)[number];\nexport const KENO_DIFFICULTIES = Object.freeze([\n  \"classic\",\n  \"low\",\n  \"medium\",\n  \"high\",\n  \"extreme\",\n] as const);\nexport type KenoDifficulty = (typeof KENO_DIFFICULTIES)[number];\nexport const KENO_ANIMATION_DURATIONS_MS = Object.freeze([0, 1000] as const);\nexport type KenoAnimationDurationMs = (typeof KENO_ANIMATION_DURATIONS_MS)[number];\nexport const KENO_DRAW_COUNT = 10;\nexport const KENO_MAX_SELECTIONS = 10;\nexport const KENO_GRID_SIZE_CLASSIC = 40;\nexport const KENO_GRID_SIZE_EXTREME = 56;\nexport const KENO_HASH = \"SHA-512\" as const;\nexport const KENO_MAX_ROUNDS = 64;\nexport const KENO_MAX_STAKE_MINOR = 1_000_000n;\nexport const KENO_SPAM_GUARD_MS = 150;\nexport const KENO_RATE_LIMIT = 60_000 / KENO_SPAM_GUARD_MS;\nexport const KENO_GAME_LIMITS = gameLimits(\"keno\");\nexport const KENO_2_GAME_LIMITS = maxWinLimits(\"keno\");\nexport const KENO_LIMIT_KEY_MAX_MULTIPLIER_100 = \"maxMultiplier100\" as const;\nexport type KenoLimitKey = KenoDifficulty | typeof KENO_LIMIT_KEY_MAX_MULTIPLIER_100;\n\nexport type KenoParameters = {\n  difficulty: KenoDifficulty;\n  selectedNumbers: number[];\n  animationDurationMs: KenoAnimationDurationMs;\n};\nexport type KenoSettlement = Readonly<{\n  grossMinor: bigint;\n  payoutMinor: bigint;\n  capped: boolean;\n}>;\n\nconst PARAMETER_KEYS = Object.freeze([\"difficulty\", \"selectedNumbers\", \"animationDurationMs\"]);\n\nconst payouts: Record<number, Record<KenoDifficulty, readonly string[]>> = {\n  1: {\n    classic: [\"0\", \"3.96\"],\n    low: [\"0.7\", \"1.85\"],\n    medium: [\"0.4\", \"2.75\"],\n    high: [\"0\", \"3.96\"],\n    extreme: [\"0\", \"5.49\"],\n  },\n  2: {\n    classic: [\"0\", \"1.9\", \"4.5\"],\n    low: [\"0\", \"2\", \"3.8\"],\n    medium: [\"0\", \"1.8\", \"5.1\"],\n    high: [\"0\", \"0\", \"17.1\"],\n    extreme: [\"0\", \"0\", \"33.54\"],\n  },\n  3: {\n    classic: [\"0\", \"1\", \"3.1\", \"10.4\"],\n    low: [\"0\", \"1.1\", \"1.38\", \"26\"],\n    medium: [\"0\", \"0\", \"2.8\", \"50\"],\n    high: [\"0\", \"0\", \"0\", \"81.5\"],\n    extreme: [\"0\", \"0\", \"0\", \"226.4\"],\n  },\n  4: {\n    classic: [\"0\", \"0.8\", \"1.8\", \"5\", \"22.5\"],\n    low: [\"0\", \"0\", \"2.2\", \"7.9\", \"90\"],\n    medium: [\"0\", \"0\", \"1.7\", \"10\", \"100\"],\n    high: [\"0\", \"0\", \"0\", \"10\", \"259\"],\n    extreme: [\"0\", \"0\", \"0\", \"0\", \"1714\"],\n  },\n  5: {\n    classic: [\"0\", \"0.25\", \"1.4\", \"4.1\", \"16.5\", \"36\"],\n    low: [\"0\", \"0\", \"1.5\", \"4.2\", \"13\", \"300\"],\n    medium: [\"0\", \"0\", \"1.4\", \"4\", \"14\", \"390\"],\n    high: [\"0\", \"0\", \"0\", \"4.5\", \"48\", \"450\"],\n    extreme: [\"0\", \"0\", \"0\", \"0\", \"126.55\", \"10000\"],\n  },\n  6: {\n    classic: [\"0\", \"0\", \"1\", \"3.68\", \"7\", \"16.5\", \"40\"],\n    low: [\"0\", \"0\", \"1.1\", \"2\", \"6.2\", \"100\", \"700\"],\n    medium: [\"0\", \"0\", \"0\", \"3\", \"9\", \"180\", \"710\"],\n    high: [\"0\", \"0\", \"0\", \"0\", \"11\", \"350\", \"710\"],\n    extreme: [\"0\", \"0\", \"0\", \"0\", \"35.4\", \"1900\", \"10000\"],\n  },\n  7: {\n    classic: [\"0\", \"0\", \"0.47\", \"3\", \"4.5\", \"14\", \"31\", \"60\"],\n    low: [\"0\", \"0\", \"1.1\", \"1.6\", \"3.5\", \"15\", \"225\", \"700\"],\n    medium: [\"0\", \"0\", \"0\", \"2\", \"7\", \"30\", \"400\", \"800\"],\n    high: [\"0\", \"0\", \"0\", \"0\", \"7\", \"90\", \"400\", \"800\"],\n    extreme: [\"0\", \"0\", \"0\", \"0\", \"18.95\", \"450\", \"5000\", \"10000\"],\n  },\n  8: {\n    classic: [\"0\", \"0\", \"0\", \"2.2\", \"4\", \"13\", \"22\", \"55\", \"70\"],\n    low: [\"0\", \"0\", \"1.1\", \"1.5\", \"2\", \"5.5\", \"39\", \"100\", \"800\"],\n    medium: [\"0\", \"0\", \"0\", \"2\", \"4\", \"11\", \"67\", \"400\", \"900\"],\n    high: [\"0\", \"0\", \"0\", \"0\", \"5\", \"20\", \"270\", \"600\", \"900\"],\n    extreme: [\"0\", \"0\", \"0\", \"0\", \"13.37\", \"180\", \"1000\", \"5000\", \"10000\"],\n  },\n  9: {\n    classic: [\"0\", \"0\", \"0\", \"1.55\", \"3\", \"8\", \"15\", \"44\", \"60\", \"85\"],\n    low: [\"0\", \"0\", \"1.1\", \"1.3\", \"1.7\", \"2.5\", \"7.5\", \"50\", \"250\", \"1000\"],\n    medium: [\"0\", \"0\", \"0\", \"2\", \"2.5\", \"5\", \"15\", \"100\", \"500\", \"1000\"],\n    high: [\"0\", \"0\", \"0\", \"0\", \"4\", \"11\", \"56\", \"500\", \"800\", \"1000\"],\n    extreme: [\"0\", \"0\", \"0\", \"0\", \"6.5\", \"90\", \"500\", \"2000\", \"5000\", \"10000\"],\n  },\n  10: {\n    classic: [\"0\", \"0\", \"0\", \"1.4\", \"2.25\", \"4.5\", \"8\", \"17\", \"50\", \"80\", \"100\"],\n    low: [\"0\", \"0\", \"1.1\", \"1.2\", \"1.3\", \"1.8\", \"3.5\", \"13\", \"50\", \"250\", \"1000\"],\n    medium: [\"0\", \"0\", \"0\", \"1.6\", \"2\", \"4\", \"7\", \"26\", \"100\", \"500\", \"1000\"],\n    high: [\"0\", \"0\", \"0\", \"0\", \"3.5\", \"8\", \"13\", \"63\", \"500\", \"800\", \"1000\"],\n    extreme: [\"0\", \"0\", \"0\", \"0\", \"3.28\", \"44\", \"300\", \"1500\", \"5000\", \"8000\", \"10000\"],\n  },\n};\n\nfunction invalid(message = \"Invalid Keno parameters\") {\n  return new RangeError(message);\n}\n\nfunction reduced(numerator: bigint, denominator: bigint): Ratio {\n  let a = numerator,\n    b = denominator;\n  while (b) [a, b] = [b, a % b];\n  return Object.freeze({ numerator: numerator / a, denominator: denominator / a });\n}\n\nfunction decimalRatio(value: string): Ratio {\n  const match = /^(0|[1-9][0-9]*)(?:\\.([0-9]*[1-9]))?$/.exec(value);\n  if (!match) throw invalid(\"Invalid Keno multiplier\");\n  const fraction = match[2] ?? \"\";\n  const denominator = 10n ** BigInt(fraction.length);\n  return reduced(BigInt(match[1]) * denominator + BigInt(fraction || \"0\"), denominator);\n}\n\nconst ratios: Record<number, Record<KenoDifficulty, readonly Ratio[]>> = {};\nfor (let selections = 1; selections <= KENO_MAX_SELECTIONS; selections++) {\n  const row = {} as Record<KenoDifficulty, readonly Ratio[]>;\n  for (const difficulty of KENO_DIFFICULTIES) {\n    Object.freeze(payouts[selections][difficulty]);\n    row[difficulty] = Object.freeze(payouts[selections][difficulty].map(decimalRatio));\n  }\n  ratios[selections] = Object.freeze(row);\n  Object.freeze(payouts[selections]);\n}\nObject.freeze(ratios);\nObject.freeze(payouts);\n\nexport const KENO_PAYOUT_TABLE: Readonly<\n  Record<number, Readonly<Record<KenoDifficulty, readonly string[]>>>\n> = payouts;\n\nfunction difficultyOf(value: unknown): KenoDifficulty {\n  if (typeof value !== \"string\" || !(KENO_DIFFICULTIES as readonly string[]).includes(value))\n    throw invalid(\"Invalid Keno difficulty\");\n  return value as KenoDifficulty;\n}\n\nfunction selectionsOf(value: unknown): number {\n  if (\n    typeof value !== \"number\" ||\n    !Number.isSafeInteger(value) ||\n    value < 1 ||\n    value > KENO_MAX_SELECTIONS\n  )\n    throw invalid(\"Select 1-10 Numbers\");\n  return value;\n}\n\nfunction stakeOf(value: unknown): bigint {\n  if (typeof value !== \"bigint\" || value < 1n) throw invalid(\"Invalid Keno stake\");\n  if (value > KENO_MAX_STAKE_MINOR) throw invalid(\"Maximum stake is $10,000.00\");\n  return value;\n}\n\nexport function kenoGridSize(difficulty: KenoDifficulty): number {\n  return difficultyOf(difficulty) === \"extreme\" ? KENO_GRID_SIZE_EXTREME : KENO_GRID_SIZE_CLASSIC;\n}\n\nexport function validateKenoParameters(input: unknown): KenoParameters {\n  if (!input || typeof input !== \"object\" || Array.isArray(input)) throw invalid();\n  const value = input as Record<string, unknown>;\n  if (\n    Object.keys(value).length !== PARAMETER_KEYS.length ||\n    PARAMETER_KEYS.some((key) => !Object.hasOwn(value, key))\n  )\n    throw invalid();\n  const difficulty = difficultyOf(value.difficulty);\n  const animationDurationMs = KENO_ANIMATION_DURATIONS_MS.find(\n    (duration) => duration === value.animationDurationMs,\n  );\n  if (animationDurationMs === undefined) throw invalid(\"Invalid Keno animation duration\");\n  const selected = value.selectedNumbers;\n  if (!Array.isArray(selected) || selected.length < 1 || selected.length > KENO_MAX_SELECTIONS)\n    throw invalid(\"Select 1-10 Numbers\");\n  const gridSize = kenoGridSize(difficulty);\n  const selectedNumbers: number[] = [];\n  for (let index = 0; index < selected.length; index++) {\n    const entry = selected[index];\n    if (typeof entry !== \"number\" || !Number.isSafeInteger(entry) || entry < 1 || entry > gridSize)\n      throw invalid(\"Select 1-10 Numbers\");\n    if (index > 0 && entry <= selectedNumbers[index - 1]) throw invalid();\n    selectedNumbers.push(entry);\n  }\n  return { difficulty, selectedNumbers, animationDurationMs };\n}\n\nexport function kenoPayoutTableDecimal(\n  selections: number,\n  difficulty: KenoDifficulty,\n): readonly string[] {\n  return payouts[selectionsOf(selections)][difficultyOf(difficulty)];\n}\n\nexport function kenoPayoutTable(selections: number, difficulty: KenoDifficulty): readonly Ratio[] {\n  return ratios[selectionsOf(selections)][difficultyOf(difficulty)];\n}\n\nexport function kenoMaximumMultiplier(selections: number, difficulty: KenoDifficulty): Ratio {\n  return kenoPayoutTable(selections, difficulty).reduce((best, value) =>\n    value.numerator * best.denominator > best.numerator * value.denominator ? value : best,\n  );\n}\n\nexport function kenoDrawWays(difficulty: KenoDifficulty): bigint {\n  return choose(kenoGridSize(difficulty), KENO_DRAW_COUNT);\n}\n\nexport function kenoHitWays(selections: number, difficulty: KenoDifficulty): readonly bigint[] {\n  const picks = selectionsOf(selections);\n  const gridSize = kenoGridSize(difficulty);\n  return Object.freeze(\n    Array.from(\n      { length: picks + 1 },\n      (_, hits) => choose(picks, hits) * choose(gridSize - picks, KENO_DRAW_COUNT - hits),\n    ),\n  );\n}\n\nexport function kenoRtp(selections: number, difficulty: KenoDifficulty): Ratio {\n  const ways = kenoHitWays(selections, difficulty);\n  let numerator = 0n,\n    denominator = 1n;\n  kenoPayoutTable(selections, difficulty).forEach((value, hits) => {\n    const next = reduced(\n      numerator * value.denominator + ways[hits] * value.numerator * denominator,\n      denominator * value.denominator,\n    );\n    numerator = next.numerator;\n    denominator = next.denominator;\n  });\n  return reduced(numerator, denominator * kenoDrawWays(difficulty));\n}\n\nexport function kenoSettlementMinor(stakeMinor: bigint, multiplier: Ratio): KenoSettlement {\n  if (typeof stakeMinor !== \"bigint\" || stakeMinor < 0n) throw invalid(\"Invalid Keno stake\");\n  const grossMinor = (stakeMinor * multiplier.numerator) / multiplier.denominator;\n  const capped = grossMinor > MAX_PAYOUT_MINOR;\n  return Object.freeze({ grossMinor, payoutMinor: capped ? MAX_PAYOUT_MINOR : grossMinor, capped });\n}\n\nexport function kenoPayoutPreviewMinor(\n  stakeMinor: bigint,\n  selections: number,\n  difficulty: KenoDifficulty,\n): readonly bigint[] {\n  if (typeof stakeMinor !== \"bigint\" || stakeMinor < 0n || stakeMinor > KENO_MAX_STAKE_MINOR)\n    throw invalid(\"Invalid Keno stake\");\n  return Object.freeze(\n    kenoPayoutTable(selections, difficulty).map(\n      (value) => kenoSettlementMinor(stakeMinor, value).payoutMinor,\n    ),\n  );\n}\n\nexport function kenoLimitKey(selections: number, difficulty: KenoDifficulty): KenoLimitKey {\n  const level = difficultyOf(difficulty);\n  if (selections === 0) return KENO_LIMIT_KEY_MAX_MULTIPLIER_100;\n  const table = kenoPayoutTableDecimal(selections, level);\n  return Number(table[table.length - 1]) > 100 ? level : KENO_LIMIT_KEY_MAX_MULTIPLIER_100;\n}\n\nexport function kenoStakeLimits(parameters: unknown) {\n  const validated = validateKenoParameters(parameters);\n  return gameLimits(\"keno\", kenoLimitKey(validated.selectedNumbers.length, validated.difficulty));\n}\n\nexport function admitKenoStake(stakeMinor: bigint, parameters: unknown): void {\n  admitStakeRange(kenoStakeLimits(parameters), stakeMinor);\n}\n\nexport function kenoMaximumPayoutMinor(stakeMinor: bigint, parameters: unknown): bigint {\n  const validated = validateKenoParameters(parameters);\n  return kenoSettlementMinor(\n    stakeOf(stakeMinor),\n    kenoMaximumMultiplier(validated.selectedNumbers.length, validated.difficulty),\n  ).payoutMinor;\n}\n\nexport function kenoPcRtp(stakeMinor: bigint, parameters: unknown): Ratio {\n  const validated = validateKenoParameters(parameters);\n  const stake = stakeOf(stakeMinor);\n  const selections = validated.selectedNumbers.length;\n  const ways = kenoHitWays(selections, validated.difficulty);\n  const total = kenoPayoutTable(selections, validated.difficulty).reduce(\n    (sum, value, hits) => sum + ways[hits] * kenoSettlementMinor(stake, value).payoutMinor,\n    0n,\n  );\n  return reduced(total, stake * kenoDrawWays(validated.difficulty));\n}\n\nexport function kenoHmacMessage(clientSeed: string, nonce: number, round: number): string {\n  return `${clientSeed}:${nonce}:keno:${round}`;\n}\n\nexport function kenoDigestWords(digest: Uint8Array): number[] {\n  if (!(digest instanceof Uint8Array) || digest.byteLength !== 64)\n    throw new Error(\"Invalid Keno HMAC digest\");\n  return Array.from(\n    { length: 16 },\n    (_, index) =>\n      ((digest[index * 4] << 24) |\n        (digest[index * 4 + 1] << 16) |\n        (digest[index * 4 + 2] << 8) |\n        digest[index * 4 + 3]) >>>\n      0,\n  );\n}\n\nexport function kenoDrawFromWords(\n  words: readonly number[],\n  difficulty: KenoDifficulty,\n): number[] | null {\n  const gridSize = kenoGridSize(difficulty);\n  const items = Array.from({ length: gridSize }, (_, index) => index + 1);\n  let cursor = 0;\n  for (let index = gridSize - 1; index >= gridSize - KENO_DRAW_COUNT; index--) {\n    const range = index + 1;\n    const limit = 0x1_0000_0000 - (0x1_0000_0000 % range);\n    let word: number;\n    do {\n      if (cursor >= words.length) return null;\n      word = words[cursor++];\n      if (typeof word !== \"number\" || !Number.isSafeInteger(word) || word < 0 || word > 0xffff_ffff)\n        throw new RangeError(\"Invalid Keno entropy word\");\n    } while (word >= limit);\n    const swap = word % range;\n    [items[index], items[swap]] = [items[swap], items[index]];\n  }\n  return items.slice(gridSize - KENO_DRAW_COUNT);\n}\n\nfunction assertEntropy(entropy: Entropy) {\n  if (\n    !entropy ||\n    typeof entropy.hmac !== \"function\" ||\n    typeof entropy.serverSeed !== \"string\" ||\n    !entropy.serverSeed.trim() ||\n    typeof entropy.clientSeed !== \"string\" ||\n    !entropy.clientSeed.trim() ||\n    typeof entropy.nonce !== \"number\" ||\n    !Number.isSafeInteger(entropy.nonce) ||\n    entropy.nonce < 0\n  )\n    throw new RangeError(\"Invalid Keno fairness seed\");\n}\n\nexport async function kenoDraw(entropy: Entropy, difficulty: KenoDifficulty): Promise<number[]> {\n  assertEntropy(entropy);\n  const level = difficultyOf(difficulty);\n  const words: number[] = [];\n  for (let round = 0; round < KENO_MAX_ROUNDS; round++) {\n    const digest = await entropy.hmac(\n      KENO_HASH,\n      entropy.serverSeed,\n      kenoHmacMessage(entropy.clientSeed, entropy.nonce, round),\n    );\n    words.push(...kenoDigestWords(digest));\n    const drawn = kenoDrawFromWords(words, level);\n    if (drawn) return drawn;\n  }\n  throw new Error(\"Keno entropy stream exhausted\");\n}\n\nexport function settleKeno(input: {\n  stakeMinor: bigint;\n  parameters: unknown;\n  drawnNumbers: readonly number[];\n}): Resolution {\n  const parameters = validateKenoParameters(input.parameters);\n  const stakeMinor = stakeOf(input.stakeMinor);\n  const gridSize = kenoGridSize(parameters.difficulty);\n  const drawn = input.drawnNumbers;\n  if (!Array.isArray(drawn) || drawn.length !== KENO_DRAW_COUNT)\n    throw new RangeError(\"Invalid Keno draw\");\n  const drawnNumbers: number[] = [];\n  for (let index = 0; index < drawn.length; index++) {\n    const entry = drawn[index];\n    if (\n      typeof entry !== \"number\" ||\n      !Number.isSafeInteger(entry) ||\n      entry < 1 ||\n      entry > gridSize ||\n      drawnNumbers.includes(entry)\n    )\n      throw new RangeError(\"Invalid Keno draw\");\n    drawnNumbers.push(entry);\n  }\n  const selected = new Set(parameters.selectedNumbers);\n  const matches = drawnNumbers.filter((entry) => selected.has(entry)).sort((a, b) => a - b);\n  const selections = parameters.selectedNumbers.length;\n  const table = kenoPayoutTableDecimal(selections, parameters.difficulty);\n  const settlement = kenoSettlementMinor(\n    stakeMinor,\n    kenoPayoutTable(selections, parameters.difficulty)[matches.length],\n  );\n  return {\n    game: \"keno\",\n    status: settlement.payoutMinor > 0n ? \"WON\" : \"LOST\",\n    payoutMinor: settlement.payoutMinor.toString(),\n    multiplier: ratioText(ratio(settlement.payoutMinor, stakeMinor)),\n    rulesVersion: KENO_RULES_VERSION,\n    result: {\n      parameters,\n      kenoRules: KENO_RULES_VERSION,\n      difficulty: parameters.difficulty,\n      gridSize,\n      drawCount: KENO_DRAW_COUNT,\n      selectedNumbers: [...parameters.selectedNumbers],\n      drawnNumbers,\n      matches,\n      hits: matches.length,\n      table: [...table],\n      tableMultiplier: table[matches.length],\n      grossPayoutMinor: settlement.grossMinor.toString(),\n      capped: settlement.capped,\n      maxPayoutMinor: MAX_PAYOUT_MINOR.toString(),\n      nominalRtp: ratioWire(kenoRtp(selections, parameters.difficulty)),\n      theoreticalRtp: ratioWire(kenoPcRtp(stakeMinor, parameters)),\n      gameOver: true,\n      decisions: [],\n    },\n  };\n}\n\nconst rules: GameRules<KenoParameters> = {\n  game: \"keno\",\n  version: KENO_RULES_VERSION,\n  stateful: false,\n  actions: Object.freeze([]),\n  rateLimitPerMinute: KENO_RATE_LIMIT,\n  limits: KENO_GAME_LIMITS,\n  admitStake: admitKenoStake,\n  validateParameters: validateKenoParameters,\n  maximumPayoutMinor: kenoMaximumPayoutMinor,\n  async resolve(input) {\n    const parameters = validateKenoParameters(input.parameters);\n    const stakeMinor = stakeOf(input.stakeMinor);\n    return settleKeno({\n      stakeMinor,\n      parameters,\n      drawnNumbers: await kenoDraw(input.entropy, parameters.difficulty),\n    });\n  },\n  reconstruct(input) {\n    const drawnNumbers = input.result?.drawnNumbers;\n    if (!Array.isArray(drawnNumbers)) throw new RangeError(\"Invalid Keno draw\");\n    return settleKeno({\n      stakeMinor: input.stakeMinor,\n      parameters: input.parameters,\n      drawnNumbers,\n    });\n  },\n};\n\nexport const kenoRulesModule: GameRules<KenoParameters> = Object.freeze(rules);\n\nexport function isKenoRulesVersion(value: unknown): value is KenoRulesVersion {\n  return (KENO_RULES_VERSIONS as readonly unknown[]).includes(value);\n}\n\nexport function kenoTopMultiplier(parameters: unknown): Ratio {\n  const validated = validateKenoParameters(parameters);\n  return kenoMaximumMultiplier(validated.selectedNumbers.length, validated.difficulty);\n}\n\nexport function admitKeno2Stake(stakeMinor: bigint, parameters: unknown): void {\n  admitMaxWinStake(kenoStakeLimits(parameters), stakeMinor, kenoTopMultiplier(parameters));\n}\n\nexport function keno2MaxBetMinor(selections: number, difficulty: KenoDifficulty): bigint {\n  const limits = gameLimits(\"keno\", kenoLimitKey(selections, difficulty));\n  return selectionMaxBetMinor(\n    limits,\n    selections > 0 ? kenoMaximumMultiplier(selections, difficulty) : null,\n  );\n}\n\nexport const keno2RulesModule: GameRules<KenoParameters> = maxWinSuccessor(\n  kenoRulesModule,\n  KENO_2_RULES_VERSION,\n  { limits: KENO_2_GAME_LIMITS, admitStake: admitKeno2Stake },\n);\n","sha256":"134c1c196debc1f1ea27f8edb81fd003a668d296c8738fe3b8da431c20b04ca2"},"src/lib/games/wheel-rules.ts":{"source":"import { ratioText, ratioWire, type Ratio, type Resolution } from \"@/games/math\";\nimport type { ActInput, Decision, Entropy, GameRules, Limits, ResolveInput } from \"./rules\";\nimport { gameLimits, maxWinLimits } from \"./limits\";\nimport { MAX_WIN_END, MAX_WIN_MINOR, admitMaxWinStake } from \"./max-win\";\n\nexport const WHEEL_RULES_VERSION = \"peeks-wheel-1\" as const;\nexport const WHEEL_2_RULES_VERSION = \"peeks-wheel-2\" as const;\nexport const WHEEL_RULES_VERSIONS = Object.freeze([\n  WHEEL_RULES_VERSION,\n  WHEEL_2_RULES_VERSION,\n] as const);\nexport type WheelRulesVersion = (typeof WHEEL_RULES_VERSIONS)[number];\nexport const WHEEL_RISKS = Object.freeze([\"low\", \"medium\", \"high\", \"risky\"] as const);\nexport type WheelRisk = (typeof WHEEL_RISKS)[number];\nexport const WHEEL_ACTIONS = Object.freeze([\"spin\", \"cashout\"] as const);\nexport const WHEEL_CLOSING_ACTIONS = Object.freeze([\"cashout\"] as const);\nexport type WheelAction = (typeof WHEEL_ACTIONS)[number];\nexport type WheelParameters = { riskLevel: WheelRisk; maxRounds?: number };\nexport type WheelChance = Readonly<{ multiplier: string; percentage: string }>;\nexport type WheelFinish =\n  | \"lost\"\n  | \"cashout\"\n  | \"maxStreak\"\n  | \"maxRounds\"\n  | \"maxPayout\"\n  | \"maxMultiplier\"\n  | typeof MAX_WIN_END;\nexport type WheelRound = Readonly<{\n  round: number;\n  ticket: number;\n  hitIndex: number;\n  baseMultiplier: string;\n  wheelResult: string;\n  roundMultipliers: readonly string[];\n}>;\nexport type WheelFairnessRound = Readonly<{\n  round: number;\n  ticket: number;\n  hitIndex: number;\n  baseMultiplier: string;\n  compoundingMultiplier: string;\n}>;\nexport type WheelRatioWire = Readonly<{\n  numerator: string;\n  denominator: string;\n  value: string;\n}>;\nexport type WheelResult = Readonly<{\n  parameters: Readonly<WheelParameters>;\n  mode: \"manual\" | \"auto\";\n  riskLevel: WheelRisk;\n  rounds: readonly WheelRound[];\n  step: number;\n  streak: number;\n  maxStreak: number;\n  baseMultipliers: readonly string[];\n  chances: readonly string[];\n  multipliers: readonly string[];\n  segments: readonly string[];\n  currentMultiplier: string;\n  potentialPayoutMinor: string;\n  grossBeforeCapMinor: string;\n  capped: boolean;\n  multiplierCapped: boolean;\n  maxMultiplier: string;\n  maxPayoutMinor: string;\n  nominalRtp: WheelRatioWire;\n  theoreticalRtp?: WheelRatioWire;\n  decisions: readonly Decision[];\n  gameOver: boolean;\n  finish: WheelFinish | null;\n}>;\nexport type WheelSecret = Readonly<{\n  rulesVersion: WheelRulesVersion;\n  parameters: Readonly<WheelParameters>;\n  decisions: readonly Decision[];\n  hits: readonly number[];\n}>;\n\nexport const WHEEL_DEFAULT_RISK: WheelRisk = \"low\";\nexport const WHEEL_SEGMENTS = 25;\nexport const WHEEL_MAX_STREAK = 10;\nexport const WHEEL_AUTO_STARTING_STREAK = 1;\nexport const WHEEL_MAX_MULTIPLIER = \"10000\";\nexport const WHEEL_MAX_STAKE_MINOR = 1_000_000n;\nexport const WHEEL_GAME_LIMITS = gameLimits(\"wheel\");\nexport const WHEEL_2_GAME_LIMITS = maxWinLimits(\"wheel\");\nexport const WHEEL_MAX_PAYOUT_MINOR = WHEEL_GAME_LIMITS.maxPayoutMinor;\nexport const WHEEL_ANIMATION_DURATION_MS = 1200;\nexport const WHEEL_HASH = \"SHA-256\" as const;\nexport const WHEEL_MAX_ENTROPY_BLOCKS = 64;\n\nfunction rows(values: readonly (readonly [string, string])[]): readonly WheelChance[] {\n  return Object.freeze(\n    values.map(([multiplier, percentage]) => Object.freeze({ multiplier, percentage })),\n  );\n}\n\nexport const WHEEL_RISK_CHANCES: Readonly<Record<WheelRisk, readonly WheelChance[]>> =\n  Object.freeze({\n    high: rows([\n      [\"0\", \"0.6\"],\n      [\"1.5\", \"0.16\"],\n      [\"2\", \"0.12\"],\n      [\"3\", \"0.08\"],\n      [\"6\", \"0.04\"],\n    ]),\n    low: rows([\n      [\"0\", \"0.2\"],\n      [\"1.1\", \"0.44\"],\n      [\"1.2\", \"0.24\"],\n      [\"1.4\", \"0.08\"],\n      [\"2\", \"0.04\"],\n    ]),\n    medium: rows([\n      [\"0\", \"0.4\"],\n      [\"1.2\", \"0.32\"],\n      [\"1.5\", \"0.16\"],\n      [\"2.2\", \"0.08\"],\n      [\"4\", \"0.04\"],\n    ]),\n    risky: rows([\n      [\"0\", \"0.84\"],\n      [\"2\", \"0.04\"],\n      [\"3\", \"0.04\"],\n      [\"4\", \"0.04\"],\n      [\"15\", \"0.04\"],\n    ]),\n  });\n\nexport const WHEEL_RISK_ORDER: Readonly<Record<WheelRisk, readonly number[]>> = Object.freeze({\n  high: Object.freeze([0, 0, 1, 2, 0, 0, 0, 3, 4, 0, 0, 0, 1, 2, 0, 0, 0, 2, 1, 0, 0, 0, 3, 1, 0]),\n  low: Object.freeze([0, 1, 2, 1, 0, 1, 2, 1, 3, 1, 2, 0, 1, 2, 1, 0, 1, 2, 3, 1, 0, 2, 1, 4, 1]),\n  medium: Object.freeze([\n    0, 1, 0, 1, 0, 2, 1, 0, 1, 3, 0, 2, 1, 0, 1, 0, 2, 1, 0, 3, 0, 1, 0, 2, 4,\n  ]),\n  risky: Object.freeze([0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 4]),\n});\n\nconst palette = Object.freeze([4541298, 0xfbc449, 4915030, 240116, 0xf37c50]);\nexport const WHEEL_SEGMENT_COLORS: Readonly<Record<WheelRisk, readonly number[]>> = Object.freeze({\n  high: palette,\n  low: palette,\n  medium: palette,\n  risky: palette,\n});\n\ntype WheelDecimal = Readonly<{ numerator: bigint; denominator: bigint }>;\ntype WheelTable = Readonly<{\n  multipliers: readonly WheelDecimal[];\n  percents: readonly bigint[];\n  weights: readonly number[];\n  total: number;\n  limit: number;\n}>;\ntype Ledger = {\n  version: WheelRulesVersion;\n  parameters: Readonly<WheelParameters>;\n  stakeMinor: bigint;\n  rounds: WheelRound[];\n  multiplier: bigint;\n  wheel: readonly bigint[];\n  decisions: Decision[];\n  finish: WheelFinish | null;\n};\n\nconst CAP = 1_000_000n;\nconst SECRET_KEYS = Object.freeze([\"rulesVersion\", \"parameters\", \"decisions\", \"hits\"]);\nconst invalid = (message: string) => new RangeError(message);\nconst record = (value: unknown): value is Record<string, unknown> =>\n  !!value && typeof value === \"object\" && !Array.isArray(value);\n\nfunction decimal(value: string): WheelDecimal {\n  const [whole, fraction = \"\"] = value.split(\".\");\n  return Object.freeze({\n    numerator: BigInt(`${whole}${fraction}`),\n    denominator: 10n ** BigInt(fraction.length),\n  });\n}\n\nfunction reduced(numerator: bigint, denominator: bigint): Ratio {\n  let a = numerator,\n    b = denominator;\n  while (b) [a, b] = [b, a % b];\n  return { numerator: numerator / a, denominator: denominator / a };\n}\n\nconst tables = Object.freeze(\n  Object.fromEntries(\n    WHEEL_RISKS.map((risk) => {\n      const chances = WHEEL_RISK_CHANCES[risk];\n      const percents = Object.freeze(\n        chances.map((row) => {\n          const share = decimal(row.percentage);\n          return (share.numerator * 100n) / share.denominator;\n        }),\n      );\n      const weights = Object.freeze(percents.map((percent) => Number(percent) * 1_000_000));\n      const total = weights.reduce((sum, weight) => sum + weight, 0);\n      return [\n        risk,\n        Object.freeze({\n          multipliers: Object.freeze(chances.map((row) => decimal(row.multiplier))),\n          percents,\n          weights,\n          total,\n          limit: 0x1_0000_0000 - (0x1_0000_0000 % total),\n        }),\n      ];\n    }),\n  ),\n) as Readonly<Record<WheelRisk, WheelTable>>;\n\nfunction riskOf(value: unknown): WheelRisk {\n  if (!WHEEL_RISKS.includes(value as WheelRisk)) throw invalid(\"Invalid Wheel risk level\");\n  return value as WheelRisk;\n}\n\nfunction stakeOf(value: unknown): bigint {\n  if (typeof value !== \"bigint\" || value <= 0n || value > WHEEL_MAX_STAKE_MINOR)\n    throw invalid(\"Invalid Wheel stake\");\n  return value;\n}\n\nfunction fairness(entropy: Entropy): Entropy {\n  if (\n    !entropy ||\n    typeof entropy !== \"object\" ||\n    typeof entropy.hmac !== \"function\" ||\n    typeof entropy.serverSeed !== \"string\" ||\n    !entropy.serverSeed.trim() ||\n    typeof entropy.clientSeed !== \"string\" ||\n    !entropy.clientSeed.trim() ||\n    !Number.isSafeInteger(entropy.nonce) ||\n    entropy.nonce < 0\n  )\n    throw invalid(\"Invalid Wheel fairness seed\");\n  return entropy;\n}\n\nfunction scaled(hundredths: bigint, factor: WheelDecimal): bigint {\n  const value = (hundredths * factor.numerator) / factor.denominator;\n  return value > CAP ? CAP : value;\n}\n\nfunction wheelFor(risk: WheelRisk, hundredths: bigint): readonly bigint[] {\n  return Object.freeze(tables[risk].multipliers.map((factor) => scaled(hundredths, factor)));\n}\n\nfunction gross(stakeMinor: bigint, hundredths: bigint): bigint {\n  return (stakeMinor * hundredths) / 100n;\n}\n\nexport function isWheelRulesVersion(value: unknown): value is WheelRulesVersion {\n  return (WHEEL_RULES_VERSIONS as readonly unknown[]).includes(value);\n}\n\nfunction edition(version: unknown): WheelRulesVersion {\n  if (!isWheelRulesVersion(version)) throw invalid(\"Invalid Wheel rules\");\n  return version;\n}\n\nfunction paysInFull(version: WheelRulesVersion): boolean {\n  return version !== WHEEL_RULES_VERSION;\n}\n\nexport function wheelLimits(version: WheelRulesVersion = WHEEL_RULES_VERSION): Limits {\n  return paysInFull(edition(version)) ? WHEEL_2_GAME_LIMITS : WHEEL_GAME_LIMITS;\n}\n\nfunction clipped(amount: bigint, version: WheelRulesVersion = WHEEL_RULES_VERSION): bigint {\n  if (paysInFull(version)) return amount;\n  return amount > WHEEL_MAX_PAYOUT_MINOR ? WHEEL_MAX_PAYOUT_MINOR : amount;\n}\n\nfunction topFactor(risk: WheelRisk): WheelDecimal {\n  const multipliers = tables[risk].multipliers;\n  return multipliers[multipliers.length - 1];\n}\n\nfunction canonical(value: unknown): string {\n  if (Array.isArray(value)) return `[${value.map(canonical).join(\",\")}]`;\n  if (value && typeof value === \"object\") {\n    const entries = value as Record<string, unknown>;\n    return `{${Object.keys(entries)\n      .filter((key) => entries[key] !== undefined)\n      .sort()\n      .map((key) => `${JSON.stringify(key)}:${canonical(entries[key])}`)\n      .join(\",\")}}`;\n  }\n  return typeof value === \"bigint\" ? `${value}n` : (JSON.stringify(value) ?? \"undefined\");\n}\n\nfunction same(left: unknown, right: unknown): boolean {\n  return canonical(left) === canonical(right);\n}\n\nexport function wheelMultiplierText(hundredths: bigint): string {\n  if (typeof hundredths !== \"bigint\" || hundredths < 0n) throw invalid(\"Invalid Wheel multiplier\");\n  const whole = hundredths / 100n,\n    fraction = hundredths % 100n;\n  if (!fraction) return whole.toString();\n  return `${whole}.${fraction.toString().padStart(2, \"0\").replace(/0$/, \"\")}`;\n}\n\nexport function wheelMultiplierHundredths(value: string): bigint {\n  if (typeof value !== \"string\" || !/^(0|[1-9]\\d*)(\\.\\d{1,2})?$/.test(value))\n    throw invalid(\"Invalid Wheel multiplier\");\n  const parsed = decimal(value);\n  return (parsed.numerator * 100n) / parsed.denominator;\n}\n\nexport function validateWheelParameters(input: unknown): WheelParameters {\n  if (!record(input)) throw invalid(\"Invalid Wheel parameters\");\n  if (\n    !Object.hasOwn(input, \"riskLevel\") ||\n    Object.keys(input).some((key) => key !== \"riskLevel\" && key !== \"maxRounds\") ||\n    !WHEEL_RISKS.includes(input.riskLevel as WheelRisk)\n  )\n    throw invalid(\"Invalid Wheel parameters\");\n  const riskLevel = input.riskLevel as WheelRisk;\n  if (!Object.hasOwn(input, \"maxRounds\")) return { riskLevel };\n  const maxRounds = input.maxRounds;\n  if (\n    typeof maxRounds !== \"number\" ||\n    !Number.isSafeInteger(maxRounds) ||\n    maxRounds < 1 ||\n    maxRounds > WHEEL_MAX_STREAK\n  )\n    throw invalid(\"Invalid Wheel parameters\");\n  return { riskLevel, maxRounds };\n}\n\nexport function validateWheelDecision(input: unknown, streak: number): Decision {\n  if (!Number.isSafeInteger(streak) || streak < 1 || streak >= WHEEL_MAX_STREAK)\n    throw invalid(\"Invalid Wheel round\");\n  if (!record(input)) throw invalid(\"Invalid Wheel action\");\n  if (\n    Object.keys(input).some(\n      (key) => input[key] !== undefined && key !== \"action\" && key !== \"expectedStep\",\n    ) ||\n    (input.action !== \"spin\" && input.action !== \"cashout\")\n  )\n    throw invalid(\"Invalid Wheel action\");\n  const step = input.expectedStep;\n  if (step === undefined) {\n    if (input.action === \"spin\") throw invalid(\"Wheel spins require the expected step\");\n  } else if (typeof step !== \"number\" || !Number.isSafeInteger(step)) {\n    throw invalid(\"Invalid Wheel action\");\n  } else if (step !== streak) throw invalid(\"This Wheel round has changed\");\n  return Object.freeze({ action: input.action, expectedStep: streak });\n}\n\nexport function wheelMessage(\n  clientSeed: string,\n  nonce: number,\n  round: number,\n  cursor: number,\n): string {\n  return `${clientSeed}:${nonce}:${round}:wheel:${cursor}`;\n}\n\nexport async function wheelTicket(\n  entropy: Entropy,\n  riskLevel: WheelRisk,\n  round: number,\n): Promise<number> {\n  const seeds = fairness(entropy),\n    table = tables[riskOf(riskLevel)];\n  if (!Number.isSafeInteger(round) || round < 1) throw invalid(\"Invalid Wheel round\");\n  for (let cursor = 0; cursor < WHEEL_MAX_ENTROPY_BLOCKS; cursor++) {\n    const digest = await seeds.hmac(\n      WHEEL_HASH,\n      seeds.serverSeed,\n      wheelMessage(seeds.clientSeed, seeds.nonce, round, cursor),\n    );\n    if (!(digest instanceof Uint8Array) || digest.byteLength !== 32)\n      throw invalid(\"Invalid Wheel HMAC digest\");\n    for (let offset = 0; offset < 32; offset += 4) {\n      const word =\n        ((digest[offset] << 24) |\n          (digest[offset + 1] << 16) |\n          (digest[offset + 2] << 8) |\n          digest[offset + 3]) >>>\n        0;\n      if (word < table.limit) return word % table.total;\n    }\n  }\n  throw invalid(\"Wheel entropy stream exhausted\");\n}\n\nexport function wheelHitIndex(riskLevel: WheelRisk, ticket: number): number {\n  const table = tables[riskOf(riskLevel)];\n  if (!Number.isSafeInteger(ticket) || ticket < 0 || ticket >= table.total)\n    throw invalid(\"Invalid Wheel ticket\");\n  let cumulative = 0;\n  for (let index = 0; index < table.weights.length; index++) {\n    cumulative += table.weights[index];\n    if (ticket < cumulative) return index;\n  }\n  throw invalid(\"Invalid Wheel ticket\");\n}\n\nexport function wheelBaseMultipliers(riskLevel: unknown): readonly string[] {\n  return Object.freeze(WHEEL_RISK_CHANCES[riskOf(riskLevel)].map((row) => row.multiplier));\n}\n\nexport function wheelChances(riskLevel: unknown): readonly string[] {\n  return Object.freeze(WHEEL_RISK_CHANCES[riskOf(riskLevel)].map((row) => row.percentage));\n}\n\nexport function wheelRoundMultipliers(riskLevel: unknown, multiplier: string): readonly string[] {\n  const risk = riskOf(riskLevel),\n    hundredths = wheelMultiplierHundredths(multiplier);\n  if (hundredths < 100n || hundredths > CAP) throw invalid(\"Invalid Wheel multiplier\");\n  return Object.freeze(wheelFor(risk, hundredths).map(wheelMultiplierText));\n}\n\nexport function wheelSegments(\n  riskLevel: unknown,\n  multipliers?: readonly string[],\n): readonly string[] {\n  const risk = riskOf(riskLevel),\n    values = multipliers ?? wheelBaseMultipliers(risk);\n  if (\n    !Array.isArray(values) ||\n    values.length !== 5 ||\n    values.some((value) => typeof value !== \"string\")\n  )\n    throw invalid(\"Invalid Wheel multipliers\");\n  return Object.freeze(WHEEL_RISK_ORDER[risk].map((index) => values[index]));\n}\n\nexport function wheelNominalRtp(riskLevel: unknown): Ratio {\n  const table = tables[riskOf(riskLevel)];\n  const denominator = table.multipliers.reduce(\n    (largest, factor) => (factor.denominator > largest ? factor.denominator : largest),\n    1n,\n  );\n  const numerator = table.multipliers.reduce(\n    (sum, factor, index) =>\n      sum + table.percents[index] * factor.numerator * (denominator / factor.denominator),\n    0n,\n  );\n  return reduced(numerator, 100n * denominator);\n}\n\nexport function wheelMaximumMultiplier(input: unknown): string {\n  const parameters = validateWheelParameters(input),\n    table = tables[parameters.riskLevel],\n    top = table.multipliers[table.multipliers.length - 1];\n  let hundredths = 100n;\n  for (let round = 0; round < (parameters.maxRounds ?? WHEEL_MAX_STREAK); round++)\n    hundredths = scaled(hundredths, top);\n  return wheelMultiplierText(hundredths);\n}\n\nexport function wheelMaximumPayoutMinor(\n  stakeMinor: bigint,\n  parameters: unknown,\n  version: WheelRulesVersion = WHEEL_RULES_VERSION,\n): bigint {\n  const amount = gross(\n    stakeOf(stakeMinor),\n    wheelMultiplierHundredths(wheelMaximumMultiplier(parameters)),\n  );\n  if (paysInFull(edition(version))) return amount > MAX_WIN_MINOR ? MAX_WIN_MINOR : amount;\n  return clipped(amount);\n}\n\nexport function wheelPayoutMinor(\n  stakeMinor: bigint,\n  multiplier: string,\n  version: WheelRulesVersion = WHEEL_RULES_VERSION,\n): bigint {\n  return clipped(\n    gross(stakeOf(stakeMinor), wheelMultiplierHundredths(multiplier)),\n    edition(version),\n  );\n}\n\nexport function admitWheel2Stake(stakeMinor: bigint, parameters: unknown): void {\n  admitMaxWinStake(\n    WHEEL_2_GAME_LIMITS,\n    stakeMinor,\n    topFactor(validateWheelParameters(parameters).riskLevel),\n  );\n}\n\nfunction ended(\n  stakeMinor: bigint,\n  hundredths: bigint,\n  rounds: number,\n  parameters: Readonly<WheelParameters>,\n  version: WheelRulesVersion = WHEEL_RULES_VERSION,\n): WheelFinish | null {\n  if (hundredths === 0n) return \"lost\";\n  if (!paysInFull(version) && gross(stakeMinor, hundredths) >= WHEEL_MAX_PAYOUT_MINOR)\n    return \"maxPayout\";\n  if (hundredths >= CAP) return \"maxMultiplier\";\n  if (rounds >= (parameters.maxRounds ?? WHEEL_MAX_STREAK))\n    return parameters.maxRounds === undefined ? \"maxStreak\" : \"maxRounds\";\n  if (\n    paysInFull(version) &&\n    gross(stakeMinor, scaled(hundredths, topFactor(parameters.riskLevel))) > MAX_WIN_MINOR\n  )\n    return MAX_WIN_END;\n  return null;\n}\n\nexport function wheelCashoutRtp(\n  stakeMinor: bigint,\n  input: unknown,\n  rounds: number,\n  version: WheelRulesVersion = WHEEL_RULES_VERSION,\n): Ratio {\n  const stake = stakeOf(stakeMinor),\n    parameters = validateWheelParameters(input),\n    table = tables[parameters.riskLevel];\n  if (\n    !Number.isSafeInteger(rounds) ||\n    rounds < 1 ||\n    rounds > (parameters.maxRounds ?? WHEEL_MAX_STREAK)\n  )\n    throw invalid(\"Invalid Wheel cashout round\");\n  let open = new Map<bigint, bigint>([[100n, 1n]]),\n    settled = 0n,\n    denominator = 1n;\n  for (let round = 1; round <= rounds && open.size; round++) {\n    const next = new Map<bigint, bigint>();\n    for (const [hundredths, weight] of open)\n      for (let index = 1; index < table.multipliers.length; index++) {\n        const value = scaled(hundredths, table.multipliers[index]);\n        next.set(value, (next.get(value) ?? 0n) + weight * table.percents[index]);\n      }\n    settled *= 100n;\n    denominator *= 100n;\n    open = new Map();\n    for (const [hundredths, weight] of next) {\n      if (round === rounds || ended(stake, hundredths, round, parameters, edition(version)))\n        settled += weight * clipped(gross(stake, hundredths), edition(version));\n      else open.set(hundredths, weight);\n    }\n  }\n  return reduced(settled, stake * denominator);\n}\n\nfunction begin(\n  stakeMinor: unknown,\n  input: unknown,\n  version: WheelRulesVersion = WHEEL_RULES_VERSION,\n): Ledger {\n  const parameters = Object.freeze(validateWheelParameters(input));\n  return {\n    version: edition(version),\n    parameters,\n    stakeMinor: stakeOf(stakeMinor),\n    rounds: [],\n    multiplier: 100n,\n    wheel: wheelFor(parameters.riskLevel, 100n),\n    decisions: [],\n    finish: null,\n  };\n}\n\nfunction pending(ledger: Ledger): boolean {\n  return (\n    ledger.finish === null &&\n    (ledger.rounds.length === 0 || ledger.parameters.maxRounds !== undefined)\n  );\n}\n\nfunction spin(ledger: Ledger, ticket: number) {\n  if (ledger.finish !== null) throw invalid(\"This Wheel round is already settled\");\n  const risk = ledger.parameters.riskLevel,\n    round = ledger.rounds.length + 1,\n    hitIndex = wheelHitIndex(risk, ticket),\n    value = ledger.wheel[hitIndex];\n  if (value > 0n) ledger.wheel = wheelFor(risk, value);\n  ledger.multiplier = value;\n  ledger.rounds.push(\n    Object.freeze({\n      round,\n      ticket,\n      hitIndex,\n      baseMultiplier: WHEEL_RISK_CHANCES[risk][hitIndex].multiplier,\n      wheelResult: wheelMultiplierText(value),\n      roundMultipliers: Object.freeze(ledger.wheel.slice(1).map(wheelMultiplierText)),\n    }),\n  );\n  ledger.finish = ended(ledger.stakeMinor, value, round, ledger.parameters, ledger.version);\n}\n\nfunction decide(ledger: Ledger, input: unknown): boolean {\n  if (ledger.finish !== null) throw invalid(\"This Wheel round is already settled\");\n  const decision = validateWheelDecision(input, ledger.rounds.length);\n  ledger.decisions.push(decision);\n  if (decision.action === \"cashout\") ledger.finish = \"cashout\";\n  return decision.action === \"spin\";\n}\n\nfunction drawer(ledger: Ledger, entropy: Entropy) {\n  return async () =>\n    spin(ledger, await wheelTicket(entropy, ledger.parameters.riskLevel, ledger.rounds.length + 1));\n}\n\nasync function play(\n  input: {\n    stakeMinor: bigint;\n    parameters: unknown;\n    entropy: Entropy;\n    decisions: readonly unknown[];\n  },\n  version: WheelRulesVersion,\n): Promise<Ledger> {\n  const ledger = begin(input.stakeMinor, input.parameters, version),\n    draw = drawer(ledger, fairness(input.entropy));\n  if (!Array.isArray(input.decisions)) throw invalid(\"Invalid Wheel decisions\");\n  while (pending(ledger)) await draw();\n  for (const decision of input.decisions) if (decide(ledger, decision)) await draw();\n  return ledger;\n}\n\nfunction settle(ledger: Ledger): Resolution {\n  const { parameters, stakeMinor, version } = ledger,\n    risk = parameters.riskLevel,\n    active = ledger.finish === null,\n    amount = gross(stakeMinor, ledger.multiplier),\n    potential = clipped(amount, version),\n    payout = active ? 0n : potential,\n    multipliers = Object.freeze(ledger.wheel.map(wheelMultiplierText)),\n    decisions = Object.freeze([...ledger.decisions]),\n    result: WheelResult = {\n      parameters,\n      mode: parameters.maxRounds === undefined ? \"manual\" : \"auto\",\n      riskLevel: risk,\n      rounds: Object.freeze([...ledger.rounds]),\n      step: ledger.rounds.length,\n      streak: ledger.rounds.length,\n      maxStreak: WHEEL_MAX_STREAK,\n      baseMultipliers: wheelBaseMultipliers(risk),\n      chances: wheelChances(risk),\n      multipliers,\n      segments: wheelSegments(risk, multipliers),\n      currentMultiplier: wheelMultiplierText(ledger.multiplier),\n      potentialPayoutMinor: potential.toString(),\n      grossBeforeCapMinor: amount.toString(),\n      capped: !paysInFull(version) && amount > WHEEL_MAX_PAYOUT_MINOR,\n      multiplierCapped: ledger.multiplier >= CAP,\n      maxMultiplier: WHEEL_MAX_MULTIPLIER,\n      maxPayoutMinor: wheelLimits(version).maxPayoutMinor.toString(),\n      nominalRtp: ratioWire(wheelNominalRtp(risk)),\n      ...(parameters.maxRounds === undefined\n        ? {}\n        : {\n            theoreticalRtp: ratioWire(\n              wheelCashoutRtp(stakeMinor, parameters, parameters.maxRounds, version),\n            ),\n          }),\n      decisions,\n      gameOver: !active,\n      finish: ledger.finish,\n    },\n    secret: WheelSecret = {\n      rulesVersion: version,\n      parameters,\n      decisions,\n      hits: Object.freeze(ledger.rounds.map((round) => round.hitIndex)),\n    };\n  return {\n    game: \"wheel\",\n    status: active ? \"ACTIVE\" : payout > 0n ? \"WON\" : \"LOST\",\n    payoutMinor: payout.toString(),\n    multiplier: ratioText({ numerator: payout, denominator: stakeMinor }),\n    rulesVersion: version,\n    result,\n    ...(active ? { secret } : {}),\n  };\n}\n\nexport async function wheelReplay(\n  input: {\n    stakeMinor: bigint;\n    parameters: unknown;\n    entropy: Entropy;\n    decisions?: readonly unknown[];\n  },\n  version: WheelRulesVersion = WHEEL_RULES_VERSION,\n): Promise<Resolution> {\n  return settle(await play({ ...input, decisions: input.decisions ?? [] }, edition(version)));\n}\n\nexport function reconstructWheel(\n  input: {\n    stakeMinor: bigint;\n    parameters: unknown;\n    result: unknown;\n  },\n  version: WheelRulesVersion = WHEEL_RULES_VERSION,\n): Resolution {\n  const ledger = begin(input.stakeMinor, input.parameters, version),\n    result = input.result;\n  if (\n    !record(result) ||\n    !Array.isArray(result.rounds) ||\n    !Array.isArray(result.decisions) ||\n    result.rounds.length < 1 ||\n    result.rounds.length > WHEEL_MAX_STREAK ||\n    result.decisions.length >= WHEEL_MAX_STREAK\n  )\n    throw invalid(\"Invalid Wheel result\");\n  const tickets = Array.from(result.rounds as unknown[], (round) => {\n    if (!record(round) || typeof round.ticket !== \"number\") throw invalid(\"Invalid Wheel result\");\n    return round.ticket;\n  });\n  let cursor = 0;\n  const draw = () => {\n    if (cursor >= tickets.length) throw invalid(\"Invalid Wheel result\");\n    spin(ledger, tickets[cursor++]);\n  };\n  while (pending(ledger)) draw();\n  for (const decision of Array.from(result.decisions as unknown[]))\n    if (decide(ledger, decision)) draw();\n  if (cursor !== tickets.length || ledger.finish === null) throw invalid(\"Invalid Wheel result\");\n  return settle(ledger);\n}\n\nexport async function wheelFairnessRounds(\n  entropy: Entropy,\n  riskLevel: unknown,\n): Promise<readonly WheelFairnessRound[]> {\n  const risk = riskOf(riskLevel),\n    table = tables[risk],\n    rounds: WheelFairnessRound[] = [];\n  let hundredths = 100n;\n  for (let round = 1; round <= WHEEL_MAX_STREAK; round++) {\n    const ticket = await wheelTicket(entropy, risk, round),\n      hitIndex = wheelHitIndex(risk, ticket);\n    hundredths = scaled(hundredths, table.multipliers[hitIndex]);\n    rounds.push(\n      Object.freeze({\n        round,\n        ticket,\n        hitIndex,\n        baseMultiplier: WHEEL_RISK_CHANCES[risk][hitIndex].multiplier,\n        compoundingMultiplier: wheelMultiplierText(hundredths),\n      }),\n    );\n  }\n  return Object.freeze(rounds);\n}\n\nfunction stateOf(input: ActInput<WheelParameters>, version: WheelRulesVersion) {\n  const parameters = validateWheelParameters(input.parameters),\n    secret = input.secret,\n    result = input.result;\n  if (\n    !record(secret) ||\n    Object.keys(secret).length !== SECRET_KEYS.length ||\n    SECRET_KEYS.some((key) => !Object.hasOwn(secret, key)) ||\n    secret.rulesVersion !== version ||\n    !same(secret.parameters, parameters) ||\n    !Array.isArray(secret.decisions) ||\n    !Array.isArray(secret.hits) ||\n    !record(result) ||\n    !Array.isArray(result.decisions) ||\n    !Array.isArray(result.rounds) ||\n    !same(result.decisions, secret.decisions)\n  )\n    throw invalid(\"Invalid Wheel round state\");\n  return {\n    parameters,\n    decisions: Array.from(secret.decisions as unknown[]),\n    hits: secret.hits,\n    rounds: result.rounds,\n  };\n}\n\nasync function act(\n  input: ActInput<WheelParameters>,\n  version: WheelRulesVersion = WHEEL_RULES_VERSION,\n): Promise<Resolution> {\n  const state = stateOf(input, edition(version)),\n    ledger = begin(input.stakeMinor, state.parameters, version),\n    draw = drawer(ledger, fairness(input.entropy));\n  while (pending(ledger)) await draw();\n  for (const decision of state.decisions) {\n    if (ledger.finish !== null) break;\n    if (decide(ledger, decision)) await draw();\n  }\n  if (\n    !same(\n      ledger.rounds.map((round) => round.hitIndex),\n      state.hits,\n    ) ||\n    !same(ledger.rounds, state.rounds) ||\n    !same(ledger.decisions, state.decisions)\n  )\n    throw invalid(\"Wheel state does not match its fairness seed\");\n  if (decide(ledger, input.decision)) await draw();\n  return settle(ledger);\n}\n\nexport const wheelRulesModule: GameRules<WheelParameters> = Object.freeze({\n  game: \"wheel\",\n  version: WHEEL_RULES_VERSION,\n  stateful: true,\n  actions: WHEEL_ACTIONS,\n  closingActions: WHEEL_CLOSING_ACTIONS,\n  limits: WHEEL_GAME_LIMITS,\n  validateParameters: validateWheelParameters,\n  maximumPayoutMinor: wheelMaximumPayoutMinor,\n  resolve: (input: ResolveInput<WheelParameters>) =>\n    wheelReplay({\n      stakeMinor: input.stakeMinor,\n      parameters: input.parameters,\n      entropy: input.entropy,\n    }),\n  act,\n  reconstruct: reconstructWheel,\n});\n\nexport const wheel2RulesModule: GameRules<WheelParameters> = Object.freeze({\n  game: \"wheel\",\n  version: WHEEL_2_RULES_VERSION,\n  stateful: true,\n  actions: WHEEL_ACTIONS,\n  closingActions: WHEEL_CLOSING_ACTIONS,\n  limits: WHEEL_2_GAME_LIMITS,\n  admitStake: admitWheel2Stake,\n  validateParameters: validateWheelParameters,\n  maximumPayoutMinor: (stakeMinor: bigint, parameters: WheelParameters) =>\n    wheelMaximumPayoutMinor(stakeMinor, parameters, WHEEL_2_RULES_VERSION),\n  resolve: (input: ResolveInput<WheelParameters>) =>\n    wheelReplay(\n      {\n        stakeMinor: input.stakeMinor,\n        parameters: input.parameters,\n        entropy: input.entropy,\n      },\n      WHEEL_2_RULES_VERSION,\n    ),\n  act: (input: ActInput<WheelParameters>) => act(input, WHEEL_2_RULES_VERSION),\n  reconstruct: (\n    input: Readonly<{\n      stakeMinor: bigint;\n      parameters: WheelParameters;\n      result: Record<string, unknown>;\n    }>,\n  ) => reconstructWheel(input, WHEEL_2_RULES_VERSION),\n});\n","sha256":"cbd22a43f60da489a263021e6a887bd7a7e09e0c81082abf01377287bd768ad1"},"src/lib/games/roulette-rules.ts":{"source":"import { ratioText, ratioWire, type Resolution } from \"@/games/math\";\nimport type { Entropy, GameRules, Limits } from \"./rules\";\nimport { gameLimits, maxWinLimits } from \"./limits\";\nimport { admitMaxWinStake, type MaxWinRatio } from \"./max-win\";\nimport {\n  ROULETTE_NET_PAYOUTS,\n  ROULETTE_POCKET_COUNT,\n  ROULETTE_TABLE_POSITIONS,\n  rouletteColor,\n  roulettePosition,\n  rouletteWinningTypes,\n  type RouletteBetType,\n} from \"./roulette-table\";\n\nexport const ROULETTE_RULES_VERSION = \"peeks-roulette-1\" as const;\nexport const ROULETTE_2_RULES_VERSION = \"peeks-roulette-2\" as const;\nexport const ROULETTE_RULES_VERSIONS = Object.freeze([\n  ROULETTE_RULES_VERSION,\n  ROULETTE_2_RULES_VERSION,\n] as const);\nexport type RouletteRulesVersion = (typeof ROULETTE_RULES_VERSIONS)[number];\nexport const ROULETTE_HASH = \"SHA-256\" as const;\nexport const ROULETTE_GAME_LIMITS = gameLimits(\"roulette\");\nexport const ROULETTE_2_GAME_LIMITS = maxWinLimits(\"roulette\");\nexport const ROULETTE_LIMITS = Object.freeze({\n  minBetMinor: ROULETTE_GAME_LIMITS.minStakeMinor,\n  maxBetMinor: ROULETTE_GAME_LIMITS.maxStakeMinor,\n  maxPayoutMinor: ROULETTE_GAME_LIMITS.maxPayoutMinor,\n});\nexport const ROULETTE_DISPLAY_RTP = 97.3;\nexport const ROULETTE_NOMINAL_RTP = Object.freeze({ numerator: 36n, denominator: 37n });\nexport const ROULETTE_WORD_RANGE = 0x1_0000_0000 - (0x1_0000_0000 % ROULETTE_POCKET_COUNT);\nexport const ROULETTE_WORD_LIMIT = 8192;\nexport const ROULETTE_WORDS_PER_DIGEST = 8;\n\nexport type RouletteBet = {\n  amount: string;\n  numbers: number[];\n  type: RouletteBetType;\n};\nexport type RouletteParameters = { bets: RouletteBet[] };\nexport type RouletteDraw = Readonly<{ cursor: number; wordIndex: number; uint32: number }>;\nexport type RouletteSettlement = {\n  type: RouletteBetType;\n  numbers: number[];\n  amount: string;\n  amountMinor: string;\n  netPayout: number;\n  multiplier: number;\n  won: boolean;\n  payoutMinor: string;\n};\n\nconst AMOUNT = /^(0|[1-9][0-9]{0,15})\\.[0-9]{2}$/;\n\nfunction plain(value: unknown): value is Record<string, unknown> {\n  if (!value || typeof value !== \"object\" || Array.isArray(value)) return false;\n  const prototype = Object.getPrototypeOf(value);\n  return prototype === Object.prototype || prototype === null;\n}\n\nfunction reduced(numerator: bigint, denominator: bigint) {\n  let a = numerator,\n    b = denominator;\n  while (b) [a, b] = [b, a % b];\n  return a > 1n\n    ? { numerator: numerator / a, denominator: denominator / a }\n    : { numerator, denominator };\n}\n\nexport function rouletteAmountMinor(amount: string): bigint {\n  if (typeof amount !== \"string\" || !AMOUNT.test(amount))\n    throw new RangeError(\"Invalid Roulette chip amount\");\n  const [whole, fraction] = amount.split(\".\");\n  return BigInt(whole) * 100n + BigInt(fraction);\n}\n\nexport function rouletteAmount(minor: bigint): string {\n  if (typeof minor !== \"bigint\" || minor < 0n) throw new RangeError(\"Invalid Roulette chip amount\");\n  return `${minor / 100n}.${(minor % 100n).toString().padStart(2, \"0\")}`;\n}\n\nexport function validateRouletteParameters(input: unknown): RouletteParameters {\n  if (!plain(input)) throw new RangeError(\"Invalid Roulette parameters\");\n  const keys = Object.keys(input);\n  if (keys.length !== 1 || keys[0] !== \"bets\") throw new RangeError(\"Invalid Roulette parameters\");\n  const source: unknown = input.bets;\n  if (!Array.isArray(source)) throw new RangeError(\"Invalid Roulette parameters\");\n  const count: unknown = source.length;\n  if (\n    typeof count !== \"number\" ||\n    !Number.isSafeInteger(count) ||\n    count > ROULETTE_TABLE_POSITIONS.length\n  )\n    throw new RangeError(\"Invalid Roulette parameters\");\n  if (count < 1) throw new RangeError(\"Place at least one chip on the Roulette table\");\n  const seen = new Set<string>();\n  const bets: RouletteBet[] = [];\n  let total = 0n;\n  for (let index = 0; index < count; index++) {\n    const bet: unknown = source[index];\n    if (\n      !plain(bet) ||\n      Object.keys(bet).length !== 3 ||\n      !Object.hasOwn(bet, \"amount\") ||\n      !Object.hasOwn(bet, \"numbers\") ||\n      !Object.hasOwn(bet, \"type\")\n    )\n      throw new RangeError(\"Invalid Roulette bet\");\n    const { amount, numbers, type } = bet;\n    const entry = roulettePosition(type, numbers);\n    if (!entry) throw new RangeError(\"Invalid Roulette bet position\");\n    if (seen.has(entry.name)) throw new RangeError(\"Duplicate Roulette bet position\");\n    seen.add(entry.name);\n    if (typeof amount !== \"string\") throw new RangeError(\"Invalid Roulette chip amount\");\n    const minor = rouletteAmountMinor(amount);\n    if (minor <= 0n) throw new RangeError(\"Invalid Roulette chip amount\");\n    total += minor;\n    bets.push({ amount, numbers: [...entry.numbers], type: entry.type });\n  }\n  if (total < ROULETTE_LIMITS.minBetMinor) throw new RangeError(\"Minimum bet is 0.10\");\n  if (total > ROULETTE_LIMITS.maxBetMinor) throw new RangeError(\"Maximum bet is 10,000.00\");\n  return { bets };\n}\n\nexport function rouletteStakeMinor(parameters: unknown): bigint {\n  return validateRouletteParameters(parameters).bets.reduce(\n    (sum, bet) => sum + rouletteAmountMinor(bet.amount),\n    0n,\n  );\n}\n\nexport function rouletteTerms(stakeMinor: bigint, parameters: unknown) {\n  const validated = validateRouletteParameters(parameters);\n  const total = validated.bets.reduce((sum, bet) => sum + rouletteAmountMinor(bet.amount), 0n);\n  if (typeof stakeMinor !== \"bigint\" || stakeMinor !== total)\n    throw new RangeError(\"Roulette stake must equal the total of its chips\");\n  return { parameters: validated, stakeMinor: total };\n}\n\nexport function isRouletteRulesVersion(value: unknown): value is RouletteRulesVersion {\n  return (ROULETTE_RULES_VERSIONS as readonly unknown[]).includes(value);\n}\n\nfunction edition(version: unknown): RouletteRulesVersion {\n  if (!isRouletteRulesVersion(version)) throw new RangeError(\"Invalid Roulette rules\");\n  return version;\n}\n\nfunction paysInFull(version: RouletteRulesVersion): boolean {\n  return version !== ROULETTE_RULES_VERSION;\n}\n\nexport function rouletteLimits(version: RouletteRulesVersion = ROULETTE_RULES_VERSION): Limits {\n  return paysInFull(edition(version)) ? ROULETTE_2_GAME_LIMITS : ROULETTE_GAME_LIMITS;\n}\n\nexport function rouletteCappedPayout(\n  grossMinor: bigint,\n  version: RouletteRulesVersion = ROULETTE_RULES_VERSION,\n) {\n  if (typeof grossMinor !== \"bigint\" || grossMinor < 0n)\n    throw new RangeError(\"Invalid Roulette payout\");\n  if (paysInFull(edition(version))) return { payoutMinor: grossMinor, capped: false };\n  return grossMinor > ROULETTE_LIMITS.maxPayoutMinor\n    ? { payoutMinor: ROULETTE_LIMITS.maxPayoutMinor, capped: true }\n    : { payoutMinor: grossMinor, capped: false };\n}\n\nfunction grossFor(parameters: RouletteParameters, pocket: number): bigint {\n  return parameters.bets.reduce(\n    (sum, bet) =>\n      bet.numbers.includes(pocket)\n        ? sum + rouletteAmountMinor(bet.amount) * BigInt(ROULETTE_NET_PAYOUTS[bet.type] + 1)\n        : sum,\n    0n,\n  );\n}\n\nexport function roulettePayouts(\n  stakeMinor: bigint,\n  parameters: unknown,\n  version: RouletteRulesVersion = ROULETTE_RULES_VERSION,\n): readonly bigint[] {\n  const terms = rouletteTerms(stakeMinor, parameters);\n  return Object.freeze(\n    Array.from(\n      { length: ROULETTE_POCKET_COUNT },\n      (_, pocket) => rouletteCappedPayout(grossFor(terms.parameters, pocket), version).payoutMinor,\n    ),\n  );\n}\n\nexport function rouletteMaximumPayoutMinor(\n  stakeMinor: bigint,\n  parameters: unknown,\n  version: RouletteRulesVersion = ROULETTE_RULES_VERSION,\n): bigint {\n  return roulettePayouts(stakeMinor, parameters, version).reduce(\n    (best, value) => (value > best ? value : best),\n    0n,\n  );\n}\n\nfunction rouletteTopMultiplier(parameters: unknown): MaxWinRatio {\n  const validated = validateRouletteParameters(parameters);\n  let top = 0n;\n  for (let pocket = 0; pocket < ROULETTE_POCKET_COUNT; pocket++) {\n    const gross = grossFor(validated, pocket);\n    if (gross > top) top = gross;\n  }\n  return {\n    numerator: top,\n    denominator: validated.bets.reduce((sum, bet) => sum + rouletteAmountMinor(bet.amount), 0n),\n  };\n}\n\nexport function admitRoulette2Stake(stakeMinor: bigint, parameters: unknown): void {\n  admitMaxWinStake(ROULETTE_2_GAME_LIMITS, stakeMinor, rouletteTopMultiplier(parameters));\n}\n\nexport function rouletteRtp(\n  stakeMinor: bigint,\n  parameters: unknown,\n  version: RouletteRulesVersion = ROULETTE_RULES_VERSION,\n) {\n  const total = roulettePayouts(stakeMinor, parameters, version).reduce(\n    (sum, value) => sum + value,\n    0n,\n  );\n  return reduced(total, stakeMinor * BigInt(ROULETTE_POCKET_COUNT));\n}\n\nexport function rouletteMessage(clientSeed: string, nonce: number, cursor: number): string {\n  return `${clientSeed}:${nonce}:roulette:${cursor}`;\n}\n\nexport function roulettePocket(draw: RouletteDraw): number {\n  if (\n    !draw ||\n    typeof draw !== \"object\" ||\n    !Number.isSafeInteger(draw.wordIndex) ||\n    draw.wordIndex < 0 ||\n    draw.wordIndex >= ROULETTE_WORD_LIMIT ||\n    draw.cursor !== Math.floor(draw.wordIndex / ROULETTE_WORDS_PER_DIGEST) ||\n    !Number.isSafeInteger(draw.uint32) ||\n    draw.uint32 < 0 ||\n    draw.uint32 >= ROULETTE_WORD_RANGE\n  )\n    throw new RangeError(\"Invalid Roulette draw\");\n  return draw.uint32 % ROULETTE_POCKET_COUNT;\n}\n\nexport function rouletteDrawFromWords(words: Iterable<number>): RouletteDraw {\n  let wordIndex = 0;\n  for (const uint32 of words) {\n    if (wordIndex >= ROULETTE_WORD_LIMIT) break;\n    if (!Number.isSafeInteger(uint32) || uint32 < 0 || uint32 >= 0x1_0000_0000)\n      throw new RangeError(\"Invalid Roulette entropy word\");\n    if (uint32 < ROULETTE_WORD_RANGE)\n      return Object.freeze({\n        cursor: Math.floor(wordIndex / ROULETTE_WORDS_PER_DIGEST),\n        wordIndex,\n        uint32,\n      });\n    wordIndex++;\n  }\n  throw new RangeError(\"roulette: entropy stream exhausted (impossible)\");\n}\n\nexport async function drawRoulette(entropy: Entropy): Promise<RouletteDraw> {\n  if (!entropy || typeof entropy !== \"object\")\n    throw new RangeError(\"Invalid Roulette fairness seed\");\n  const { clientSeed, nonce, serverSeed } = entropy;\n  if (\n    typeof entropy.hmac !== \"function\" ||\n    typeof serverSeed !== \"string\" ||\n    !serverSeed.trim() ||\n    typeof clientSeed !== \"string\" ||\n    !clientSeed.trim() ||\n    typeof nonce !== \"number\" ||\n    !Number.isSafeInteger(nonce) ||\n    nonce < 0\n  )\n    throw new RangeError(\"Invalid Roulette fairness seed\");\n  for (let cursor = 0; cursor * ROULETTE_WORDS_PER_DIGEST < ROULETTE_WORD_LIMIT; cursor++) {\n    const digest = await entropy.hmac(\n      ROULETTE_HASH,\n      serverSeed,\n      rouletteMessage(clientSeed, nonce, cursor),\n    );\n    if (!(digest instanceof Uint8Array) || digest.byteLength !== 32)\n      throw new RangeError(\"Invalid Roulette HMAC digest\");\n    const view = new DataView(digest.buffer, digest.byteOffset, digest.byteLength);\n    for (let word = 0; word < ROULETTE_WORDS_PER_DIGEST; word++) {\n      const uint32 = view.getUint32(word * 4);\n      if (uint32 < ROULETTE_WORD_RANGE)\n        return Object.freeze({\n          cursor,\n          wordIndex: cursor * ROULETTE_WORDS_PER_DIGEST + word,\n          uint32,\n        });\n    }\n  }\n  throw new RangeError(\"roulette: entropy stream exhausted (impossible)\");\n}\n\nfunction drawSnapshot(value: unknown): RouletteDraw {\n  if (!value || typeof value !== \"object\") throw new RangeError(\"Invalid Roulette draw\");\n  const { cursor, uint32, wordIndex } = value as Record<string, unknown>;\n  const draw = Object.freeze({\n    cursor: cursor as number,\n    wordIndex: wordIndex as number,\n    uint32: uint32 as number,\n  });\n  roulettePocket(draw);\n  return draw;\n}\n\nexport function rouletteRecordedDraw(result: unknown): RouletteDraw {\n  const draw = plain(result) ? result.draw : undefined;\n  if (\n    !plain(draw) ||\n    Object.keys(draw).length !== 3 ||\n    !Object.hasOwn(draw, \"cursor\") ||\n    !Object.hasOwn(draw, \"wordIndex\") ||\n    !Object.hasOwn(draw, \"uint32\")\n  )\n    throw new RangeError(\"Invalid Roulette draw\");\n  return drawSnapshot(draw);\n}\n\nexport function settleRoulette(input: {\n  stakeMinor: bigint;\n  parameters: unknown;\n  draw: RouletteDraw;\n  version?: RouletteRulesVersion;\n}): Resolution {\n  const version = edition(input.version ?? ROULETTE_RULES_VERSION),\n    terms = rouletteTerms(input.stakeMinor, input.parameters);\n  const draw = drawSnapshot(input.draw);\n  const pocket = roulettePocket(draw);\n  const bets: RouletteSettlement[] = terms.parameters.bets.map((bet) => {\n    const amountMinor = rouletteAmountMinor(bet.amount);\n    const netPayout = ROULETTE_NET_PAYOUTS[bet.type];\n    const won = bet.numbers.includes(pocket);\n    return {\n      type: bet.type,\n      numbers: [...bet.numbers],\n      amount: bet.amount,\n      amountMinor: amountMinor.toString(),\n      netPayout,\n      multiplier: netPayout + 1,\n      won,\n      payoutMinor: (won ? amountMinor * BigInt(netPayout + 1) : 0n).toString(),\n    };\n  });\n  const grossMinor = bets.reduce((sum, bet) => sum + BigInt(bet.payoutMinor), 0n);\n  const { payoutMinor, capped } = rouletteCappedPayout(grossMinor, version);\n  const payouts = roulettePayouts(terms.stakeMinor, terms.parameters, version);\n  const rtp = reduced(\n    payouts.reduce((sum, value) => sum + value, 0n),\n    terms.stakeMinor * BigInt(ROULETTE_POCKET_COUNT),\n  );\n  const multiplier = reduced(payoutMinor, terms.stakeMinor);\n  return {\n    game: \"roulette\",\n    status: payoutMinor > 0n ? \"WON\" : \"LOST\",\n    payoutMinor: payoutMinor.toString(),\n    multiplier: ratioText(multiplier),\n    rulesVersion: version,\n    result: {\n      rouletteRules: version,\n      parameters: terms.parameters,\n      winningNumber: pocket,\n      color: rouletteColor(pocket),\n      winningTypes: rouletteWinningTypes(pocket),\n      bets,\n      totalStakeMinor: terms.stakeMinor.toString(),\n      grossPayoutMinor: grossMinor.toString(),\n      capped,\n      multiplierRatio: ratioWire(multiplier),\n      netPayouts: { ...ROULETTE_NET_PAYOUTS },\n      theoreticalRtp: ratioWire(rtp),\n      maximumPayoutMinor: payouts\n        .reduce((best, value) => (value > best ? value : best), 0n)\n        .toString(),\n      maxPayoutMinor: rouletteLimits(version).maxPayoutMinor.toString(),\n      draw: { cursor: draw.cursor, wordIndex: draw.wordIndex, uint32: draw.uint32 },\n      decisions: [],\n    },\n  };\n}\n\nfunction rouletteRules(version: RouletteRulesVersion): GameRules<RouletteParameters> {\n  const successor = paysInFull(version);\n  return Object.freeze({\n    game: \"roulette\",\n    version,\n    stateful: false,\n    actions: Object.freeze([] as string[]),\n    limits: rouletteLimits(version),\n    ...(successor ? { admitStake: admitRoulette2Stake } : {}),\n    validateParameters: validateRouletteParameters,\n    maximumPayoutMinor: successor\n      ? (stakeMinor: bigint, parameters: RouletteParameters) =>\n          rouletteMaximumPayoutMinor(stakeMinor, parameters, version)\n      : rouletteMaximumPayoutMinor,\n    async resolve(input) {\n      const terms = rouletteTerms(input.stakeMinor, input.parameters);\n      const draw = await drawRoulette(input.entropy);\n      return settleRoulette({\n        stakeMinor: terms.stakeMinor,\n        parameters: terms.parameters,\n        draw,\n        version,\n      });\n    },\n    reconstruct(input) {\n      return settleRoulette({\n        stakeMinor: input.stakeMinor,\n        parameters: input.parameters,\n        draw: rouletteRecordedDraw(input.result),\n        version,\n      });\n    },\n  } satisfies GameRules<RouletteParameters>);\n}\n\nexport const rouletteRulesModule: GameRules<RouletteParameters> =\n  rouletteRules(ROULETTE_RULES_VERSION);\nexport const roulette2RulesModule: GameRules<RouletteParameters> =\n  rouletteRules(ROULETTE_2_RULES_VERSION);\n","sha256":"5db12e25e1655697349caafda51e4e7f8769235bf2d37a37ab7549a137b8be63"},"src/lib/games/roulette-table.ts":{"source":"export const ROULETTE_NET_PAYOUTS = Object.freeze({\n  black: 1,\n  corner: 8,\n  even: 1,\n  \"first-column\": 2,\n  \"first-dozen\": 2,\n  \"first-four\": 8,\n  high: 1,\n  low: 1,\n  odd: 1,\n  red: 1,\n  \"second-column\": 2,\n  \"second-dozen\": 2,\n  \"six-line\": 5,\n  split: 17,\n  \"straight-up\": 35,\n  street: 11,\n  \"third-column\": 2,\n  \"third-dozen\": 2,\n  \"zero-split\": 17,\n  \"zero-trio\": 11,\n} as const);\nexport type RouletteBetType = keyof typeof ROULETTE_NET_PAYOUTS;\nexport const ROULETTE_BET_TYPES = Object.freeze(\n  Object.keys(ROULETTE_NET_PAYOUTS) as RouletteBetType[],\n);\n\nexport const ROULETTE_INSIDE_BET_TYPES = Object.freeze([\n  \"STRAIGHT\",\n  \"SPLIT\",\n  \"STREET\",\n  \"SIXLINE\",\n  \"CORNER\",\n  \"BASKET\",\n  \"TRIO\",\n] as const);\nexport const ROULETTE_OUTSIDE_BET_TYPES = Object.freeze([\n  \"LOW\",\n  \"HIGH\",\n  \"EVEN\",\n  \"ODD\",\n  \"RED\",\n  \"BLACK\",\n  \"DOZEN_1\",\n  \"DOZEN_2\",\n  \"DOZEN_3\",\n  \"COLUMN_1\",\n  \"COLUMN_2\",\n  \"COLUMN_3\",\n] as const);\nexport type RouletteInsideBetType = (typeof ROULETTE_INSIDE_BET_TYPES)[number];\nexport type RouletteOutsideBetType = (typeof ROULETTE_OUTSIDE_BET_TYPES)[number];\nexport type RouletteTableBetType = RouletteInsideBetType | RouletteOutsideBetType;\nexport type RouletteCategory = \"INSIDE\" | \"OUTSIDE\";\nexport type RouletteOutsideType = \"HALF\" | \"DOZEN\" | \"COLUMN\";\nexport type RouletteColor = \"green\" | \"red\" | \"black\";\nexport type RoulettePosition = Readonly<{\n  name: string;\n  betType: RouletteTableBetType;\n  category: RouletteCategory;\n  outsideType: RouletteOutsideType | null;\n  type: RouletteBetType;\n  numbers: readonly number[];\n}>;\n\nexport const ROULETTE_POCKET_COUNT = 37;\nexport const ROULETTE_BLACK = Object.freeze([\n  2, 4, 6, 8, 10, 11, 13, 15, 17, 20, 22, 24, 26, 28, 29, 31, 33, 35,\n]);\nexport const ROULETTE_RED = Object.freeze([\n  1, 3, 5, 7, 9, 12, 14, 16, 18, 19, 21, 23, 25, 27, 30, 32, 34, 36,\n]);\nexport const ROULETTE_WHEEL = Object.freeze([\n  32, 15, 19, 4, 21, 2, 25, 17, 34, 6, 27, 13, 36, 11, 30, 8, 23, 10, 5, 24, 16, 33, 1, 20, 14, 31,\n  9, 22, 18, 29, 7, 28, 12, 35, 3, 26, 0,\n]);\nexport const ROULETTE_REMOVED_HITBOXES = Object.freeze([\n  \"SPLIT_34_37\",\n  \"SIXLINE_34_35_36_37_38_39\",\n  \"CORNER_34_35_37_38\",\n  \"SPLIT_35_38\",\n  \"CORNER_35_36_39_38\",\n  \"SPLIT_36_39\",\n]);\n\nconst pockets = Array.from({ length: ROULETTE_POCKET_COUNT }, (_, index) => index);\nconst column = (first: number) => Array.from({ length: 12 }, (_, index) => first + 3 * index);\nconst outsideNumbers: Record<RouletteOutsideBetType, number[]> = {\n  LOW: pockets.slice(1, Math.ceil(pockets.length / 2)),\n  EVEN: pockets.filter((pocket) => pocket % 2 === 0).slice(1),\n  RED: pockets.filter((pocket) => !ROULETTE_BLACK.includes(pocket)).slice(1),\n  BLACK: [...ROULETTE_BLACK],\n  ODD: pockets.filter((pocket) => pocket % 2 !== 0),\n  HIGH: pockets.slice(Math.ceil(pockets.length / 2), pockets.length),\n  DOZEN_1: pockets.slice(1, 13),\n  DOZEN_2: pockets.slice(13, 25),\n  DOZEN_3: pockets.slice(25, pockets.length),\n  COLUMN_1: column(1),\n  COLUMN_2: column(2),\n  COLUMN_3: column(3),\n};\nconst outsideOrder: readonly [RouletteOutsideBetType, RouletteOutsideType][] = [\n  [\"LOW\", \"HALF\"],\n  [\"EVEN\", \"HALF\"],\n  [\"RED\", \"HALF\"],\n  [\"BLACK\", \"HALF\"],\n  [\"ODD\", \"HALF\"],\n  [\"HIGH\", \"HALF\"],\n  [\"DOZEN_1\", \"DOZEN\"],\n  [\"DOZEN_2\", \"DOZEN\"],\n  [\"DOZEN_3\", \"DOZEN\"],\n  [\"COLUMN_1\", \"COLUMN\"],\n  [\"COLUMN_2\", \"COLUMN\"],\n  [\"COLUMN_3\", \"COLUMN\"],\n];\n\nexport function rouletteApiBetType(\n  betType: RouletteTableBetType,\n  numbers: readonly number[],\n): RouletteBetType {\n  const zero = numbers.includes(0);\n  switch (betType) {\n    case \"STRAIGHT\":\n      return \"straight-up\";\n    case \"SPLIT\":\n      return zero ? \"zero-split\" : \"split\";\n    case \"STREET\":\n      return \"street\";\n    case \"TRIO\":\n      return \"zero-trio\";\n    case \"SIXLINE\":\n      return \"six-line\";\n    case \"CORNER\":\n      return \"corner\";\n    case \"BASKET\":\n      return \"first-four\";\n    case \"LOW\":\n      return \"low\";\n    case \"HIGH\":\n      return \"high\";\n    case \"EVEN\":\n      return \"even\";\n    case \"ODD\":\n      return \"odd\";\n    case \"RED\":\n      return \"red\";\n    case \"BLACK\":\n      return \"black\";\n    case \"DOZEN_1\":\n      return \"first-dozen\";\n    case \"DOZEN_2\":\n      return \"second-dozen\";\n    case \"DOZEN_3\":\n      return \"third-dozen\";\n    case \"COLUMN_1\":\n      return \"first-column\";\n    case \"COLUMN_2\":\n      return \"second-column\";\n    case \"COLUMN_3\":\n      return \"third-column\";\n    default:\n      return \"straight-up\";\n  }\n}\n\nfunction position(\n  betType: RouletteTableBetType,\n  numbers: number[],\n  outsideType: RouletteOutsideType | null = null,\n): RoulettePosition {\n  const inside = (ROULETTE_INSIDE_BET_TYPES as readonly string[]).includes(betType);\n  return Object.freeze({\n    name: inside ? `${betType}_${numbers.join(\"_\")}` : betType,\n    betType,\n    category: inside ? \"INSIDE\" : \"OUTSIDE\",\n    outsideType,\n    type: rouletteApiBetType(betType, numbers),\n    numbers: Object.freeze([...numbers]),\n  });\n}\n\nfunction hitboxes(): RoulettePosition[] {\n  const result = [\n    position(\"SPLIT\", [0, 1]),\n    position(\"SPLIT\", [0, 2]),\n    position(\"SPLIT\", [0, 3]),\n    position(\"TRIO\", [0, 1, 2]),\n    position(\"TRIO\", [0, 2, 3]),\n    position(\"BASKET\", [0, 1, 2, 3]),\n  ];\n  for (const first of outsideNumbers.COLUMN_1)\n    result.push(\n      position(\"SPLIT\", [first, first + 3]),\n      position(\"SPLIT\", [first, first + 1]),\n      position(\"STREET\", [first, first + 1, first + 2]),\n      position(\"SIXLINE\", [first, first + 1, first + 2, first + 3, first + 4, first + 5]),\n      position(\"CORNER\", [first, first + 1, first + 3, first + 4]),\n    );\n  for (const middle of outsideNumbers.COLUMN_2)\n    result.push(position(\"SPLIT\", [middle, middle + 3]));\n  for (const top of outsideNumbers.COLUMN_3)\n    result.push(\n      position(\"SPLIT\", [top, top + 3]),\n      position(\"SPLIT\", [top, top - 1]),\n      position(\"CORNER\", [top - 1, top, top + 3, top + 2]),\n    );\n  return result.filter((entry) => !ROULETTE_REMOVED_HITBOXES.includes(entry.name));\n}\n\nexport const ROULETTE_TABLE_POSITIONS: readonly RoulettePosition[] = Object.freeze([\n  ...pockets.map((pocket) => position(\"STRAIGHT\", [pocket])),\n  ...outsideOrder.map(([betType, outsideType]) =>\n    position(betType, outsideNumbers[betType], outsideType),\n  ),\n  ...hitboxes(),\n]);\n\nconst byKey = new Map(\n  ROULETTE_TABLE_POSITIONS.map((entry) => [`${entry.type}|${entry.numbers.join(\",\")}`, entry]),\n);\nconst byName = new Map(ROULETTE_TABLE_POSITIONS.map((entry) => [entry.name, entry]));\n\nexport function roulettePosition(type: unknown, numbers: unknown): RoulettePosition | undefined {\n  if (\n    typeof type !== \"string\" ||\n    !Object.hasOwn(ROULETTE_NET_PAYOUTS, type) ||\n    !Array.isArray(numbers)\n  )\n    return undefined;\n  const length: unknown = numbers.length;\n  if (typeof length !== \"number\" || !Number.isSafeInteger(length) || length < 1 || length > 18)\n    return undefined;\n  const values: number[] = [];\n  for (let index = 0; index < length; index++) {\n    const value: unknown = numbers[index];\n    if (typeof value !== \"number\" || !Number.isSafeInteger(value) || value < 0) return undefined;\n    values.push(value);\n  }\n  return byKey.get(`${type}|${values.join(\",\")}`);\n}\n\nexport function roulettePositionByName(name: unknown): RoulettePosition | undefined {\n  return typeof name === \"string\" ? byName.get(name) : undefined;\n}\n\nexport function rouletteColor(pocket: number): RouletteColor {\n  if (!Number.isSafeInteger(pocket) || pocket < 0 || pocket >= ROULETTE_POCKET_COUNT)\n    throw new RangeError(\"Invalid Roulette pocket\");\n  return pocket === 0 ? \"green\" : ROULETTE_RED.includes(pocket) ? \"red\" : \"black\";\n}\n\nexport function rouletteWinningTypes(pocket: number): RouletteBetType[] {\n  rouletteColor(pocket);\n  if (pocket === 0) return [];\n  const remainder = pocket % 3;\n  return [\n    ROULETTE_BLACK.includes(pocket) ? \"black\" : \"red\",\n    pocket % 2 === 0 ? \"even\" : \"odd\",\n    pocket <= 18 ? \"low\" : \"high\",\n    pocket <= 12 ? \"first-dozen\" : pocket <= 24 ? \"second-dozen\" : \"third-dozen\",\n    remainder === 1 ? \"first-column\" : remainder === 2 ? \"second-column\" : \"third-column\",\n  ];\n}\n","sha256":"85932350a88f143c954f66d3f1bcc9771fdff824747ac479f2e9940c4df9f9d2"},"src/lib/games/mines-rules.ts":{"source":"import { MAX_PAYOUT_MINOR, ratio, ratioText, ratioWire, type Resolution } from \"@/games/math\";\nimport type { ActInput, Decision, Entropy, GameRules, Limits, ResolveInput } from \"./rules\";\nimport { gameLimits, maxWinLimits } from \"./limits\";\nimport {\n  MAX_WIN_END,\n  MAX_WIN_MINOR,\n  MAX_WIN_MULTIPLIER_HUNDREDTHS,\n  admitMaxWinStake,\n  hundredthsRatio,\n  unavailableSelectionMessage,\n} from \"./max-win\";\n\nexport const MINES_RULES_VERSION = \"peeks-mines-1\" as const;\nexport const MINES_2_RULES_VERSION = \"peeks-mines-2\" as const;\nexport const MINES_RULES_VERSIONS = Object.freeze([\n  MINES_RULES_VERSION,\n  MINES_2_RULES_VERSION,\n] as const);\nexport type MinesRulesVersion = (typeof MINES_RULES_VERSIONS)[number];\nexport const MINES_GRID_SIZES = Object.freeze([25, 36, 49, 64] as const);\nexport const MINES_ACTIONS = Object.freeze([\"reveal\", \"cashout\"] as const);\nexport const MINES_CLOSING_ACTIONS = Object.freeze([\"cashout\"] as const);\nexport const MINES_HOUSE_EDGE_PERCENT = 4;\nexport const MINES_RTP_PERCENT = 96;\nexport const MINES_MAX_MULTIPLIER = \"1000000.00\" as const;\nexport const MINES_2_MAX_MULTIPLIER = \"10000000.00\" as const;\nexport const MINES_MAX_STAKE_MINOR = 1_000_000n;\nexport const MINES_GAME_LIMITS = gameLimits(\"mines\");\nexport const MINES_2_GAME_LIMITS = maxWinLimits(\"mines\");\nexport const MINES_HMAC = \"SHA-512\" as const;\nexport const MINES_MAX_ENTROPY_BLOCKS = 4096;\n\nexport type MinesGridSize = (typeof MINES_GRID_SIZES)[number];\nexport type MinesManualParameters = { mines: number; tiles: MinesGridSize };\nexport type MinesAutoParameters = {\n  mines: number;\n  selectedTiles: number[];\n  tiles: MinesGridSize;\n};\nexport type MinesParameters = MinesManualParameters | MinesAutoParameters;\nexport type MinesCell = \"M\" | 0;\nexport type MinesEndReason =\n  | \"mine\"\n  | \"cashout\"\n  | \"cleared\"\n  | \"maxMultiplier\"\n  | \"maxPayout\"\n  | typeof MAX_WIN_END;\nexport type MinesWireRatio = ReturnType<typeof ratioWire>;\nexport type MinesReveal = { index: number; cell: MinesCell };\nexport type MinesResult = {\n  minesRules: MinesRulesVersion;\n  parameters: MinesParameters;\n  mode: \"manual\" | \"auto\";\n  gems: number;\n  step: number;\n  revealed: number[];\n  revealedCellsCount: number;\n  lastReveal: MinesReveal | null;\n  decisions: Decision[];\n  multiplierTable: string[];\n  multiplier: string;\n  nextMultiplier: string | null;\n  currentPayoutMinor: string;\n  nextPayoutMinor: string | null;\n  maxMultiplier: typeof MINES_MAX_MULTIPLIER | typeof MINES_2_MAX_MULTIPLIER;\n  maxPayoutMinor: string;\n  win: boolean;\n  endReason: MinesEndReason | null;\n  capped: boolean;\n  uncappedPayoutMinor?: string;\n  multiplierCapped: boolean;\n  board: MinesCell[] | null;\n  minePositions: number[] | null;\n  mineHits: number[];\n  rtpReveals: number;\n  nominalRtp: MinesWireRatio;\n  theoreticalRtp: MinesWireRatio;\n};\nexport type MinesSecret = {\n  minesRules: MinesRulesVersion;\n  parameters: MinesManualParameters;\n  minePositions: number[];\n  revealed: number[];\n  decisions: Decision[];\n};\nexport type MinesDraw = {\n  board: MinesCell[];\n  minePositions: number[];\n  mineOrder: number[];\n  blocks: number;\n};\nexport type MinesQuote = {\n  multiplier: string;\n  grossMinor: bigint;\n  payoutMinor: bigint;\n  capped: boolean;\n  multiplierCapped: boolean;\n  endsRound: boolean;\n};\nexport type MinesEntropy = Pick<Entropy, \"serverSeed\" | \"clientSeed\" | \"nonce\" | \"hmac\">;\n\ntype Levels = { hundredths: bigint[]; raw: bigint[] };\ntype Progress = {\n  revealed: number[];\n  decisions: Decision[];\n  lastReveal: MinesReveal | null;\n  endReason: MinesEndReason | null;\n};\ntype Settlement = Progress & {\n  stakeMinor: bigint;\n  parameters: MinesParameters;\n  levels: Levels;\n  minePositions: number[];\n  version: MinesRulesVersion;\n};\n\nconst DECIMAL_PLACES = 10n ** 20n;\nconst ROUNDING_STEP = 10n ** 18n;\nconst ROUNDING_HALF = 5n * 10n ** 17n;\nconst MAX_HUNDREDTHS = 100_000_000n;\nconst RETURN_PERCENT = BigInt(100 - MINES_HOUSE_EDGE_PERCENT);\nconst WORD_RANGE = 0x1_0000_0000;\nconst SECRET_KEYS = [\"minesRules\", \"parameters\", \"minePositions\", \"revealed\", \"decisions\"];\n\nconst invalid = (message = \"Invalid Mines parameters\") => new RangeError(message);\n\nexport function isMinesRulesVersion(value: unknown): value is MinesRulesVersion {\n  return (MINES_RULES_VERSIONS as readonly unknown[]).includes(value);\n}\n\nfunction edition(version: unknown): MinesRulesVersion {\n  if (!isMinesRulesVersion(version)) throw invalid(\"Invalid Mines rules\");\n  return version;\n}\n\nfunction paysInFull(version: MinesRulesVersion): boolean {\n  return version !== MINES_RULES_VERSION;\n}\n\nexport function minesLimits(version: MinesRulesVersion = MINES_RULES_VERSION): Limits {\n  return paysInFull(edition(version)) ? MINES_2_GAME_LIMITS : MINES_GAME_LIMITS;\n}\n\nexport function minesMaxMultiplierText(version: MinesRulesVersion = MINES_RULES_VERSION) {\n  return paysInFull(edition(version)) ? MINES_2_MAX_MULTIPLIER : MINES_MAX_MULTIPLIER;\n}\n\nfunction record(value: unknown): value is Record<string, unknown> {\n  return !!value && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction whole(value: unknown, min: number, max: number, message: string): number {\n  if (typeof value !== \"number\" || !Number.isSafeInteger(value) || value < min || value > max)\n    throw invalid(message);\n  return value === 0 ? 0 : value;\n}\n\nfunction gridSize(value: unknown): MinesGridSize {\n  if (!MINES_GRID_SIZES.includes(value as MinesGridSize)) throw invalid();\n  return value as MinesGridSize;\n}\n\nexport function validateMinesParameters(input: unknown): MinesParameters {\n  if (!record(input)) throw invalid();\n  const auto = Object.hasOwn(input, \"selectedTiles\");\n  if (\n    Object.keys(input).length !== (auto ? 3 : 2) ||\n    !Object.hasOwn(input, \"mines\") ||\n    !Object.hasOwn(input, \"tiles\")\n  )\n    throw invalid();\n  const tiles = gridSize(input.tiles);\n  const mines = whole(input.mines, 1, tiles - 1, \"Invalid Mines parameters\");\n  if (!auto) return { mines, tiles };\n  const selected = input.selectedTiles;\n  if (!Array.isArray(selected)) throw invalid();\n  if (selected.length === 0) throw invalid(\"Please select at least one tile\");\n  if (selected.length > tiles - mines) throw invalid();\n  const selectedTiles = Array.from(selected, (tile) =>\n    whole(tile, 0, tiles - 1, \"Invalid Mines parameters\"),\n  );\n  if (new Set(selectedTiles).size !== selectedTiles.length) throw invalid();\n  return { mines, selectedTiles, tiles };\n}\n\nfunction copyParameters(parameters: MinesParameters): MinesParameters {\n  return \"selectedTiles\" in parameters\n    ? {\n        mines: parameters.mines,\n        selectedTiles: [...parameters.selectedTiles],\n        tiles: parameters.tiles,\n      }\n    : { mines: parameters.mines, tiles: parameters.tiles };\n}\n\nfunction levelsOf(\n  tiles: number,\n  mines: number,\n  version: MinesRulesVersion = MINES_RULES_VERSION,\n): Levels {\n  const gems = tiles - mines,\n    full = paysInFull(version),\n    hundredths: bigint[] = [],\n    raw: bigint[] = [];\n  let product = 1n,\n    scale = 1n;\n  for (let reveals = 0; reveals <= gems; reveals++) {\n    if (reveals > 0) {\n      const safe = BigInt(gems - reveals + 1),\n        remaining = BigInt(tiles - reveals + 1);\n      product *= (2n * safe * DECIMAL_PLACES + remaining) / (2n * remaining);\n      scale *= DECIMAL_PLACES;\n    }\n    const quotient =\n      (2n * RETURN_PERCENT * scale * DECIMAL_PLACES + 100n * product) / (200n * product);\n    const value = (quotient + ROUNDING_HALF) / ROUNDING_STEP;\n    raw.push(value);\n    if (!full) hundredths.push(value > MAX_HUNDREDTHS ? MAX_HUNDREDTHS : value);\n    else if (hundredths.length === reveals && value <= MAX_WIN_MULTIPLIER_HUNDREDTHS)\n      hundredths.push(value);\n  }\n  return { hundredths, raw };\n}\n\nfunction decimal(hundredths: bigint): string {\n  return `${hundredths / 100n}.${(hundredths % 100n).toString().padStart(2, \"0\")}`;\n}\n\nfunction gross(\n  stakeMinor: bigint,\n  hundredths: bigint,\n  version: MinesRulesVersion = MINES_RULES_VERSION,\n) {\n  const amount = (stakeMinor * hundredths) / 100n;\n  if (paysInFull(version)) return { grossMinor: amount, payoutMinor: amount, capped: false };\n  return {\n    grossMinor: amount,\n    payoutMinor: amount > MAX_PAYOUT_MINOR ? MAX_PAYOUT_MINOR : amount,\n    capped: amount > MAX_PAYOUT_MINOR,\n  };\n}\n\nfunction reduced(numerator: bigint, denominator: bigint) {\n  let a = numerator,\n    b = denominator;\n  while (b) [a, b] = [b, a % b];\n  return ratio(numerator / a, denominator / a);\n}\n\nfunction survival(tiles: number, mines: number, reveals: number) {\n  let numerator = 1n,\n    denominator = 1n;\n  for (let step = 0; step < reveals; step++) {\n    numerator *= BigInt(tiles - mines - step);\n    denominator *= BigInt(tiles - step);\n  }\n  return { numerator, denominator };\n}\n\nfunction revealsOf(levels: Levels, reveals: unknown): number {\n  return whole(reveals, 0, levels.hundredths.length - 1, \"Invalid Mines reveal count\");\n}\n\nfunction assertStake(stakeMinor: unknown): bigint {\n  if (typeof stakeMinor !== \"bigint\" || stakeMinor < 1n || stakeMinor > MINES_MAX_STAKE_MINOR)\n    throw invalid(\"Invalid Mines stake\");\n  return stakeMinor;\n}\n\nfunction ending(\n  stakeMinor: bigint,\n  levels: Levels,\n  found: number,\n  gems: number,\n  closing: boolean,\n  version: MinesRulesVersion = MINES_RULES_VERSION,\n): MinesEndReason | null {\n  if (found === 0) return null;\n  if (paysInFull(version)) {\n    if (found === gems) return \"cleared\";\n    const next = levels.hundredths[found + 1];\n    if (next === undefined || (stakeMinor * next) / 100n > MAX_WIN_MINOR) return MAX_WIN_END;\n    return closing ? \"cashout\" : null;\n  }\n  if (gross(stakeMinor, levels.hundredths[found]).grossMinor >= MAX_PAYOUT_MINOR)\n    return \"maxPayout\";\n  if (levels.hundredths[found] >= MAX_HUNDREDTHS) return \"maxMultiplier\";\n  if (found === gems) return \"cleared\";\n  return closing ? \"cashout\" : null;\n}\n\nexport function minesMultiplierTable(\n  parameters: unknown,\n  version: MinesRulesVersion = MINES_RULES_VERSION,\n): readonly string[] {\n  const validated = validateMinesParameters(parameters);\n  return Object.freeze(\n    levelsOf(validated.tiles, validated.mines, edition(version)).hundredths.map(decimal),\n  );\n}\n\nexport function minesMultiplier(\n  parameters: unknown,\n  reveals: number,\n  version: MinesRulesVersion = MINES_RULES_VERSION,\n): string {\n  const validated = validateMinesParameters(parameters),\n    levels = levelsOf(validated.tiles, validated.mines, edition(version));\n  return decimal(levels.hundredths[revealsOf(levels, reveals)]);\n}\n\nexport function minesQuote(\n  stakeMinor: bigint,\n  parameters: unknown,\n  reveals: number,\n  version: MinesRulesVersion = MINES_RULES_VERSION,\n): MinesQuote {\n  const validated = validateMinesParameters(parameters),\n    levels = levelsOf(validated.tiles, validated.mines, edition(version)),\n    count = revealsOf(levels, reveals),\n    amount = gross(assertStake(stakeMinor), levels.hundredths[count], version);\n  return {\n    multiplier: decimal(levels.hundredths[count]),\n    ...amount,\n    multiplierCapped: !paysInFull(version) && levels.raw[count] > MAX_HUNDREDTHS,\n    endsRound:\n      count > 0 &&\n      ending(stakeMinor, levels, count, validated.tiles - validated.mines, false, version) !== null,\n  };\n}\n\nexport function minesRtp(\n  parameters: unknown,\n  reveals: number,\n  version: MinesRulesVersion = MINES_RULES_VERSION,\n) {\n  const validated = validateMinesParameters(parameters),\n    levels = levelsOf(validated.tiles, validated.mines, edition(version)),\n    count = revealsOf(levels, reveals),\n    odds = survival(validated.tiles, validated.mines, count);\n  return reduced(odds.numerator * levels.hundredths[count], odds.denominator * 100n);\n}\n\nexport function minesStakeRtp(\n  stakeMinor: bigint,\n  parameters: unknown,\n  reveals: number,\n  version: MinesRulesVersion = MINES_RULES_VERSION,\n) {\n  const validated = validateMinesParameters(parameters),\n    levels = levelsOf(validated.tiles, validated.mines, edition(version)),\n    count = revealsOf(levels, reveals),\n    stake = assertStake(stakeMinor),\n    odds = survival(validated.tiles, validated.mines, count),\n    amount = gross(stake, levels.hundredths[count], version);\n  return reduced(odds.numerator * amount.payoutMinor, odds.denominator * stake);\n}\n\nexport function minesMaximumPayoutMinor(\n  stakeMinor: bigint,\n  parameters: unknown,\n  version: MinesRulesVersion = MINES_RULES_VERSION,\n): bigint {\n  const validated = validateMinesParameters(parameters),\n    stake = assertStake(stakeMinor),\n    levels = levelsOf(validated.tiles, validated.mines, edition(version));\n  if (\"selectedTiles\" in validated) {\n    const count = validated.selectedTiles.length;\n    if (count >= levels.hundredths.length) throw invalid(unavailableSelectionMessage());\n    return gross(stake, levels.hundredths[count], version).payoutMinor;\n  }\n  if (paysInFull(version)) {\n    let best = 0n;\n    for (const hundredths of levels.hundredths.slice(1)) {\n      const amount = gross(stake, hundredths, version).payoutMinor;\n      if (amount > MAX_WIN_MINOR) break;\n      best = amount;\n    }\n    return best;\n  }\n  return levels.hundredths.slice(1).reduce((best, hundredths) => {\n    const amount = gross(stake, hundredths).payoutMinor;\n    return amount > best ? amount : best;\n  }, 0n);\n}\n\nexport function minesTopMultiplierHundredths(\n  parameters: unknown,\n  version: MinesRulesVersion = MINES_RULES_VERSION,\n): bigint {\n  const validated = validateMinesParameters(parameters),\n    levels = levelsOf(validated.tiles, validated.mines, edition(version)),\n    count = \"selectedTiles\" in validated ? validated.selectedTiles.length : 1;\n  return levels.raw[count];\n}\n\nexport function admitMinesStake(\n  stakeMinor: bigint,\n  parameters: unknown,\n  version: MinesRulesVersion = MINES_2_RULES_VERSION,\n): void {\n  admitMaxWinStake(\n    minesLimits(version),\n    stakeMinor,\n    hundredthsRatio(minesTopMultiplierHundredths(parameters, version)),\n  );\n}\n\nfunction assertEntropy(entropy: MinesEntropy) {\n  if (\n    !record(entropy) ||\n    typeof entropy.serverSeed !== \"string\" ||\n    !entropy.serverSeed.trim() ||\n    typeof entropy.clientSeed !== \"string\" ||\n    !entropy.clientSeed.trim() ||\n    typeof entropy.nonce !== \"number\" ||\n    !Number.isSafeInteger(entropy.nonce) ||\n    entropy.nonce < 0 ||\n    typeof entropy.hmac !== \"function\"\n  )\n    throw invalid(\"Invalid Mines fairness seed\");\n}\n\nexport async function minesDraw(\n  entropy: MinesEntropy,\n  parameters: { tiles: number; mines: number },\n): Promise<MinesDraw> {\n  assertEntropy(entropy);\n  if (!record(parameters)) throw invalid();\n  const tiles = gridSize(parameters.tiles),\n    mines = whole(parameters.mines, 0, tiles - 1, \"Invalid Mines parameters\"),\n    prefix = `${entropy.clientSeed}:${entropy.nonce}:mines`,\n    items = Array.from({ length: tiles }, (_, index) => index),\n    words: number[] = [];\n  let blocks = 0,\n    position = 0;\n  const next = async () => {\n    if (position === words.length) {\n      if (blocks >= MINES_MAX_ENTROPY_BLOCKS) throw invalid(\"Mines entropy stream exhausted\");\n      const digest = await entropy.hmac(MINES_HMAC, entropy.serverSeed, `${prefix}:${blocks++}`);\n      if (!(digest instanceof Uint8Array) || digest.byteLength !== 64)\n        throw invalid(\"Invalid Mines HMAC digest\");\n      for (let offset = 0; offset < 64; offset += 4)\n        words.push(\n          ((digest[offset] << 24) |\n            (digest[offset + 1] << 16) |\n            (digest[offset + 2] << 8) |\n            digest[offset + 3]) >>>\n            0,\n        );\n    }\n    return words[position++];\n  };\n  for (let index = tiles - 1; index >= tiles - mines; index--) {\n    const range = index + 1,\n      limit = WORD_RANGE - (WORD_RANGE % range);\n    let value = await next();\n    while (value >= limit) value = await next();\n    const pick = value % range;\n    [items[index], items[pick]] = [items[pick], items[index]];\n  }\n  const mineOrder = items.slice(tiles - mines),\n    minePositions = [...mineOrder].sort((a, b) => a - b),\n    board = Array.from(\n      { length: tiles },\n      (_, index): MinesCell => (mineOrder.includes(index) ? \"M\" : 0),\n    );\n  return { board, minePositions, mineOrder, blocks };\n}\n\nfunction settle(input: Settlement): Resolution {\n  const { stakeMinor: stake, parameters, levels, version } = input,\n    gems = parameters.tiles - parameters.mines,\n    mines = new Set(input.minePositions),\n    mineHits = input.revealed.filter((tile) => mines.has(tile)),\n    found = input.revealed.length - mineHits.length,\n    active = input.endReason === null,\n    won = !active && input.endReason !== \"mine\",\n    settled = won ? gross(stake, levels.hundredths[found], version) : null,\n    current = active && found > 0 ? gross(stake, levels.hundredths[found], version) : settled,\n    rtpReveals = active ? input.revealed.length + 1 : input.revealed.length,\n    odds = survival(parameters.tiles, parameters.mines, rtpReveals),\n    payoutMinor = settled ? settled.payoutMinor : 0n,\n    result: MinesResult = {\n      minesRules: version,\n      parameters: copyParameters(parameters),\n      mode: \"selectedTiles\" in parameters ? \"auto\" : \"manual\",\n      gems,\n      step: input.revealed.length,\n      revealed: [...input.revealed],\n      revealedCellsCount: found,\n      lastReveal: input.lastReveal ? { ...input.lastReveal } : null,\n      decisions: input.decisions.map((decision) => ({ ...decision })),\n      multiplierTable: levels.hundredths.map(decimal),\n      multiplier: current ? decimal(levels.hundredths[found]) : \"0.00\",\n      nextMultiplier: active ? decimal(levels.hundredths[found + 1]) : null,\n      currentPayoutMinor: (current ? current.payoutMinor : 0n).toString(),\n      nextPayoutMinor: active\n        ? gross(stake, levels.hundredths[found + 1], version).payoutMinor.toString()\n        : null,\n      maxMultiplier: minesMaxMultiplierText(version),\n      maxPayoutMinor: minesLimits(version).maxPayoutMinor.toString(),\n      win: won,\n      endReason: input.endReason,\n      capped: settled ? settled.capped : false,\n      ...(settled?.capped ? { uncappedPayoutMinor: settled.grossMinor.toString() } : {}),\n      multiplierCapped: won && !paysInFull(version) && levels.raw[found] > MAX_HUNDREDTHS,\n      board: active\n        ? null\n        : Array.from(\n            { length: parameters.tiles },\n            (_, index): MinesCell => (mines.has(index) ? \"M\" : 0),\n          ),\n      minePositions: active ? null : [...input.minePositions],\n      mineHits,\n      rtpReveals,\n      nominalRtp: ratioWire(\n        reduced(odds.numerator * levels.hundredths[rtpReveals], odds.denominator * 100n),\n      ),\n      theoreticalRtp: ratioWire(\n        reduced(\n          odds.numerator * gross(stake, levels.hundredths[rtpReveals], version).payoutMinor,\n          odds.denominator * stake,\n        ),\n      ),\n    };\n  const secret: MinesSecret | null =\n    active && !(\"selectedTiles\" in parameters)\n      ? {\n          minesRules: version,\n          parameters: { mines: parameters.mines, tiles: parameters.tiles },\n          minePositions: [...input.minePositions],\n          revealed: [...input.revealed],\n          decisions: input.decisions.map((decision) => ({ ...decision })),\n        }\n      : null;\n  return {\n    game: \"mines\",\n    status: active ? \"ACTIVE\" : payoutMinor > 0n ? \"WON\" : \"LOST\",\n    payoutMinor: payoutMinor.toString(),\n    multiplier: ratioText(ratio(payoutMinor, stake)),\n    result,\n    ...(secret ? { secret } : {}),\n    rulesVersion: version,\n  };\n}\n\nfunction decisionOf(input: unknown, tiles: number, step: number): Decision {\n  const message = \"Choose a Mines tile or cash out\";\n  if (!record(input)) throw invalid(message);\n  const allowed =\n    input.action === \"reveal\"\n      ? [\"action\", \"selection\", \"expectedStep\"]\n      : input.action === \"cashout\"\n        ? [\"action\", \"expectedStep\"]\n        : null;\n  if (\n    !allowed ||\n    Object.entries(input).some(([key, value]) => value !== undefined && !allowed.includes(key))\n  )\n    throw invalid(message);\n  const expectedStep =\n    input.expectedStep === undefined\n      ? undefined\n      : whole(input.expectedStep, 0, tiles, \"Mines round has changed\");\n  if (expectedStep !== undefined && expectedStep !== step) throw invalid(\"Mines round has changed\");\n  const expected = expectedStep === undefined ? {} : { expectedStep };\n  if (input.action === \"cashout\") return { action: \"cashout\", ...expected };\n  return {\n    action: \"reveal\",\n    selection: whole(input.selection, 0, tiles - 1, \"Choose an unrevealed tile\"),\n    ...expected,\n  };\n}\n\nfunction tileList(value: unknown, tiles: number, length?: number): number[] {\n  if (!Array.isArray(value) || (length !== undefined && value.length !== length))\n    throw invalid(\"Invalid Mines state\");\n  const list = Array.from(value, (tile) => whole(tile, 0, tiles - 1, \"Invalid Mines state\"));\n  if (new Set(list).size !== list.length) throw invalid(\"Invalid Mines state\");\n  return list;\n}\n\nfunction stateOf(\n  secret: unknown,\n  parameters: MinesManualParameters,\n  stakeMinor: bigint,\n  levels: Levels,\n  version: MinesRulesVersion,\n) {\n  if (\n    !record(secret) ||\n    secret.minesRules !== version ||\n    Object.keys(secret).length !== SECRET_KEYS.length ||\n    SECRET_KEYS.some((key) => !Object.hasOwn(secret, key))\n  )\n    throw invalid(\"Invalid Mines state\");\n  const stored = validateMinesParameters(secret.parameters);\n  if (\n    \"selectedTiles\" in stored ||\n    stored.tiles !== parameters.tiles ||\n    stored.mines !== parameters.mines\n  )\n    throw invalid(\"Invalid Mines state\");\n  const gems = parameters.tiles - parameters.mines,\n    minePositions = minePositionsOf(secret.minePositions, parameters),\n    revealed = tileList(secret.revealed, parameters.tiles);\n  if (\n    revealed.length >= gems ||\n    revealed.some((tile) => minePositions.includes(tile)) ||\n    ending(stakeMinor, levels, revealed.length, gems, false, version) !== null ||\n    !Array.isArray(secret.decisions) ||\n    secret.decisions.length !== revealed.length\n  )\n    throw invalid(\"Invalid Mines state\");\n  const decisions = Array.from(secret.decisions, (item: unknown, step) => {\n    const decision = decisionOf(item, parameters.tiles, step);\n    if (decision.action !== \"reveal\" || decision.selection !== revealed[step])\n      throw invalid(\"Invalid Mines state\");\n    return decision;\n  });\n  return { minePositions, revealed, decisions };\n}\n\nfunction minePositionsOf(value: unknown, parameters: { tiles: number; mines: number }): number[] {\n  const positions = tileList(value, parameters.tiles, parameters.mines);\n  if (positions.some((tile, index) => index > 0 && positions[index - 1] >= tile))\n    throw invalid(\"Invalid Mines state\");\n  return positions;\n}\n\nfunction autoplay(\n  stakeMinor: bigint,\n  parameters: MinesAutoParameters,\n  levels: Levels,\n  minePositions: number[],\n  version: MinesRulesVersion,\n): Resolution {\n  if (parameters.selectedTiles.length >= levels.hundredths.length)\n    throw invalid(unavailableSelectionMessage());\n  const hit = parameters.selectedTiles.some((tile) => minePositions.includes(tile));\n  return settle({\n    stakeMinor,\n    parameters,\n    levels,\n    minePositions,\n    version,\n    revealed: [...parameters.selectedTiles],\n    decisions: [],\n    lastReveal: null,\n    endReason: hit\n      ? \"mine\"\n      : paysInFull(version)\n        ? parameters.selectedTiles.length === parameters.tiles - parameters.mines\n          ? \"cleared\"\n          : \"cashout\"\n        : ending(\n            stakeMinor,\n            levels,\n            parameters.selectedTiles.length,\n            parameters.tiles - parameters.mines,\n            true,\n          ),\n  });\n}\n\nfunction advance(\n  stakeMinor: bigint,\n  parameters: MinesManualParameters,\n  levels: Levels,\n  minePositions: number[],\n  progress: Progress,\n  input: unknown,\n  version: MinesRulesVersion,\n): Progress {\n  const step = progress.revealed.length,\n    decision = decisionOf(input, parameters.tiles, step),\n    decisions = [...progress.decisions, decision];\n  if (decision.action === \"cashout\") {\n    if (step === 0) throw invalid(\"You must select at least one tile to cash out\");\n    return { revealed: progress.revealed, decisions, lastReveal: null, endReason: \"cashout\" };\n  }\n  const index = decision.selection as number;\n  if (progress.revealed.includes(index)) throw invalid(\"Choose an unrevealed tile\");\n  const mine = minePositions.includes(index),\n    revealed = [...progress.revealed, index];\n  return {\n    revealed,\n    decisions,\n    lastReveal: { index, cell: mine ? \"M\" : 0 },\n    endReason: mine\n      ? \"mine\"\n      : ending(\n          stakeMinor,\n          levels,\n          revealed.length,\n          parameters.tiles - parameters.mines,\n          false,\n          version,\n        ),\n  };\n}\n\nexport async function resolveMines(\n  input: ResolveInput<MinesParameters>,\n  version: MinesRulesVersion = MINES_RULES_VERSION,\n): Promise<Resolution> {\n  const parameters = validateMinesParameters(input.parameters),\n    stakeMinor = assertStake(input.stakeMinor),\n    levels = levelsOf(parameters.tiles, parameters.mines, edition(version)),\n    draw = await minesDraw(input.entropy, parameters);\n  if (\"selectedTiles\" in parameters)\n    return autoplay(stakeMinor, parameters, levels, draw.minePositions, version);\n  return settle({\n    stakeMinor,\n    parameters,\n    levels,\n    minePositions: draw.minePositions,\n    version,\n    revealed: [],\n    decisions: [],\n    lastReveal: null,\n    endReason: null,\n  });\n}\n\nexport async function actMines(\n  input: ActInput<MinesParameters>,\n  version: MinesRulesVersion = MINES_RULES_VERSION,\n): Promise<Resolution> {\n  const parameters = validateMinesParameters(input.parameters);\n  if (\"selectedTiles\" in parameters)\n    throw invalid(\"The game you were trying to play already ended\");\n  const stakeMinor = assertStake(input.stakeMinor),\n    levels = levelsOf(parameters.tiles, parameters.mines, edition(version)),\n    state = stateOf(input.secret, parameters, stakeMinor, levels, version),\n    draw = await minesDraw(input.entropy, parameters);\n  if (draw.minePositions.some((tile, index) => tile !== state.minePositions[index]))\n    throw invalid(\"Invalid Mines state\");\n  return settle({\n    stakeMinor,\n    parameters,\n    levels,\n    minePositions: state.minePositions,\n    version,\n    ...advance(\n      stakeMinor,\n      parameters,\n      levels,\n      state.minePositions,\n      { revealed: state.revealed, decisions: state.decisions, lastReveal: null, endReason: null },\n      input.decision,\n      version,\n    ),\n  });\n}\n\nexport function reconstructMines(\n  input: {\n    stakeMinor: bigint;\n    parameters: unknown;\n    result: unknown;\n  },\n  version: MinesRulesVersion = MINES_RULES_VERSION,\n): Resolution {\n  const parameters = validateMinesParameters(input.parameters),\n    stakeMinor = assertStake(input.stakeMinor),\n    levels = levelsOf(parameters.tiles, parameters.mines, edition(version)),\n    result = input.result;\n  if (!record(result) || !Array.isArray(result.decisions)) throw invalid(\"Invalid Mines result\");\n  const minePositions = minePositionsOf(result.minePositions, parameters);\n  if (\"selectedTiles\" in parameters) {\n    if (result.decisions.length) throw invalid(\"Invalid Mines result\");\n    return autoplay(stakeMinor, parameters, levels, minePositions, version);\n  }\n  let progress: Progress = { revealed: [], decisions: [], lastReveal: null, endReason: null };\n  for (const decision of Array.from(result.decisions as unknown[])) {\n    if (progress.endReason !== null) throw invalid(\"Invalid Mines result\");\n    progress = advance(stakeMinor, parameters, levels, minePositions, progress, decision, version);\n  }\n  if (progress.endReason === null) throw invalid(\"Invalid Mines result\");\n  return settle({ stakeMinor, parameters, levels, minePositions, version, ...progress });\n}\n\nfunction minesRules(version: MinesRulesVersion): GameRules<MinesParameters> {\n  const full = paysInFull(version);\n  return Object.freeze({\n    game: \"mines\",\n    version,\n    stateful: true,\n    actions: MINES_ACTIONS,\n    closingActions: MINES_CLOSING_ACTIONS,\n    limits: minesLimits(version),\n    ...(full\n      ? {\n          admitStake: (stakeMinor: bigint, parameters: MinesParameters) =>\n            admitMinesStake(stakeMinor, parameters, version),\n        }\n      : {}),\n    validateParameters: validateMinesParameters,\n    maximumPayoutMinor: full\n      ? (stakeMinor: bigint, parameters: MinesParameters) =>\n          minesMaximumPayoutMinor(stakeMinor, parameters, version)\n      : minesMaximumPayoutMinor,\n    resolve: full\n      ? (input: ResolveInput<MinesParameters>) => resolveMines(input, version)\n      : resolveMines,\n    act: full ? (input: ActInput<MinesParameters>) => actMines(input, version) : actMines,\n    reconstruct: full\n      ? (input: Parameters<typeof reconstructMines>[0]) => reconstructMines(input, version)\n      : reconstructMines,\n  } satisfies GameRules<MinesParameters>);\n}\n\nexport const minesRulesModule: GameRules<MinesParameters> = minesRules(MINES_RULES_VERSION);\nexport const mines2RulesModule: GameRules<MinesParameters> = minesRules(MINES_2_RULES_VERSION);\n","sha256":"333e5a43a13fc67375e31b7edb69abddd2f45982a80a4e0256358dde31882130"},"src/lib/games/tower-rules.ts":{"source":"import { MAX_PAYOUT_MINOR, ratio, ratioText, type Ratio, type Resolution } from \"@/games/math\";\nimport { createEntropy } from \"./entropy\";\nimport type { ActInput, Entropy, GameRules, Limits, ResolveInput } from \"./rules\";\nimport {\n  admitStakeRange,\n  exceedsMaxPayout,\n  gameLimits,\n  maxWinLimits,\n  maximumPayoutMessage,\n} from \"./limits\";\nimport { MAX_WIN_END, MAX_WIN_MINOR, admitMaxWinStake, hundredthsRatio } from \"./max-win\";\n\nexport const TOWER_RULES_VERSION = \"peeks-tower-1\" as const;\nexport const TOWER_2_RULES_VERSION = \"peeks-tower-2\" as const;\nexport const TOWER_RULES_VERSIONS = Object.freeze([\n  TOWER_RULES_VERSION,\n  TOWER_2_RULES_VERSION,\n] as const);\nexport type TowerRulesVersion = (typeof TOWER_RULES_VERSIONS)[number];\nexport const TOWER_ROWS = 8;\nexport const TOWER_MAX_STAKE_MINOR = 1_000_000n;\nexport const TOWER_GAME_LIMITS = gameLimits(\"tower\");\nexport const TOWER_2_GAME_LIMITS = maxWinLimits(\"tower\");\nexport const TOWER_STREAM_CURSORS = 1024;\nexport const TOWER_ACTIONS = Object.freeze([\"bet\", \"cashout\"] as const);\nexport const TOWER_CLOSING_ACTIONS = Object.freeze([\"cashout\"] as const);\nexport const TOWER_PROCESSING_DELAYS = Object.freeze({\n  standard: 500,\n  instant: 0,\n} as const);\nexport const TOWER_DIFFICULTIES = Object.freeze([\n  \"easy\",\n  \"medium\",\n  \"hard\",\n  \"expert\",\n  \"master\",\n] as const);\nexport type TowerDifficulty = (typeof TOWER_DIFFICULTIES)[number];\nexport type TowerAnimationDuration =\n  (typeof TOWER_PROCESSING_DELAYS)[keyof typeof TOWER_PROCESSING_DELAYS];\nexport type TowerManualParameters = { difficulty: TowerDifficulty };\nexport type TowerAutoParameters = {\n  animationDurationMs: TowerAnimationDuration;\n  difficulty: TowerDifficulty;\n  selectedTileIndexes: number[];\n};\nexport type TowerParameters = TowerManualParameters | TowerAutoParameters;\nexport type TowerConfig = Readonly<{\n  fairValueProduces: \"lose-index\" | \"win-index\";\n  roundMultiplier: string;\n  weights: readonly number[];\n}>;\nexport type TowerEndReason = \"cashout\" | \"complete\" | \"miss\" | \"maxPayout\" | typeof MAX_WIN_END;\nexport type TowerRow = {\n  round: number;\n  selectedTileIndex: number;\n  playerRoundWin: boolean;\n  roundTileResult: boolean[];\n  multiplier: string;\n};\nexport type TowerReveal = { round: number; fairValue: number; roundTileResult: boolean[] };\nexport type TowerDecision =\n  | { action: \"bet\"; selection: number; expectedStep: number }\n  | { action: \"cashout\"; expectedStep: number };\nexport type TowerResult = {\n  towerRules: TowerRulesVersion;\n  parameters: TowerParameters;\n  difficulty: TowerDifficulty;\n  mode: \"manual\" | \"auto\";\n  tileCount: number;\n  multipliers: string[];\n  step: number;\n  rows: TowerRow[];\n  currentMultiplier: string;\n  potentialPayoutMinor: string;\n  nextMultiplier?: string;\n  nextPayoutMinor?: string;\n  canCashout: boolean;\n  gameOver: boolean;\n  endedBy?: TowerEndReason;\n  capped: boolean;\n  uncappedPayoutMinor?: string;\n  maxPayoutMinor: string;\n  decisions: TowerDecision[];\n  tower?: TowerReveal[];\n};\nexport type TowerSecret = {\n  towerRules: TowerRulesVersion;\n  difficulty: TowerDifficulty;\n  fairValues: number[];\n  decisions: TowerDecision[];\n};\n\ntype Progress = {\n  version: TowerRulesVersion;\n  rows: TowerRow[];\n  decisions: TowerDecision[];\n  endedBy: TowerEndReason | null;\n};\n\nconst configs: Readonly<Record<TowerDifficulty, TowerConfig>> = Object.freeze({\n  easy: Object.freeze({\n    fairValueProduces: \"lose-index\",\n    roundMultiplier: \"1.28\",\n    weights: Object.freeze([25, 25, 25, 25]),\n  }),\n  medium: Object.freeze({\n    fairValueProduces: \"lose-index\",\n    roundMultiplier: \"1.44\",\n    weights: Object.freeze([33.3333, 33.3333, 33.3333]),\n  }),\n  hard: Object.freeze({\n    fairValueProduces: \"win-index\",\n    roundMultiplier: \"1.92\",\n    weights: Object.freeze([50, 50]),\n  }),\n  expert: Object.freeze({\n    fairValueProduces: \"win-index\",\n    roundMultiplier: \"2.88\",\n    weights: Object.freeze([33.3333, 33.3333, 33.3333]),\n  }),\n  master: Object.freeze({\n    fairValueProduces: \"win-index\",\n    roundMultiplier: \"3.84\",\n    weights: Object.freeze([25, 25, 25, 25]),\n  }),\n});\n\nconst tables: Readonly<Record<TowerDifficulty, readonly string[]>> = Object.freeze({\n  easy: Object.freeze([\"1.28\", \"1.7\", \"2.27\", \"3.03\", \"4.04\", \"5.39\", \"7.19\", \"9.58\"]),\n  medium: Object.freeze([\"1.44\", \"2.16\", \"3.24\", \"4.86\", \"7.29\", \"10.93\", \"16.4\", \"24.6\"]),\n  hard: Object.freeze([\"1.92\", \"3.84\", \"7.68\", \"15.36\", \"30.72\", \"61.44\", \"122.88\", \"245.76\"]),\n  expert: Object.freeze([\n    \"2.88\",\n    \"8.64\",\n    \"25.92\",\n    \"77.76\",\n    \"233.28\",\n    \"699.84\",\n    \"2099.52\",\n    \"6298.56\",\n  ]),\n  master: Object.freeze([\n    \"3.84\",\n    \"15.36\",\n    \"61.44\",\n    \"245.76\",\n    \"983.04\",\n    \"3932.16\",\n    \"15728.64\",\n    \"62914.56\",\n  ]),\n});\n\nconst SECRET_KEYS = Object.freeze([\"towerRules\", \"difficulty\", \"fairValues\", \"decisions\"]);\nconst AUTO_KEYS = Object.freeze([\"animationDurationMs\", \"difficulty\", \"selectedTileIndexes\"]);\nconst BET_KEYS = Object.freeze([\"action\", \"selection\", \"expectedStep\"]);\nconst CASHOUT_KEYS = Object.freeze([\"action\", \"expectedStep\"]);\n\nfunction hundredths(value: string): bigint {\n  const [whole, fraction = \"\"] = value.split(\".\");\n  return BigInt(whole) * 100n + BigInt(fraction.padEnd(2, \"0\"));\n}\n\nconst cents = Object.freeze(\n  Object.fromEntries(\n    TOWER_DIFFICULTIES.map((difficulty) => [\n      difficulty,\n      Object.freeze(tables[difficulty].map(hundredths)),\n    ]),\n  ) as Record<TowerDifficulty, readonly bigint[]>,\n);\n\nfunction fixed(value: bigint): string {\n  return `${value / 100n}.${(value % 100n).toString().padStart(2, \"0\")}`;\n}\n\nfunction reduce(numerator: bigint, denominator: bigint): Ratio {\n  let a = numerator,\n    b = denominator;\n  while (b) [a, b] = [b, a % b];\n  return { numerator: numerator / a, denominator: denominator / a };\n}\n\nfunction record(value: unknown): value is Record<string, unknown> {\n  return !!value && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction isDifficulty(value: unknown): value is TowerDifficulty {\n  return typeof value === \"string\" && (TOWER_DIFFICULTIES as readonly string[]).includes(value);\n}\n\nfunction difficultyOf(value: unknown): TowerDifficulty {\n  if (!isDifficulty(value)) throw new RangeError(\"Invalid Tower difficulty\");\n  return value;\n}\n\nfunction tileIndex(value: unknown, tiles: number, message: string): number {\n  if (typeof value !== \"number\" || !Number.isSafeInteger(value) || value < 0 || value >= tiles)\n    throw new RangeError(message);\n  return value + 0;\n}\n\nfunction roundOf(value: unknown): number {\n  if (typeof value !== \"number\" || !Number.isSafeInteger(value) || value < 1 || value > TOWER_ROWS)\n    throw new RangeError(\"Invalid Tower round\");\n  return value;\n}\n\nfunction stakeOf(value: unknown): bigint {\n  if (typeof value !== \"bigint\" || value <= 0n || value > TOWER_MAX_STAKE_MINOR)\n    throw new RangeError(\"Invalid Tower stake\");\n  return value;\n}\n\nfunction seeded(value: unknown): boolean {\n  const entropy = value as Partial<Entropy> | null;\n  return (\n    !!entropy &&\n    typeof entropy === \"object\" &&\n    typeof entropy.serverSeed === \"string\" &&\n    !!entropy.serverSeed.trim() &&\n    typeof entropy.clientSeed === \"string\" &&\n    !!entropy.clientSeed.trim() &&\n    typeof entropy.nonce === \"number\" &&\n    Number.isSafeInteger(entropy.nonce) &&\n    entropy.nonce >= 0\n  );\n}\n\nfunction entropyOf(value: unknown): Entropy {\n  if (!seeded(value) || typeof (value as Entropy).hmac !== \"function\")\n    throw new RangeError(\"Invalid Tower fairness seed\");\n  return value as Entropy;\n}\n\nexport function isTowerRulesVersion(value: unknown): value is TowerRulesVersion {\n  return (TOWER_RULES_VERSIONS as readonly unknown[]).includes(value);\n}\n\nfunction edition(version: unknown): TowerRulesVersion {\n  if (!isTowerRulesVersion(version)) throw new RangeError(\"Invalid Tower rules\");\n  return version;\n}\n\nfunction paysInFull(version: TowerRulesVersion): boolean {\n  return version !== TOWER_RULES_VERSION;\n}\n\nexport function towerLimits(version: TowerRulesVersion = TOWER_RULES_VERSION): Limits {\n  return paysInFull(edition(version)) ? TOWER_2_GAME_LIMITS : TOWER_GAME_LIMITS;\n}\n\nexport function towerConfig(difficulty: unknown) {\n  const value = difficultyOf(difficulty),\n    config = configs[value],\n    tileCount = config.weights.length;\n  return {\n    difficulty: value,\n    fairValueProduces: config.fairValueProduces,\n    roundMultiplier: config.roundMultiplier,\n    weights: config.weights,\n    tileCount,\n    safeTileCount: config.fairValueProduces === \"lose-index\" ? tileCount - 1 : 1,\n  };\n}\n\nexport function towerMultipliers(difficulty: unknown): readonly string[] {\n  return tables[difficultyOf(difficulty)];\n}\n\nexport function towerMultiplier(difficulty: unknown, round: number): Ratio {\n  return reduce(cents[difficultyOf(difficulty)][roundOf(round) - 1], 100n);\n}\n\nexport function towerMultiplierText(difficulty: unknown, round: number): string {\n  return fixed(cents[difficultyOf(difficulty)][roundOf(round) - 1]);\n}\n\nexport function towerRoundTileResult(difficulty: unknown, fairValue: number): boolean[] {\n  const config = configs[difficultyOf(difficulty)],\n    tiles = config.weights.length,\n    drawn = tileIndex(fairValue, tiles, \"Invalid Tower fair value\");\n  return Array.from({ length: tiles }, (_, tile) =>\n    config.fairValueProduces === \"win-index\" ? tile === drawn : tile !== drawn,\n  );\n}\n\nexport function towerMessage(\n  clientSeed: string,\n  nonce: number,\n  round: number,\n  cursor: number,\n): string {\n  return `${clientSeed}:${nonce}:${round}:tower:${cursor}`;\n}\n\nexport async function towerFairValue(\n  entropy: Entropy,\n  difficulty: unknown,\n  round: number,\n): Promise<number> {\n  const source = entropyOf(entropy),\n    tiles = configs[difficultyOf(difficulty)].weights.length,\n    index = roundOf(round),\n    limit = 0x1_0000_0000 - (0x1_0000_0000 % tiles);\n  for (let cursor = 0; cursor < TOWER_STREAM_CURSORS; cursor++) {\n    const digest = await source.hmac(\n      \"SHA-256\",\n      source.serverSeed,\n      towerMessage(source.clientSeed, source.nonce, index, cursor),\n    );\n    if (!(digest instanceof Uint8Array) || digest.byteLength !== 32)\n      throw new RangeError(\"Invalid Tower HMAC digest\");\n    for (let offset = 0; offset < digest.byteLength; offset += 4) {\n      const word =\n        ((digest[offset] << 24) |\n          (digest[offset + 1] << 16) |\n          (digest[offset + 2] << 8) |\n          digest[offset + 3]) >>>\n        0;\n      if (word < limit) return word % tiles;\n    }\n  }\n  throw new RangeError(\"Tower entropy stream exhausted\");\n}\n\nexport async function towerFairValues(entropy: Entropy, difficulty: unknown): Promise<number[]> {\n  const values: number[] = [];\n  for (let round = 1; round <= TOWER_ROWS; round++)\n    values.push(await towerFairValue(entropy, difficulty, round));\n  return values;\n}\n\nexport function towerWebEntropy(input: {\n  serverSeed: string;\n  clientSeed: string;\n  nonce: number;\n}): Entropy {\n  if (!seeded(input)) throw new RangeError(\"Invalid Tower fairness seed\");\n  return createEntropy(input);\n}\n\nfunction reveal(difficulty: TowerDifficulty, fairValues: readonly number[]) {\n  return fairValues.map(\n    (fairValue, index): TowerReveal => ({\n      round: index + 1,\n      fairValue,\n      roundTileResult: towerRoundTileResult(difficulty, fairValue),\n    }),\n  );\n}\n\nexport async function towerFairness(input: {\n  serverSeed: string;\n  clientSeed: string;\n  nonce: number;\n  difficulty: unknown;\n}): Promise<TowerReveal[]> {\n  const difficulty = difficultyOf(input.difficulty);\n  return reveal(\n    difficulty,\n    await towerFairValues(\n      towerWebEntropy({\n        serverSeed: input.serverSeed,\n        clientSeed: input.clientSeed,\n        nonce: input.nonce,\n      }),\n      difficulty,\n    ),\n  );\n}\n\nexport function towerPayout(\n  stakeMinor: bigint,\n  multiplierHundredths: bigint,\n  version: TowerRulesVersion = TOWER_RULES_VERSION,\n) {\n  if (typeof multiplierHundredths !== \"bigint\" || multiplierHundredths < 0n)\n    throw new RangeError(\"Invalid Tower multiplier\");\n  const uncapped = (stakeOf(stakeMinor) * multiplierHundredths) / 100n,\n    capped = !paysInFull(edition(version)) && uncapped > MAX_PAYOUT_MINOR;\n  return {\n    payoutMinor: capped ? MAX_PAYOUT_MINOR : uncapped,\n    uncappedPayoutMinor: uncapped,\n    capped,\n  };\n}\n\nfunction overMaxWin(stakeMinor: bigint, multiplierHundredths: bigint): boolean {\n  return (stakeMinor * multiplierHundredths) / 100n > MAX_WIN_MINOR;\n}\n\nexport function towerStopRow(\n  stakeMinor: bigint,\n  difficulty: unknown,\n  version: TowerRulesVersion = TOWER_RULES_VERSION,\n): number {\n  const stake = stakeOf(stakeMinor),\n    table = cents[difficultyOf(difficulty)];\n  if (paysInFull(edition(version))) {\n    for (let round = 1; round < TOWER_ROWS; round++)\n      if (overMaxWin(stake, table[round])) return round;\n    return TOWER_ROWS;\n  }\n  for (let round = 1; round < TOWER_ROWS; round++)\n    if ((stake * table[round - 1]) / 100n >= MAX_PAYOUT_MINOR) return round;\n  return TOWER_ROWS;\n}\n\nexport function validateTowerParameters(input: unknown): TowerParameters {\n  const message = \"Invalid Tower parameters\";\n  if (!record(input) || !Object.hasOwn(input, \"difficulty\") || !isDifficulty(input.difficulty))\n    throw new RangeError(message);\n  const keys = Object.keys(input),\n    difficulty = input.difficulty;\n  if (keys.length === 1) return { difficulty };\n  if (\n    keys.length !== AUTO_KEYS.length ||\n    AUTO_KEYS.some((key) => !Object.hasOwn(input, key)) ||\n    !Array.isArray(input.selectedTileIndexes)\n  )\n    throw new RangeError(message);\n  const duration = input.animationDurationMs;\n  if (duration !== TOWER_PROCESSING_DELAYS.standard && duration !== TOWER_PROCESSING_DELAYS.instant)\n    throw new RangeError(\"Invalid Tower animation duration\");\n  const picks = input.selectedTileIndexes as unknown[];\n  if (!picks.length) throw new RangeError(\"Please select at least one tile\");\n  if (picks.length > TOWER_ROWS) throw new RangeError(message);\n  const tiles = configs[difficulty].weights.length;\n  return {\n    animationDurationMs: (duration + 0) as TowerAnimationDuration,\n    difficulty,\n    selectedTileIndexes: Array.from(picks, (index) => tileIndex(index, tiles, message)),\n  };\n}\n\nexport function towerMaximumPayoutMinor(\n  stakeMinor: bigint,\n  parameters: unknown,\n  version: TowerRulesVersion = TOWER_RULES_VERSION,\n): bigint {\n  const validated = validateTowerParameters(parameters),\n    rows =\n      \"selectedTileIndexes\" in validated\n        ? validated.selectedTileIndexes.length\n        : paysInFull(edition(version))\n          ? towerStopRow(stakeMinor, validated.difficulty, version)\n          : TOWER_ROWS;\n  return towerPayout(stakeMinor, cents[validated.difficulty][rows - 1], version).payoutMinor;\n}\n\nexport function towerTopMultiplierHundredths(parameters: unknown): bigint {\n  const validated = validateTowerParameters(parameters),\n    rows = \"selectedTileIndexes\" in validated ? validated.selectedTileIndexes.length : 1;\n  return cents[validated.difficulty][rows - 1];\n}\n\nexport function admitTower2Stake(stakeMinor: bigint, parameters: unknown): void {\n  admitMaxWinStake(\n    TOWER_2_GAME_LIMITS,\n    stakeMinor,\n    hundredthsRatio(towerTopMultiplierHundredths(parameters)),\n  );\n}\n\nexport function towerAutoOverMaxPayout(stakeMinor: bigint, parameters: unknown): boolean {\n  const validated = validateTowerParameters(parameters);\n  return (\n    \"selectedTileIndexes\" in validated &&\n    exceedsMaxPayout(TOWER_GAME_LIMITS, stakeOf(stakeMinor), {\n      numerator: cents[validated.difficulty][validated.selectedTileIndexes.length - 1],\n      denominator: 100n,\n    })\n  );\n}\n\nexport function admitTowerStake(stakeMinor: bigint, parameters: unknown): void {\n  admitStakeRange(TOWER_GAME_LIMITS, stakeMinor);\n  if (towerAutoOverMaxPayout(stakeMinor, parameters))\n    throw new RangeError(maximumPayoutMessage(TOWER_GAME_LIMITS.maxPayoutMinor));\n}\n\nexport function towerRtp(difficulty: unknown, rounds: number): Ratio {\n  const config = towerConfig(difficulty),\n    count = roundOf(rounds),\n    exponent = BigInt(count);\n  return reduce(\n    BigInt(config.safeTileCount) ** exponent * cents[config.difficulty][count - 1],\n    BigInt(config.tileCount) ** exponent * 100n,\n  );\n}\n\nexport function towerPcRtp(\n  stakeMinor: bigint,\n  difficulty: unknown,\n  rounds: number,\n  version: TowerRulesVersion = TOWER_RULES_VERSION,\n): Ratio {\n  const config = towerConfig(difficulty),\n    stake = stakeOf(stakeMinor),\n    target = roundOf(rounds),\n    stop = towerStopRow(stake, config.difficulty, version),\n    count = target < stop ? target : stop,\n    exponent = BigInt(count),\n    payout = towerPayout(stake, cents[config.difficulty][count - 1], version).payoutMinor;\n  return reduce(\n    BigInt(config.safeTileCount) ** exponent * payout,\n    BigInt(config.tileCount) ** exponent * stake,\n  );\n}\n\nfunction start(version: TowerRulesVersion = TOWER_RULES_VERSION): Progress {\n  return { version, rows: [], decisions: [], endedBy: null };\n}\n\nfunction stopAfter(\n  stakeMinor: bigint,\n  difficulty: TowerDifficulty,\n  round: number,\n  version: TowerRulesVersion,\n): TowerEndReason | null {\n  const table = cents[difficulty];\n  if (paysInFull(version)) return overMaxWin(stakeMinor, table[round]) ? MAX_WIN_END : null;\n  return (stakeMinor * table[round - 1]) / 100n >= MAX_PAYOUT_MINOR ? \"maxPayout\" : null;\n}\n\nfunction climb(\n  stakeMinor: bigint,\n  difficulty: TowerDifficulty,\n  fairValues: readonly number[],\n  progress: Progress,\n  pick: number,\n  final: boolean,\n): Progress {\n  const round = progress.rows.length + 1,\n    factor = cents[difficulty][round - 1],\n    roundTileResult = towerRoundTileResult(difficulty, fairValues[round - 1]),\n    playerRoundWin = roundTileResult[pick];\n  return {\n    version: progress.version,\n    rows: [\n      ...progress.rows,\n      {\n        round,\n        selectedTileIndex: pick,\n        playerRoundWin,\n        roundTileResult,\n        multiplier: playerRoundWin ? fixed(factor) : \"0.00\",\n      },\n    ],\n    decisions: progress.decisions,\n    endedBy: !playerRoundWin\n      ? \"miss\"\n      : final || round === TOWER_ROWS\n        ? \"complete\"\n        : stopAfter(stakeMinor, difficulty, round, progress.version),\n  };\n}\n\nfunction decide(input: unknown, difficulty: TowerDifficulty, step: number): TowerDecision {\n  const message = \"Invalid Tower action\";\n  if (!record(input)) throw new RangeError(message);\n  const allowed =\n    input.action === \"bet\" ? BET_KEYS : input.action === \"cashout\" ? CASHOUT_KEYS : null;\n  if (\n    !allowed ||\n    Object.keys(input).some((key) => input[key] !== undefined && !allowed.includes(key))\n  )\n    throw new RangeError(message);\n  if (input.expectedStep !== undefined) {\n    if (typeof input.expectedStep !== \"number\" || !Number.isSafeInteger(input.expectedStep))\n      throw new RangeError(message);\n    if (input.expectedStep !== step) throw new RangeError(\"Tower round has changed\");\n  }\n  if (input.action === \"cashout\") return { action: \"cashout\", expectedStep: step };\n  return {\n    action: \"bet\",\n    selection: tileIndex(input.selection, configs[difficulty].weights.length, \"Invalid Tower tile\"),\n    expectedStep: step,\n  };\n}\n\nfunction apply(\n  stakeMinor: bigint,\n  difficulty: TowerDifficulty,\n  fairValues: readonly number[],\n  progress: Progress,\n  input: unknown,\n): Progress {\n  if (progress.endedBy !== null)\n    throw new RangeError(\"The game you were trying to play already ended\");\n  const decision = decide(input, difficulty, progress.rows.length),\n    decisions = [...progress.decisions, decision];\n  if (decision.action === \"cashout\") {\n    if (!progress.rows.length)\n      throw new RangeError(\"You must select at least one tile to cash out\");\n    return { version: progress.version, rows: progress.rows, decisions, endedBy: \"cashout\" };\n  }\n  return {\n    ...climb(stakeMinor, difficulty, fairValues, progress, decision.selection, false),\n    decisions,\n  };\n}\n\nfunction autoplay(\n  stakeMinor: bigint,\n  parameters: TowerAutoParameters,\n  fairValues: readonly number[],\n  version: TowerRulesVersion = TOWER_RULES_VERSION,\n): Progress {\n  const picks = parameters.selectedTileIndexes;\n  let progress = start(version);\n  for (let index = 0; index < picks.length && progress.endedBy === null; index++)\n    progress = climb(\n      stakeMinor,\n      parameters.difficulty,\n      fairValues,\n      progress,\n      picks[index],\n      index === picks.length - 1,\n    );\n  return progress;\n}\n\nfunction settle(\n  stakeMinor: bigint,\n  parameters: TowerParameters,\n  fairValues: readonly number[],\n  progress: Progress,\n): Resolution {\n  const version = progress.version,\n    difficulty = parameters.difficulty,\n    auto = \"selectedTileIndexes\" in parameters,\n    table = cents[difficulty],\n    active = progress.endedBy === null,\n    missed = progress.endedBy === \"miss\",\n    cleared = missed ? progress.rows.length - 1 : progress.rows.length,\n    current = cleared > 0 && !missed ? table[cleared - 1] : 0n,\n    quote = towerPayout(stakeMinor, current, version),\n    payout = active || missed ? 0n : quote.payoutMinor,\n    next = active ? table[cleared] : null,\n    result: TowerResult = {\n      towerRules: version,\n      parameters: auto\n        ? {\n            animationDurationMs: parameters.animationDurationMs,\n            difficulty,\n            selectedTileIndexes: [...parameters.selectedTileIndexes],\n          }\n        : { difficulty },\n      difficulty,\n      mode: auto ? \"auto\" : \"manual\",\n      tileCount: configs[difficulty].weights.length,\n      multipliers: table.map(fixed),\n      step: progress.rows.length,\n      rows: progress.rows.map((row) => ({ ...row, roundTileResult: [...row.roundTileResult] })),\n      currentMultiplier: fixed(current),\n      potentialPayoutMinor: quote.payoutMinor.toString(),\n      ...(next === null\n        ? {}\n        : {\n            nextMultiplier: fixed(next),\n            nextPayoutMinor: towerPayout(stakeMinor, next, version).payoutMinor.toString(),\n          }),\n      canCashout: active && cleared > 0,\n      gameOver: !active,\n      ...(progress.endedBy === null ? {} : { endedBy: progress.endedBy }),\n      capped: quote.capped,\n      ...(quote.capped ? { uncappedPayoutMinor: quote.uncappedPayoutMinor.toString() } : {}),\n      maxPayoutMinor: towerLimits(version).maxPayoutMinor.toString(),\n      decisions: progress.decisions.map((decision) => ({ ...decision })),\n      ...(active ? {} : { tower: reveal(difficulty, fairValues) }),\n    },\n    secret: TowerSecret | null = active\n      ? {\n          towerRules: version,\n          difficulty,\n          fairValues: [...fairValues],\n          decisions: progress.decisions.map((decision) => ({ ...decision })),\n        }\n      : null;\n  return {\n    game: \"tower\",\n    status: active ? \"ACTIVE\" : missed ? \"LOST\" : \"WON\",\n    payoutMinor: payout.toString(),\n    multiplier: ratioText(ratio(payout, stakeMinor)),\n    result,\n    ...(secret ? { secret } : {}),\n    rulesVersion: version,\n  };\n}\n\nfunction restore(\n  difficulty: TowerDifficulty,\n  stakeMinor: bigint,\n  secret: unknown,\n  version: TowerRulesVersion = TOWER_RULES_VERSION,\n) {\n  const message = \"Invalid Tower state\";\n  if (\n    !record(secret) ||\n    secret.towerRules !== version ||\n    Object.keys(secret).length !== SECRET_KEYS.length ||\n    SECRET_KEYS.some((key) => !Object.hasOwn(secret, key)) ||\n    secret.difficulty !== difficulty ||\n    !Array.isArray(secret.fairValues) ||\n    secret.fairValues.length !== TOWER_ROWS ||\n    !Array.isArray(secret.decisions) ||\n    secret.decisions.length >= TOWER_ROWS\n  )\n    throw new RangeError(message);\n  const tiles = configs[difficulty].weights.length,\n    fairValues = Array.from(secret.fairValues as unknown[], (value) =>\n      tileIndex(value, tiles, message),\n    );\n  let progress = start(version);\n  for (const item of Array.from(secret.decisions as unknown[])) {\n    if (\n      !record(item) ||\n      item.action !== \"bet\" ||\n      item.expectedStep !== progress.rows.length ||\n      Object.keys(item).length !== BET_KEYS.length ||\n      BET_KEYS.some((key) => !Object.hasOwn(item, key))\n    )\n      throw new RangeError(message);\n    try {\n      progress = apply(stakeMinor, difficulty, fairValues, progress, item);\n    } catch {\n      throw new RangeError(message);\n    }\n    if (progress.endedBy !== null) throw new RangeError(message);\n  }\n  return { fairValues, progress };\n}\n\nexport async function resolveTower(\n  input: ResolveInput<TowerParameters>,\n  version: TowerRulesVersion = TOWER_RULES_VERSION,\n): Promise<Resolution> {\n  const parameters = validateTowerParameters(input.parameters),\n    stakeMinor = stakeOf(input.stakeMinor),\n    fairValues = await towerFairValues(input.entropy, parameters.difficulty);\n  return settle(\n    stakeMinor,\n    parameters,\n    fairValues,\n    \"selectedTileIndexes\" in parameters\n      ? autoplay(stakeMinor, parameters, fairValues, edition(version))\n      : start(edition(version)),\n  );\n}\n\nexport async function actTower(\n  input: ActInput<TowerParameters>,\n  version: TowerRulesVersion = TOWER_RULES_VERSION,\n): Promise<Resolution> {\n  const parameters = validateTowerParameters(input.parameters);\n  if (\"selectedTileIndexes\" in parameters)\n    throw new RangeError(\"The game you were trying to play already ended\");\n  const stakeMinor = stakeOf(input.stakeMinor),\n    state = restore(parameters.difficulty, stakeMinor, input.secret, edition(version)),\n    fairValues = await towerFairValues(input.entropy, parameters.difficulty);\n  if (fairValues.some((value, index) => value !== state.fairValues[index]))\n    throw new RangeError(\"Invalid Tower state\");\n  return settle(\n    stakeMinor,\n    parameters,\n    fairValues,\n    apply(stakeMinor, parameters.difficulty, fairValues, state.progress, input.decision),\n  );\n}\n\nexport function reconstructTower(\n  input: {\n    stakeMinor: bigint;\n    parameters: unknown;\n    result: unknown;\n  },\n  version: TowerRulesVersion = TOWER_RULES_VERSION,\n): Resolution {\n  const parameters = validateTowerParameters(input.parameters),\n    stakeMinor = stakeOf(input.stakeMinor),\n    result = input.result,\n    message = \"Invalid Tower result\";\n  if (\n    !record(result) ||\n    !Array.isArray(result.decisions) ||\n    !Array.isArray(result.tower) ||\n    result.tower.length !== TOWER_ROWS\n  )\n    throw new RangeError(message);\n  const tiles = configs[parameters.difficulty].weights.length,\n    fairValues = Array.from(result.tower as unknown[], (entry, index) => {\n      if (!record(entry) || entry.round !== index + 1) throw new RangeError(message);\n      return tileIndex(entry.fairValue, tiles, message);\n    }),\n    decisions = Array.from(result.decisions as unknown[]);\n  if (\"selectedTileIndexes\" in parameters) {\n    if (decisions.length) throw new RangeError(message);\n    return settle(\n      stakeMinor,\n      parameters,\n      fairValues,\n      autoplay(stakeMinor, parameters, fairValues, edition(version)),\n    );\n  }\n  let progress = start(edition(version));\n  for (const decision of decisions)\n    progress = apply(stakeMinor, parameters.difficulty, fairValues, progress, decision);\n  if (progress.endedBy === null) throw new RangeError(message);\n  return settle(stakeMinor, parameters, fairValues, progress);\n}\n\nexport const towerRulesModule: GameRules<TowerParameters> = Object.freeze({\n  game: \"tower\",\n  version: TOWER_RULES_VERSION,\n  stateful: true,\n  actions: TOWER_ACTIONS,\n  closingActions: TOWER_CLOSING_ACTIONS,\n  limits: TOWER_GAME_LIMITS,\n  admitStake: admitTowerStake,\n  validateParameters: validateTowerParameters,\n  maximumPayoutMinor: towerMaximumPayoutMinor,\n  resolve: resolveTower,\n  act: actTower,\n  reconstruct: reconstructTower,\n});\n\nexport const tower2RulesModule: GameRules<TowerParameters> = Object.freeze({\n  game: \"tower\",\n  version: TOWER_2_RULES_VERSION,\n  stateful: true,\n  actions: TOWER_ACTIONS,\n  closingActions: TOWER_CLOSING_ACTIONS,\n  limits: TOWER_2_GAME_LIMITS,\n  admitStake: admitTower2Stake,\n  validateParameters: validateTowerParameters,\n  maximumPayoutMinor: (stakeMinor: bigint, parameters: TowerParameters) =>\n    towerMaximumPayoutMinor(stakeMinor, parameters, TOWER_2_RULES_VERSION),\n  resolve: (input: ResolveInput<TowerParameters>) => resolveTower(input, TOWER_2_RULES_VERSION),\n  act: (input: ActInput<TowerParameters>) => actTower(input, TOWER_2_RULES_VERSION),\n  reconstruct: (\n    input: Readonly<{\n      stakeMinor: bigint;\n      parameters: TowerParameters;\n      result: Record<string, unknown>;\n    }>,\n  ) => reconstructTower(input, TOWER_2_RULES_VERSION),\n});\n","sha256":"1c328dd40747b4cec2706c7b84e05b2c485b63f58bc527ff8224ce7b3af47502"},"src/lib/games/entropy.ts":{"source":"import type { Entropy, Hash } from \"./rules\";\n\nexport const HASHES: readonly Hash[] = Object.freeze([\"SHA-256\", \"SHA-512\"]);\n\nconst encoder = new TextEncoder();\n\ntype Bytes = ArrayBuffer | Uint8Array;\n\ntype Primitives = {\n  hmac(algorithm: Hash, secret: string, message: string): Bytes | Promise<Bytes>;\n  digest(algorithm: Hash, message: string): Bytes | Promise<Bytes>;\n};\n\ntype NodeHashing = {\n  createHmac(\n    algorithm: string,\n    key: Uint8Array,\n  ): { update(data: Uint8Array): { digest(): Uint8Array } };\n  createHash(algorithm: string): { update(data: Uint8Array): { digest(): Uint8Array } };\n};\n\nconst NODE_NAMES: Record<Hash, string> = { \"SHA-256\": \"sha256\", \"SHA-512\": \"sha512\" };\n\nfunction nodeHashing(): NodeHashing | null {\n  if (typeof process === \"undefined\" || typeof process.getBuiltinModule !== \"function\") return null;\n  const crypto = process.getBuiltinModule(\"node:crypto\") as NodeHashing | undefined;\n  return typeof crypto?.createHmac === \"function\" && typeof crypto.createHash === \"function\"\n    ? crypto\n    : null;\n}\n\nfunction nodePrimitives(crypto: NodeHashing): Primitives {\n  return {\n    hmac: (algorithm, secret, message) =>\n      crypto\n        .createHmac(NODE_NAMES[algorithm], encoder.encode(secret))\n        .update(encoder.encode(message))\n        .digest(),\n    digest: (algorithm, message) =>\n      crypto.createHash(NODE_NAMES[algorithm]).update(encoder.encode(message)).digest(),\n  };\n}\n\nfunction subtleCrypto(): SubtleCrypto {\n  const subtle = globalThis.crypto?.subtle;\n  if (!subtle) throw new Error(\"Web Crypto is unavailable\");\n  return subtle;\n}\n\nfunction webPrimitives(subtle: SubtleCrypto): Primitives {\n  const keys: Record<Hash, Map<string, Promise<CryptoKey>>> = {\n    \"SHA-256\": new Map(),\n    \"SHA-512\": new Map(),\n  };\n  function key(algorithm: Hash, secret: string) {\n    const cache = keys[algorithm];\n    let pending = cache.get(secret);\n    if (!pending) {\n      pending = subtle.importKey(\n        \"raw\",\n        encoder.encode(secret),\n        { name: \"HMAC\", hash: algorithm },\n        false,\n        [\"sign\"],\n      );\n      cache.set(secret, pending);\n      pending.catch(() => cache.delete(secret));\n    }\n    return pending;\n  }\n  return {\n    hmac: async (algorithm, secret, message) =>\n      subtle.sign(\"HMAC\", await key(algorithm, secret), encoder.encode(message)),\n    digest: (algorithm, message) => subtle.digest(algorithm, encoder.encode(message)),\n  };\n}\n\nfunction algorithmOf(value: unknown): Hash {\n  if (!HASHES.includes(value as Hash)) throw new RangeError(\"Unsupported fairness hash\");\n  return value as Hash;\n}\n\nfunction textOf(value: unknown, message: string): string {\n  if (typeof value !== \"string\") throw new RangeError(message);\n  return value;\n}\n\nexport function createEntropy(input: {\n  serverSeed: string;\n  clientSeed: string;\n  nonce: number;\n}): Entropy {\n  if (\n    !input ||\n    typeof input !== \"object\" ||\n    typeof input.serverSeed !== \"string\" ||\n    !input.serverSeed ||\n    typeof input.clientSeed !== \"string\" ||\n    !input.clientSeed ||\n    typeof input.nonce !== \"number\" ||\n    !Number.isSafeInteger(input.nonce) ||\n    input.nonce < 0\n  )\n    throw new RangeError(\"Invalid fairness seed\");\n  const node = nodeHashing();\n  const primitives = node ? nodePrimitives(node) : webPrimitives(subtleCrypto());\n  return Object.freeze({\n    serverSeed: input.serverSeed,\n    clientSeed: input.clientSeed,\n    nonce: input.nonce,\n    async hmac(algorithm: Hash, secret: string, message: string) {\n      const hash = algorithmOf(algorithm),\n        material = textOf(secret, \"Invalid HMAC key\"),\n        text = textOf(message, \"Invalid HMAC message\");\n      if (!material) throw new RangeError(\"Invalid HMAC key\");\n      return new Uint8Array(await primitives.hmac(hash, material, text));\n    },\n    async digest(algorithm: Hash, message: string) {\n      const hash = algorithmOf(algorithm),\n        text = textOf(message, \"Invalid digest message\");\n      return new Uint8Array(await primitives.digest(hash, text));\n    },\n  });\n}\n","sha256":"3c85cdafa1dc70fdb96d4f21497f5e1c604902fac5b8ea64f895c82db1375110"},"src/lib/games/blackjack-rules.ts":{"source":"import { ratio, ratioText, ratioWire, type Resolution } from \"@/games/math\";\nimport type { ActInput, Decision, GameRules, ResolveInput } from \"./rules\";\nimport {\n  BLACKJACK_DECKS,\n  BLACKJACK_MAIN_ODDS,\n  BLACKJACK_PAYOUT_SECTIONS,\n  BLACKJACK_PERFECT_PAIR_ODDS,\n  BLACKJACK_RTP_ENTRIES,\n  BLACKJACK_SHOE_SIZE,\n  BLACKJACK_TWENTY_ONE_ODDS,\n  isBlackjackCard,\n  blackjackCanSplit,\n  blackjackCardRank,\n  blackjackCardValue,\n  blackjackHandTotals,\n  blackjackHandValue,\n  blackjackOdds,\n  blackjackPerfectPair,\n  blackjackPerfectPairRtp,\n  blackjackSideBetOdds,\n  blackjackTwentyOne,\n  blackjackTwentyOneRtp,\n  type BlackjackCard,\n  type BlackjackRatio,\n  type BlackjackSideBetResult,\n  type BlackjackSideBetType,\n  type BlackjackTotals,\n} from \"./blackjack-cards\";\nimport { createBlackjackShoe } from \"./blackjack-shoe\";\nimport { admitStakeRange, gameLimits, maxWinLimits, maximumBetMessage } from \"./limits\";\nimport { MAX_WIN_MINOR, maxBetForPayout, maxBetMessage } from \"./max-win\";\nimport type { Limits } from \"./rules\";\n\nexport const BLACKJACK_RULES_VERSION = \"peeks-blackjack-1\" as const;\nexport const BLACKJACK_2_RULES_VERSION = \"peeks-blackjack-2\" as const;\nexport const BLACKJACK_RULES_VERSIONS = Object.freeze([\n  BLACKJACK_RULES_VERSION,\n  BLACKJACK_2_RULES_VERSION,\n] as const);\nexport type BlackjackRulesVersion = (typeof BLACKJACK_RULES_VERSIONS)[number];\nexport const BLACKJACK_PC_POLICY = \"peeks-pc-floor-1\" as const;\nexport const BLACKJACK_MAX_STAKE_MINOR = 1_000_000n;\nexport const BLACKJACK_GAME_LIMITS = gameLimits(\"blackjack\", \"main-bet\");\nexport const BLACKJACK_2_GAME_LIMITS = maxWinLimits(\"blackjack\", \"main-bet\");\nexport const BLACKJACK_SIDE_BET_LIMITS = gameLimits(\"blackjack\", \"side-bet\");\nexport const BLACKJACK_MAX_PAYOUT_MINOR = BLACKJACK_GAME_LIMITS.maxPayoutMinor;\nexport const BLACKJACK_MAX_HANDS = 2;\nexport const BLACKJACK_DEALER_STANDS = 17;\nexport const BLACKJACK_TRANSITION_CARDS = 32;\nexport const BLACKJACK_ACTIONS = Object.freeze([\n  \"hit\",\n  \"stand\",\n  \"double\",\n  \"split\",\n  \"insurance\",\n] as const);\nexport const BLACKJACK_CLOSING_ACTIONS = Object.freeze([\"stand\", \"insurance\"] as const);\nexport const BLACKJACK_BUTTONS = Object.freeze([\"double\", \"hit\", \"stand\", \"split\"] as const);\nexport const BLACKJACK_INSURANCE_CHOICES = Object.freeze([\"accept\", \"decline\"] as const);\nexport const BLACKJACK_SIDE_BET_KEYS = Object.freeze({\n  perfectPair: \"perfect_pair\",\n  twentyOne: \"21_plus_3\",\n} as const);\n\nexport type BlackjackRulesAction = (typeof BLACKJACK_ACTIONS)[number];\nexport type BlackjackButton = (typeof BLACKJACK_BUTTONS)[number];\nexport type BlackjackInsuranceChoice = (typeof BLACKJACK_INSURANCE_CHOICES)[number];\nexport type BlackjackSideBets = { perfectPair?: string; twentyOne?: string };\nexport type BlackjackParameters = { sideBets?: BlackjackSideBets };\nexport type BlackjackDecision = Readonly<{\n  action: BlackjackRulesAction;\n  choice?: BlackjackInsuranceChoice;\n  expectedStep: number;\n}>;\nexport type BlackjackHandResult = \"win\" | \"lose\" | \"push\";\nexport type BlackjackAvailableActions = Readonly<Record<BlackjackButton, boolean>>;\nexport type BlackjackPublicHand = {\n  cards: BlackjackCard[];\n  stakeMinor: string;\n  availableActions: BlackjackAvailableActions;\n  isActive: boolean;\n  isBust: boolean;\n  isDoubled: boolean;\n  isStand: boolean;\n  isSplit: boolean;\n  isBlackjack: boolean;\n  result: BlackjackHandResult | null;\n  total: BlackjackTotals;\n  value: number;\n  payoutMinor: string | null;\n};\nexport type BlackjackPublicDealer = {\n  cards: (BlackjackCard | null)[];\n  isRevealed: boolean;\n  isBlackjack: boolean;\n  total: BlackjackTotals;\n  value: number;\n};\nexport type BlackjackSideBetOutcome = {\n  type: BlackjackSideBetType;\n  amountMinor: string;\n  subType: BlackjackSideBetResult | null;\n  odds: string | null;\n  multiplier: string;\n  payoutMinor: string;\n};\nexport type BlackjackRatioWire = { numerator: string; denominator: string; value: string };\nexport type BlackjackPublicResult = {\n  blackjackRules: BlackjackRulesVersion;\n  settlementPolicy: typeof BLACKJACK_PC_POLICY;\n  parameters: BlackjackParameters;\n  status: \"playerTurn\" | \"finished\";\n  step: number;\n  actions: BlackjackRulesAction[];\n  currentHandIndex: number;\n  isSplit: boolean;\n  insuranceOffered: boolean;\n  insuranceTaken: boolean;\n  insuranceWon: boolean;\n  initialStakeMinor: string;\n  totalStakeMinor: string;\n  baseBetMinor: string;\n  betAmountMinor: string;\n  insuranceCostMinor: string;\n  dealerHand: BlackjackPublicDealer;\n  playerHands: BlackjackPublicHand[];\n  sideBetsResult: BlackjackSideBetOutcome[];\n  decisions: {\n    action: BlackjackRulesAction;\n    choice?: BlackjackInsuranceChoice;\n    expectedStep: number;\n  }[];\n  payoutTable: {\n    title: string | null;\n    rows: { description: string | null; label: string; value: string }[];\n  }[];\n  rtp: {\n    entries: { labelKey: string; rtp: number }[];\n    perfectPair: BlackjackRatioWire;\n    twentyOne: BlackjackRatioWire;\n  };\n  maxPayoutMinor: string;\n  maximumPayoutMinor: string;\n  capped: boolean;\n  payoutMinor?: string;\n  mainPayoutMinor?: string;\n  insurancePayoutMinor?: string;\n  sideBetsPayoutMinor?: string;\n  drawnCards?: BlackjackCard[];\n  uncappedPayoutMinor?: string;\n};\n\ntype Terms = Readonly<{\n  version: BlackjackRulesVersion;\n  parameters: BlackjackParameters;\n  stakeMinor: bigint;\n  baseMinor: bigint;\n  perfectPairMinor: bigint;\n  twentyOneMinor: bigint;\n  insuranceMinor: bigint;\n  maximumPayoutMinor: bigint;\n  uncappedMaximumPayoutMinor: bigint;\n}>;\ntype Hand = {\n  cards: BlackjackCard[];\n  stakeMinor: bigint;\n  availableActions: Record<BlackjackButton, boolean>;\n  isBust: boolean;\n  isDoubled: boolean;\n  isStand: boolean;\n  isSplit: boolean;\n  isBlackjack: boolean;\n  result: BlackjackHandResult | null;\n};\ntype Round = {\n  terms: Terms;\n  source: readonly BlackjackCard[];\n  used: number;\n  dealer: BlackjackCard[];\n  isRevealed: boolean;\n  hands: Hand[];\n  currentHandIndex: number;\n  isSplit: boolean;\n  insuranceOffered: boolean;\n  insuranceTaken: boolean;\n  insuranceWon: boolean;\n  finished: boolean;\n  decisions: BlackjackDecision[];\n  extraMinor: bigint;\n  lastExtraMinor: bigint;\n  sideBets: BlackjackSideBetOutcome[];\n};\ntype Secret = Readonly<{\n  parameters: BlackjackParameters;\n  initialStakeMinor: bigint;\n  cards: BlackjackCard[];\n  decisions: BlackjackDecision[];\n}>;\n\nconst MINOR = /^[1-9]\\d{0,6}$/;\nconst STAKE = /^[1-9]\\d{0,15}$/;\nconst STANDARD = blackjackOdds(BLACKJACK_MAIN_ODDS.standard_win);\nconst INSURANCE = blackjackOdds(BLACKJACK_MAIN_ODDS.insurance);\nconst NATURAL = blackjackOdds(BLACKJACK_MAIN_ODDS.blackjack);\nconst DOUBLE_FACTOR = 2n;\nconst closed: BlackjackAvailableActions = Object.freeze({\n  double: false,\n  hit: false,\n  split: false,\n  stand: false,\n});\n\nfunction largest(table: Readonly<Record<string, string>>): BlackjackRatio {\n  return Object.values(table)\n    .map(blackjackOdds)\n    .reduce((best, value) =>\n      value.numerator * best.denominator > best.numerator * value.denominator ? value : best,\n    );\n}\n\nconst PERFECT_PAIR_TOP = largest(BLACKJACK_PERFECT_PAIR_ODDS);\nconst TWENTY_ONE_TOP = largest(BLACKJACK_TWENTY_ONE_ODDS);\nconst PERFECT_PAIR_RTP = ratioWire(blackjackPerfectPairRtp());\nconst TWENTY_ONE_RTP = ratioWire(blackjackTwentyOneRtp());\n\nfunction gross(amount: bigint, odds: BlackjackRatio): bigint {\n  return (amount * odds.numerator) / odds.denominator;\n}\n\nfunction minimum(a: bigint, b: bigint): bigint {\n  return a < b ? a : b;\n}\n\nfunction object(value: unknown): value is Record<string, unknown> {\n  if (!value || typeof value !== \"object\" || Array.isArray(value)) return false;\n  const prototype = Object.getPrototypeOf(value);\n  return prototype === Object.prototype || prototype === null;\n}\n\nexport function validateBlackjackParameters(input: unknown): BlackjackParameters {\n  if (!object(input)) throw new RangeError(\"Invalid Blackjack parameters\");\n  const keys = Object.keys(input);\n  if (keys.length === 0) return {};\n  if (keys.length !== 1 || keys[0] !== \"sideBets\" || !object(input.sideBets))\n    throw new RangeError(\"Invalid Blackjack parameters\");\n  const sideBets = input.sideBets,\n    names = Object.keys(sideBets);\n  if (names.length === 0 || names.some((name) => name !== \"perfectPair\" && name !== \"twentyOne\"))\n    throw new RangeError(\"Invalid Blackjack side bets\");\n  const result: BlackjackSideBets = {};\n  for (const name of [\"perfectPair\", \"twentyOne\"] as const) {\n    if (!Object.hasOwn(sideBets, name)) continue;\n    const amount = sideBets[name];\n    if (\n      typeof amount !== \"string\" ||\n      !MINOR.test(amount) ||\n      BigInt(amount) > BLACKJACK_MAX_STAKE_MINOR\n    )\n      throw new RangeError(\"Invalid Blackjack side bet amount\");\n    result[name] = amount;\n  }\n  return { sideBets: result };\n}\n\nexport function isBlackjackRulesVersion(value: unknown): value is BlackjackRulesVersion {\n  return (BLACKJACK_RULES_VERSIONS as readonly unknown[]).includes(value);\n}\n\nfunction edition(version: unknown): BlackjackRulesVersion {\n  if (!isBlackjackRulesVersion(version)) throw new RangeError(\"Invalid Blackjack rules\");\n  return version;\n}\n\nfunction paysInFull(version: BlackjackRulesVersion): boolean {\n  return version !== BLACKJACK_RULES_VERSION;\n}\n\nexport function blackjackLimits(version: BlackjackRulesVersion = BLACKJACK_RULES_VERSION): Limits {\n  return paysInFull(edition(version)) ? BLACKJACK_2_GAME_LIMITS : BLACKJACK_GAME_LIMITS;\n}\n\nexport function blackjackStakeTerms(\n  stakeMinor: bigint,\n  parameters: unknown,\n  version: BlackjackRulesVersion = BLACKJACK_RULES_VERSION,\n): Terms {\n  const validated = validateBlackjackParameters(parameters);\n  if (typeof stakeMinor !== \"bigint\" || stakeMinor <= 0n || stakeMinor > BLACKJACK_MAX_STAKE_MINOR)\n    throw new RangeError(\"Invalid Blackjack stake\");\n  const perfectPairMinor = BigInt(validated.sideBets?.perfectPair ?? \"0\"),\n    twentyOneMinor = BigInt(validated.sideBets?.twentyOne ?? \"0\"),\n    baseMinor = stakeMinor - perfectPairMinor - twentyOneMinor;\n  if (baseMinor < 1n) throw new RangeError(\"Side bets require a main bet to play\");\n  const uncapped =\n    gross(baseMinor * BigInt(BLACKJACK_MAX_HANDS) * DOUBLE_FACTOR, STANDARD) +\n    gross(perfectPairMinor, PERFECT_PAIR_TOP) +\n    gross(twentyOneMinor, TWENTY_ONE_TOP);\n  return Object.freeze({\n    version: edition(version),\n    parameters: validated,\n    stakeMinor,\n    baseMinor,\n    perfectPairMinor,\n    twentyOneMinor,\n    insuranceMinor: baseMinor / 2n,\n    maximumPayoutMinor: paysInFull(version)\n      ? uncapped\n      : minimum(uncapped, BLACKJACK_MAX_PAYOUT_MINOR),\n    uncappedMaximumPayoutMinor: uncapped,\n  });\n}\n\nexport function blackjackMaximumPayoutMinor(\n  stakeMinor: bigint,\n  parameters: unknown,\n  version: BlackjackRulesVersion = BLACKJACK_RULES_VERSION,\n): bigint {\n  return blackjackStakeTerms(stakeMinor, parameters, version).maximumPayoutMinor;\n}\n\nexport function admitBlackjackStake(stakeMinor: bigint, parameters: unknown): void {\n  const terms = blackjackStakeTerms(stakeMinor, parameters);\n  admitStakeRange(BLACKJACK_GAME_LIMITS, terms.baseMinor);\n  for (const amount of [terms.perfectPairMinor, terms.twentyOneMinor])\n    if (amount > BLACKJACK_SIDE_BET_LIMITS.maxStakeMinor)\n      throw new RangeError(maximumBetMessage(BLACKJACK_SIDE_BET_LIMITS.maxStakeMinor));\n}\n\nexport function admitBlackjack2Stake(stakeMinor: bigint, parameters: unknown): void {\n  admitBlackjackStake(stakeMinor, parameters);\n  const top = blackjackStakeTerms(\n    stakeMinor,\n    parameters,\n    BLACKJACK_2_RULES_VERSION,\n  ).uncappedMaximumPayoutMinor;\n  if (top > MAX_WIN_MINOR) throw new RangeError(maxBetMessage(maxBetForPayout(stakeMinor, top)));\n}\n\nexport function blackjackMaximumStakeMinor(stakeMinor: bigint, parameters: unknown): bigint {\n  const terms = blackjackStakeTerms(stakeMinor, parameters);\n  return (\n    terms.stakeMinor +\n    terms.baseMinor * (BigInt(BLACKJACK_MAX_HANDS) * DOUBLE_FACTOR - 1n) +\n    terms.insuranceMinor\n  );\n}\n\nfunction draw(round: Round): BlackjackCard {\n  if (round.used >= round.source.length)\n    throw new RangeError(\"The committed Blackjack shoe is exhausted\");\n  return round.source[round.used++];\n}\n\nfunction hand(cards: BlackjackCard[], stakeMinor: bigint, isSplit: boolean): Hand {\n  return {\n    cards,\n    stakeMinor,\n    availableActions: { double: true, hit: true, split: false, stand: true },\n    isBust: false,\n    isDoubled: false,\n    isStand: false,\n    isSplit,\n    isBlackjack: false,\n    result: null,\n  };\n}\n\nfunction close(target: Hand) {\n  target.availableActions = { ...closed };\n}\n\nfunction finish(round: Round) {\n  round.finished = true;\n  round.isRevealed = true;\n  round.insuranceOffered = false;\n  for (const target of round.hands) close(target);\n}\n\nfunction dealerTurn(round: Round) {\n  round.isRevealed = true;\n  while (blackjackHandValue(round.dealer) < BLACKJACK_DEALER_STANDS) round.dealer.push(draw(round));\n  const dealer = blackjackHandValue(round.dealer);\n  for (const target of round.hands) {\n    if (target.isBust) {\n      target.result = \"lose\";\n      continue;\n    }\n    const value = blackjackHandValue(target.cards);\n    target.result = dealer > 21 || value > dealer ? \"win\" : value < dealer ? \"lose\" : \"push\";\n  }\n  finish(round);\n}\n\nfunction advance(round: Round) {\n  const next = round.hands.findIndex(\n    (target, index) => index > round.currentHandIndex && !target.isStand && !target.isBust,\n  );\n  if (next !== -1) round.currentHandIndex = next;\n  else if (round.hands.some((target) => !target.isBust)) dealerTurn(round);\n  else finish(round);\n}\n\nfunction factorText(factor: BlackjackRatio): string {\n  return factor.denominator === 1n ? factor.numerator.toString() : ratioText(factor);\n}\n\nfunction settleSideBets(\n  terms: Terms,\n  first: BlackjackCard,\n  second: BlackjackCard,\n  upcard: BlackjackCard,\n): BlackjackSideBetOutcome[] {\n  const outcomes: BlackjackSideBetOutcome[] = [];\n  const entries: [BlackjackSideBetType, bigint, BlackjackSideBetResult | null][] = [\n    [\"perfect_pair\", terms.perfectPairMinor, blackjackPerfectPair(first, second)],\n    [\"21_plus_3\", terms.twentyOneMinor, blackjackTwentyOne(first, second, upcard)],\n  ];\n  for (const [type, amount, subType] of entries) {\n    if (amount === 0n) continue;\n    const odds = subType ? blackjackSideBetOdds(type, subType) : null,\n      factor = odds ? blackjackOdds(odds) : null;\n    outcomes.push({\n      type,\n      amountMinor: amount.toString(),\n      subType,\n      odds,\n      multiplier: factor ? factorText(factor) : \"0\",\n      payoutMinor: (factor ? gross(amount, factor) : 0n).toString(),\n    });\n  }\n  return outcomes;\n}\n\nfunction deal(terms: Terms, source: readonly BlackjackCard[]): Round {\n  const round: Round = {\n    terms,\n    source,\n    used: 0,\n    dealer: [],\n    isRevealed: false,\n    hands: [],\n    currentHandIndex: 0,\n    isSplit: false,\n    insuranceOffered: false,\n    insuranceTaken: false,\n    insuranceWon: false,\n    finished: false,\n    decisions: [],\n    extraMinor: 0n,\n    lastExtraMinor: 0n,\n    sideBets: [],\n  };\n  const first = draw(round),\n    upcard = draw(round),\n    second = draw(round),\n    hole = draw(round),\n    player = hand([first, second], terms.baseMinor, false);\n  player.availableActions.split = blackjackCanSplit(first, second);\n  round.dealer = [upcard, hole];\n  round.hands = [player];\n  round.sideBets = settleSideBets(terms, first, second, upcard);\n  const playerNatural = blackjackHandValue(player.cards) === 21,\n    dealerNatural = blackjackHandValue(round.dealer) === 21;\n  if (playerNatural) {\n    player.isBlackjack = true;\n    player.result = dealerNatural ? \"push\" : \"win\";\n    finish(round);\n  } else if (dealerNatural && blackjackCardValue(upcard) === 10) {\n    player.result = \"lose\";\n    finish(round);\n  } else if (blackjackCardRank(upcard) === \"A\") {\n    if (terms.insuranceMinor > 0n) round.insuranceOffered = true;\n    else if (dealerNatural) {\n      player.result = \"lose\";\n      finish(round);\n    }\n  }\n  return round;\n}\n\nfunction current(round: Round): Hand {\n  return round.hands[round.currentHandIndex];\n}\n\nfunction available(round: Round): BlackjackRulesAction[] {\n  if (round.finished) return [];\n  if (round.insuranceOffered) return [\"insurance\"];\n  const target = current(round);\n  return BLACKJACK_BUTTONS.filter((action) => target.availableActions[action]);\n}\n\nexport function validateBlackjackDecision(input: unknown): BlackjackDecision {\n  if (!object(input)) throw new RangeError(\"Invalid Blackjack decision\");\n  const keys = Object.keys(input).filter((key) => input[key] !== undefined);\n  if (keys.some((key) => key !== \"action\" && key !== \"choice\" && key !== \"expectedStep\"))\n    throw new RangeError(\"Invalid Blackjack decision\");\n  const action = input.action,\n    step = input.expectedStep;\n  if (typeof action !== \"string\" || !(BLACKJACK_ACTIONS as readonly string[]).includes(action))\n    throw new RangeError(\"Choose Hit, Stand, Double, Split or Insurance\");\n  if (typeof step !== \"number\" || !Number.isSafeInteger(step) || step < 0)\n    throw new RangeError(\"Blackjack actions require the expected step\");\n  if (action === \"insurance\") {\n    const choice = input.choice;\n    if (\n      typeof choice !== \"string\" ||\n      !(BLACKJACK_INSURANCE_CHOICES as readonly string[]).includes(choice)\n    )\n      throw new RangeError(\"Accept or decline insurance\");\n    return Object.freeze({\n      action,\n      choice: choice as BlackjackInsuranceChoice,\n      expectedStep: step,\n    });\n  }\n  if (input.choice !== undefined) throw new RangeError(\"Only insurance takes a choice\");\n  return Object.freeze({ action: action as BlackjackRulesAction, expectedStep: step });\n}\n\nfunction permitted(round: Round, decision: BlackjackDecision) {\n  if (decision.expectedStep !== round.decisions.length)\n    throw new RangeError(\"This Blackjack hand has changed. Refresh your active round.\");\n  if (round.finished) throw new RangeError(\"This Blackjack round is already settled\");\n  if (round.insuranceOffered && decision.action !== \"insurance\")\n    throw new RangeError(\"Insurance must be accepted or declined first\");\n  if (!available(round).includes(decision.action))\n    throw new RangeError(`${decision.action} is not available for this Blackjack hand`);\n}\n\nfunction extraFor(terms: Terms, decision: BlackjackDecision): bigint {\n  if (decision.action === \"double\" || decision.action === \"split\") return terms.baseMinor;\n  if (decision.action === \"insurance\" && decision.choice === \"accept\") return terms.insuranceMinor;\n  return 0n;\n}\n\nfunction apply(round: Round, input: unknown) {\n  const decision = validateBlackjackDecision(input);\n  permitted(round, decision);\n  const extra = extraFor(round.terms, decision),\n    base = round.terms.baseMinor;\n  round.decisions.push(decision);\n  round.extraMinor += extra;\n  round.lastExtraMinor = extra;\n  if (decision.action === \"insurance\") {\n    round.insuranceOffered = false;\n    round.insuranceTaken = decision.choice === \"accept\";\n    if (blackjackHandValue(round.dealer) === 21) {\n      for (const target of round.hands) target.result = \"lose\";\n      round.insuranceWon = round.insuranceTaken;\n      finish(round);\n    }\n    return;\n  }\n  const target = current(round);\n  if (decision.action === \"split\") {\n    const [first, second] = target.cards,\n      aces = blackjackCardRank(first) === \"A\" && blackjackCardRank(second) === \"A\",\n      left = hand([first, draw(round)], base, true),\n      right = hand([second, draw(round)], base, true);\n    round.isSplit = true;\n    round.hands.splice(round.currentHandIndex, 1, left, right);\n    if (aces) {\n      for (const split of [left, right]) {\n        split.isStand = true;\n        close(split);\n      }\n      dealerTurn(round);\n      return;\n    }\n    if (blackjackHandValue(left.cards) === 21) {\n      left.isStand = true;\n      close(left);\n      round.currentHandIndex += 1;\n    }\n    if (blackjackHandValue(right.cards) === 21) {\n      right.isStand = true;\n      close(right);\n    }\n    if (left.isStand && right.isStand) advance(round);\n    return;\n  }\n  if (decision.action === \"double\") {\n    target.isDoubled = true;\n    target.stakeMinor += base;\n    target.cards.push(draw(round));\n    if (blackjackHandValue(target.cards) > 21) {\n      target.isBust = true;\n      target.result = \"lose\";\n    }\n    close(target);\n    advance(round);\n    return;\n  }\n  if (decision.action === \"stand\") {\n    target.isStand = true;\n    close(target);\n    advance(round);\n    return;\n  }\n  target.cards.push(draw(round));\n  const value = blackjackHandValue(target.cards);\n  if (value > 21) {\n    target.isBust = true;\n    target.result = \"lose\";\n    close(target);\n    advance(round);\n  } else if (value === 21) {\n    close(target);\n    advance(round);\n  } else target.availableActions = { double: false, hit: true, split: false, stand: true };\n}\n\nfunction handPayout(target: Hand): bigint {\n  if (target.result === \"push\") return target.stakeMinor;\n  if (target.result !== \"win\") return 0n;\n  return gross(target.stakeMinor, target.isBlackjack ? NATURAL : STANDARD);\n}\n\nfunction settlement(round: Round) {\n  const mainMinor = round.hands.reduce((sum, target) => sum + handPayout(target), 0n),\n    insuranceMinor = round.insuranceWon ? gross(round.terms.insuranceMinor, INSURANCE) : 0n,\n    sideBetsMinor = round.sideBets.reduce((sum, side) => sum + BigInt(side.payoutMinor), 0n),\n    uncappedMinor = mainMinor + insuranceMinor + sideBetsMinor;\n  return {\n    mainMinor,\n    insuranceMinor,\n    sideBetsMinor,\n    uncappedMinor,\n    payoutMinor: paysInFull(round.terms.version)\n      ? uncappedMinor\n      : minimum(uncappedMinor, BLACKJACK_MAX_PAYOUT_MINOR),\n  };\n}\n\nfunction mainWagerMinor(round: Round): bigint {\n  return (\n    round.hands.reduce((sum, target) => sum + target.stakeMinor, 0n) +\n    (round.insuranceTaken ? round.terms.insuranceMinor : 0n)\n  );\n}\n\nfunction publicHands(round: Round): BlackjackPublicHand[] {\n  return round.hands.map((target, index) => ({\n    cards: [...target.cards],\n    stakeMinor: target.stakeMinor.toString(),\n    availableActions: { ...target.availableActions },\n    isActive: !round.finished && index === round.currentHandIndex,\n    isBust: target.isBust,\n    isDoubled: target.isDoubled,\n    isStand: target.isStand,\n    isSplit: target.isSplit,\n    isBlackjack: target.isBlackjack,\n    result: target.result,\n    total: blackjackHandTotals(target.cards),\n    value: blackjackHandValue(target.cards),\n    payoutMinor: round.finished ? handPayout(target).toString() : null,\n  }));\n}\n\nfunction publicDealer(round: Round): BlackjackPublicDealer {\n  const visible = round.isRevealed ? [...round.dealer] : [round.dealer[0]];\n  return {\n    cards: round.isRevealed ? [...round.dealer] : [round.dealer[0], null],\n    isRevealed: round.isRevealed,\n    isBlackjack:\n      round.isRevealed && round.dealer.length === 2 && blackjackHandValue(round.dealer) === 21,\n    total: blackjackHandTotals(visible),\n    value: blackjackHandValue(visible),\n  };\n}\n\nfunction copyParameters(parameters: BlackjackParameters): BlackjackParameters {\n  return parameters.sideBets ? { sideBets: { ...parameters.sideBets } } : {};\n}\n\nfunction payoutTable() {\n  return BLACKJACK_PAYOUT_SECTIONS.map((section) => ({\n    title: section.title,\n    rows: section.rows.map((row) => ({ ...row })),\n  }));\n}\n\nfunction resolution(round: Round, acted: boolean): Resolution {\n  const terms = round.terms,\n    settled = round.finished ? settlement(round) : null,\n    payout = settled ? settled.payoutMinor : 0n,\n    totalStake = terms.stakeMinor + round.extraMinor,\n    capped = !!settled && settled.uncappedMinor > settled.payoutMinor;\n  const result: BlackjackPublicResult = {\n    blackjackRules: terms.version,\n    settlementPolicy: BLACKJACK_PC_POLICY,\n    parameters: copyParameters(terms.parameters),\n    status: round.finished ? \"finished\" : \"playerTurn\",\n    step: round.decisions.length,\n    actions: available(round),\n    currentHandIndex: round.currentHandIndex,\n    isSplit: round.isSplit,\n    insuranceOffered: round.insuranceOffered,\n    insuranceTaken: round.insuranceTaken,\n    insuranceWon: round.insuranceWon,\n    initialStakeMinor: terms.stakeMinor.toString(),\n    totalStakeMinor: totalStake.toString(),\n    baseBetMinor: terms.baseMinor.toString(),\n    betAmountMinor: mainWagerMinor(round).toString(),\n    insuranceCostMinor: terms.insuranceMinor.toString(),\n    dealerHand: publicDealer(round),\n    playerHands: publicHands(round),\n    sideBetsResult: round.sideBets.map((side) => ({ ...side })),\n    decisions: round.decisions.map((decision) => ({ ...decision })),\n    payoutTable: payoutTable(),\n    rtp: {\n      entries: BLACKJACK_RTP_ENTRIES.map((entry) => ({ ...entry })),\n      perfectPair: { ...PERFECT_PAIR_RTP },\n      twentyOne: { ...TWENTY_ONE_RTP },\n    },\n    maxPayoutMinor: blackjackLimits(terms.version).maxPayoutMinor.toString(),\n    maximumPayoutMinor: terms.maximumPayoutMinor.toString(),\n    capped,\n  };\n  if (settled) {\n    result.payoutMinor = settled.payoutMinor.toString();\n    result.mainPayoutMinor = settled.mainMinor.toString();\n    result.insurancePayoutMinor = settled.insuranceMinor.toString();\n    result.sideBetsPayoutMinor = settled.sideBetsMinor.toString();\n    result.drawnCards = round.source.slice(0, round.used);\n    if (capped) result.uncappedPayoutMinor = settled.uncappedMinor.toString();\n  }\n  return {\n    game: \"blackjack\",\n    status: round.finished ? (payout > 0n ? \"WON\" : \"LOST\") : \"ACTIVE\",\n    payoutMinor: payout.toString(),\n    multiplier: ratioText(ratio(payout, totalStake)),\n    result,\n    ...(round.finished\n      ? {}\n      : {\n          secret: {\n            blackjackRules: terms.version,\n            parameters: copyParameters(terms.parameters),\n            initialStakeMinor: terms.stakeMinor.toString(),\n            cards: round.source.slice(0, round.used),\n            decisions: round.decisions.map((decision) => ({ ...decision })),\n          },\n        }),\n    rulesVersion: terms.version,\n    ...(acted ? { additionalStakeMinor: round.lastExtraMinor.toString() } : {}),\n  };\n}\n\nfunction cards(input: unknown): BlackjackCard[] {\n  if (!Array.isArray(input) || input.length > BLACKJACK_SHOE_SIZE)\n    throw new RangeError(\"Invalid Blackjack card sequence\");\n  return Array.from(input, (value) => {\n    if (!isBlackjackCard(value)) throw new RangeError(\"Invalid Blackjack card sequence\");\n    return value;\n  });\n}\n\nfunction shoeCards(input: unknown): BlackjackCard[] {\n  const sequence = cards(input),\n    copies = new Map<BlackjackCard, number>();\n  for (const value of sequence) {\n    const count = (copies.get(value) ?? 0) + 1;\n    if (count > BLACKJACK_DECKS) throw new RangeError(\"Invalid Blackjack card sequence\");\n    copies.set(value, count);\n  }\n  return sequence;\n}\n\nfunction same(a: unknown, b: unknown): boolean {\n  return JSON.stringify(a) === JSON.stringify(b);\n}\n\nfunction readSecret(\n  input: unknown,\n  parameters: unknown,\n  version: BlackjackRulesVersion = BLACKJACK_RULES_VERSION,\n): Secret {\n  if (!object(input)) throw new RangeError(\"Invalid Blackjack state\");\n  const keys = Object.keys(input).sort();\n  if (\n    !same(keys, [\"blackjackRules\", \"cards\", \"decisions\", \"initialStakeMinor\", \"parameters\"]) ||\n    input.blackjackRules !== version ||\n    typeof input.initialStakeMinor !== \"string\" ||\n    !STAKE.test(input.initialStakeMinor) ||\n    !Array.isArray(input.decisions)\n  )\n    throw new RangeError(\"Invalid Blackjack state\");\n  const validated = validateBlackjackParameters(input.parameters);\n  if (!same(validated, validateBlackjackParameters(parameters)))\n    throw new RangeError(\"Blackjack parameters do not match the active round\");\n  return {\n    parameters: validated,\n    initialStakeMinor: BigInt(input.initialStakeMinor),\n    cards: shoeCards(input.cards),\n    decisions: input.decisions.map(validateBlackjackDecision),\n  };\n}\n\nfunction replay(\n  terms: Terms,\n  source: readonly BlackjackCard[],\n  decisions: readonly unknown[],\n): Round {\n  const round = deal(terms, source);\n  for (const decision of decisions) apply(round, decision);\n  return round;\n}\n\nfunction restore(\n  secret: Secret,\n  source: readonly BlackjackCard[],\n  version: BlackjackRulesVersion = BLACKJACK_RULES_VERSION,\n): Round {\n  const round = replay(\n    blackjackStakeTerms(secret.initialStakeMinor, secret.parameters, version),\n    source,\n    secret.decisions,\n  );\n  if (\n    round.finished ||\n    round.used !== secret.cards.length ||\n    secret.cards.some((value, index) => source[index] !== value)\n  )\n    throw new RangeError(\"This Blackjack round does not match its committed shoe\");\n  return round;\n}\n\nexport function playBlackjack(\n  input: {\n    stakeMinor: bigint;\n    parameters: unknown;\n    cards: readonly string[];\n    decisions?: readonly unknown[];\n  },\n  version: BlackjackRulesVersion = BLACKJACK_RULES_VERSION,\n): Resolution {\n  const terms = blackjackStakeTerms(input.stakeMinor, input.parameters, version),\n    decisions = input.decisions ?? [];\n  if (!Array.isArray(decisions)) throw new RangeError(\"Invalid Blackjack decisions\");\n  const round = replay(terms, cards(input.cards), decisions);\n  return resolution(round, decisions.length > 0);\n}\n\nexport async function resolveBlackjack(\n  input: ResolveInput<BlackjackParameters>,\n  version: BlackjackRulesVersion = BLACKJACK_RULES_VERSION,\n): Promise<Resolution> {\n  const terms = blackjackStakeTerms(input.stakeMinor, input.parameters, version);\n  const source = await createBlackjackShoe(input.entropy).ensure(4);\n  return resolution(deal(terms, source), false);\n}\n\nexport async function actBlackjack(\n  input: ActInput<BlackjackParameters>,\n  version: BlackjackRulesVersion = BLACKJACK_RULES_VERSION,\n): Promise<Resolution> {\n  const secret = readSecret(input.secret, input.parameters, version),\n    decision = validateBlackjackDecision(input.decision),\n    source = await createBlackjackShoe(input.entropy).ensure(\n      Math.min(BLACKJACK_SHOE_SIZE, secret.cards.length + BLACKJACK_TRANSITION_CARDS),\n    ),\n    round = restore(secret, source, version);\n  if (\n    typeof input.stakeMinor !== \"bigint\" ||\n    input.stakeMinor !== round.terms.stakeMinor + round.extraMinor\n  )\n    throw new RangeError(\"Invalid Blackjack stake\");\n  apply(round, decision);\n  return resolution(round, true);\n}\n\nexport function reconstructBlackjack(\n  input: Readonly<{\n    stakeMinor: bigint;\n    parameters: BlackjackParameters;\n    result: Record<string, unknown>;\n  }>,\n  version: BlackjackRulesVersion = BLACKJACK_RULES_VERSION,\n): Resolution {\n  const result = input.result;\n  if (\n    !object(result) ||\n    result.blackjackRules !== edition(version) ||\n    typeof result.initialStakeMinor !== \"string\" ||\n    !STAKE.test(result.initialStakeMinor) ||\n    !Array.isArray(result.decisions)\n  )\n    throw new RangeError(\"Invalid Blackjack result\");\n  const terms = blackjackStakeTerms(BigInt(result.initialStakeMinor), input.parameters, version),\n    source = shoeCards(result.drawnCards),\n    round = replay(terms, source, result.decisions);\n  if (!round.finished || round.used !== source.length)\n    throw new RangeError(\"Invalid Blackjack result\");\n  if (\n    typeof input.stakeMinor !== \"bigint\" ||\n    input.stakeMinor !== terms.stakeMinor + round.extraMinor\n  )\n    throw new RangeError(\"Invalid Blackjack stake\");\n  return resolution(round, result.decisions.length > 0);\n}\n\nexport function blackjackAdditionalStakeMinor(\n  input: Readonly<{\n    parameters: BlackjackParameters;\n    secret: Record<string, unknown>;\n    decision: Decision;\n  }>,\n  version: BlackjackRulesVersion = BLACKJACK_RULES_VERSION,\n): bigint {\n  const secret = readSecret(input.secret, input.parameters, version),\n    round = restore(secret, secret.cards, version),\n    decision = validateBlackjackDecision(input.decision);\n  permitted(round, decision);\n  return extraFor(round.terms, decision);\n}\n\nexport const blackjackRulesModule: GameRules<BlackjackParameters> = Object.freeze({\n  game: \"blackjack\",\n  version: BLACKJACK_RULES_VERSION,\n  stateful: true,\n  actions: BLACKJACK_ACTIONS,\n  closingActions: BLACKJACK_CLOSING_ACTIONS,\n  limits: BLACKJACK_GAME_LIMITS,\n  admitStake: admitBlackjackStake,\n  validateParameters: validateBlackjackParameters,\n  maximumPayoutMinor: blackjackMaximumPayoutMinor,\n  maximumStakeMinor: blackjackMaximumStakeMinor,\n  resolve: resolveBlackjack,\n  act: actBlackjack,\n  additionalStakeMinor: blackjackAdditionalStakeMinor,\n  reconstruct: reconstructBlackjack,\n});\n\nexport const blackjack2RulesModule: GameRules<BlackjackParameters> = Object.freeze({\n  game: \"blackjack\",\n  version: BLACKJACK_2_RULES_VERSION,\n  stateful: true,\n  actions: BLACKJACK_ACTIONS,\n  closingActions: BLACKJACK_CLOSING_ACTIONS,\n  limits: BLACKJACK_2_GAME_LIMITS,\n  admitStake: admitBlackjack2Stake,\n  validateParameters: validateBlackjackParameters,\n  maximumPayoutMinor: (stakeMinor: bigint, parameters: BlackjackParameters) =>\n    blackjackMaximumPayoutMinor(stakeMinor, parameters, BLACKJACK_2_RULES_VERSION),\n  maximumStakeMinor: blackjackMaximumStakeMinor,\n  resolve: (input: ResolveInput<BlackjackParameters>) =>\n    resolveBlackjack(input, BLACKJACK_2_RULES_VERSION),\n  act: (input: ActInput<BlackjackParameters>) => actBlackjack(input, BLACKJACK_2_RULES_VERSION),\n  additionalStakeMinor: (\n    input: Readonly<{\n      parameters: BlackjackParameters;\n      secret: Record<string, unknown>;\n      decision: Decision;\n    }>,\n  ) => blackjackAdditionalStakeMinor(input, BLACKJACK_2_RULES_VERSION),\n  reconstruct: (\n    input: Readonly<{\n      stakeMinor: bigint;\n      parameters: BlackjackParameters;\n      result: Record<string, unknown>;\n    }>,\n  ) => reconstructBlackjack(input, BLACKJACK_2_RULES_VERSION),\n});\n","sha256":"40d55754a090dd6e6dda7cc353e3302b70f6cefd039cce3a1ed9328752b8a2ed"},"src/lib/games/blackjack-cards.ts":{"source":"export const BLACKJACK_RANKS = Object.freeze([\n  \"A\",\n  \"2\",\n  \"3\",\n  \"4\",\n  \"5\",\n  \"6\",\n  \"7\",\n  \"8\",\n  \"9\",\n  \"T\",\n  \"J\",\n  \"Q\",\n  \"K\",\n] as const);\nexport const BLACKJACK_SUITS = Object.freeze([\"c\", \"d\", \"h\", \"s\"] as const);\nexport const BLACKJACK_DECKS = 8;\nexport const BLACKJACK_SHOE_SIZE =\n  BLACKJACK_DECKS * BLACKJACK_RANKS.length * BLACKJACK_SUITS.length;\nexport const BLACKJACK_SIDE_BETS = Object.freeze([\"perfect_pair\", \"21_plus_3\"] as const);\nexport const BLACKJACK_SIDE_BET_LABELS = Object.freeze({\n  \"21_plus_3\": \"21+3\",\n  perfect_pair: \"Perfect Pair\",\n} as const);\nexport const BLACKJACK_SIDE_BET_RESULT_NAMES = Object.freeze({\n  coloured_pair: \"Colored Pair\",\n  flush: \"Flush\",\n  mixed_pair: \"Mixed Pair\",\n  perfect_pair: \"Perfect Pair\",\n  straight: \"Straight\",\n  straight_flush: \"Straight Flush\",\n  suited_trips: \"Suited Trips\",\n  three_of_a_kind: \"Three of a Kind\",\n} as const);\nexport const BLACKJACK_MAIN_ODDS = Object.freeze({\n  standard_win: \"1:1\",\n  insurance: \"2:1\",\n  blackjack: \"3:2\",\n} as const);\nexport const BLACKJACK_PERFECT_PAIR_ODDS = Object.freeze({\n  perfect_pair: \"25:1\",\n  coloured_pair: \"12:1\",\n  mixed_pair: \"6:1\",\n} as const);\nexport const BLACKJACK_TWENTY_ONE_ODDS = Object.freeze({\n  suited_trips: \"100:1\",\n  straight_flush: \"40:1\",\n  three_of_a_kind: \"30:1\",\n  straight: \"10:1\",\n  flush: \"5:1\",\n} as const);\nexport const BLACKJACK_PAYOUT_SECTIONS = Object.freeze([\n  Object.freeze({\n    title: null,\n    rows: Object.freeze([\n      Object.freeze({\n        description: null,\n        label: \"payouts_standard_win\",\n        value: BLACKJACK_MAIN_ODDS.standard_win,\n      }),\n      Object.freeze({\n        description: null,\n        label: \"blackjack_insurance\",\n        value: BLACKJACK_MAIN_ODDS.insurance,\n      }),\n      Object.freeze({\n        description: null,\n        label: \"games_blackjack_title\",\n        value: BLACKJACK_MAIN_ODDS.blackjack,\n      }),\n    ]),\n  }),\n  Object.freeze({\n    title: \"payouts_perfect_pairs_sidebet\",\n    rows: Object.freeze(\n      Object.entries(BLACKJACK_PERFECT_PAIR_ODDS).map(([name, value]) =>\n        Object.freeze({\n          description: `payouts_${name}_desc`,\n          label: `payouts_${name}`,\n          value,\n        }),\n      ),\n    ),\n  }),\n  Object.freeze({\n    title: \"payouts_213_sidebet\",\n    rows: Object.freeze(\n      Object.entries(BLACKJACK_TWENTY_ONE_ODDS).map(([name, value]) =>\n        Object.freeze({\n          description: `payouts_${name}_desc`,\n          label: `payouts_${name}`,\n          value,\n        }),\n      ),\n    ),\n  }),\n]);\nexport const BLACKJACK_RTP_ENTRIES = Object.freeze([\n  Object.freeze({ labelKey: \"standard\", rtp: 99.6 }),\n  Object.freeze({ labelKey: \"payouts_perfect_pairs_sidebet\", rtp: 95.9 }),\n  Object.freeze({ labelKey: \"payouts_213_sidebet\", rtp: 96.3 }),\n]);\n\nexport type BlackjackRank = (typeof BLACKJACK_RANKS)[number];\nexport type BlackjackSuit = (typeof BLACKJACK_SUITS)[number];\nexport type BlackjackCard = `${BlackjackRank}${BlackjackSuit}`;\nexport type BlackjackSideBetType = (typeof BLACKJACK_SIDE_BETS)[number];\nexport type BlackjackPerfectPairResult = keyof typeof BLACKJACK_PERFECT_PAIR_ODDS;\nexport type BlackjackTwentyOneResult = keyof typeof BLACKJACK_TWENTY_ONE_ODDS;\nexport type BlackjackSideBetResult = BlackjackPerfectPairResult | BlackjackTwentyOneResult;\nexport type BlackjackRatio = Readonly<{ numerator: bigint; denominator: bigint }>;\nexport type BlackjackTotals = Readonly<{ low: number; high: number }>;\n\nconst values: Readonly<Record<BlackjackRank, number>> = Object.freeze({\n  2: 2,\n  3: 3,\n  4: 4,\n  5: 5,\n  6: 6,\n  7: 7,\n  8: 8,\n  9: 9,\n  A: 11,\n  J: 10,\n  K: 10,\n  Q: 10,\n  T: 10,\n});\nconst order: Readonly<Record<BlackjackRank, number>> = Object.freeze(\n  Object.fromEntries(BLACKJACK_RANKS.map((rank, index) => [rank, index + 1])) as Record<\n    BlackjackRank,\n    number\n  >,\n);\nconst red: ReadonlySet<BlackjackSuit> = new Set<BlackjackSuit>([\"d\", \"h\"]);\nconst deck: readonly BlackjackCard[] = Object.freeze(\n  BLACKJACK_SUITS.flatMap((suit) =>\n    BLACKJACK_RANKS.map((rank) => `${rank}${suit}` as BlackjackCard),\n  ),\n);\nconst known: ReadonlySet<string> = new Set<string>(deck);\n\nfunction reduce(numerator: bigint, denominator: bigint): BlackjackRatio {\n  let a = numerator,\n    b = denominator;\n  while (b) [a, b] = [b, a % b];\n  return Object.freeze({ numerator: numerator / a, denominator: denominator / a });\n}\n\nexport function isBlackjackCard(value: unknown): value is BlackjackCard {\n  return typeof value === \"string\" && known.has(value);\n}\n\nfunction card(value: unknown): BlackjackCard {\n  if (!isBlackjackCard(value)) throw new RangeError(\"Invalid Blackjack card\");\n  return value;\n}\n\nexport function blackjackCardRank(value: BlackjackCard): BlackjackRank {\n  return card(value).slice(0, -1) as BlackjackRank;\n}\n\nexport function blackjackCardSuit(value: BlackjackCard): BlackjackSuit {\n  return card(value).slice(-1) as BlackjackSuit;\n}\n\nexport function blackjackCardValue(value: BlackjackCard): number {\n  return values[blackjackCardRank(value)];\n}\n\nexport function blackjackHandValue(cards: readonly BlackjackCard[]): number {\n  let total = 0,\n    aces = 0;\n  for (const value of cards) {\n    if (blackjackCardRank(value) === \"A\") {\n      aces += 1;\n      total += 11;\n    } else total += blackjackCardValue(value);\n  }\n  while (total > 21 && aces > 0) {\n    total -= 10;\n    aces -= 1;\n  }\n  return total;\n}\n\nexport function blackjackHandTotals(cards: readonly BlackjackCard[]): BlackjackTotals {\n  if (cards.length === 0) return Object.freeze({ low: 0, high: 0 });\n  const low = cards.reduce(\n    (sum, value) => sum + (blackjackCardRank(value) === \"A\" ? 1 : blackjackCardValue(value)),\n    0,\n  );\n  const aces = cards.filter((value) => blackjackCardRank(value) === \"A\").length;\n  let high = low;\n  for (let index = 1; index <= aces; index++) {\n    const next = low + 10 * index;\n    if (next <= 21) high = next;\n    else break;\n  }\n  return Object.freeze({ low, high });\n}\n\nexport function blackjackCanSplit(first: BlackjackCard, second: BlackjackCard): boolean {\n  return blackjackCardValue(first) === blackjackCardValue(second);\n}\n\nexport function blackjackDeck(): BlackjackCard[] {\n  return Array.from({ length: BLACKJACK_DECKS }, () => [...deck]).flat();\n}\n\nexport function blackjackOdds(value: string): BlackjackRatio {\n  const match = /^([1-9]\\d*):([1-9]\\d*)$/.exec(value);\n  if (!match) throw new RangeError(\"Invalid Blackjack odds\");\n  const net = BigInt(match[1]),\n    base = BigInt(match[2]);\n  return reduce(net + base, base);\n}\n\nexport function blackjackPerfectPair(\n  first: BlackjackCard,\n  second: BlackjackCard,\n): BlackjackPerfectPairResult | null {\n  if (blackjackCardRank(first) !== blackjackCardRank(second)) return null;\n  const a = blackjackCardSuit(first),\n    b = blackjackCardSuit(second);\n  if (a === b) return \"perfect_pair\";\n  return red.has(a) === red.has(b) ? \"coloured_pair\" : \"mixed_pair\";\n}\n\nexport function blackjackTwentyOne(\n  first: BlackjackCard,\n  second: BlackjackCard,\n  upcard: BlackjackCard,\n): BlackjackTwentyOneResult | null {\n  const cards = [first, second, upcard];\n  const ranks = cards.map((value) => order[blackjackCardRank(value)]).sort((a, b) => a - b);\n  const suits = cards.map(blackjackCardSuit);\n  const sameRank = ranks[0] === ranks[2];\n  const sameSuit = suits[0] === suits[1] && suits[1] === suits[2];\n  const sequential =\n    (ranks[1] === ranks[0] + 1 && ranks[2] === ranks[1] + 1) ||\n    (ranks[0] === 1 && ranks[1] === 12 && ranks[2] === 13);\n  if (sameRank && sameSuit) return \"suited_trips\";\n  if (sequential && sameSuit) return \"straight_flush\";\n  if (sameRank) return \"three_of_a_kind\";\n  if (sequential) return \"straight\";\n  if (sameSuit) return \"flush\";\n  return null;\n}\n\nexport function blackjackSideBetOdds(\n  type: BlackjackSideBetType,\n  result: BlackjackSideBetResult,\n): string {\n  const table: Readonly<Record<string, string>> =\n    type === \"perfect_pair\" ? BLACKJACK_PERFECT_PAIR_ODDS : BLACKJACK_TWENTY_ONE_ODDS;\n  if (!Object.hasOwn(table, result)) throw new RangeError(\"Invalid Blackjack side bet result\");\n  return table[result];\n}\n\nfunction falling(start: bigint, count: number): bigint {\n  let product = 1n;\n  for (let index = 0n; index < BigInt(count); index++) product *= start - index;\n  return product;\n}\n\nexport function blackjackPerfectPairCounts() {\n  const copies = BigInt(BLACKJACK_DECKS),\n    types = BigInt(BLACKJACK_RANKS.length * BLACKJACK_SUITS.length),\n    sameColourSuits = 1n,\n    otherColourSuits = 2n;\n  return Object.freeze({\n    perfect_pair: types * copies * (copies - 1n),\n    coloured_pair: types * copies * copies * sameColourSuits,\n    mixed_pair: types * copies * copies * otherColourSuits,\n    total: falling(BigInt(BLACKJACK_SHOE_SIZE), 2),\n  });\n}\n\nexport function blackjackTwentyOneCounts() {\n  const copies = BigInt(BLACKJACK_DECKS),\n    ranks = BigInt(BLACKJACK_RANKS.length),\n    suits = BigInt(BLACKJACK_SUITS.length),\n    sequences = ranks - 1n,\n    orders = 6n,\n    perRank = copies * suits,\n    perSuit = copies * ranks;\n  const suited_trips = ranks * suits * falling(copies, 3);\n  const straight_flush = sequences * suits * orders * copies ** 3n;\n  const three_of_a_kind = ranks * falling(perRank, 3) - suited_trips;\n  const straight = sequences * orders * perRank ** 3n - straight_flush;\n  const flush = suits * falling(perSuit, 3) - suited_trips - straight_flush;\n  return Object.freeze({\n    suited_trips,\n    straight_flush,\n    three_of_a_kind,\n    straight,\n    flush,\n    total: falling(BigInt(BLACKJACK_SHOE_SIZE), 3),\n  });\n}\n\nfunction expectation(\n  counts: Readonly<Record<string, bigint>>,\n  odds: Readonly<Record<string, string>>,\n): BlackjackRatio {\n  let numerator = 0n,\n    denominator = 1n;\n  for (const [name, value] of Object.entries(odds)) {\n    const gross = blackjackOdds(value);\n    numerator = numerator * gross.denominator + counts[name] * gross.numerator * denominator;\n    denominator *= gross.denominator;\n  }\n  return reduce(numerator, denominator * counts.total);\n}\n\nexport function blackjackPerfectPairRtp(): BlackjackRatio {\n  return expectation(blackjackPerfectPairCounts(), BLACKJACK_PERFECT_PAIR_ODDS);\n}\n\nexport function blackjackTwentyOneRtp(): BlackjackRatio {\n  return expectation(blackjackTwentyOneCounts(), BLACKJACK_TWENTY_ONE_ODDS);\n}\n","sha256":"d97ec0e587462c113ca2af8e5823dc44b745017f203339219a90cdb58863f9d6"},"src/lib/games/blackjack-shoe.ts":{"source":"import type { Entropy } from \"./rules\";\nimport { BLACKJACK_SHOE_SIZE, blackjackDeck, type BlackjackCard } from \"./blackjack-cards\";\n\nexport const BLACKJACK_HASH = \"SHA-512\" as const;\nexport const BLACKJACK_GAME_NAME = \"blackjack\" as const;\nexport const BLACKJACK_VERIFIER_CARDS = 16;\nexport const BLACKJACK_DIGEST_BYTES = 64;\n\nconst RANGE = 0x1_0000_0000;\nconst REJECTION_LIMIT = 4096;\n\nexport type BlackjackShoe = Readonly<{\n  cards: readonly BlackjackCard[];\n  ensure(count: number): Promise<readonly BlackjackCard[]>;\n}>;\n\nexport function blackjackMessage(clientSeed: string, nonce: number, round: number): string {\n  return `${clientSeed}:${nonce}:${BLACKJACK_GAME_NAME}:${round}`;\n}\n\nexport function assertBlackjackEntropy(entropy: Entropy): void {\n  if (\n    !entropy ||\n    typeof entropy !== \"object\" ||\n    typeof entropy.serverSeed !== \"string\" ||\n    !entropy.serverSeed.trim() ||\n    typeof entropy.clientSeed !== \"string\" ||\n    !entropy.clientSeed.trim() ||\n    typeof entropy.nonce !== \"number\" ||\n    !Number.isSafeInteger(entropy.nonce) ||\n    entropy.nonce < 0 ||\n    typeof entropy.hmac !== \"function\"\n  )\n    throw new RangeError(\"Invalid Blackjack fairness seed\");\n}\n\nexport function createBlackjackShoe(entropy: Entropy): BlackjackShoe {\n  assertBlackjackEntropy(entropy);\n  const deck = blackjackDeck(),\n    drawn: BlackjackCard[] = [];\n  let round = 0,\n    offset = BLACKJACK_DIGEST_BYTES,\n    block: Uint8Array = new Uint8Array(0),\n    pending: Promise<unknown> = Promise.resolve();\n  async function word(): Promise<number> {\n    if (offset >= block.length) {\n      const digest = await entropy.hmac(\n        BLACKJACK_HASH,\n        entropy.serverSeed,\n        blackjackMessage(entropy.clientSeed, entropy.nonce, round),\n      );\n      if (!(digest instanceof Uint8Array) || digest.byteLength !== BLACKJACK_DIGEST_BYTES)\n        throw new RangeError(\"Invalid Blackjack HMAC digest\");\n      block = digest;\n      offset = 0;\n      round += 1;\n    }\n    const value =\n      ((block[offset] << 24) |\n        (block[offset + 1] << 16) |\n        (block[offset + 2] << 8) |\n        block[offset + 3]) >>>\n      0;\n    offset += 4;\n    return value;\n  }\n  async function unbiased(range: number): Promise<number> {\n    const limit = RANGE - (RANGE % range);\n    for (let attempt = 0; attempt < REJECTION_LIMIT; attempt++) {\n      const value = await word();\n      if (value < limit) return value % range;\n    }\n    throw new RangeError(\"Failed to generate unbiased value: entropy stream exhausted\");\n  }\n  async function fill(count: number) {\n    while (drawn.length < count) {\n      const remaining = deck.length - drawn.length,\n        index = await unbiased(remaining),\n        last = remaining - 1,\n        top = deck[last];\n      deck[last] = deck[index];\n      deck[index] = top;\n      drawn.push(deck[last]);\n    }\n    return drawn;\n  }\n  return Object.freeze({\n    cards: drawn,\n    ensure(count: number) {\n      if (!Number.isSafeInteger(count) || count < 0 || count > BLACKJACK_SHOE_SIZE)\n        return Promise.reject(new RangeError(\"Invalid Blackjack draw count\"));\n      const next = pending.then(() => fill(count));\n      pending = next.catch(() => undefined);\n      return next;\n    },\n  });\n}\n\nexport async function blackjackShoeCards(\n  entropy: Entropy,\n  count: number = BLACKJACK_SHOE_SIZE,\n): Promise<BlackjackCard[]> {\n  return [...(await createBlackjackShoe(entropy).ensure(count))];\n}\n\nexport function blackjackVerifierCards(entropy: Entropy): Promise<BlackjackCard[]> {\n  return blackjackShoeCards(entropy, BLACKJACK_VERIFIER_CARDS);\n}\n","sha256":"d117427ad9f887f7cad459cb2c868829267cfba4c7b3c801b92ae37ffb569e87"},"src/lib/games/coinflip-rules.ts":{"source":"import { MAX_PAYOUT_MINOR, ratioText, type Resolution } from \"@/games/math\";\nimport type { Entropy, GameRules } from \"./rules\";\nimport { gameLimits, maxWinLimits } from \"./limits\";\nimport { admitMaxWinStake, type MaxWinRatio } from \"./max-win\";\nimport { maxWinSuccessor } from \"./max-win-successor\";\n\nexport const COINFLIP_RULES_VERSION = \"peeks-coinflip-2\" as const;\nexport const COINFLIP_3_RULES_VERSION = \"peeks-coinflip-3\" as const;\nexport const COINFLIP_RULES_VERSIONS = Object.freeze([\n  COINFLIP_RULES_VERSION,\n  COINFLIP_3_RULES_VERSION,\n] as const);\nexport type CoinflipRulesVersion = (typeof COINFLIP_RULES_VERSIONS)[number];\nexport const COINFLIP_SETTLEMENT_POLICY = \"peeks-pc-floor-cap-1\" as const;\nexport const COINFLIP_GAME = \"coinflip\" as const;\nexport const COINFLIP_HASH = \"SHA-256\" as const;\nexport const COINFLIP_CURSOR = 0;\nexport const COINFLIP_MIN_COIN_COUNT = 1;\nexport const COINFLIP_MAX_COIN_COUNT = 20;\nexport const COINFLIP_DEFAULT_COIN_COUNT = 1;\nexport const COINFLIP_DEFAULT_TARGET = 1;\nexport const COINFLIP_DEFAULT_PLAY_MODE = \"classic\" as const;\nexport const COINFLIP_DEFAULT_SIDE = \"heads\" as const;\nexport const COINFLIP_SIDES = Object.freeze([\"heads\", \"tails\"] as const);\nexport const COINFLIP_BIT_VALUES = Object.freeze([\"tails\", \"heads\"] as const);\nexport const COINFLIP_MODES = Object.freeze([\"classic\", \"target\"] as const);\nexport const COINFLIP_HOUSE_EDGE_PERCENTAGE = \"1\" as const;\nexport const COINFLIP_TARGET_CHANCE_LIMIT = \"0.85\" as const;\nexport const COINFLIP_ODDS_DECIMALS = 4;\nexport const COINFLIP_CHANCE_SIGNIFICANT_DIGITS = 4;\nexport const COINFLIP_SPAM_GUARD_MS = 150;\nexport const COINFLIP_RATE_LIMIT = 60_000 / COINFLIP_SPAM_GUARD_MS;\nexport const COINFLIP_MIN_STAKE_MINOR = 10n;\nexport const COINFLIP_MAX_STAKE_MINOR = 1_000_000n;\nexport const COINFLIP_MAX_PAYOUT_MINOR = MAX_PAYOUT_MINOR;\nexport const COINFLIP_GAME_LIMITS = gameLimits(\"coinflip\");\nexport const COINFLIP_3_GAME_LIMITS = maxWinLimits(\"coinflip\");\nexport const COINFLIP_LIMITS = Object.freeze({\n  minMinor: COINFLIP_MIN_STAKE_MINOR,\n  maxMinor: COINFLIP_MAX_STAKE_MINOR,\n  maxPayoutMinor: COINFLIP_MAX_PAYOUT_MINOR,\n});\nexport const COINFLIP_TIMINGS = Object.freeze({\n  fastRevealDurationSeconds: 0.5,\n  instantDurationSeconds: 0.2,\n  revealDurationSeconds: 1.5,\n  revealSpinEase: \"back.out(0.7)\",\n  revealStaggerSeconds: 0.12,\n  revealStaggerWindowSeconds: 1,\n  selectionDurationSeconds: 0.5,\n} as const);\n\nexport type CoinflipSide = (typeof COINFLIP_SIDES)[number];\nexport type CoinflipMode = (typeof COINFLIP_MODES)[number];\nexport type CoinflipFlipKind = \"reveal\" | \"selection\";\nexport type CoinflipClassicParameters = {\n  animationDurationMs: number;\n  mode: \"classic\";\n  sides: CoinflipSide[];\n};\nexport type CoinflipTargetParameters = {\n  animationDurationMs: number;\n  coins: number;\n  mode: \"target\";\n  side: CoinflipSide;\n  target: number;\n};\nexport type CoinflipParameters = CoinflipClassicParameters | CoinflipTargetParameters;\nexport type CoinflipAnimationModes = Readonly<{\n  animationsMode: boolean;\n  fastReveal: boolean;\n  instantMode: boolean;\n}>;\nexport type CoinflipRatio = Readonly<{ numerator: bigint; denominator: bigint }>;\nexport type CoinflipWireRatio = Readonly<{\n  numerator: string;\n  denominator: string;\n  value: string;\n}>;\nexport type CoinflipOdds = Readonly<{ chance: string; multiplier: string }>;\nexport type CoinflipTerms = Readonly<{\n  parameters: CoinflipParameters;\n  mode: CoinflipMode;\n  coins: number;\n  requiredHits: number;\n  selectedSide: CoinflipSide;\n  selectedSides: readonly CoinflipSide[];\n  winningOutcomes: bigint;\n  totalOutcomes: bigint;\n  multiplierUnits: bigint;\n  quoteMultiplier: string;\n  chance: string;\n}>;\nexport type CoinflipDraw = Readonly<{\n  cursor: typeof COINFLIP_CURSOR;\n  uint32: number;\n}>;\nexport type CoinflipOutcome = Readonly<{\n  terms: CoinflipTerms;\n  results: readonly CoinflipSide[];\n  matches: readonly boolean[];\n  hits: number;\n  win: boolean;\n}>;\nexport type CoinflipResult = {\n  parameters: CoinflipParameters;\n  coinflipRules: CoinflipRulesVersion;\n  settlementPolicy: typeof COINFLIP_SETTLEMENT_POLICY;\n  mode: CoinflipMode;\n  coins: number;\n  requiredHits: number;\n  selectedSide: CoinflipSide;\n  selectedSides: CoinflipSide[];\n  draw: { cursor: typeof COINFLIP_CURSOR; uint32: number };\n  results: CoinflipSide[];\n  matches: boolean[];\n  hits: number;\n  win: boolean;\n  chance: string;\n  quoteMultiplier: string;\n  resultMultiplier: string;\n  winningOutcomes: string;\n  totalOutcomes: string;\n  winChance: CoinflipWireRatio;\n  nominalMultiplier: CoinflipWireRatio;\n  nominalRtp: CoinflipWireRatio;\n  theoreticalRtp: CoinflipWireRatio;\n  potentialPayoutMinor: string;\n  grossPayoutMinor: string;\n  maxPayoutMinor: string;\n  capped: boolean;\n  decisions: [];\n};\nexport type CoinflipResolution = Resolution & {\n  game: typeof COINFLIP_GAME;\n  status: \"WON\" | \"LOST\";\n  rulesVersion: CoinflipRulesVersion;\n  result: CoinflipResult;\n};\n\nconst CLASSIC_KEYS = Object.freeze([\"animationDurationMs\", \"mode\", \"sides\"]);\nconst TARGET_KEYS = Object.freeze([\"animationDurationMs\", \"coins\", \"mode\", \"side\", \"target\"]);\nconst DRAW_KEYS = Object.freeze([\"cursor\", \"uint32\"]);\nconst UNIT_SCALE = 10n ** BigInt(COINFLIP_ODDS_DECIMALS);\nconst RETURN_PERCENT = 100n - BigInt(COINFLIP_HOUSE_EDGE_PERCENTAGE);\nconst [LIMIT_WHOLE, LIMIT_FRACTION] = COINFLIP_TARGET_CHANCE_LIMIT.split(\".\");\nconst LIMIT_DENOMINATOR = 10n ** BigInt(LIMIT_FRACTION.length);\nconst LIMIT_NUMERATOR = BigInt(LIMIT_WHOLE) * LIMIT_DENOMINATOR + BigInt(LIMIT_FRACTION);\nconst invalidParameters = () => new RangeError(\"Invalid Coinflip parameters\");\n\nconst record = (value: unknown): value is Record<string, unknown> =>\n  !!value && typeof value === \"object\" && !Array.isArray(value);\n\nconst exactKeys = (value: Record<string, unknown>, keys: readonly string[]) =>\n  Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));\n\nconst tails: readonly (readonly bigint[])[] = Object.freeze(\n  Array.from({ length: COINFLIP_MAX_COIN_COUNT + 1 }, (_, coins) => {\n    const row = [1n];\n    for (let index = 1; index <= coins; index++)\n      row.push((row[index - 1] * BigInt(coins - index + 1)) / BigInt(index));\n    const sums = Array<bigint>(coins + 2).fill(0n);\n    for (let index = coins; index >= 0; index--) sums[index] = sums[index + 1] + row[index];\n    return Object.freeze(sums);\n  }),\n);\n\nfunction coinCount(value: unknown): number {\n  if (\n    typeof value !== \"number\" ||\n    !Number.isSafeInteger(value) ||\n    value < COINFLIP_MIN_COIN_COUNT ||\n    value > COINFLIP_MAX_COIN_COUNT\n  )\n    throw invalidParameters();\n  return value;\n}\n\nfunction sideOf(value: unknown): CoinflipSide {\n  if (value !== \"heads\" && value !== \"tails\") throw invalidParameters();\n  return value;\n}\n\nfunction reduced(numerator: bigint, denominator: bigint): CoinflipRatio {\n  if (numerator < 0n || denominator <= 0n) throw new RangeError(\"Invalid Coinflip ratio\");\n  let a = numerator,\n    b = denominator;\n  while (b) [a, b] = [b, a % b];\n  const divisor = a || 1n;\n  return Object.freeze({ numerator: numerator / divisor, denominator: denominator / divisor });\n}\n\nfunction wire(value: CoinflipRatio): CoinflipWireRatio {\n  return Object.freeze({\n    numerator: value.numerator.toString(),\n    denominator: value.denominator.toString(),\n    value: ratioText(value, 8),\n  });\n}\n\nfunction plain(units: bigint, scale: number): string {\n  const base = 10n ** BigInt(scale);\n  const fraction = (units % base).toString().padStart(scale, \"0\").replace(/0+$/, \"\");\n  return fraction ? `${units / base}.${fraction}` : (units / base).toString();\n}\n\nfunction fixed(units: bigint, scale: number): string {\n  const base = 10n ** BigInt(scale);\n  return `${units / base}.${(units % base).toString().padStart(scale, \"0\")}`;\n}\n\nexport function coinflipRevealStaggerSeconds(count: number, modes: CoinflipAnimationModes): number {\n  if (!modes.animationsMode || modes.instantMode || count <= 1) return 0;\n  return Math.min(\n    COINFLIP_TIMINGS.revealStaggerSeconds,\n    COINFLIP_TIMINGS.revealStaggerWindowSeconds / (count - 1),\n  );\n}\n\nexport function coinflipFlipDurationSeconds(\n  kind: CoinflipFlipKind,\n  modes: CoinflipAnimationModes,\n): number {\n  if (!modes.animationsMode) return 0;\n  if (kind === \"selection\") return COINFLIP_TIMINGS.selectionDurationSeconds;\n  if (modes.instantMode) return COINFLIP_TIMINGS.instantDurationSeconds;\n  return modes.fastReveal\n    ? COINFLIP_TIMINGS.fastRevealDurationSeconds\n    : COINFLIP_TIMINGS.revealDurationSeconds;\n}\n\nexport function coinflipAnimationDurationMs(coins: number, modes: CoinflipAnimationModes): number {\n  const count = Math.max(1, coinCount(coins));\n  return Math.round(\n    (coinflipRevealStaggerSeconds(count, modes) * (count - 1) +\n      coinflipFlipDurationSeconds(\"reveal\", modes)) *\n      1000,\n  );\n}\n\nconst durations: Readonly<Record<CoinflipMode, readonly (readonly number[])[]>> = Object.freeze(\n  Object.fromEntries(\n    COINFLIP_MODES.map((mode) => [\n      mode,\n      Object.freeze(\n        Array.from({ length: COINFLIP_MAX_COIN_COUNT + 1 }, (_, coins) => {\n          if (coins < COINFLIP_MIN_COIN_COUNT) return Object.freeze([] as number[]);\n          const values = new Set<number>();\n          const fast =\n            mode === \"classic\" && coins === COINFLIP_DEFAULT_COIN_COUNT ? [false, true] : [false];\n          for (const animationsMode of [false, true])\n            for (const instantMode of [false, true])\n              for (const fastReveal of fast)\n                values.add(\n                  coinflipAnimationDurationMs(coins, {\n                    animationsMode,\n                    fastReveal,\n                    instantMode,\n                  }),\n                );\n          return Object.freeze([...values].sort((a, b) => a - b));\n        }),\n      ),\n    ]),\n  ) as Record<CoinflipMode, readonly (readonly number[])[]>,\n);\n\nexport function coinflipAnimationDurations(mode: CoinflipMode, coins: number): readonly number[] {\n  if (mode !== \"classic\" && mode !== \"target\") throw invalidParameters();\n  return durations[mode][coinCount(coins)];\n}\n\nfunction animationDuration(value: unknown, mode: CoinflipMode, coins: number): number {\n  if (\n    typeof value !== \"number\" ||\n    !Number.isSafeInteger(value) ||\n    !coinflipAnimationDurations(mode, coins).includes(value)\n  )\n    throw invalidParameters();\n  return value + 0;\n}\n\nexport function coinflipWinningOutcomes(coins: number, target: number): bigint {\n  const count = coinCount(coins);\n  if (typeof target !== \"number\" || !Number.isSafeInteger(target) || target < 1 || target > count)\n    throw invalidParameters();\n  return tails[count][target];\n}\n\nexport function coinflipTotalOutcomes(coins: number): bigint {\n  return 2n ** BigInt(coinCount(coins));\n}\n\nexport function coinflipMinTarget(coins: number): number {\n  const count = coinCount(coins),\n    total = coinflipTotalOutcomes(count);\n  for (let target = 1; target <= count; target++)\n    if (LIMIT_DENOMINATOR * tails[count][target] <= LIMIT_NUMERATOR * total) return target;\n  return count;\n}\n\nexport function coinflipMultiplierUnits(coins: number, target: number): bigint {\n  return (\n    (RETURN_PERCENT * UNIT_SCALE * coinflipTotalOutcomes(coins)) /\n    (100n * coinflipWinningOutcomes(coins, target))\n  );\n}\n\nexport function coinflipMultiplierText(coins: number, target: number): string {\n  return plain(coinflipMultiplierUnits(coins, target), COINFLIP_ODDS_DECIMALS);\n}\n\nexport function coinflipChanceText(coins: number, target: number): string {\n  const winning = coinflipWinningOutcomes(coins, target),\n    total = coinflipTotalOutcomes(coins),\n    percent = 100n * winning;\n  if (percent >= total) return fixed((percent * UNIT_SCALE) / total, COINFLIP_ODDS_DECIMALS);\n  let lead = 1;\n  while (percent * 10n ** BigInt(lead) < total) lead++;\n  const scale = lead + COINFLIP_CHANCE_SIGNIFICANT_DIGITS - 1;\n  return plain((percent * 10n ** BigInt(scale)) / total, scale);\n}\n\nexport function coinflipPlayOdds(input: {\n  coins: number;\n  playMode: CoinflipMode;\n  target: number;\n}): CoinflipOdds {\n  if (!record(input) || (input.playMode !== \"classic\" && input.playMode !== \"target\"))\n    throw invalidParameters();\n  const target = input.playMode === \"classic\" ? input.coins : input.target;\n  return Object.freeze({\n    chance: coinflipChanceText(input.coins, target),\n    multiplier: coinflipMultiplierText(input.coins, target),\n  });\n}\n\nexport function validateCoinflipParameters(input: unknown): CoinflipParameters {\n  if (!record(input)) throw invalidParameters();\n  if (input.mode === \"classic\") {\n    if (!exactKeys(input, CLASSIC_KEYS) || !Array.isArray(input.sides)) throw invalidParameters();\n    coinCount(input.sides.length);\n    const sides = Array.from(input.sides as unknown[], (side) => sideOf(side));\n    return {\n      animationDurationMs: animationDuration(input.animationDurationMs, \"classic\", sides.length),\n      mode: \"classic\",\n      sides,\n    };\n  }\n  if (input.mode !== \"target\" || !exactKeys(input, TARGET_KEYS)) throw invalidParameters();\n  const coins = coinCount(input.coins),\n    side = sideOf(input.side),\n    target = input.target;\n  if (\n    typeof target !== \"number\" ||\n    !Number.isSafeInteger(target) ||\n    target < coinflipMinTarget(coins) ||\n    target > coins\n  )\n    throw invalidParameters();\n  return {\n    animationDurationMs: animationDuration(input.animationDurationMs, \"target\", coins),\n    coins,\n    mode: \"target\",\n    side,\n    target,\n  };\n}\n\nfunction copyParameters(parameters: CoinflipParameters): CoinflipParameters {\n  return parameters.mode === \"classic\"\n    ? {\n        animationDurationMs: parameters.animationDurationMs,\n        mode: \"classic\",\n        sides: [...parameters.sides],\n      }\n    : {\n        animationDurationMs: parameters.animationDurationMs,\n        coins: parameters.coins,\n        mode: \"target\",\n        side: parameters.side,\n        target: parameters.target,\n      };\n}\n\nexport function coinflipTerms(input: unknown): CoinflipTerms {\n  const parameters = validateCoinflipParameters(input),\n    classic = parameters.mode === \"classic\",\n    coins = classic ? parameters.sides.length : parameters.coins,\n    requiredHits = classic ? coins : parameters.target,\n    selectedSides = classic\n      ? [...parameters.sides]\n      : Array.from({ length: coins }, () => parameters.side);\n  return Object.freeze({\n    parameters,\n    mode: parameters.mode,\n    coins,\n    requiredHits,\n    selectedSide: selectedSides[0],\n    selectedSides: Object.freeze(selectedSides),\n    winningOutcomes: coinflipWinningOutcomes(coins, requiredHits),\n    totalOutcomes: coinflipTotalOutcomes(coins),\n    multiplierUnits: coinflipMultiplierUnits(coins, requiredHits),\n    quoteMultiplier: coinflipMultiplierText(coins, requiredHits),\n    chance: coinflipChanceText(coins, requiredHits),\n  });\n}\n\nexport function coinflipStake(stakeMinor: unknown): bigint {\n  if (typeof stakeMinor !== \"bigint\") throw new RangeError(\"Invalid Coinflip stake\");\n  if (stakeMinor < COINFLIP_MIN_STAKE_MINOR) throw new RangeError(\"Minimum bet is 0.10\");\n  if (stakeMinor > COINFLIP_MAX_STAKE_MINOR) throw new RangeError(\"Maximum bet is 10,000.00\");\n  return stakeMinor;\n}\n\nexport function coinflipUncappedPayoutMinor(stakeMinor: bigint, parameters: unknown): bigint {\n  const stake = coinflipStake(stakeMinor);\n  return (stake * coinflipTerms(parameters).multiplierUnits) / UNIT_SCALE;\n}\n\nexport function coinflipMaximumPayoutMinor(stakeMinor: bigint, parameters: unknown): bigint {\n  const gross = coinflipUncappedPayoutMinor(stakeMinor, parameters);\n  return gross > COINFLIP_MAX_PAYOUT_MINOR ? COINFLIP_MAX_PAYOUT_MINOR : gross;\n}\n\nexport function coinflipWinChance(parameters: unknown): CoinflipRatio {\n  const terms = coinflipTerms(parameters);\n  return reduced(terms.winningOutcomes, terms.totalOutcomes);\n}\n\nexport function coinflipNominalRtp(parameters: unknown): CoinflipRatio {\n  const terms = coinflipTerms(parameters);\n  return reduced(terms.winningOutcomes * terms.multiplierUnits, terms.totalOutcomes * UNIT_SCALE);\n}\n\nexport function coinflipPcRtp(stakeMinor: bigint, parameters: unknown): CoinflipRatio {\n  const stake = coinflipStake(stakeMinor),\n    terms = coinflipTerms(parameters);\n  return reduced(\n    terms.winningOutcomes * coinflipMaximumPayoutMinor(stake, terms.parameters),\n    terms.totalOutcomes * stake,\n  );\n}\n\nexport function coinflipHmacMessage(clientSeed: string, nonce: number): string {\n  return `${clientSeed}:${nonce}:coinflip:${COINFLIP_CURSOR}`;\n}\n\nexport function coinflipUint32FromDigest(digest: Uint8Array): number {\n  if (!(digest instanceof Uint8Array) || digest.byteLength !== 32)\n    throw new RangeError(\"Invalid Coinflip HMAC digest\");\n  return ((digest[0] << 24) | (digest[1] << 16) | (digest[2] << 8) | digest[3]) >>> 0;\n}\n\nfunction uint32Of(value: unknown): number {\n  if (typeof value !== \"number\" || !Number.isSafeInteger(value) || value < 0 || value > 0xffff_ffff)\n    throw new RangeError(\"Invalid Coinflip draw\");\n  return value;\n}\n\nexport function coinflipResultsFromUint32(uint32: number, coins: number): CoinflipSide[] {\n  const word = uint32Of(uint32),\n    count = coinCount(coins);\n  return Array.from(\n    { length: count },\n    (_, index) => COINFLIP_BIT_VALUES[(word >>> (31 - index)) & 1],\n  );\n}\n\nfunction fairnessSeeds(entropy: Entropy) {\n  if (\n    !record(entropy) ||\n    typeof entropy.hmac !== \"function\" ||\n    typeof entropy.serverSeed !== \"string\" ||\n    !entropy.serverSeed.trim() ||\n    typeof entropy.clientSeed !== \"string\" ||\n    !entropy.clientSeed.trim() ||\n    typeof entropy.nonce !== \"number\" ||\n    !Number.isSafeInteger(entropy.nonce) ||\n    entropy.nonce < 0\n  )\n    throw new RangeError(\"Invalid Coinflip fairness seed\");\n  return entropy;\n}\n\nexport async function coinflipDraw(entropy: Entropy): Promise<number> {\n  const seeds = fairnessSeeds(entropy);\n  const digest = await seeds.hmac(\n    COINFLIP_HASH,\n    seeds.serverSeed,\n    coinflipHmacMessage(seeds.clientSeed, seeds.nonce),\n  );\n  return coinflipUint32FromDigest(digest);\n}\n\nexport async function coinflipFairnessResults(entropy: Entropy): Promise<CoinflipSide[]> {\n  return coinflipResultsFromUint32(await coinflipDraw(entropy), COINFLIP_MAX_COIN_COUNT);\n}\n\nexport function coinflipOutcome(input: { parameters: unknown; uint32: number }): CoinflipOutcome {\n  const terms = coinflipTerms(input.parameters),\n    results = coinflipResultsFromUint32(input.uint32, terms.coins),\n    matches = results.map((side, index) => side === terms.selectedSides[index]),\n    hits = matches.filter(Boolean).length;\n  return Object.freeze({\n    terms,\n    results: Object.freeze(results),\n    matches: Object.freeze(matches),\n    hits,\n    win: hits >= terms.requiredHits,\n  });\n}\n\nexport function settleCoinflip(input: {\n  stakeMinor: bigint;\n  parameters: unknown;\n  uint32: number;\n}): CoinflipResolution {\n  const stake = coinflipStake(input.stakeMinor),\n    uint32 = uint32Of(input.uint32),\n    { terms, results, matches, hits, win } = coinflipOutcome({\n      parameters: input.parameters,\n      uint32,\n    }),\n    uncapped = (stake * terms.multiplierUnits) / UNIT_SCALE,\n    potential = uncapped > COINFLIP_MAX_PAYOUT_MINOR ? COINFLIP_MAX_PAYOUT_MINOR : uncapped,\n    gross = win ? uncapped : 0n,\n    capped = gross > COINFLIP_MAX_PAYOUT_MINOR,\n    payout = capped ? COINFLIP_MAX_PAYOUT_MINOR : gross;\n  return {\n    game: COINFLIP_GAME,\n    status: payout > 0n ? \"WON\" : \"LOST\",\n    payoutMinor: payout.toString(),\n    multiplier: ratioText({ numerator: payout, denominator: stake }),\n    rulesVersion: COINFLIP_RULES_VERSION,\n    result: {\n      parameters: copyParameters(terms.parameters),\n      coinflipRules: COINFLIP_RULES_VERSION,\n      settlementPolicy: COINFLIP_SETTLEMENT_POLICY,\n      mode: terms.mode,\n      coins: terms.coins,\n      requiredHits: terms.requiredHits,\n      selectedSide: terms.selectedSide,\n      selectedSides: [...terms.selectedSides],\n      draw: { cursor: COINFLIP_CURSOR, uint32 },\n      results: [...results],\n      matches: [...matches],\n      hits,\n      win,\n      chance: terms.chance,\n      quoteMultiplier: terms.quoteMultiplier,\n      resultMultiplier: win ? terms.quoteMultiplier : \"0\",\n      winningOutcomes: terms.winningOutcomes.toString(),\n      totalOutcomes: terms.totalOutcomes.toString(),\n      winChance: wire(reduced(terms.winningOutcomes, terms.totalOutcomes)),\n      nominalMultiplier: wire(reduced(terms.multiplierUnits, UNIT_SCALE)),\n      nominalRtp: wire(\n        reduced(terms.winningOutcomes * terms.multiplierUnits, terms.totalOutcomes * UNIT_SCALE),\n      ),\n      theoreticalRtp: wire(reduced(terms.winningOutcomes * potential, terms.totalOutcomes * stake)),\n      potentialPayoutMinor: potential.toString(),\n      grossPayoutMinor: gross.toString(),\n      maxPayoutMinor: COINFLIP_MAX_PAYOUT_MINOR.toString(),\n      capped,\n      decisions: [],\n    },\n  };\n}\n\nexport function coinflipRecordedDraw(result: unknown): number {\n  const draw = record(result) ? result.draw : undefined;\n  if (!record(draw) || !exactKeys(draw, DRAW_KEYS) || draw.cursor !== COINFLIP_CURSOR)\n    throw new RangeError(\"Invalid Coinflip draw\");\n  return uint32Of(draw.uint32);\n}\n\nconst rules: GameRules<CoinflipParameters> = {\n  game: COINFLIP_GAME,\n  version: COINFLIP_RULES_VERSION,\n  formerly: \"coinflip-1\",\n  stateful: false,\n  actions: Object.freeze([]),\n  rateLimitPerMinute: COINFLIP_RATE_LIMIT,\n  limits: COINFLIP_GAME_LIMITS,\n  validateParameters: validateCoinflipParameters,\n  maximumPayoutMinor: coinflipMaximumPayoutMinor,\n  async resolve(input) {\n    const stakeMinor = coinflipStake(input.stakeMinor),\n      parameters = validateCoinflipParameters(input.parameters),\n      uint32 = await coinflipDraw(input.entropy);\n    return settleCoinflip({ stakeMinor, parameters, uint32 });\n  },\n  reconstruct(input) {\n    return settleCoinflip({\n      stakeMinor: input.stakeMinor,\n      parameters: input.parameters,\n      uint32: coinflipRecordedDraw(input.result),\n    });\n  },\n};\n\nexport const coinflipRulesModule: GameRules<CoinflipParameters> = Object.freeze(rules);\n\nexport function isCoinflipRulesVersion(value: unknown): value is CoinflipRulesVersion {\n  return (COINFLIP_RULES_VERSIONS as readonly unknown[]).includes(value);\n}\n\nexport function coinflipTopMultiplier(parameters: unknown): MaxWinRatio {\n  return Object.freeze({\n    numerator: coinflipTerms(parameters).multiplierUnits,\n    denominator: UNIT_SCALE,\n  });\n}\n\nexport function admitCoinflip3Stake(stakeMinor: bigint, parameters: unknown): void {\n  admitMaxWinStake(COINFLIP_GAME_LIMITS, stakeMinor, coinflipTopMultiplier(parameters));\n}\n\nexport const coinflip3RulesModule: GameRules<CoinflipParameters> = maxWinSuccessor(\n  coinflipRulesModule,\n  COINFLIP_3_RULES_VERSION,\n  { limits: COINFLIP_3_GAME_LIMITS, admitStake: admitCoinflip3Stake },\n);\n","sha256":"70b06a3533ece1c6def9d8a9fe63cad4238d6f1dc5228e44b0b8a568f6a204de"},"src/lib/games/moles-rules.ts":{"source":"import {\n  MAX_PAYOUT_MINOR,\n  ratio,\n  ratioText,\n  ratioWire,\n  type Ratio,\n  type Resolution,\n} from \"@/games/math\";\nimport { createEntropy } from \"./entropy\";\nimport type { ActInput, Decision, Entropy, GameRules, Limits, ResolveInput } from \"./rules\";\nimport { gameLimits, maxWinLimits } from \"./limits\";\nimport {\n  MAX_WIN_END,\n  MAX_WIN_MINOR,\n  MAX_WIN_MULTIPLIER_HUNDREDTHS,\n  admitMaxWinStake,\n  hundredthsRatio,\n  unavailableSelectionMessage,\n} from \"./max-win\";\n\nexport const MOLES_RULES_VERSION = \"peeks-moles-2\" as const;\nexport const MOLES_3_RULES_VERSION = \"peeks-moles-3\" as const;\nexport const MOLES_RULES_VERSIONS = Object.freeze([\n  MOLES_RULES_VERSION,\n  MOLES_3_RULES_VERSION,\n] as const);\nexport type MolesRulesVersion = (typeof MOLES_RULES_VERSIONS)[number];\nexport const MOLES_GAME_NAME = \"moles\" as const;\nexport const MOLES_HOLES = 7;\nexport const MOLES_COUNTS = Object.freeze([1, 2, 3, 4, 5, 6] as const);\nexport const MOLES_DEFAULT_COUNT = 3;\nexport const MOLES_MAX_ROUNDS = 9;\nexport const MOLES_AUTOBET_MAX_PICKS = 9;\nexport const MOLES_HOUSE_EDGE_PERCENT = 4;\nexport const MOLES_RTP_PERCENT = 96;\nexport const MOLES_MAX_MULTIPLIER = \"1000000.00\" as const;\nexport const MOLES_3_MAX_MULTIPLIER = \"10000000.00\" as const;\nexport const MOLES_MAX_STAKE_MINOR = 1_000_000n;\nexport const MOLES_GAME_LIMITS = gameLimits(\"moles\");\nexport const MOLES_3_GAME_LIMITS = maxWinLimits(\"moles\");\nexport const MOLES_HMAC = \"SHA-256\" as const;\nexport const MOLES_DIGEST_BYTES = 32;\nexport const MOLES_MAX_ENTROPY_BLOCKS = 1024;\nexport const MOLES_PROCESSING_DELAYS = Object.freeze({\n  standard: 1000,\n  instant: 0,\n} as const);\nexport const MOLES_ACTIONS = Object.freeze([\"reveal\", \"cashout\"] as const);\nexport const MOLES_CLOSING_ACTIONS = Object.freeze([\"cashout\"] as const);\nexport const MOLES_SPAM_GUARD_MS = 150;\nexport const MOLES_RATE_LIMIT = 60_000 / MOLES_SPAM_GUARD_MS;\n\nexport type MolesCount = (typeof MOLES_COUNTS)[number];\nexport type MolesAnimationDuration =\n  (typeof MOLES_PROCESSING_DELAYS)[keyof typeof MOLES_PROCESSING_DELAYS];\nexport type MolesManualParameters = { moleCount: number };\nexport type MolesAutoParameters = {\n  animationDurationMs: MolesAnimationDuration;\n  moleCount: number;\n  selectedHoleIndexes: number[];\n};\nexport type MolesParameters = MolesManualParameters | MolesAutoParameters;\nexport type MolesEndReason = \"miss\" | \"cashout\" | \"complete\" | \"maxPayout\" | typeof MAX_WIN_END;\nexport type MolesWireRatio = ReturnType<typeof ratioWire>;\nexport type MolesRound = {\n  round: number;\n  moleCount: number;\n  moleIndexes: number[];\n  multiplier: string;\n  playerRoundWin: boolean;\n  selectedHoleIndex: number;\n};\nexport type MolesResult = {\n  molesRules: MolesRulesVersion;\n  parameters: MolesParameters;\n  mode: \"manual\" | \"auto\";\n  moleCount: number;\n  holeCount: number;\n  maxRounds: number;\n  step: number;\n  roundsWon: number;\n  molesResult: MolesRound[];\n  multiplierTable: string[];\n  multiplier: string;\n  nextMultiplier: string | null;\n  currentPayoutMinor: string;\n  nextPayoutMinor: string | null;\n  canCashout: boolean;\n  gameOver: boolean;\n  win: boolean;\n  endReason: MolesEndReason | null;\n  capped: boolean;\n  grossPayoutMinor: string;\n  multiplierCapped: boolean;\n  maxMultiplier: typeof MOLES_MAX_MULTIPLIER | typeof MOLES_3_MAX_MULTIPLIER;\n  maxPayoutMinor: string;\n  decisions: Decision[];\n  boards: number[][] | null;\n  rtpRounds: number;\n  nominalRtp: MolesWireRatio;\n  theoreticalRtp: MolesWireRatio;\n};\nexport type MolesSecret = {\n  molesRules: MolesRulesVersion;\n  moleCount: number;\n  boards: number[][];\n  decisions: Decision[];\n};\nexport type MolesBoard = { round: number; moleIndexes: number[]; blocks: number };\nexport type MolesFairnessRound = { round: number; moleCount: number; moleIndexes: number[] };\nexport type MolesQuote = {\n  round: number;\n  multiplier: string;\n  grossMinor: bigint;\n  payoutMinor: bigint;\n  capped: boolean;\n  multiplierCapped: boolean;\n  endsRound: boolean;\n};\nexport type MolesEntropy = Pick<Entropy, \"serverSeed\" | \"clientSeed\" | \"nonce\" | \"hmac\">;\n\ntype Levels = Readonly<{ hundredths: readonly bigint[]; raw: readonly bigint[] }>;\ntype Progress = {\n  rounds: MolesRound[];\n  decisions: Decision[];\n  endReason: MolesEndReason | null;\n};\n\nconst DECIMAL_PLACES = 10n ** 20n;\nconst MAX_HUNDREDTHS = 100_000_000n;\nconst HOLES = BigInt(MOLES_HOLES);\nconst RETURN_PERCENT = BigInt(100 - MOLES_HOUSE_EDGE_PERCENT);\nconst WORD_RANGE = 0x1_0000_0000;\nconst SECRET_KEYS = Object.freeze([\"molesRules\", \"moleCount\", \"boards\", \"decisions\"]);\nconst MANUAL_KEYS = Object.freeze([\"moleCount\"]);\nconst AUTO_KEYS = Object.freeze([\"animationDurationMs\", \"moleCount\", \"selectedHoleIndexes\"]);\nconst REVEAL_KEYS = Object.freeze([\"action\", \"selection\", \"expectedStep\"]);\nconst CASHOUT_KEYS = Object.freeze([\"action\", \"expectedStep\"]);\nconst HOLE_INDEXES = Object.freeze(Array.from({ length: MOLES_HOLES }, (_, index) => index));\n\nconst invalid = (message = \"Invalid Moles parameters\") => new RangeError(message);\n\nfunction record(value: unknown): value is Record<string, unknown> {\n  return !!value && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction whole(value: unknown, min: number, max: number, message: string): number {\n  if (typeof value !== \"number\" || !Number.isSafeInteger(value) || value < min || value > max)\n    throw invalid(message);\n  return value + 0;\n}\n\nfunction countOf(value: unknown, message = \"Invalid Moles parameters\"): number {\n  return whole(value, MOLES_COUNTS[0], MOLES_COUNTS.at(-1)!, message);\n}\n\nfunction holeOf(value: unknown, message: string): number {\n  return whole(value, 0, MOLES_HOLES - 1, message);\n}\n\nfunction roundOf(value: unknown): number {\n  return whole(value, 1, MOLES_MAX_ROUNDS, \"Invalid Moles round\");\n}\n\nfunction stakeOf(value: unknown): bigint {\n  if (typeof value !== \"bigint\" || value < 1n || value > MOLES_MAX_STAKE_MINOR)\n    throw invalid(\"Invalid Moles stake\");\n  return value;\n}\n\nfunction halfUp(numerator: bigint, denominator: bigint): bigint {\n  return (2n * numerator + denominator) / (2n * denominator);\n}\n\nfunction fixed(hundredths: bigint): string {\n  return `${hundredths / 100n}.${(hundredths % 100n).toString().padStart(2, \"0\")}`;\n}\n\nfunction reduced(numerator: bigint, denominator: bigint): Ratio {\n  let a = numerator,\n    b = denominator;\n  while (b) [a, b] = [b, a % b];\n  return a ? ratio(numerator / a, denominator / a) : ratio(0n, 1n);\n}\n\nfunction computeLevels(moleCount: number, full: boolean): Levels {\n  const count = BigInt(moleCount),\n    growth = halfUp(HOLES * DECIMAL_PLACES, count),\n    first = halfUp(HOLES * RETURN_PERCENT * DECIMAL_PLACES, 100n * count),\n    hundredths: bigint[] = [],\n    raw: bigint[] = [];\n  let value = first,\n    scale = DECIMAL_PLACES;\n  for (let round = 1; round <= MOLES_MAX_ROUNDS; round++) {\n    if (round > 1) {\n      value *= growth;\n      scale *= DECIMAL_PLACES;\n    }\n    const truncated = (value * 100n) / scale;\n    raw.push(truncated);\n    if (!full) hundredths.push(truncated > MAX_HUNDREDTHS ? MAX_HUNDREDTHS : truncated);\n    else if (hundredths.length === round - 1 && truncated <= MAX_WIN_MULTIPLIER_HUNDREDTHS)\n      hundredths.push(truncated);\n  }\n  return Object.freeze({ hundredths: Object.freeze(hundredths), raw: Object.freeze(raw) });\n}\n\nfunction levelTables(full: boolean) {\n  const levels: Readonly<Record<number, Levels>> = Object.freeze(\n    Object.fromEntries(MOLES_COUNTS.map((count) => [count, computeLevels(count, full)])),\n  );\n  const tables: Readonly<Record<number, readonly string[]>> = Object.freeze(\n    Object.fromEntries(\n      MOLES_COUNTS.map((count) => [count, Object.freeze(levels[count].hundredths.map(fixed))]),\n    ),\n  );\n  return { levels, tables };\n}\n\nconst EDITIONS = Object.freeze({\n  [MOLES_RULES_VERSION]: levelTables(false),\n  [MOLES_3_RULES_VERSION]: levelTables(true),\n});\nconst levels = EDITIONS[MOLES_RULES_VERSION].levels;\n\nexport function isMolesRulesVersion(value: unknown): value is MolesRulesVersion {\n  return (MOLES_RULES_VERSIONS as readonly unknown[]).includes(value);\n}\n\nfunction edition(version: unknown): MolesRulesVersion {\n  if (!isMolesRulesVersion(version)) throw invalid(\"Invalid Moles rules\");\n  return version;\n}\n\nfunction paysInFull(version: MolesRulesVersion): boolean {\n  return version !== MOLES_RULES_VERSION;\n}\n\nfunction levelsFor(version: MolesRulesVersion, moleCount: number): Levels {\n  return EDITIONS[edition(version)].levels[moleCount];\n}\n\nexport function molesLimits(version: MolesRulesVersion = MOLES_RULES_VERSION): Limits {\n  return paysInFull(edition(version)) ? MOLES_3_GAME_LIMITS : MOLES_GAME_LIMITS;\n}\n\nexport function molesMaxMultiplierText(version: MolesRulesVersion = MOLES_RULES_VERSION) {\n  return paysInFull(edition(version)) ? MOLES_3_MAX_MULTIPLIER : MOLES_MAX_MULTIPLIER;\n}\n\nexport function molesRoundsFor(\n  moleCount: unknown,\n  version: MolesRulesVersion = MOLES_RULES_VERSION,\n): number {\n  return levelsFor(version, countOf(moleCount, \"Invalid Moles count\")).hundredths.length;\n}\n\nfunction gross(\n  stakeMinor: bigint,\n  hundredths: bigint,\n  version: MolesRulesVersion = MOLES_RULES_VERSION,\n) {\n  const amount = (stakeMinor * hundredths) / 100n;\n  if (paysInFull(version)) return { grossMinor: amount, payoutMinor: amount, capped: false };\n  return {\n    grossMinor: amount,\n    payoutMinor: amount > MAX_PAYOUT_MINOR ? MAX_PAYOUT_MINOR : amount,\n    capped: amount > MAX_PAYOUT_MINOR,\n  };\n}\n\nfunction isAuto(parameters: MolesParameters): parameters is MolesAutoParameters {\n  return \"selectedHoleIndexes\" in parameters;\n}\n\nfunction copyParameters(parameters: MolesParameters): MolesParameters {\n  return isAuto(parameters)\n    ? {\n        animationDurationMs: parameters.animationDurationMs,\n        moleCount: parameters.moleCount,\n        selectedHoleIndexes: [...parameters.selectedHoleIndexes],\n      }\n    : { moleCount: parameters.moleCount };\n}\n\nexport function validateMolesParameters(input: unknown): MolesParameters {\n  if (!record(input)) throw invalid();\n  const auto = Object.hasOwn(input, \"selectedHoleIndexes\"),\n    keys = auto ? AUTO_KEYS : MANUAL_KEYS;\n  if (Object.keys(input).length !== keys.length || keys.some((key) => !Object.hasOwn(input, key)))\n    throw invalid();\n  const moleCount = countOf(input.moleCount);\n  if (!auto) return { moleCount };\n  const animationDurationMs = Object.values(MOLES_PROCESSING_DELAYS).find(\n    (duration) => duration === input.animationDurationMs,\n  );\n  if (animationDurationMs === undefined) throw invalid();\n  const picks = input.selectedHoleIndexes;\n  if (!Array.isArray(picks)) throw invalid();\n  if (picks.length === 0) throw invalid(\"Please select at least one tile\");\n  if (picks.length > MOLES_AUTOBET_MAX_PICKS) throw invalid();\n  return {\n    animationDurationMs,\n    moleCount,\n    selectedHoleIndexes: Array.from(picks as unknown[], (pick) =>\n      holeOf(pick, \"Invalid Moles parameters\"),\n    ),\n  };\n}\n\nfunction roundIn(level: Levels, round: unknown): number {\n  const index = roundOf(round);\n  if (index > level.hundredths.length) throw invalid(\"Invalid Moles round\");\n  return index;\n}\n\nexport function molesMultiplierTable(\n  moleCount: unknown,\n  version: MolesRulesVersion = MOLES_RULES_VERSION,\n): readonly string[] {\n  return EDITIONS[edition(version)].tables[countOf(moleCount, \"Invalid Moles count\")];\n}\n\nexport function molesMultiplierHundredths(\n  moleCount: unknown,\n  round: unknown,\n  version: MolesRulesVersion = MOLES_RULES_VERSION,\n): bigint {\n  const level = levelsFor(version, countOf(moleCount, \"Invalid Moles count\"));\n  return level.hundredths[roundIn(level, round) - 1];\n}\n\nexport function molesMultiplier(\n  moleCount: unknown,\n  round: unknown,\n  version: MolesRulesVersion = MOLES_RULES_VERSION,\n): string {\n  return fixed(molesMultiplierHundredths(moleCount, round, version));\n}\n\nexport function molesRawMultiplier(moleCount: unknown, round: unknown): string {\n  return fixed(levels[countOf(moleCount, \"Invalid Moles count\")].raw[roundOf(round) - 1]);\n}\n\nfunction stopsAfter(\n  stakeMinor: bigint,\n  level: Levels,\n  round: number,\n  version: MolesRulesVersion,\n): boolean {\n  if (!paysInFull(version))\n    return (\n      round === MOLES_MAX_ROUNDS ||\n      gross(stakeMinor, level.hundredths[round - 1]).grossMinor >= MAX_PAYOUT_MINOR\n    );\n  const next = level.hundredths[round];\n  return next === undefined || (stakeMinor * next) / 100n > MAX_WIN_MINOR;\n}\n\nexport function molesQuote(\n  stakeMinor: bigint,\n  moleCount: unknown,\n  round: unknown,\n  version: MolesRulesVersion = MOLES_RULES_VERSION,\n): MolesQuote {\n  const stake = stakeOf(stakeMinor),\n    count = countOf(moleCount, \"Invalid Moles count\"),\n    level = levelsFor(version, count),\n    index = roundIn(level, round),\n    amount = gross(stake, level.hundredths[index - 1], version);\n  return {\n    round: index,\n    multiplier: fixed(level.hundredths[index - 1]),\n    ...amount,\n    multiplierCapped: !paysInFull(version) && level.raw[index - 1] > MAX_HUNDREDTHS,\n    endsRound: stopsAfter(stake, level, index, version),\n  };\n}\n\nexport function molesStopRound(\n  stakeMinor: bigint,\n  moleCount: unknown,\n  rounds: unknown,\n  version: MolesRulesVersion = MOLES_RULES_VERSION,\n) {\n  const stake = stakeOf(stakeMinor),\n    count = countOf(moleCount, \"Invalid Moles count\"),\n    level = levelsFor(version, count),\n    horizon = roundIn(level, rounds);\n  for (let round = 1; round < horizon; round++)\n    if (stopsAfter(stake, level, round, version)) return round;\n  return horizon;\n}\n\nexport function molesRtp(\n  moleCount: unknown,\n  rounds: unknown,\n  version: MolesRulesVersion = MOLES_RULES_VERSION,\n): Ratio {\n  const count = countOf(moleCount, \"Invalid Moles count\"),\n    level = levelsFor(version, count),\n    horizon = roundIn(level, rounds),\n    exponent = BigInt(horizon);\n  return reduced(\n    BigInt(count) ** exponent * level.hundredths[horizon - 1],\n    HOLES ** exponent * 100n,\n  );\n}\n\nexport function molesStakeRtp(\n  stakeMinor: bigint,\n  moleCount: unknown,\n  rounds: unknown,\n  version: MolesRulesVersion = MOLES_RULES_VERSION,\n) {\n  const stake = stakeOf(stakeMinor),\n    count = countOf(moleCount, \"Invalid Moles count\"),\n    stop = molesStopRound(stake, count, rounds, version),\n    exponent = BigInt(stop);\n  return reduced(\n    BigInt(count) ** exponent *\n      gross(stake, levelsFor(version, count).hundredths[stop - 1], version).payoutMinor,\n    HOLES ** exponent * stake,\n  );\n}\n\nexport function molesMaximumPayoutMinor(\n  stakeMinor: bigint,\n  parameters: unknown,\n  version: MolesRulesVersion = MOLES_RULES_VERSION,\n): bigint {\n  const validated = validateMolesParameters(parameters),\n    stake = stakeOf(stakeMinor),\n    table = levelsFor(version, validated.moleCount).hundredths;\n  if (isAuto(validated) && validated.selectedHoleIndexes.length > table.length)\n    throw invalid(unavailableSelectionMessage());\n  const horizon = isAuto(validated) ? validated.selectedHoleIndexes.length : MOLES_MAX_ROUNDS;\n  if (paysInFull(version)) {\n    let best = 0n;\n    for (const hundredths of table.slice(0, horizon)) {\n      const amount = gross(stake, hundredths, version).payoutMinor;\n      if (amount > MAX_WIN_MINOR) break;\n      best = amount;\n    }\n    return best;\n  }\n  return table.slice(0, horizon).reduce((best, hundredths) => {\n    const amount = gross(stake, hundredths).payoutMinor;\n    return amount > best ? amount : best;\n  }, 0n);\n}\n\nexport function molesTopMultiplierHundredths(parameters: unknown): bigint {\n  const validated = validateMolesParameters(parameters),\n    picks = isAuto(validated) ? validated.selectedHoleIndexes.length : 1;\n  return levels[validated.moleCount].raw[picks - 1];\n}\n\nexport function admitMolesStake(\n  stakeMinor: bigint,\n  parameters: unknown,\n  version: MolesRulesVersion = MOLES_3_RULES_VERSION,\n): void {\n  admitMaxWinStake(\n    molesLimits(version),\n    stakeMinor,\n    hundredthsRatio(molesTopMultiplierHundredths(parameters)),\n  );\n}\n\nfunction entropyOf(value: unknown): MolesEntropy {\n  const entropy = value as MolesEntropy | null;\n  if (\n    !record(entropy) ||\n    typeof entropy.hmac !== \"function\" ||\n    typeof entropy.serverSeed !== \"string\" ||\n    !entropy.serverSeed.trim() ||\n    typeof entropy.clientSeed !== \"string\" ||\n    !entropy.clientSeed.trim() ||\n    typeof entropy.nonce !== \"number\" ||\n    !Number.isSafeInteger(entropy.nonce) ||\n    entropy.nonce < 0\n  )\n    throw invalid(\"Invalid Moles fairness seed\");\n  return entropy;\n}\n\nexport function molesMessage(\n  clientSeed: string,\n  nonce: number,\n  round: number,\n  cursor: number,\n): string {\n  return `${clientSeed}:${nonce}:${round}:${MOLES_GAME_NAME}:${cursor}`;\n}\n\nexport async function molesBoard(\n  entropy: MolesEntropy,\n  moleCount: unknown,\n  round: unknown,\n): Promise<MolesBoard> {\n  const source = entropyOf(entropy),\n    count = countOf(moleCount, \"Invalid Moles count\"),\n    index = roundOf(round),\n    items = [...HOLE_INDEXES],\n    words: number[] = [];\n  let blocks = 0,\n    position = 0;\n  const next = async () => {\n    if (position === words.length) {\n      if (blocks >= MOLES_MAX_ENTROPY_BLOCKS)\n        throw invalid(\"Failed to generate unbiased value: entropy stream exhausted\");\n      const digest = await source.hmac(\n        MOLES_HMAC,\n        source.serverSeed,\n        molesMessage(source.clientSeed, source.nonce, index, blocks++),\n      );\n      if (!(digest instanceof Uint8Array) || digest.byteLength !== MOLES_DIGEST_BYTES)\n        throw invalid(\"Invalid Moles HMAC digest\");\n      for (let offset = 0; offset < digest.byteLength; offset += 4)\n        words.push(\n          ((digest[offset] << 24) |\n            (digest[offset + 1] << 16) |\n            (digest[offset + 2] << 8) |\n            digest[offset + 3]) >>>\n            0,\n        );\n    }\n    return words[position++];\n  };\n  for (let top = MOLES_HOLES - 1; top >= MOLES_HOLES - count; top--) {\n    const range = top + 1,\n      limit = WORD_RANGE - (WORD_RANGE % range);\n    let value = await next();\n    while (value >= limit) value = await next();\n    const pick = value % range;\n    [items[top], items[pick]] = [items[pick], items[top]];\n  }\n  return { round: index, moleIndexes: items.slice(MOLES_HOLES - count), blocks };\n}\n\nexport async function molesBoards(entropy: MolesEntropy, moleCount: unknown): Promise<number[][]> {\n  const boards: number[][] = [];\n  for (let round = 1; round <= MOLES_MAX_ROUNDS; round++)\n    boards.push((await molesBoard(entropy, moleCount, round)).moleIndexes);\n  return boards;\n}\n\nexport async function molesFairness(input: {\n  serverSeed: string;\n  clientSeed: string;\n  nonce: number;\n  moleCount: unknown;\n}): Promise<MolesFairnessRound[]> {\n  if (!record(input)) throw invalid(\"Invalid Moles fairness seed\");\n  const moleCount = countOf(input.moleCount, \"Invalid Moles count\"),\n    entropy = createEntropy({\n      serverSeed: input.serverSeed,\n      clientSeed: input.clientSeed,\n      nonce: input.nonce,\n    });\n  return (await molesBoards(entropy, moleCount)).map((moleIndexes, index) => ({\n    round: index + 1,\n    moleCount,\n    moleIndexes,\n  }));\n}\n\nfunction boardsOf(value: unknown, moleCount: number, message: string): number[][] {\n  if (!Array.isArray(value) || value.length !== MOLES_MAX_ROUNDS) throw invalid(message);\n  return Array.from(value as unknown[], (board) => {\n    if (!Array.isArray(board) || board.length !== moleCount) throw invalid(message);\n    const holes = Array.from(board as unknown[], (hole) => holeOf(hole, message));\n    if (new Set(holes).size !== holes.length) throw invalid(message);\n    return holes;\n  });\n}\n\nfunction sameBoards(left: readonly number[][], right: readonly number[][]): boolean {\n  return (\n    left.length === right.length &&\n    left.every(\n      (board, index) =>\n        board.length === right[index].length &&\n        board.every((hole, position) => hole === right[index][position]),\n    )\n  );\n}\n\nfunction same(value: unknown, expected: unknown): boolean {\n  if (Array.isArray(expected))\n    return (\n      Array.isArray(value) &&\n      value.length === expected.length &&\n      expected.every((entry, index) => same(value[index], entry))\n    );\n  if (record(expected)) {\n    if (!record(value)) return false;\n    const keys = Object.keys(expected);\n    return (\n      Object.keys(value).length === keys.length &&\n      keys.every((key) => Object.hasOwn(value, key) && same(value[key], expected[key]))\n    );\n  }\n  return value === expected;\n}\n\nfunction decide(input: unknown, step: number): Decision {\n  const message = \"Choose a Moles hole or cash out\";\n  if (!record(input)) throw invalid(message);\n  const allowed =\n    input.action === \"reveal\" ? REVEAL_KEYS : input.action === \"cashout\" ? CASHOUT_KEYS : null;\n  if (\n    !allowed ||\n    Object.keys(input).some((key) => input[key] !== undefined && !allowed.includes(key))\n  )\n    throw invalid(message);\n  const expectedStep =\n    input.expectedStep === undefined\n      ? undefined\n      : whole(input.expectedStep, 0, Number.MAX_SAFE_INTEGER, message);\n  if (expectedStep !== undefined && expectedStep !== step) throw invalid(\"Moles round has changed\");\n  const expected = expectedStep === undefined ? {} : { expectedStep };\n  if (input.action === \"cashout\") return { action: \"cashout\", ...expected };\n  return {\n    action: \"reveal\",\n    selection: holeOf(input.selection, \"Unsupported selected hole index\"),\n    ...expected,\n  };\n}\n\nfunction start(): Progress {\n  return { rounds: [], decisions: [], endReason: null };\n}\n\nfunction advance(\n  stakeMinor: bigint,\n  moleCount: number,\n  boards: readonly number[][],\n  rounds: readonly MolesRound[],\n  selection: number,\n  closing: boolean,\n  version: MolesRulesVersion,\n): Pick<Progress, \"rounds\" | \"endReason\"> {\n  const round = rounds.length + 1,\n    board = boards[round - 1],\n    level = levelsFor(version, moleCount),\n    hundredths = level.hundredths[round - 1],\n    hit = board.includes(selection),\n    played = [\n      ...rounds,\n      {\n        round,\n        moleCount,\n        moleIndexes: [...board],\n        multiplier: hit ? fixed(hundredths) : \"0.00\",\n        playerRoundWin: hit,\n        selectedHoleIndex: selection,\n      },\n    ];\n  if (!hit) return { rounds: played, endReason: \"miss\" };\n  if (paysInFull(version)) {\n    if (round === MOLES_MAX_ROUNDS) return { rounds: played, endReason: \"complete\" };\n    if (closing) return { rounds: played, endReason: \"cashout\" };\n    if (stopsAfter(stakeMinor, level, round, version))\n      return { rounds: played, endReason: MAX_WIN_END };\n    return { rounds: played, endReason: null };\n  }\n  if (gross(stakeMinor, hundredths).grossMinor >= MAX_PAYOUT_MINOR)\n    return { rounds: played, endReason: \"maxPayout\" };\n  if (round === MOLES_MAX_ROUNDS) return { rounds: played, endReason: \"complete\" };\n  return { rounds: played, endReason: closing ? \"cashout\" : null };\n}\n\nfunction apply(\n  stakeMinor: bigint,\n  moleCount: number,\n  boards: readonly number[][],\n  progress: Progress,\n  input: unknown,\n  version: MolesRulesVersion,\n): Progress {\n  if (progress.endReason !== null) throw invalid(\"The game you were trying to play already ended\");\n  const step = progress.rounds.length,\n    decision = decide(input, step),\n    decisions = [...progress.decisions, decision];\n  if (decision.action === \"cashout\") {\n    if (step === 0) throw invalid(\"You must select at least one tile to cash out\");\n    return { rounds: progress.rounds, decisions, endReason: \"cashout\" };\n  }\n  return {\n    ...advance(stakeMinor, moleCount, boards, progress.rounds, decision.selection!, false, version),\n    decisions,\n  };\n}\n\nfunction autoplay(\n  stakeMinor: bigint,\n  parameters: MolesAutoParameters,\n  boards: readonly number[][],\n  version: MolesRulesVersion,\n): Progress {\n  let progress = start();\n  const picks = parameters.selectedHoleIndexes;\n  if (picks.length > levelsFor(version, parameters.moleCount).hundredths.length)\n    throw invalid(unavailableSelectionMessage());\n  for (let index = 0; index < picks.length && progress.endReason === null; index++)\n    progress = {\n      ...advance(\n        stakeMinor,\n        parameters.moleCount,\n        boards,\n        progress.rounds,\n        picks[index],\n        index === picks.length - 1,\n        version,\n      ),\n      decisions: [],\n    };\n  return progress;\n}\n\nfunction settle(\n  stakeMinor: bigint,\n  parameters: MolesParameters,\n  boards: readonly number[][],\n  progress: Progress,\n  version: MolesRulesVersion,\n): Resolution {\n  const moleCount = parameters.moleCount,\n    level = levelsFor(version, moleCount),\n    auto = isAuto(parameters),\n    active = progress.endReason === null,\n    lost = progress.endReason === \"miss\",\n    step = progress.rounds.length,\n    roundsWon = lost ? step - 1 : step,\n    current = !lost && roundsWon > 0 ? level.hundredths[roundsWon - 1] : 0n,\n    amount = gross(stakeMinor, current, version),\n    payoutMinor = active || lost ? 0n : amount.payoutMinor,\n    next = active ? level.hundredths[step] : null,\n    rtpRounds = auto ? parameters.selectedHoleIndexes.length : active ? step + 1 : step,\n    result: MolesResult = {\n      molesRules: version,\n      parameters: copyParameters(parameters),\n      mode: auto ? \"auto\" : \"manual\",\n      moleCount,\n      holeCount: MOLES_HOLES,\n      maxRounds: MOLES_MAX_ROUNDS,\n      step,\n      roundsWon,\n      molesResult: progress.rounds.map((round) => ({\n        ...round,\n        moleIndexes: [...round.moleIndexes],\n      })),\n      multiplierTable: [...EDITIONS[version].tables[moleCount]],\n      multiplier: fixed(current),\n      nextMultiplier: next === null ? null : fixed(next),\n      currentPayoutMinor: amount.payoutMinor.toString(),\n      nextPayoutMinor:\n        next === null ? null : gross(stakeMinor, next, version).payoutMinor.toString(),\n      canCashout: active && step > 0,\n      gameOver: !active,\n      win: !active && !lost,\n      endReason: progress.endReason,\n      capped: amount.capped,\n      grossPayoutMinor: amount.grossMinor.toString(),\n      multiplierCapped:\n        roundsWon > 0 && !lost && !paysInFull(version) && level.raw[roundsWon - 1] > MAX_HUNDREDTHS,\n      maxMultiplier: molesMaxMultiplierText(version),\n      maxPayoutMinor: molesLimits(version).maxPayoutMinor.toString(),\n      decisions: progress.decisions.map((decision) => ({ ...decision })),\n      boards: active ? null : boards.map((board) => [...board]),\n      rtpRounds,\n      nominalRtp: ratioWire(molesRtp(moleCount, rtpRounds, version)),\n      theoreticalRtp: ratioWire(molesStakeRtp(stakeMinor, moleCount, rtpRounds, version)),\n    },\n    secret: MolesSecret | null = active\n      ? {\n          molesRules: version,\n          moleCount,\n          boards: boards.map((board) => [...board]),\n          decisions: progress.decisions.map((decision) => ({ ...decision })),\n        }\n      : null;\n  return {\n    game: \"moles\",\n    status: active ? \"ACTIVE\" : payoutMinor > 0n ? \"WON\" : \"LOST\",\n    payoutMinor: payoutMinor.toString(),\n    multiplier: ratioText(ratio(payoutMinor, stakeMinor)),\n    result,\n    ...(secret ? { secret } : {}),\n    rulesVersion: version,\n  };\n}\n\nfunction restore(\n  secret: unknown,\n  parameters: MolesManualParameters,\n  stakeMinor: bigint,\n  version: MolesRulesVersion,\n): { boards: number[][]; progress: Progress } {\n  const message = \"Invalid Moles state\";\n  if (\n    !record(secret) ||\n    secret.molesRules !== version ||\n    Object.keys(secret).length !== SECRET_KEYS.length ||\n    SECRET_KEYS.some((key) => !Object.hasOwn(secret, key)) ||\n    secret.moleCount !== parameters.moleCount ||\n    !Array.isArray(secret.decisions) ||\n    secret.decisions.length >= MOLES_MAX_ROUNDS\n  )\n    throw invalid(message);\n  const boards = boardsOf(secret.boards, parameters.moleCount, message);\n  let progress = start();\n  for (const item of Array.from(secret.decisions as unknown[])) {\n    if (!record(item) || item.action !== \"reveal\") throw invalid(message);\n    try {\n      progress = apply(stakeMinor, parameters.moleCount, boards, progress, item, version);\n    } catch {\n      throw invalid(message);\n    }\n    if (progress.endReason !== null) throw invalid(message);\n  }\n  return { boards, progress };\n}\n\nexport async function resolveMoles(\n  input: ResolveInput<MolesParameters>,\n  version: MolesRulesVersion = MOLES_RULES_VERSION,\n): Promise<Resolution> {\n  const parameters = validateMolesParameters(input.parameters),\n    stakeMinor = stakeOf(input.stakeMinor),\n    boards = await molesBoards(input.entropy, parameters.moleCount);\n  return settle(\n    stakeMinor,\n    parameters,\n    boards,\n    isAuto(parameters) ? autoplay(stakeMinor, parameters, boards, edition(version)) : start(),\n    version,\n  );\n}\n\nexport async function actMoles(\n  input: ActInput<MolesParameters>,\n  version: MolesRulesVersion = MOLES_RULES_VERSION,\n): Promise<Resolution> {\n  const parameters = validateMolesParameters(input.parameters);\n  if (isAuto(parameters)) throw invalid(\"The game you were trying to play already ended\");\n  const stakeMinor = stakeOf(input.stakeMinor),\n    state = restore(input.secret, parameters, stakeMinor, edition(version));\n  if (\n    !same(\n      input.result,\n      settle(stakeMinor, parameters, state.boards, state.progress, version).result,\n    )\n  )\n    throw invalid(\"Invalid Moles state\");\n  const boards = await molesBoards(input.entropy, parameters.moleCount);\n  if (!sameBoards(boards, state.boards)) throw invalid(\"Invalid Moles state\");\n  return settle(\n    stakeMinor,\n    parameters,\n    boards,\n    apply(stakeMinor, parameters.moleCount, boards, state.progress, input.decision, version),\n    version,\n  );\n}\n\nexport function reconstructMoles(\n  input: {\n    stakeMinor: bigint;\n    parameters: unknown;\n    result: unknown;\n  },\n  version: MolesRulesVersion = MOLES_RULES_VERSION,\n): Resolution {\n  const parameters = validateMolesParameters(input.parameters),\n    stakeMinor = stakeOf(input.stakeMinor),\n    result = input.result,\n    message = \"Invalid Moles result\";\n  edition(version);\n  if (!record(result) || !Array.isArray(result.decisions)) throw invalid(message);\n  const boards = boardsOf(result.boards, parameters.moleCount, message),\n    decisions = Array.from(result.decisions as unknown[]);\n  if (isAuto(parameters)) {\n    if (decisions.length) throw invalid(message);\n    return settle(\n      stakeMinor,\n      parameters,\n      boards,\n      autoplay(stakeMinor, parameters, boards, version),\n      version,\n    );\n  }\n  let progress = start();\n  for (const decision of decisions) {\n    if (progress.endReason !== null) throw invalid(message);\n    progress = apply(stakeMinor, parameters.moleCount, boards, progress, decision, version);\n  }\n  if (progress.endReason === null) throw invalid(message);\n  return settle(stakeMinor, parameters, boards, progress, version);\n}\n\nexport const molesRulesModule: GameRules<MolesParameters> = Object.freeze({\n  game: \"moles\",\n  version: MOLES_RULES_VERSION,\n  formerly: \"moles-1\",\n  stateful: true,\n  actions: MOLES_ACTIONS,\n  closingActions: MOLES_CLOSING_ACTIONS,\n  rateLimitPerMinute: MOLES_RATE_LIMIT,\n  limits: MOLES_GAME_LIMITS,\n  validateParameters: validateMolesParameters,\n  maximumPayoutMinor: molesMaximumPayoutMinor,\n  resolve: resolveMoles,\n  act: actMoles,\n  reconstruct: reconstructMoles,\n});\n\nexport const moles3RulesModule: GameRules<MolesParameters> = Object.freeze({\n  game: \"moles\",\n  version: MOLES_3_RULES_VERSION,\n  stateful: true,\n  actions: MOLES_ACTIONS,\n  closingActions: MOLES_CLOSING_ACTIONS,\n  rateLimitPerMinute: MOLES_RATE_LIMIT,\n  limits: MOLES_3_GAME_LIMITS,\n  admitStake: (stakeMinor: bigint, parameters: MolesParameters) =>\n    admitMolesStake(stakeMinor, parameters, MOLES_3_RULES_VERSION),\n  validateParameters: validateMolesParameters,\n  maximumPayoutMinor: (stakeMinor: bigint, parameters: MolesParameters) =>\n    molesMaximumPayoutMinor(stakeMinor, parameters, MOLES_3_RULES_VERSION),\n  resolve: (input: ResolveInput<MolesParameters>) => resolveMoles(input, MOLES_3_RULES_VERSION),\n  act: (input: ActInput<MolesParameters>) => actMoles(input, MOLES_3_RULES_VERSION),\n  reconstruct: (input: Parameters<typeof reconstructMoles>[0]) =>\n    reconstructMoles(input, MOLES_3_RULES_VERSION),\n} satisfies GameRules<MolesParameters>);\n","sha256":"a6a43fefff63579e997a4aca361762e6277f7193aa5fab4e89c8d4823a359228"},"src/lib/games/baccarat-rules.ts":{"source":"import {\n  MAX_PAYOUT_MINOR,\n  ratio,\n  ratioText,\n  ratioWire,\n  type Ratio,\n  type Resolution,\n} from \"@/games/math\";\nimport type { Entropy, GameRules, Limits, ResolveInput } from \"./rules\";\nimport { admitStakeRange, gameLimits, maxWinLimits } from \"./limits\";\nimport { MAX_WIN_MINOR, maxBetForPayout, maxBetMessage } from \"./max-win\";\n\nexport const BACCARAT_RULES_VERSION = \"peeks-baccarat-1\" as const;\nexport const BACCARAT_2_RULES_VERSION = \"peeks-baccarat-2\" as const;\nexport const BACCARAT_RULES_VERSIONS = Object.freeze([\n  BACCARAT_RULES_VERSION,\n  BACCARAT_2_RULES_VERSION,\n] as const);\nexport type BaccaratRulesVersion = (typeof BACCARAT_RULES_VERSIONS)[number];\nexport const BACCARAT_SETTLEMENT_POLICY = \"peeks-pc-floor-1\" as const;\nexport const BACCARAT_GAME_NAME = \"baccarat\" as const;\nexport const BACCARAT_HASH = \"SHA-512\" as const;\nexport const BACCARAT_MESSAGE_FORMAT = \"{clientSeed}:{nonce}:baccarat:{cursor}\" as const;\nexport const BACCARAT_DECK_COUNT = 8;\nexport const BACCARAT_MAX_HAND_CARDS = 3;\nexport const BACCARAT_STAKE_RETURNED_MULTIPLIER = 1;\nexport const BACCARAT_THIRD_CARD_SLOT_INDEX = 2;\nexport const BACCARAT_SHOE_SIZE = BACCARAT_DECK_COUNT * 52;\nexport const BACCARAT_STREAM_LIMIT = 1024;\nexport const BACCARAT_MIN_BET_MINOR = 1n;\nexport const BACCARAT_MAX_STAKE_MINOR = 1_000_000n;\n\nexport const BACCARAT_CARD_RANKS = Object.freeze([\n  \"A\",\n  \"2\",\n  \"3\",\n  \"4\",\n  \"5\",\n  \"6\",\n  \"7\",\n  \"8\",\n  \"9\",\n  \"T\",\n  \"J\",\n  \"Q\",\n  \"K\",\n] as const);\nexport const BACCARAT_CARD_SUITS = Object.freeze([\"c\", \"d\", \"h\", \"s\"] as const);\nexport type BaccaratRank = (typeof BACCARAT_CARD_RANKS)[number];\nexport type BaccaratSuit = (typeof BACCARAT_CARD_SUITS)[number];\nexport type BaccaratCard = `${BaccaratRank}${BaccaratSuit}`;\nexport const BACCARAT_CARD_VALUES: Readonly<Record<BaccaratRank, number>> = Object.freeze({\n  2: 2,\n  3: 3,\n  4: 4,\n  5: 5,\n  6: 6,\n  7: 7,\n  8: 8,\n  9: 9,\n  A: 1,\n  J: 0,\n  K: 0,\n  Q: 0,\n  T: 0,\n});\n\nexport const BACCARAT_MAIN_BET_TYPES = Object.freeze([\"player\", \"tie\", \"banker\"] as const);\nexport const BACCARAT_SIDE_BET_TYPES = Object.freeze([\n  \"player_pair\",\n  \"player_bonus\",\n  \"perfect_pair\",\n  \"banker_pair\",\n  \"banker_bonus\",\n  \"either_pair\",\n] as const);\nexport const BACCARAT_SELECTIONS = Object.freeze([\n  ...BACCARAT_MAIN_BET_TYPES,\n  ...BACCARAT_SIDE_BET_TYPES,\n] as const);\nexport type BaccaratMainBetType = (typeof BACCARAT_MAIN_BET_TYPES)[number];\nexport type BaccaratSideBetType = (typeof BACCARAT_SIDE_BET_TYPES)[number];\nexport type BaccaratSelection = (typeof BACCARAT_SELECTIONS)[number];\nexport type BaccaratWagerKind = \"main\" | \"side\";\nexport type BaccaratWinner = \"player\" | \"banker\" | \"tie\";\nexport type BaccaratOwner = \"player\" | \"banker\";\nexport type BaccaratWager = {\n  amountMinor: string;\n  kind: BaccaratWagerKind;\n  selection: BaccaratSelection;\n};\nexport type BaccaratParameters = { wagers: BaccaratWager[] };\n\nexport const BACCARAT_LIMIT_KEYS = Object.freeze({\n  MainBetNonTie: \"main-bet-non-tie\",\n  MainBetTie: \"main-bet-tie\",\n  SideBet: \"side-bet\",\n} as const);\nexport type BaccaratLimitKey = (typeof BACCARAT_LIMIT_KEYS)[keyof typeof BACCARAT_LIMIT_KEYS];\nexport const BACCARAT_CHIP_LIMIT_KEY = BACCARAT_LIMIT_KEYS.MainBetNonTie;\nexport const BACCARAT_REFERENCE_LIMITS = Object.freeze({\n  currency: \"USD\",\n  minBet: 0.1,\n  maxBet: Object.freeze({ \"main-bet-tie\": 10_000, \"main-bet-non-tie\": 50_000, \"side-bet\": 3_000 }),\n  maxPayout: 190_000,\n} as const);\nexport type BaccaratBetLimit = Readonly<{ minMinor: bigint; maxMinor: bigint }>;\n\nfunction mappedLimit(key: BaccaratLimitKey): BaccaratBetLimit {\n  return Object.freeze({\n    minMinor: BACCARAT_MIN_BET_MINOR,\n    maxMinor: gameLimits(\"baccarat\", key).maxStakeMinor,\n  });\n}\n\nexport const BACCARAT_GAME_LIMITS = gameLimits(\"baccarat\", BACCARAT_CHIP_LIMIT_KEY);\nexport const BACCARAT_2_GAME_LIMITS = maxWinLimits(\"baccarat\", BACCARAT_CHIP_LIMIT_KEY);\nexport const BACCARAT_MAX_PAYOUT_MINOR = BACCARAT_GAME_LIMITS.maxPayoutMinor;\nexport const BACCARAT_BET_LIMITS: Readonly<Record<BaccaratLimitKey, BaccaratBetLimit>> =\n  Object.freeze({\n    \"main-bet-non-tie\": mappedLimit(\"main-bet-non-tie\"),\n    \"main-bet-tie\": mappedLimit(\"main-bet-tie\"),\n    \"side-bet\": mappedLimit(\"side-bet\"),\n  });\nexport const BACCARAT_TIE_MAX_MINOR = BACCARAT_BET_LIMITS[\"main-bet-tie\"].maxMinor;\nexport const BACCARAT_SIDE_BET_MAX_MINOR = BACCARAT_BET_LIMITS[\"side-bet\"].maxMinor;\n\nexport const BACCARAT_NET_PAYOUTS: Readonly<Record<BaccaratSelection, string>> = Object.freeze({\n  banker: \"0.95\",\n  banker_bonus: \"1\",\n  banker_pair: \"11\",\n  either_pair: \"5\",\n  perfect_pair: \"25\",\n  player: \"1\",\n  player_bonus: \"1\",\n  player_pair: \"11\",\n  tie: \"8\",\n});\nexport const BACCARAT_BONUS_LABEL = \"1-30:1\";\nexport const BACCARAT_PAYOUT_LABELS: Readonly<Record<BaccaratSelection, string>> = Object.freeze({\n  banker: \"0.95:1\",\n  banker_bonus: BACCARAT_BONUS_LABEL,\n  banker_pair: \"11:1\",\n  either_pair: \"5:1\",\n  perfect_pair: \"25:1\",\n  player: \"1:1\",\n  player_bonus: BACCARAT_BONUS_LABEL,\n  player_pair: \"11:1\",\n  tie: \"8:1\",\n});\nexport const BACCARAT_BONUS_MARGINS = Object.freeze([4, 5, 6, 7, 8, 9] as const);\nexport type BaccaratBonusMargin = (typeof BACCARAT_BONUS_MARGINS)[number];\nexport const BACCARAT_BONUS_NET_PAYOUTS: Readonly<{\n  natural: string;\n  margins: Readonly<Record<BaccaratBonusMargin, string>>;\n}> = Object.freeze({\n  natural: \"1\",\n  margins: Object.freeze({ 4: \"1\", 5: \"2\", 6: \"4\", 7: \"6\", 8: \"10\", 9: \"30\" }),\n});\nexport const BACCARAT_DISPLAY_RTP = Object.freeze(\n  (\n    [\n      [\"player\", \"98.76\"],\n      [\"banker\", \"98.94\"],\n      [\"tie\", \"85.64\"],\n      [\"player_pair\", \"89.64\"],\n      [\"banker_pair\", \"89.64\"],\n      [\"either_pair\", \"86.29\"],\n      [\"player_bonus\", \"97.35\"],\n      [\"banker_bonus\", \"90.63\"],\n    ] as const\n  ).map(([selection, rtp]) => Object.freeze({ selection, rtp })),\n);\n\nexport const BACCARAT_ROUND_COUNTS = Object.freeze({\n  total: 4_998_398_275_503_360n,\n  player: 2_230_518_282_592_256n,\n  banker: 2_292_252_566_437_888n,\n  tie: 475_627_426_473_216n,\n});\nexport type BaccaratBonusCounts = Readonly<\n  { natural: bigint; naturalTie: bigint } & Record<BaccaratBonusMargin, bigint>\n>;\nexport const BACCARAT_BONUS_COUNTS: Readonly<Record<BaccaratOwner, BaccaratBonusCounts>> =\n  Object.freeze({\n    player: Object.freeze({\n      natural: 812_685_054_124_032n,\n      naturalTie: 89_325_908_267_520n,\n      4: 186_780_352_174_080n,\n      5: 166_169_165_987_840n,\n      6: 141_238_897_317_888n,\n      7: 89_590_261_473_280n,\n      8: 34_097_645_543_424n,\n      9: 18_409_431_764_992n,\n    }),\n    banker: Object.freeze({\n      natural: 812_685_054_124_032n,\n      naturalTie: 89_325_908_267_520n,\n      4: 201_147_167_287_296n,\n      5: 157_275_882_332_160n,\n      6: 119_200_072_366_080n,\n      7: 79_517_099_278_336n,\n      8: 28_305_092_784_128n,\n      9: 15_390_342_909_952n,\n    }),\n  });\nexport const BACCARAT_PAIR_COUNTS = Object.freeze({\n  total: 70_957_530n,\n  playerPair: 5_300_442n,\n  bankerPair: 5_300_442n,\n  eitherPair: 10_204_890n,\n  perfectPair: 2_373_546n,\n});\n\nexport type BaccaratRound = Readonly<{\n  playerCards: readonly BaccaratCard[];\n  bankerCards: readonly BaccaratCard[];\n  playerTotal: number;\n  bankerTotal: number;\n  winner: BaccaratWinner;\n}>;\nexport type BaccaratShoeDraw = Readonly<{\n  remaining: number;\n  index: number;\n  card: BaccaratCard;\n}>;\nexport type BaccaratRecordedDeal = Readonly<{\n  round: BaccaratRound;\n  draws: readonly BaccaratShoeDraw[];\n}>;\nexport type BaccaratDeal = BaccaratRecordedDeal & Readonly<{ message: string }>;\nexport type BaccaratWagerResult = Readonly<{\n  amountMinor: string;\n  kind: BaccaratWagerKind;\n  selection: BaccaratSelection;\n  multiplier: string;\n  payoutMinor: string;\n  won: boolean | null;\n}>;\nexport type BaccaratSettlement = Readonly<{\n  wagers: readonly BaccaratWagerResult[];\n  grossPayoutMinor: bigint;\n  payoutMinor: bigint;\n  capped: boolean;\n}>;\n\ntype Summary = Readonly<{\n  winner: BaccaratWinner;\n  playerTotal: number;\n  bankerTotal: number;\n  playerNatural: boolean;\n  bankerNatural: boolean;\n  playerPair: boolean;\n  bankerPair: boolean;\n  perfectPair: boolean;\n}>;\n\nconst invalid = (message = \"Invalid Baccarat parameters\") => new RangeError(message);\nconst CARD_PATTERN = /^[A2-9TJQK][cdhs]$/;\nconst AMOUNT_PATTERN = /^[1-9]\\d{0,6}$/;\nconst WAGER_KEYS = Object.freeze([\"amountMinor\", \"kind\", \"selection\"]);\nconst DRAW_KEYS = Object.freeze([\"remaining\", \"index\", \"card\"]);\nconst FAIRNESS_KEYS = Object.freeze([\"hash\", \"messageFormat\"]);\n\nfunction reduce(numerator: bigint, denominator: bigint): Ratio {\n  let a = numerator < 0n ? -numerator : numerator,\n    b = denominator;\n  while (b) [a, b] = [b, a % b];\n  return Object.freeze({ numerator: numerator / a, denominator: denominator / a });\n}\n\nfunction decimal(value: string): Ratio {\n  const [whole, fraction = \"\"] = value.split(\".\");\n  const scale = 10n ** BigInt(fraction.length);\n  return reduce(BigInt(whole) * scale + BigInt(fraction || \"0\"), scale);\n}\n\nfunction gross(net: string): Ratio {\n  const value = decimal(net);\n  return reduce(value.numerator + value.denominator, value.denominator);\n}\n\nfunction decimalText(value: Ratio): string {\n  const whole = value.numerator / value.denominator;\n  let remainder = value.numerator % value.denominator,\n    digits = \"\";\n  while (remainder > 0n) {\n    if (digits.length === 8) throw invalid(\"Non-terminating Baccarat multiplier\");\n    remainder *= 10n;\n    digits += (remainder / value.denominator).toString();\n    remainder %= value.denominator;\n  }\n  return digits ? `${whole}.${digits}` : whole.toString();\n}\n\nfunction floorText(numerator: bigint, denominator: bigint, digits: number): string {\n  const scale = 10n ** BigInt(digits),\n    fixed = (numerator * scale) / denominator,\n    fraction = (fixed % scale).toString().padStart(digits, \"0\").replace(/0+$/, \"\");\n  return fraction ? `${fixed / scale}.${fraction}` : (fixed / scale).toString();\n}\n\nfunction pay(amount: bigint, multiplier: Ratio): bigint {\n  return (amount * multiplier.numerator) / multiplier.denominator;\n}\n\nconst ZERO = reduce(0n, 1n);\nconst PUSH = reduce(BigInt(BACCARAT_STAKE_RETURNED_MULTIPLIER), 1n);\nconst SPOT_GROSS = Object.freeze(\n  Object.fromEntries(\n    BACCARAT_SELECTIONS.map((selection) => [selection, gross(BACCARAT_NET_PAYOUTS[selection])]),\n  ) as Record<BaccaratSelection, Ratio>,\n);\nconst BONUS_NATURAL = gross(BACCARAT_BONUS_NET_PAYOUTS.natural);\nconst BONUS_MARGIN = Object.freeze(\n  Object.fromEntries(\n    BACCARAT_BONUS_MARGINS.map((margin) => [\n      margin,\n      gross(BACCARAT_BONUS_NET_PAYOUTS.margins[margin]),\n    ]),\n  ) as Record<BaccaratBonusMargin, Ratio>,\n);\n\nfunction isMain(selection: BaccaratSelection): selection is BaccaratMainBetType {\n  return (BACCARAT_MAIN_BET_TYPES as readonly string[]).includes(selection);\n}\n\nfunction isSelection(value: unknown): value is BaccaratSelection {\n  return typeof value === \"string\" && (BACCARAT_SELECTIONS as readonly string[]).includes(value);\n}\n\nexport function baccaratBetLimitKey(selection: BaccaratSelection): BaccaratLimitKey {\n  if (!isSelection(selection)) throw invalid(\"Invalid Baccarat wager\");\n  return isMain(selection)\n    ? selection === \"tie\"\n      ? BACCARAT_LIMIT_KEYS.MainBetTie\n      : BACCARAT_LIMIT_KEYS.MainBetNonTie\n    : BACCARAT_LIMIT_KEYS.SideBet;\n}\n\nfunction isCard(value: unknown): value is BaccaratCard {\n  return typeof value === \"string\" && CARD_PATTERN.test(value);\n}\n\nfunction plainObject(value: unknown): value is Record<string, unknown> {\n  if (!value || typeof value !== \"object\" || Array.isArray(value)) return false;\n  const prototype = Object.getPrototypeOf(value);\n  return prototype === Object.prototype || prototype === null;\n}\n\nfunction exactKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {\n  const own = Reflect.ownKeys(value);\n  return own.length === keys.length && keys.every((key) => Object.hasOwn(value, key));\n}\n\nfunction formatMinor(value: bigint): string {\n  const whole = (value / 100n).toString().replace(/\\B(?=(\\d{3})+(?!\\d))/g, \",\");\n  return `${whole}.${(value % 100n).toString().padStart(2, \"0\")}`;\n}\n\nexport function validateBaccaratParameters(input: unknown): BaccaratParameters {\n  if (!plainObject(input) || !exactKeys(input, [\"wagers\"])) throw invalid();\n  const wagers = input.wagers;\n  if (!Array.isArray(wagers) || wagers.length < 1 || wagers.length > BACCARAT_SELECTIONS.length)\n    throw invalid();\n  const seen = new Set<BaccaratSelection>();\n  let total = 0n;\n  const parsed: BaccaratWager[] = [];\n  for (let index = 0; index < wagers.length; index++) {\n    if (!Object.hasOwn(wagers, index)) throw invalid(\"Invalid Baccarat wager\");\n    const wager: unknown = wagers[index];\n    if (!plainObject(wager) || !exactKeys(wager, WAGER_KEYS))\n      throw invalid(\"Invalid Baccarat wager\");\n    const { amountMinor, kind, selection } = wager;\n    if (\n      !isSelection(selection) ||\n      seen.has(selection) ||\n      kind !== (isMain(selection) ? \"main\" : \"side\") ||\n      typeof amountMinor !== \"string\" ||\n      !AMOUNT_PATTERN.test(amountMinor)\n    )\n      throw invalid(\"Invalid Baccarat wager\");\n    seen.add(selection);\n    parsed.push({ amountMinor, kind: isMain(selection) ? \"main\" : \"side\", selection });\n  }\n  if (!BACCARAT_MAIN_BET_TYPES.some((selection) => seen.has(selection)))\n    throw invalid(\"Side bets require a main bet to play\");\n  const ordered = BACCARAT_SELECTIONS.flatMap((selection) =>\n    parsed.filter((wager) => wager.selection === selection),\n  );\n  for (const wager of ordered) {\n    const amount = BigInt(wager.amountMinor),\n      limit = BACCARAT_BET_LIMITS[baccaratBetLimitKey(wager.selection)];\n    if (amount < limit.minMinor) throw invalid(`Minimum bet is ${formatMinor(limit.minMinor)}`);\n    if (amount > limit.maxMinor) throw invalid(`Maximum bet is ${formatMinor(limit.maxMinor)}`);\n    total += amount;\n  }\n  if (total > BACCARAT_MAX_STAKE_MINOR)\n    throw invalid(`Maximum bet is ${formatMinor(BACCARAT_MAX_STAKE_MINOR)}`);\n  return { wagers: ordered };\n}\n\nexport function baccaratStakeMinor(parameters: unknown): bigint {\n  return validateBaccaratParameters(parameters).wagers.reduce(\n    (sum, wager) => sum + BigInt(wager.amountMinor),\n    0n,\n  );\n}\n\nfunction checkedWagers(stakeMinor: bigint, parameters: unknown): BaccaratParameters {\n  const validated = validateBaccaratParameters(parameters);\n  if (\n    typeof stakeMinor !== \"bigint\" ||\n    stakeMinor <= 0n ||\n    stakeMinor > BACCARAT_MAX_STAKE_MINOR ||\n    baccaratStakeMinor(validated) !== stakeMinor\n  )\n    throw invalid(\"Baccarat wagers must equal the total bet\");\n  return validated;\n}\n\nexport function baccaratCardValue(card: BaccaratCard): number {\n  if (!isCard(card)) throw invalid(\"Invalid Baccarat card\");\n  return BACCARAT_CARD_VALUES[card[0] as BaccaratRank];\n}\n\nexport function baccaratHandTotal(cards: readonly BaccaratCard[]): number {\n  return cards.reduce((sum, card) => sum + baccaratCardValue(card), 0) % 10;\n}\n\nexport function baccaratWinner(playerTotal: number, bankerTotal: number): BaccaratWinner {\n  return playerTotal === bankerTotal ? \"tie\" : playerTotal > bankerTotal ? \"player\" : \"banker\";\n}\n\nfunction bankerDrawsValue(bankerTotal: number, playerThird: number | null): boolean {\n  if (bankerTotal >= 7) return false;\n  if (playerThird === null) return bankerTotal <= 5;\n  if (bankerTotal <= 2) return true;\n  switch (bankerTotal) {\n    case 3:\n      return playerThird !== 8;\n    case 4:\n      return playerThird >= 2 && playerThird <= 7;\n    case 5:\n      return playerThird >= 4 && playerThird <= 7;\n    case 6:\n      return playerThird >= 6 && playerThird <= 7;\n    default:\n      return false;\n  }\n}\n\nexport function baccaratBankerDraws(\n  bankerTotal: number,\n  playerThird: BaccaratCard | null,\n): boolean {\n  if (!Number.isInteger(bankerTotal) || bankerTotal < 0 || bankerTotal > 9)\n    throw invalid(\"Invalid Baccarat total\");\n  return bankerDrawsValue(\n    bankerTotal,\n    playerThird === null ? null : baccaratCardValue(playerThird),\n  );\n}\n\nexport function baccaratPlayRound(draw: () => BaccaratCard): BaccaratRound {\n  const playerCards: BaccaratCard[] = [],\n    bankerCards: BaccaratCard[] = [];\n  playerCards.push(draw());\n  bankerCards.push(draw());\n  playerCards.push(draw());\n  bankerCards.push(draw());\n  let playerTotal = baccaratHandTotal(playerCards),\n    bankerTotal = baccaratHandTotal(bankerCards);\n  if (playerTotal < 8 && bankerTotal < 8) {\n    let third: BaccaratCard | null = null;\n    if (playerTotal <= 5) {\n      third = draw();\n      playerCards.push(third);\n      playerTotal = baccaratHandTotal(playerCards);\n    }\n    if (baccaratBankerDraws(bankerTotal, third)) {\n      bankerCards.push(draw());\n      bankerTotal = baccaratHandTotal(bankerCards);\n    }\n  }\n  return Object.freeze({\n    playerCards: Object.freeze(playerCards),\n    bankerCards: Object.freeze(bankerCards),\n    playerTotal,\n    bankerTotal,\n    winner: baccaratWinner(playerTotal, bankerTotal),\n  });\n}\n\nexport function baccaratRoundFromCards(cards: readonly string[]): BaccaratRound {\n  if (!Array.isArray(cards) || cards.length < 4 || cards.length > 6)\n    throw invalid(\"Invalid Baccarat card sequence\");\n  let next = 0;\n  const round = baccaratPlayRound(() => {\n    if (next >= cards.length) throw invalid(\"Invalid Baccarat card sequence\");\n    const card = cards[next++];\n    if (!isCard(card)) throw invalid(\"Invalid Baccarat card\");\n    return card;\n  });\n  if (next !== cards.length) throw invalid(\"Invalid Baccarat card sequence\");\n  return round;\n}\n\nexport function baccaratVisibleSlotOrder(owner: BaccaratOwner, cardCount: number): number[] {\n  if (\n    (owner !== \"player\" && owner !== \"banker\") ||\n    !Number.isInteger(cardCount) ||\n    cardCount < 0 ||\n    cardCount > BACCARAT_MAX_HAND_CARDS\n  )\n    throw invalid(\"Invalid Baccarat hand\");\n  const initial = Array.from({ length: Math.min(2, cardCount) }, (_, slot) => slot);\n  if (cardCount <= 2) return initial;\n  return owner === \"player\"\n    ? [BACCARAT_THIRD_CARD_SLOT_INDEX, ...initial]\n    : [...initial, BACCARAT_THIRD_CARD_SLOT_INDEX];\n}\n\nexport function baccaratDealOrder(round: BaccaratRound) {\n  const order: { owner: BaccaratOwner; slotIndex: number; card: BaccaratCard }[] = [];\n  for (let slotIndex = 0; slotIndex < 2; slotIndex++) {\n    order.push({ owner: \"player\", slotIndex, card: round.playerCards[slotIndex] });\n    order.push({ owner: \"banker\", slotIndex, card: round.bankerCards[slotIndex] });\n  }\n  if (round.playerCards[2])\n    order.push({ owner: \"player\", slotIndex: 2, card: round.playerCards[2] });\n  if (round.bankerCards[2])\n    order.push({ owner: \"banker\", slotIndex: 2, card: round.bankerCards[2] });\n  return order;\n}\n\nfunction checkedRound(round: BaccaratRound): BaccaratRound {\n  if (\n    !round ||\n    !Array.isArray(round.playerCards) ||\n    !Array.isArray(round.bankerCards) ||\n    round.playerCards.length < 2 ||\n    round.playerCards.length > BACCARAT_MAX_HAND_CARDS ||\n    round.bankerCards.length < 2 ||\n    round.bankerCards.length > BACCARAT_MAX_HAND_CARDS\n  )\n    throw invalid(\"Invalid Baccarat round\");\n  const replayed = baccaratRoundFromCards(baccaratDealOrder(round).map((entry) => entry.card));\n  if (\n    replayed.playerCards.join() !== round.playerCards.join() ||\n    replayed.bankerCards.join() !== round.bankerCards.join() ||\n    replayed.playerTotal !== round.playerTotal ||\n    replayed.bankerTotal !== round.bankerTotal ||\n    replayed.winner !== round.winner\n  )\n    throw invalid(\"Invalid Baccarat round\");\n  return replayed;\n}\n\nexport function baccaratShoeCard(position: number): BaccaratCard {\n  if (!Number.isInteger(position) || position < 0 || position >= BACCARAT_SHOE_SIZE)\n    throw invalid(\"Invalid Baccarat shoe position\");\n  const code = position % 52;\n  return `${BACCARAT_CARD_RANKS[code % 13]}${BACCARAT_CARD_SUITS[Math.floor(code / 13)]}`;\n}\n\nfunction validEntropy(entropy: Entropy): boolean {\n  return (\n    !!entropy &&\n    typeof entropy.serverSeed === \"string\" &&\n    !!entropy.serverSeed.trim() &&\n    typeof entropy.clientSeed === \"string\" &&\n    !!entropy.clientSeed.trim() &&\n    typeof entropy.nonce === \"number\" &&\n    Number.isSafeInteger(entropy.nonce) &&\n    entropy.nonce >= 0 &&\n    typeof entropy.hmac === \"function\"\n  );\n}\n\nexport function baccaratMessage(entropy: Pick<Entropy, \"clientSeed\" | \"nonce\">) {\n  return `${entropy.clientSeed}:${entropy.nonce}:${BACCARAT_GAME_NAME}`;\n}\n\nexport async function baccaratShoeDraws(\n  entropy: Entropy,\n  count: number,\n): Promise<readonly BaccaratShoeDraw[]> {\n  if (!validEntropy(entropy)) throw invalid(\"Invalid Baccarat fairness seed\");\n  if (!Number.isInteger(count) || count < 0 || count > BACCARAT_SHOE_SIZE)\n    throw invalid(\"Invalid Baccarat draw count\");\n  const message = baccaratMessage(entropy),\n    words: number[] = [],\n    shoe = Array.from({ length: BACCARAT_SHOE_SIZE }, (_, position) => position),\n    draws: BaccaratShoeDraw[] = [];\n  let cursor = 0,\n    used = 0;\n  const word = async () => {\n    if (used === words.length) {\n      if (cursor >= BACCARAT_STREAM_LIMIT) throw invalid(\"Baccarat entropy stream exhausted\");\n      const digest = await entropy.hmac(BACCARAT_HASH, entropy.serverSeed, `${message}:${cursor}`);\n      if (!(digest instanceof Uint8Array) || digest.byteLength !== 64)\n        throw invalid(\"Invalid Baccarat HMAC digest\");\n      for (let offset = 0; offset < 64; offset += 4)\n        words.push(\n          ((digest[offset] << 24) |\n            (digest[offset + 1] << 16) |\n            (digest[offset + 2] << 8) |\n            digest[offset + 3]) >>>\n            0,\n        );\n      cursor++;\n    }\n    return words[used++];\n  };\n  for (let drawn = 0; drawn < count; drawn++) {\n    const remaining = BACCARAT_SHOE_SIZE - drawn,\n      limit = 0x1_0000_0000 - (0x1_0000_0000 % remaining);\n    let value = await word();\n    while (value >= limit) value = await word();\n    const index = value % remaining,\n      last = remaining - 1,\n      selected = shoe[index];\n    shoe[index] = shoe[last];\n    shoe[last] = selected;\n    draws.push(Object.freeze({ remaining, index, card: baccaratShoeCard(selected) }));\n  }\n  return Object.freeze(draws);\n}\n\nexport async function baccaratDeal(entropy: Entropy): Promise<BaccaratDeal> {\n  const draws = await baccaratShoeDraws(entropy, 2 * BACCARAT_MAX_HAND_CARDS);\n  let next = 0;\n  const round = baccaratPlayRound(() => draws[next++].card);\n  return Object.freeze({\n    round,\n    draws: Object.freeze(draws.slice(0, next)),\n    message: baccaratMessage(entropy),\n  });\n}\n\nexport function isBaccaratRulesVersion(value: unknown): value is BaccaratRulesVersion {\n  return (BACCARAT_RULES_VERSIONS as readonly unknown[]).includes(value);\n}\n\nfunction edition(version: unknown): BaccaratRulesVersion {\n  if (!isBaccaratRulesVersion(version)) throw invalid(\"Invalid Baccarat rules\");\n  return version;\n}\n\nfunction paysInFull(version: BaccaratRulesVersion): boolean {\n  return version !== BACCARAT_RULES_VERSION;\n}\n\nexport function baccaratLimits(version: BaccaratRulesVersion = BACCARAT_RULES_VERSION): Limits {\n  return paysInFull(edition(version)) ? BACCARAT_2_GAME_LIMITS : BACCARAT_GAME_LIMITS;\n}\n\nexport function baccaratRecordedDeal(\n  result: unknown,\n  version: BaccaratRulesVersion = BACCARAT_RULES_VERSION,\n): BaccaratRecordedDeal {\n  if (\n    !plainObject(result) ||\n    result.baccaratRules !== version ||\n    !plainObject(result.fairness) ||\n    !exactKeys(result.fairness, FAIRNESS_KEYS) ||\n    result.fairness.hash !== BACCARAT_HASH ||\n    result.fairness.messageFormat !== BACCARAT_MESSAGE_FORMAT\n  )\n    throw invalid(\"Invalid Baccarat result\");\n  const source = result.shoeDraws;\n  if (!Array.isArray(source) || source.length < 4 || source.length > 2 * BACCARAT_MAX_HAND_CARDS)\n    throw invalid(\"Invalid Baccarat result\");\n  const shoe = Array.from({ length: BACCARAT_SHOE_SIZE }, (_, position) => position),\n    draws: BaccaratShoeDraw[] = [];\n  for (let drawn = 0; drawn < source.length; drawn++) {\n    const entry: unknown = Object.hasOwn(source, drawn) ? source[drawn] : undefined;\n    if (!plainObject(entry) || !exactKeys(entry, DRAW_KEYS))\n      throw invalid(\"Invalid Baccarat shoe draw\");\n    const remaining = BACCARAT_SHOE_SIZE - drawn,\n      { index, card } = entry;\n    if (\n      entry.remaining !== remaining ||\n      typeof index !== \"number\" ||\n      !Number.isInteger(index) ||\n      index < 0 ||\n      index >= remaining\n    )\n      throw invalid(\"Invalid Baccarat shoe draw\");\n    const last = remaining - 1,\n      selected = shoe[index],\n      expected = baccaratShoeCard(selected);\n    if (card !== expected) throw invalid(\"Invalid Baccarat shoe draw\");\n    shoe[index] = shoe[last];\n    shoe[last] = selected;\n    draws.push(Object.freeze({ remaining, index, card: expected }));\n  }\n  return Object.freeze({\n    round: baccaratRoundFromCards(draws.map((draw) => draw.card)),\n    draws: Object.freeze(draws),\n  });\n}\n\nfunction pair(cards: readonly BaccaratCard[]): boolean {\n  return cards.length >= 2 && cards[0][0] === cards[1][0];\n}\n\nfunction perfectPair(cards: readonly BaccaratCard[]): boolean {\n  return pair(cards) && cards[0][1] === cards[1][1];\n}\n\nfunction summarize(round: BaccaratRound): Summary {\n  return {\n    winner: round.winner,\n    playerTotal: round.playerTotal,\n    bankerTotal: round.bankerTotal,\n    playerNatural: round.playerCards.length === 2 && round.playerTotal >= 8,\n    bankerNatural: round.bankerCards.length === 2 && round.bankerTotal >= 8,\n    playerPair: pair(round.playerCards),\n    bankerPair: pair(round.bankerCards),\n    perfectPair: perfectPair(round.playerCards) || perfectPair(round.bankerCards),\n  };\n}\n\nfunction bonusRatio(side: BaccaratOwner, summary: Summary): Ratio {\n  if (summary.winner === \"tie\") return summary.playerNatural && summary.bankerNatural ? PUSH : ZERO;\n  if (summary.winner !== side) return ZERO;\n  if (side === \"player\" ? summary.playerNatural : summary.bankerNatural) return BONUS_NATURAL;\n  const margin =\n    side === \"player\"\n      ? summary.playerTotal - summary.bankerTotal\n      : summary.bankerTotal - summary.playerTotal;\n  return BONUS_MARGIN[margin as BaccaratBonusMargin] ?? ZERO;\n}\n\nfunction spotRatio(selection: BaccaratSelection, summary: Summary): Ratio {\n  switch (selection) {\n    case \"player\":\n    case \"banker\":\n    case \"tie\":\n      return summary.winner === selection\n        ? SPOT_GROSS[selection]\n        : summary.winner === \"tie\"\n          ? PUSH\n          : ZERO;\n    case \"player_pair\":\n      return summary.playerPair ? SPOT_GROSS.player_pair : ZERO;\n    case \"banker_pair\":\n      return summary.bankerPair ? SPOT_GROSS.banker_pair : ZERO;\n    case \"either_pair\":\n      return summary.playerPair || summary.bankerPair ? SPOT_GROSS.either_pair : ZERO;\n    case \"perfect_pair\":\n      return summary.perfectPair ? SPOT_GROSS.perfect_pair : ZERO;\n    case \"player_bonus\":\n      return bonusRatio(\"player\", summary);\n    case \"banker_bonus\":\n      return bonusRatio(\"banker\", summary);\n  }\n}\n\nexport function baccaratSpotMultiplier(selection: BaccaratSelection, round: BaccaratRound): Ratio {\n  if (!isSelection(selection)) throw invalid(\"Invalid Baccarat wager\");\n  return spotRatio(selection, summarize(checkedRound(round)));\n}\n\nconst OUTCOME_VECTORS: readonly (readonly Ratio[])[] = (() => {\n  type Terminal = Readonly<{\n    playerTotal: number;\n    bankerTotal: number;\n    playerNatural: boolean;\n    bankerNatural: boolean;\n    playerPairs: boolean;\n    bankerPairs: boolean;\n  }>;\n  const terminals = new Map<string, Terminal>(),\n    vectors = new Map<string, readonly Ratio[]>(),\n    values = Array.from({ length: 10 }, (_, value) => value),\n    unpaired: readonly (readonly [boolean, boolean])[] = [[false, false]],\n    paired: readonly (readonly [boolean, boolean])[] = [\n      [false, false],\n      [true, false],\n      [true, true],\n    ];\n  for (const player of values)\n    for (const banker of values) {\n      const add = (playerTotal: number, bankerTotal: number) => {\n        const terminal: Terminal = {\n          playerTotal,\n          bankerTotal,\n          playerNatural: player >= 8,\n          bankerNatural: banker >= 8,\n          playerPairs: player % 2 === 0,\n          bankerPairs: banker % 2 === 0,\n        };\n        terminals.set(JSON.stringify(terminal), terminal);\n      };\n      if (player >= 8 || banker >= 8) add(player, banker);\n      else\n        for (const third of player <= 5 ? values : [null]) {\n          const finalPlayer = third === null ? player : (player + third) % 10;\n          if (bankerDrawsValue(banker, third))\n            for (const bankerThird of values) add(finalPlayer, (banker + bankerThird) % 10);\n          else add(finalPlayer, banker);\n        }\n    }\n  for (const terminal of terminals.values())\n    for (const [playerPair, playerPerfect] of terminal.playerPairs ? paired : unpaired)\n      for (const [bankerPair, bankerPerfect] of terminal.bankerPairs ? paired : unpaired) {\n        const summary: Summary = {\n          winner: baccaratWinner(terminal.playerTotal, terminal.bankerTotal),\n          playerTotal: terminal.playerTotal,\n          bankerTotal: terminal.bankerTotal,\n          playerNatural: terminal.playerNatural,\n          bankerNatural: terminal.bankerNatural,\n          playerPair,\n          bankerPair,\n          perfectPair: playerPerfect || bankerPerfect,\n        };\n        const vector = BACCARAT_SELECTIONS.map((selection) => spotRatio(selection, summary));\n        const key = vector.map((value) => `${value.numerator}/${value.denominator}`).join();\n        if (!vectors.has(key)) vectors.set(key, Object.freeze(vector));\n      }\n  return Object.freeze([...vectors.values()]);\n})();\n\nexport function baccaratOutcomeVectors(): readonly (readonly Ratio[])[] {\n  return OUTCOME_VECTORS;\n}\n\nconst PAIR_SCALE = BACCARAT_ROUND_COUNTS.total / BACCARAT_PAIR_COUNTS.total;\n\nfunction binary(win: Ratio, hits: bigint): readonly (readonly [Ratio, bigint])[] {\n  return Object.freeze([\n    Object.freeze([win, hits * PAIR_SCALE] as const),\n    Object.freeze([ZERO, (BACCARAT_PAIR_COUNTS.total - hits) * PAIR_SCALE] as const),\n  ]);\n}\n\nfunction bonusDistribution(side: BaccaratOwner): readonly (readonly [Ratio, bigint])[] {\n  const counts = BACCARAT_BONUS_COUNTS[side];\n  const entries: (readonly [Ratio, bigint])[] = [\n    [BONUS_NATURAL, counts.natural],\n    [PUSH, counts.naturalTie],\n    ...BACCARAT_BONUS_MARGINS.map((margin) => [BONUS_MARGIN[margin], counts[margin]] as const),\n  ];\n  const paid = entries.reduce((sum, [, count]) => sum + count, 0n);\n  return Object.freeze(\n    [...entries, [ZERO, BACCARAT_ROUND_COUNTS.total - paid] as const].map((entry) =>\n      Object.freeze(entry),\n    ),\n  );\n}\n\nconst DISTRIBUTIONS: Readonly<Record<BaccaratSelection, readonly (readonly [Ratio, bigint])[]>> =\n  Object.freeze({\n    player: Object.freeze([\n      Object.freeze([SPOT_GROSS.player, BACCARAT_ROUND_COUNTS.player] as const),\n      Object.freeze([PUSH, BACCARAT_ROUND_COUNTS.tie] as const),\n      Object.freeze([ZERO, BACCARAT_ROUND_COUNTS.banker] as const),\n    ]),\n    tie: Object.freeze([\n      Object.freeze([SPOT_GROSS.tie, BACCARAT_ROUND_COUNTS.tie] as const),\n      Object.freeze([ZERO, BACCARAT_ROUND_COUNTS.player + BACCARAT_ROUND_COUNTS.banker] as const),\n    ]),\n    banker: Object.freeze([\n      Object.freeze([SPOT_GROSS.banker, BACCARAT_ROUND_COUNTS.banker] as const),\n      Object.freeze([PUSH, BACCARAT_ROUND_COUNTS.tie] as const),\n      Object.freeze([ZERO, BACCARAT_ROUND_COUNTS.player] as const),\n    ]),\n    player_pair: binary(SPOT_GROSS.player_pair, BACCARAT_PAIR_COUNTS.playerPair),\n    player_bonus: bonusDistribution(\"player\"),\n    perfect_pair: binary(SPOT_GROSS.perfect_pair, BACCARAT_PAIR_COUNTS.perfectPair),\n    banker_pair: binary(SPOT_GROSS.banker_pair, BACCARAT_PAIR_COUNTS.bankerPair),\n    banker_bonus: bonusDistribution(\"banker\"),\n    either_pair: binary(SPOT_GROSS.either_pair, BACCARAT_PAIR_COUNTS.eitherPair),\n  });\n\nexport function baccaratSpotDistribution(\n  selection: BaccaratSelection,\n): readonly (readonly [Ratio, bigint])[] {\n  if (!isSelection(selection)) throw invalid(\"Invalid Baccarat wager\");\n  return DISTRIBUTIONS[selection];\n}\n\nfunction add(left: Ratio, right: Ratio): Ratio {\n  return reduce(\n    left.numerator * right.denominator + right.numerator * left.denominator,\n    left.denominator * right.denominator,\n  );\n}\n\nexport function baccaratSpotRtp(selection: BaccaratSelection): Ratio {\n  return baccaratSpotDistribution(selection).reduce(\n    (sum, [multiplier, count]) =>\n      add(\n        sum,\n        reduce(count * multiplier.numerator, BACCARAT_ROUND_COUNTS.total * multiplier.denominator),\n      ),\n    ZERO,\n  );\n}\n\nexport function baccaratNominalRtp(stakeMinor: bigint, parameters: unknown): Ratio {\n  const { wagers } = checkedWagers(stakeMinor, parameters);\n  return wagers.reduce((sum, wager) => {\n    const spot = baccaratSpotRtp(wager.selection);\n    return add(\n      sum,\n      reduce(BigInt(wager.amountMinor) * spot.numerator, stakeMinor * spot.denominator),\n    );\n  }, ZERO);\n}\n\nexport function baccaratTheoreticalRtp(stakeMinor: bigint, parameters: unknown): Ratio {\n  const { wagers } = checkedWagers(stakeMinor, parameters);\n  let numerator = 0n;\n  for (const wager of wagers) {\n    const amount = BigInt(wager.amountMinor);\n    for (const [multiplier, count] of DISTRIBUTIONS[wager.selection])\n      numerator += count * pay(amount, multiplier);\n  }\n  return reduce(numerator, stakeMinor * BACCARAT_ROUND_COUNTS.total);\n}\n\nfunction grossMaximumMinor(stakeMinor: bigint, parameters: unknown): bigint {\n  const { wagers } = checkedWagers(stakeMinor, parameters);\n  const indexed = wagers.map(\n    (wager) => [BigInt(wager.amountMinor), BACCARAT_SELECTIONS.indexOf(wager.selection)] as const,\n  );\n  let best = 0n;\n  for (const vector of OUTCOME_VECTORS) {\n    let total = 0n;\n    for (const [amount, index] of indexed) total += pay(amount, vector[index]);\n    if (total > best) best = total;\n  }\n  return best;\n}\n\nexport function baccaratMaximumPayoutMinor(\n  stakeMinor: bigint,\n  parameters: unknown,\n  version: BaccaratRulesVersion = BACCARAT_RULES_VERSION,\n): bigint {\n  const best = grossMaximumMinor(stakeMinor, parameters);\n  if (paysInFull(edition(version))) return best;\n  return best > BACCARAT_MAX_PAYOUT_MINOR ? BACCARAT_MAX_PAYOUT_MINOR : best;\n}\n\nexport function baccaratSettlement(\n  input: Readonly<{ stakeMinor: bigint; parameters: unknown; round: BaccaratRound }>,\n  capMinor: bigint = BACCARAT_MAX_PAYOUT_MINOR,\n): BaccaratSettlement {\n  const { wagers } = checkedWagers(input.stakeMinor, input.parameters);\n  if (typeof capMinor !== \"bigint\" || capMinor <= 0n || capMinor > MAX_PAYOUT_MINOR)\n    throw invalid(\"Invalid Baccarat payout cap\");\n  const summary = summarize(checkedRound(input.round));\n  let grossPayoutMinor = 0n;\n  const results = wagers.map((wager) => {\n    const multiplier = spotRatio(wager.selection, summary),\n      payout = pay(BigInt(wager.amountMinor), multiplier);\n    grossPayoutMinor += payout;\n    return Object.freeze({\n      amountMinor: wager.amountMinor,\n      kind: wager.kind,\n      selection: wager.selection,\n      multiplier: decimalText(multiplier),\n      payoutMinor: payout.toString(),\n      won:\n        multiplier.numerator === 0n\n          ? false\n          : multiplier.numerator === multiplier.denominator\n            ? null\n            : multiplier.numerator > multiplier.denominator,\n    });\n  });\n  const capped = grossPayoutMinor > capMinor;\n  return Object.freeze({\n    wagers: Object.freeze(results),\n    grossPayoutMinor,\n    payoutMinor: capped ? capMinor : grossPayoutMinor,\n    capped,\n  });\n}\n\nfunction checkedTotals(payoutMinor: bigint, stakeMinor: bigint) {\n  if (\n    typeof payoutMinor !== \"bigint\" ||\n    typeof stakeMinor !== \"bigint\" ||\n    stakeMinor <= 0n ||\n    payoutMinor < 0n\n  )\n    throw invalid(\"Invalid Baccarat stake\");\n}\n\nexport function baccaratTotalMultiplier(payoutMinor: bigint, stakeMinor: bigint): string {\n  checkedTotals(payoutMinor, stakeMinor);\n  return floorText(payoutMinor, stakeMinor, 3);\n}\n\nexport function baccaratRoundWin(payoutMinor: bigint, stakeMinor: bigint): boolean {\n  checkedTotals(payoutMinor, stakeMinor);\n  return (payoutMinor * 1000n) / stakeMinor > 1000n;\n}\n\nexport function baccaratPayoutTable() {\n  return {\n    labels: { ...BACCARAT_PAYOUT_LABELS },\n    multipliers: Object.fromEntries(\n      BACCARAT_SELECTIONS.filter(\n        (selection) => selection !== \"player_bonus\" && selection !== \"banker_bonus\",\n      ).map((selection) => [selection, decimalText(SPOT_GROSS[selection])]),\n    ),\n    bonus: {\n      natural: decimalText(BONUS_NATURAL),\n      naturalTie: decimalText(PUSH),\n      margins: Object.fromEntries(\n        BACCARAT_BONUS_MARGINS.map((margin) => [String(margin), decimalText(BONUS_MARGIN[margin])]),\n      ),\n    },\n  };\n}\n\nfunction resolution(\n  stakeMinor: bigint,\n  parameters: BaccaratParameters,\n  deal: BaccaratRecordedDeal,\n  version: BaccaratRulesVersion = BACCARAT_RULES_VERSION,\n): Resolution {\n  const round = deal.round,\n    summary = summarize(round),\n    settlement = baccaratSettlement(\n      { stakeMinor, parameters, round },\n      paysInFull(version) ? MAX_WIN_MINOR : BACCARAT_MAX_PAYOUT_MINOR,\n    ),\n    payout = settlement.payoutMinor,\n    revealed: Record<BaccaratOwner, BaccaratCard[]> = { player: [], banker: [] };\n  return {\n    game: \"baccarat\",\n    status: payout > 0n ? \"WON\" : \"LOST\",\n    payoutMinor: payout.toString(),\n    multiplier: ratioText(ratio(payout, stakeMinor)),\n    rulesVersion: version,\n    result: {\n      baccaratRules: version,\n      settlementPolicy: BACCARAT_SETTLEMENT_POLICY,\n      gameName: BACCARAT_GAME_NAME,\n      decks: BACCARAT_DECK_COUNT,\n      parameters: { wagers: parameters.wagers.map((wager) => ({ ...wager })) },\n      fairness: { hash: BACCARAT_HASH, messageFormat: BACCARAT_MESSAGE_FORMAT },\n      shoeDraws: deal.draws.map((draw) => ({ ...draw })),\n      dealOrder: baccaratDealOrder(round).map((entry) => {\n        revealed[entry.owner].push(entry.card);\n        return {\n          ...entry,\n          totals: {\n            player: revealed.player.length ? baccaratHandTotal(revealed.player) : null,\n            banker: revealed.banker.length ? baccaratHandTotal(revealed.banker) : null,\n          },\n        };\n      }),\n      playerCards: [...round.playerCards],\n      bankerCards: [...round.bankerCards],\n      playerTotal: round.playerTotal,\n      bankerTotal: round.bankerTotal,\n      winner: round.winner,\n      natural: summary.playerNatural || summary.bankerNatural,\n      playerNatural: summary.playerNatural,\n      bankerNatural: summary.bankerNatural,\n      playerPair: summary.playerPair,\n      bankerPair: summary.bankerPair,\n      perfectPair: summary.perfectPair,\n      margin: Math.abs(round.playerTotal - round.bankerTotal),\n      wagers: settlement.wagers.map((wager) => ({ ...wager })),\n      totalBetMinor: stakeMinor.toString(),\n      grossPayoutMinor: settlement.grossPayoutMinor.toString(),\n      totalMultiplier: baccaratTotalMultiplier(payout, stakeMinor),\n      win: baccaratRoundWin(payout, stakeMinor),\n      capped: settlement.capped,\n      payoutTable: baccaratPayoutTable(),\n      nominalRtp: ratioWire(baccaratNominalRtp(stakeMinor, parameters)),\n      theoreticalRtp: ratioWire(baccaratTheoreticalRtp(stakeMinor, parameters)),\n      maxPayoutMinor: baccaratLimits(version).maxPayoutMinor.toString(),\n      decisions: [],\n    },\n  };\n}\n\nexport function admitBaccaratStake(stakeMinor: bigint, parameters: unknown): void {\n  checkedWagers(stakeMinor, parameters);\n  admitStakeRange(\n    {\n      minStakeMinor: BACCARAT_GAME_LIMITS.minStakeMinor,\n      maxStakeMinor: BACCARAT_MAX_STAKE_MINOR,\n    },\n    stakeMinor,\n  );\n}\n\nexport function admitBaccarat2Stake(stakeMinor: bigint, parameters: unknown): void {\n  admitBaccaratStake(stakeMinor, parameters);\n  const top = grossMaximumMinor(stakeMinor, parameters);\n  if (top > MAX_WIN_MINOR) throw invalid(maxBetMessage(maxBetForPayout(stakeMinor, top)));\n}\n\nexport function reconstructBaccarat(\n  input: Readonly<{\n    stakeMinor: bigint;\n    parameters: BaccaratParameters;\n    result: Record<string, unknown>;\n  }>,\n  version: BaccaratRulesVersion = BACCARAT_RULES_VERSION,\n): Resolution {\n  const parameters = checkedWagers(input.stakeMinor, input.parameters);\n  return resolution(\n    input.stakeMinor,\n    parameters,\n    baccaratRecordedDeal(input.result, edition(version)),\n    version,\n  );\n}\n\nexport const baccaratRulesModule: GameRules<BaccaratParameters> = Object.freeze({\n  game: \"baccarat\",\n  version: BACCARAT_RULES_VERSION,\n  stateful: false,\n  actions: Object.freeze([] as string[]),\n  limits: BACCARAT_GAME_LIMITS,\n  admitStake: admitBaccaratStake,\n  validateParameters: validateBaccaratParameters,\n  maximumPayoutMinor: baccaratMaximumPayoutMinor,\n  async resolve(input: ResolveInput<BaccaratParameters>) {\n    const parameters = checkedWagers(input.stakeMinor, input.parameters);\n    return resolution(input.stakeMinor, parameters, await baccaratDeal(input.entropy));\n  },\n  reconstruct: reconstructBaccarat,\n});\n\nexport const baccarat2RulesModule: GameRules<BaccaratParameters> = Object.freeze({\n  game: \"baccarat\",\n  version: BACCARAT_2_RULES_VERSION,\n  stateful: false,\n  actions: Object.freeze([] as string[]),\n  limits: BACCARAT_2_GAME_LIMITS,\n  admitStake: admitBaccarat2Stake,\n  validateParameters: validateBaccaratParameters,\n  maximumPayoutMinor: (stakeMinor: bigint, parameters: BaccaratParameters) =>\n    baccaratMaximumPayoutMinor(stakeMinor, parameters, BACCARAT_2_RULES_VERSION),\n  async resolve(input: ResolveInput<BaccaratParameters>) {\n    const parameters = checkedWagers(input.stakeMinor, input.parameters);\n    return resolution(\n      input.stakeMinor,\n      parameters,\n      await baccaratDeal(input.entropy),\n      BACCARAT_2_RULES_VERSION,\n    );\n  },\n  reconstruct: (\n    input: Readonly<{\n      stakeMinor: bigint;\n      parameters: BaccaratParameters;\n      result: Record<string, unknown>;\n    }>,\n  ) => reconstructBaccarat(input, BACCARAT_2_RULES_VERSION),\n});\n","sha256":"8e6a49284190f6da3239510c94bb3a3af9176e45f844da5ca2ec0ac7963445a4"},"src/lib/games/hilo-rules.ts":{"source":"import { ratio, ratioText, ratioWire, type Resolution } from \"@/games/math\";\nimport {\n  hiloDecimal,\n  hiloDecimalCompare,\n  hiloDecimalDivide,\n  hiloDecimalFixed,\n  hiloDecimalFromNumber,\n  hiloDecimalTimes,\n  hiloDecimalToNumber,\n} from \"./hilo-decimal\";\nimport type { ActInput, Decision, Entropy, GameRules, Limits, ResolveInput } from \"./rules\";\nimport { gameLimits, maxWinLimits } from \"./limits\";\nimport { MAX_WIN_END, MAX_WIN_MINOR, unavailableSelectionMessage } from \"./max-win\";\n\nexport const HILO_RULES_VERSION = \"peeks-hilo-1\" as const;\nexport const HILO_2_RULES_VERSION = \"peeks-hilo-2\" as const;\nexport const HILO_RULES_VERSIONS = Object.freeze([\n  HILO_RULES_VERSION,\n  HILO_2_RULES_VERSION,\n] as const);\nexport type HiloRulesVersion = (typeof HILO_RULES_VERSIONS)[number];\nexport const HILO_CARD_RANKS = Object.freeze([\n  \"A\",\n  \"2\",\n  \"3\",\n  \"4\",\n  \"5\",\n  \"6\",\n  \"7\",\n  \"8\",\n  \"9\",\n  \"T\",\n  \"J\",\n  \"Q\",\n  \"K\",\n] as const);\nexport type HiloRank = (typeof HILO_CARD_RANKS)[number];\nexport const HILO_CARD_RANK_VALUES: Readonly<Record<HiloRank, number>> = Object.freeze({\n  2: 2,\n  3: 3,\n  4: 4,\n  5: 5,\n  6: 6,\n  7: 7,\n  8: 8,\n  9: 9,\n  A: 1,\n  J: 11,\n  K: 13,\n  Q: 12,\n  T: 10,\n});\nexport const HILO_SUIT_CODES = Object.freeze([\"h\", \"d\", \"c\", \"s\"] as const);\nexport type HiloSuit = (typeof HILO_SUIT_CODES)[number];\nexport const HILO_PROVABLY_FAIR_SUIT_ORDER = Object.freeze([\"c\", \"d\", \"h\", \"s\"] as const);\nexport type HiloCard = readonly [HiloRank, HiloSuit];\nexport const HILO_DECK: readonly HiloCard[] = Object.freeze(\n  HILO_PROVABLY_FAIR_SUIT_ORDER.flatMap((suit) =>\n    HILO_CARD_RANKS.map((rank) => Object.freeze([rank, suit] as const)),\n  ),\n);\nexport const HILO_HOUSE_EDGE = \"0.96\";\nexport const HILO_MULTIPLIER_DECIMAL_SCALE = 2;\nexport const HILO_NEUTRAL_MULTIPLIER = \"1.00\";\nexport const HILO_LOST_MULTIPLIER = \"0.00\";\nexport const HILO_MAX_SKIPS = 10;\nexport const HILO_GAME_LIMITS = gameLimits(\"hilo\");\nexport const HILO_MIN_STAKE_MINOR = HILO_GAME_LIMITS.minStakeMinor;\nexport const HILO_MAX_STAKE_MINOR = HILO_GAME_LIMITS.maxStakeMinor;\nexport const HILO_MAX_PAYOUT_MINOR = HILO_GAME_LIMITS.maxPayoutMinor;\nexport const HILO_LIMITS = Object.freeze({\n  minMinor: HILO_MIN_STAKE_MINOR,\n  maxMinor: HILO_MAX_STAKE_MINOR,\n  maxPayoutMinor: HILO_MAX_PAYOUT_MINOR,\n});\nexport const HILO_2_GAME_LIMITS = maxWinLimits(\"hilo\");\nexport const HILO_2_LIMITS = Object.freeze({\n  minMinor: HILO_2_GAME_LIMITS.minStakeMinor,\n  maxMinor: HILO_2_GAME_LIMITS.maxStakeMinor,\n  maxPayoutMinor: HILO_2_GAME_LIMITS.maxPayoutMinor,\n});\nexport const HILO_MAX_DECISIONS = 204;\nexport const HILO_RTP_PERCENT = 96;\nexport const HILO_STREAM_ACCEPTANCE = 2 ** 32 - (2 ** 32 % 52);\nexport const HILO_GUESSES = Object.freeze([\n  \"higher_same\",\n  \"lower_same\",\n  \"higher\",\n  \"lower\",\n  \"same\",\n] as const);\nexport type HiloGuess = (typeof HILO_GUESSES)[number];\nexport const HILO_ACTIONS = Object.freeze([...HILO_GUESSES, \"skip\", \"cashout\"] as const);\nexport type HiloAction = (typeof HILO_ACTIONS)[number];\nexport const HILO_CLOSING_ACTIONS = Object.freeze([\"cashout\"] as const);\nexport const HILO_BACKEND_ACTIONS: Readonly<Record<Exclude<HiloAction, \"cashout\">, string>> =\n  Object.freeze({\n    higher_same: \"higher-same\",\n    lower_same: \"lower-same\",\n    higher: \"higher\",\n    lower: \"lower\",\n    same: \"same\",\n    skip: \"skip\",\n  });\nexport type HiloButton = \"high\" | \"low\";\nexport type HiloChoice = \"high\" | \"high-same\" | \"low\" | \"low-same\" | \"same\";\nexport const HILO_LABELS = Object.freeze({\n  ace_being_lowest: \"hilo_ace_being_lowest\",\n  higher: \"hilo_higher\",\n  higher_same: \"hilo_higher_same\",\n  king_being_highest: \"hilo_king_being_highest\",\n  lower: \"hilo_lower\",\n  lower_same: \"hilo_lower_same\",\n  match_current_card: \"hilo_match_current_card\",\n  same: \"hilo_same\",\n  skip: \"skip_card\",\n  skip_card: \"hilo_skip_card\",\n  start: \"hilo_start\",\n});\n\nexport type HiloParameters = { startingCard: [HiloRank, HiloSuit] | null };\nexport type HiloDecision = { action: HiloAction; expectedStep: number };\nexport type HiloOutcome = \"start\" | \"win\" | \"loss\" | \"skip\";\nexport type HiloEndReason = \"loss\" | \"cashout\" | \"max_payout\" | typeof MAX_WIN_END;\nexport type HiloEntry = {\n  index: number;\n  round: number | null;\n  card: [HiloRank, HiloSuit];\n  action: \"start\" | \"skip\" | HiloGuess;\n  choice: HiloChoice | null;\n  outcome: HiloOutcome;\n  multiplier: string;\n};\nexport type HiloOption = {\n  button: HiloButton;\n  action: HiloGuess;\n  choice: HiloChoice;\n  labelKey: string;\n  hintKey: string;\n  winningRanks: number;\n  totalRanks: 13;\n  winPercentage: string;\n  multiplier: string;\n  payoutMinor: string;\n  capped: boolean;\n};\nexport type HiloResult = {\n  hiloRules: HiloRulesVersion;\n  parameters: HiloParameters;\n  startingCard: [HiloRank, HiloSuit];\n  startingCardSource: \"player\" | \"seed\";\n  currentCard: [HiloRank, HiloSuit];\n  history: HiloEntry[];\n  step: number;\n  nextRound: number | null;\n  currentMultiplier: string;\n  correctGuesses: number;\n  skipsUsed: number;\n  skipsRemaining: number;\n  maxSkips: number;\n  canCashout: boolean;\n  canSkip: boolean;\n  options: HiloOption[];\n  potentialPayoutMinor: string;\n  grossPayoutMinor: string;\n  payoutMinor: string;\n  capped: boolean;\n  maxPayoutReached: boolean;\n  maxPayoutMinor: string;\n  gameOver: boolean;\n  endReason: HiloEndReason | null;\n  houseEdge: string;\n  nominalRtp: ReturnType<typeof ratioWire>;\n  decisions: HiloDecision[];\n};\n\ntype HiloState = {\n  version: HiloRulesVersion;\n  parameters: HiloParameters;\n  source: \"player\" | \"seed\";\n  history: HiloEntry[];\n  decisions: HiloDecision[];\n  multiplier: string;\n  correctGuesses: number;\n  skipsUsed: number;\n  status: Resolution[\"status\"];\n  endReason: HiloEndReason | null;\n};\ntype HiloPlan = Readonly<{\n  action: HiloAction;\n  step: number;\n  draws: boolean;\n  multiplier: string | null;\n}>;\n\nconst ONE = hiloDecimal(\"1\"),\n  THIRTEEN = hiloDecimal(\"13\"),\n  HUNDRED = hiloDecimal(\"100\"),\n  EDGE = hiloDecimalFromNumber(Number(HILO_HOUSE_EDGE)),\n  STREAM_LIMIT = 1024,\n  invalid = (message = \"Invalid Hilo parameters\") => new RangeError(message);\n\nfunction isRank(value: unknown): value is HiloRank {\n  return (HILO_CARD_RANKS as readonly unknown[]).includes(value);\n}\n\nfunction isSuit(value: unknown): value is HiloSuit {\n  return (HILO_SUIT_CODES as readonly unknown[]).includes(value);\n}\n\nfunction card(value: unknown): [HiloRank, HiloSuit] {\n  if (\n    !Array.isArray(value) ||\n    value.length !== 2 ||\n    Object.keys(value).length !== 2 ||\n    !isRank(value[0]) ||\n    !isSuit(value[1])\n  )\n    throw invalid(\"Invalid Hilo card\");\n  return [value[0], value[1]];\n}\n\nfunction copy(value: HiloCard): [HiloRank, HiloSuit] {\n  return [value[0], value[1]];\n}\n\nfunction record(value: unknown): value is Record<string, unknown> {\n  return !!value && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction own(value: Record<string, unknown>, key: string): unknown {\n  return Object.hasOwn(value, key) ? value[key] : undefined;\n}\n\nexport function validateHiloParameters(input: unknown): HiloParameters {\n  if (\n    !record(input) ||\n    Object.keys(input).length !== 1 ||\n    !Object.hasOwn(input, \"startingCard\") ||\n    input.startingCard === undefined\n  )\n    throw invalid();\n  return { startingCard: input.startingCard === null ? null : card(input.startingCard) };\n}\n\nfunction stake(value: unknown): bigint {\n  if (typeof value !== \"bigint\") throw new RangeError(\"Invalid Hilo bet amount\");\n  if (value < HILO_MIN_STAKE_MINOR) throw new RangeError(\"Minimum bet is 0.10\");\n  if (value > HILO_MAX_STAKE_MINOR) throw new RangeError(\"Maximum bet is 10,000.00\");\n  return value;\n}\n\nfunction entropy(value: Entropy): Entropy {\n  if (\n    !value ||\n    typeof value.serverSeed !== \"string\" ||\n    !value.serverSeed.trim() ||\n    typeof value.clientSeed !== \"string\" ||\n    !value.clientSeed.trim() ||\n    !Number.isSafeInteger(value.nonce) ||\n    value.nonce < 0 ||\n    typeof value.hmac !== \"function\"\n  )\n    throw new RangeError(\"Invalid Hilo fairness seed\");\n  return value;\n}\n\nexport function hiloCardValue(rank: HiloRank): number {\n  if (!isRank(rank)) throw invalid(\"Invalid Hilo card\");\n  return HILO_CARD_RANK_VALUES[rank];\n}\n\nexport function hiloDisplayRank(rank: HiloRank): string {\n  return rank === \"T\" ? \"10\" : rank;\n}\n\nexport function hiloIsRedSuit(suit: HiloSuit): boolean {\n  return suit === \"h\" || suit === \"d\";\n}\n\nexport function hiloBackendAction(button: HiloButton, current: HiloCard): HiloGuess {\n  const rank = current[0];\n  return rank === \"A\"\n    ? button === \"high\"\n      ? \"higher\"\n      : \"same\"\n    : rank === \"K\"\n      ? button === \"high\"\n        ? \"same\"\n        : \"lower\"\n      : button === \"high\"\n        ? \"higher_same\"\n        : \"lower_same\";\n}\n\nexport function hiloChoice(action: string): HiloChoice | null {\n  const value = typeof action === \"string\" ? action.toLowerCase().replace(\"_\", \"-\") : \"\";\n  return value === \"same\"\n    ? \"same\"\n    : value === \"higher-same\"\n      ? \"high-same\"\n      : value === \"higher\" || value === \"high\"\n        ? \"high\"\n        : value === \"lower-same\"\n          ? \"low-same\"\n          : value === \"lower\" || value === \"low\"\n            ? \"low\"\n            : null;\n}\n\nexport function hiloHighLabelKey(rank: HiloRank): string {\n  return rank === \"A\"\n    ? HILO_LABELS.higher\n    : rank === \"K\"\n      ? HILO_LABELS.same\n      : HILO_LABELS.higher_same;\n}\n\nexport function hiloLowLabelKey(rank: HiloRank): string {\n  return rank === \"A\"\n    ? HILO_LABELS.same\n    : rank === \"K\"\n      ? HILO_LABELS.lower\n      : HILO_LABELS.lower_same;\n}\n\nexport function hiloHighHintKey(rank: HiloRank): string {\n  return rank === \"K\" ? HILO_LABELS.match_current_card : HILO_LABELS.king_being_highest;\n}\n\nexport function hiloLowHintKey(rank: HiloRank): string {\n  return rank === \"A\" ? HILO_LABELS.match_current_card : HILO_LABELS.ace_being_lowest;\n}\n\nexport function hiloIsWinningChoice(\n  previous: HiloCard,\n  next: HiloCard,\n  choice: HiloChoice,\n): boolean {\n  const before = hiloCardValue(previous[0]),\n    after = hiloCardValue(next[0]);\n  return after === before\n    ? choice === \"same\" || choice === \"high-same\" || choice === \"low-same\"\n    : choice !== \"same\" &&\n        (choice === \"high\" || choice === \"high-same\" ? after > before : after < before);\n}\n\nexport function hiloFactor(rank: HiloRank, action: HiloGuess): number {\n  const value = hiloCardValue(rank),\n    edge = rank === \"A\" || rank === \"K\";\n  if (action === \"higher_same\" && !edge)\n    return hiloDecimalToNumber(hiloDecimalDivide(THIRTEEN, hiloDecimalFromNumber(13 - value + 1)));\n  if (action === \"lower_same\" && !edge)\n    return hiloDecimalToNumber(hiloDecimalDivide(THIRTEEN, hiloDecimalFromNumber(value)));\n  if ((action === \"higher\" && rank === \"A\") || (action === \"lower\" && rank === \"K\"))\n    return hiloDecimalToNumber(hiloDecimalDivide(THIRTEEN, hiloDecimalFromNumber(12)));\n  if (action === \"same\" && edge) return 13;\n  throw invalid(`Unsupported player action ${String(action)} for card rank ${rank}`);\n}\n\nexport function hiloWinningRanks(rank: HiloRank, action: HiloGuess): number {\n  hiloFactor(rank, action);\n  const value = hiloCardValue(rank);\n  return action === \"higher_same\"\n    ? 13 - value + 1\n    : action === \"lower_same\"\n      ? value\n      : action === \"same\"\n        ? 1\n        : 12;\n}\n\nfunction multiplierText(value: unknown): string {\n  if (typeof value !== \"string\" || !/^(0|[1-9]\\d*)\\.\\d{2}$/.test(value))\n    throw invalid(\"Invalid Hilo multiplier\");\n  return value;\n}\n\nexport function hiloNextMultiplier(input: {\n  cardRank: HiloRank;\n  playerAction: HiloGuess;\n  previousMultiplier: string;\n}): string {\n  let factor = hiloDecimalFromNumber(hiloFactor(input.cardRank, input.playerAction));\n  const previous = hiloDecimalFromNumber(Number(multiplierText(input.previousMultiplier)));\n  if (hiloDecimalCompare(previous, ONE) === 0) factor = hiloDecimalTimes(factor, EDGE);\n  return hiloDecimalFixed(\n    hiloDecimalFromNumber(hiloDecimalToNumber(hiloDecimalTimes(factor, previous))),\n    HILO_MULTIPLIER_DECIMAL_SCALE,\n    \"half-up\",\n  );\n}\n\nexport function hiloWinPercentage(rank: HiloRank, action: HiloGuess): string {\n  const percent = hiloDecimalToNumber(\n      hiloDecimalTimes(\n        hiloDecimalDivide(ONE, hiloDecimalFromNumber(hiloFactor(rank, action))),\n        HUNDRED,\n      ),\n    ),\n    stored = Number(\n      hiloDecimalFixed(hiloDecimalFromNumber(percent), HILO_MULTIPLIER_DECIMAL_SCALE, \"down\"),\n    );\n  return hiloDecimalFixed(hiloDecimalFromNumber(stored), HILO_MULTIPLIER_DECIMAL_SCALE, \"down\");\n}\n\nexport function hiloGrossPayoutMinor(stakeMinor: bigint, multiplier: string): bigint {\n  const [whole, fraction] = multiplierText(multiplier).split(\".\");\n  return (stake(stakeMinor) * (BigInt(whole) * 100n + BigInt(fraction))) / 100n;\n}\n\nexport function isHiloRulesVersion(value: unknown): value is HiloRulesVersion {\n  return (HILO_RULES_VERSIONS as readonly unknown[]).includes(value);\n}\n\nfunction edition(version: unknown): HiloRulesVersion {\n  if (!isHiloRulesVersion(version)) throw invalid(\"Invalid Hilo rules\");\n  return version;\n}\n\nfunction paysInFull(version: HiloRulesVersion): boolean {\n  return version !== HILO_RULES_VERSION;\n}\n\nexport function hiloLimits(version: HiloRulesVersion = HILO_RULES_VERSION): Limits {\n  return paysInFull(edition(version)) ? HILO_2_GAME_LIMITS : HILO_GAME_LIMITS;\n}\n\nfunction capped(value: bigint, version: HiloRulesVersion = HILO_RULES_VERSION) {\n  if (paysInFull(version)) return value;\n  return value > HILO_MAX_PAYOUT_MINOR ? HILO_MAX_PAYOUT_MINOR : value;\n}\n\nfunction overMaxWin(stakeMinor: bigint, multiplier: string): boolean {\n  return hiloGrossPayoutMinor(stakeMinor, multiplier) > MAX_WIN_MINOR;\n}\n\nexport function hiloRoundMessage(\n  seed: Readonly<{ clientSeed: string; nonce: number }>,\n  round: number,\n): string {\n  if (!Number.isSafeInteger(round) || round < 0) throw new RangeError(\"Invalid Hilo round\");\n  return `${seed.clientSeed}:${seed.nonce}:${round}:hilo`;\n}\n\nexport function hiloIndexFromDigest(digest: Uint8Array): number | null {\n  if (!(digest instanceof Uint8Array) || digest.byteLength !== 32)\n    throw new RangeError(\"Invalid Hilo HMAC digest\");\n  for (let offset = 0; offset < digest.byteLength; offset += 4) {\n    const word =\n      ((digest[offset] << 24) |\n        (digest[offset + 1] << 16) |\n        (digest[offset + 2] << 8) |\n        digest[offset + 3]) >>>\n      0;\n    if (word < HILO_STREAM_ACCEPTANCE) return word % HILO_DECK.length;\n  }\n  return null;\n}\n\nexport async function hiloCardAt(source: Entropy, round: number): Promise<HiloCard> {\n  const seed = entropy(source),\n    message = hiloRoundMessage(seed, round);\n  for (let counter = 0; counter < STREAM_LIMIT; counter++) {\n    const index = hiloIndexFromDigest(\n      await seed.hmac(\"SHA-256\", seed.serverSeed, `${message}:${counter}`),\n    );\n    if (index !== null) return HILO_DECK[index];\n  }\n  throw new RangeError(\"Hilo entropy stream exhausted\");\n}\n\nfunction decision(value: unknown): { action: HiloAction; expectedStep?: number } {\n  if (!record(value)) throw invalid(\"Invalid Hilo action\");\n  const action = own(value, \"action\"),\n    expectedStep = own(value, \"expectedStep\");\n  if (\n    Object.entries(value).some(\n      ([key, entry]) => entry !== undefined && key !== \"action\" && key !== \"expectedStep\",\n    ) ||\n    !(HILO_ACTIONS as readonly unknown[]).includes(action) ||\n    (expectedStep !== undefined &&\n      (typeof expectedStep !== \"number\" || !Number.isSafeInteger(expectedStep) || expectedStep < 0))\n  )\n    throw invalid(\"Invalid Hilo action\");\n  return {\n    action: action as HiloAction,\n    ...(expectedStep === undefined ? {} : { expectedStep: (expectedStep as number) + 0 }),\n  };\n}\n\nfunction initial(\n  parameters: HiloParameters,\n  start: HiloCard,\n  version: HiloRulesVersion = HILO_RULES_VERSION,\n): HiloState {\n  const seeded = parameters.startingCard === null;\n  return {\n    version,\n    parameters,\n    source: seeded ? \"seed\" : \"player\",\n    history: [\n      {\n        index: 0,\n        round: seeded ? 0 : null,\n        card: copy(start),\n        action: \"start\",\n        choice: null,\n        outcome: \"start\",\n        multiplier: HILO_NEUTRAL_MULTIPLIER,\n      },\n    ],\n    decisions: [],\n    multiplier: HILO_NEUTRAL_MULTIPLIER,\n    correctGuesses: 0,\n    skipsUsed: 0,\n    status: \"ACTIVE\",\n    endReason: null,\n  };\n}\n\nasync function begin(\n  parameters: HiloParameters,\n  seed: Entropy,\n  version: HiloRulesVersion = HILO_RULES_VERSION,\n) {\n  return initial(parameters, parameters.startingCard ?? (await hiloCardAt(seed, 0)), version);\n}\n\nfunction callsOver(state: HiloState, current: HiloCard, stakeMinor: bigint): boolean[] {\n  return ([\"high\", \"low\"] as const).map((button) =>\n    overMaxWin(\n      stakeMinor,\n      hiloNextMultiplier({\n        cardRank: current[0],\n        playerAction: hiloBackendAction(button, current),\n        previousMultiplier: state.multiplier,\n      }),\n    ),\n  );\n}\n\nfunction plan(state: HiloState, input: unknown, stakeMinor?: bigint): HiloPlan {\n  const next = decision(input),\n    step = state.history.length - 1,\n    current = state.history[step].card;\n  if (state.status !== \"ACTIVE\") throw invalid(\"This Hilo round is already settled\");\n  if (next.action !== \"cashout\" && next.expectedStep === undefined)\n    throw invalid(\"Hilo actions require the expected step\");\n  if (next.expectedStep !== undefined && next.expectedStep !== step)\n    throw invalid(\"This Hilo card has changed. Refresh your active round.\");\n  if (next.action === \"cashout\") {\n    if (!state.correctGuesses) throw invalid(\"Make a correct guess before cashing out\");\n    return { action: next.action, step, draws: false, multiplier: null };\n  }\n  if (next.action === \"skip\") {\n    if (state.skipsUsed >= HILO_MAX_SKIPS) throw invalid(\"No Hilo skips remaining\");\n    return { action: next.action, step, draws: true, multiplier: null };\n  }\n  const multiplier = hiloNextMultiplier({\n    cardRank: current[0],\n    playerAction: next.action,\n    previousMultiplier: state.multiplier,\n  });\n  if (paysInFull(state.version) && stakeMinor !== undefined && overMaxWin(stakeMinor, multiplier))\n    throw invalid(unavailableSelectionMessage());\n  return { action: next.action, step, draws: true, multiplier };\n}\n\nfunction settle(\n  state: HiloState,\n  planned: HiloPlan,\n  stakeMinor: bigint,\n  drawn: HiloCard | null,\n): HiloState {\n  const step = planned.step,\n    decisions = [...state.decisions, { action: planned.action, expectedStep: step }];\n  if (planned.action === \"cashout\")\n    return { ...state, decisions, status: \"WON\", endReason: \"cashout\" };\n  if (!drawn) throw invalid(\"Invalid Hilo card\");\n  const entry = {\n    index: step + 1,\n    round: step + 1,\n    card: copy(drawn),\n  };\n  if (planned.action === \"skip\")\n    return stopAtMaxWin(\n      {\n        ...state,\n        decisions,\n        skipsUsed: state.skipsUsed + 1,\n        history: [\n          ...state.history,\n          { ...entry, action: \"skip\", choice: null, outcome: \"skip\", multiplier: state.multiplier },\n        ],\n      },\n      drawn,\n      stakeMinor,\n    );\n  const action = planned.action,\n    multiplier = planned.multiplier!,\n    choice = hiloChoice(action)!,\n    won = hiloIsWinningChoice(state.history[step].card, drawn, choice),\n    history: HiloEntry[] = [\n      ...state.history,\n      {\n        ...entry,\n        action,\n        choice,\n        outcome: won ? \"win\" : \"loss\",\n        multiplier: won ? multiplier : HILO_LOST_MULTIPLIER,\n      },\n    ];\n  if (!won)\n    return {\n      ...state,\n      decisions,\n      history,\n      multiplier: HILO_LOST_MULTIPLIER,\n      status: \"LOST\",\n      endReason: \"loss\",\n    };\n  if (paysInFull(state.version))\n    return stopAtMaxWin(\n      {\n        ...state,\n        decisions,\n        history,\n        multiplier,\n        correctGuesses: state.correctGuesses + 1,\n      },\n      drawn,\n      stakeMinor,\n    );\n  const reached = hiloGrossPayoutMinor(stakeMinor, multiplier) >= HILO_MAX_PAYOUT_MINOR;\n  return {\n    ...state,\n    decisions,\n    history,\n    multiplier,\n    correctGuesses: state.correctGuesses + 1,\n    status: reached ? \"WON\" : \"ACTIVE\",\n    endReason: reached ? \"max_payout\" : null,\n  };\n}\n\nfunction stopAtMaxWin(state: HiloState, current: HiloCard, stakeMinor: bigint): HiloState {\n  if (\n    !paysInFull(state.version) ||\n    state.correctGuesses === 0 ||\n    !callsOver(state, current, stakeMinor).every(Boolean)\n  )\n    return state;\n  return { ...state, status: \"WON\", endReason: MAX_WIN_END };\n}\n\nasync function advance(\n  state: HiloState,\n  input: unknown,\n  stakeMinor: bigint,\n  seed: Entropy,\n): Promise<HiloState> {\n  const planned = plan(state, input, stakeMinor);\n  return settle(\n    state,\n    planned,\n    stakeMinor,\n    planned.draws ? await hiloCardAt(seed, planned.step + 1) : null,\n  );\n}\n\nfunction option(\n  button: HiloButton,\n  current: HiloCard,\n  multiplier: string,\n  stakeMinor: bigint,\n  version: HiloRulesVersion,\n): HiloOption {\n  const rank = current[0],\n    action = hiloBackendAction(button, current),\n    next = hiloNextMultiplier({\n      cardRank: rank,\n      playerAction: action,\n      previousMultiplier: multiplier,\n    }),\n    gross = hiloGrossPayoutMinor(stakeMinor, next);\n  return {\n    button,\n    action,\n    choice: hiloChoice(action)!,\n    labelKey: button === \"high\" ? hiloHighLabelKey(rank) : hiloLowLabelKey(rank),\n    hintKey: button === \"high\" ? hiloHighHintKey(rank) : hiloLowHintKey(rank),\n    winningRanks: hiloWinningRanks(rank, action),\n    totalRanks: 13,\n    winPercentage: hiloWinPercentage(rank, action),\n    multiplier: next,\n    payoutMinor: capped(gross, version).toString(),\n    capped: gross > hiloLimits(version).maxPayoutMinor,\n  };\n}\n\nfunction publish(stakeMinor: bigint, state: HiloState): Resolution {\n  const version = state.version,\n    active = state.status === \"ACTIVE\",\n    step = state.history.length - 1,\n    current = state.history[step].card,\n    gross = state.status === \"LOST\" ? 0n : hiloGrossPayoutMinor(stakeMinor, state.multiplier),\n    payout = state.status === \"WON\" ? capped(gross, version) : 0n,\n    skipsRemaining = active ? HILO_MAX_SKIPS - state.skipsUsed : 0,\n    decisions = state.decisions.map((entry) => ({ ...entry })),\n    result: HiloResult = {\n      hiloRules: version,\n      parameters: {\n        startingCard: state.parameters.startingCard ? copy(state.parameters.startingCard) : null,\n      },\n      startingCard: copy(state.history[0].card),\n      startingCardSource: state.source,\n      currentCard: copy(current),\n      history: state.history.map((entry) => ({ ...entry, card: copy(entry.card) })),\n      step,\n      nextRound: active ? step + 1 : null,\n      currentMultiplier: state.multiplier,\n      correctGuesses: state.correctGuesses,\n      skipsUsed: state.skipsUsed,\n      skipsRemaining,\n      maxSkips: HILO_MAX_SKIPS,\n      canCashout: active && state.correctGuesses > 0,\n      canSkip: skipsRemaining > 0,\n      options: active\n        ? [\n            option(\"high\", current, state.multiplier, stakeMinor, version),\n            option(\"low\", current, state.multiplier, stakeMinor, version),\n          ]\n        : [],\n      potentialPayoutMinor: (active ? capped(gross, version) : payout).toString(),\n      grossPayoutMinor: gross.toString(),\n      payoutMinor: payout.toString(),\n      capped: !paysInFull(version) && state.status === \"WON\" && gross > HILO_MAX_PAYOUT_MINOR,\n      maxPayoutReached: state.endReason === \"max_payout\" || state.endReason === MAX_WIN_END,\n      maxPayoutMinor: hiloLimits(version).maxPayoutMinor.toString(),\n      gameOver: !active,\n      endReason: state.endReason,\n      houseEdge: HILO_HOUSE_EDGE,\n      nominalRtp: ratioWire(ratio(BigInt(HILO_RTP_PERCENT), 100n)),\n      decisions,\n    };\n  return {\n    game: \"hilo\",\n    status: state.status,\n    payoutMinor: payout.toString(),\n    multiplier: ratioText(ratio(payout, stakeMinor)),\n    result,\n    ...(active\n      ? {\n          secret: {\n            hiloRules: version,\n            parameters: result.parameters,\n            decisions: decisions.map((entry) => ({ ...entry })),\n          },\n        }\n      : {}),\n    rulesVersion: version,\n  };\n}\n\nconst SECRET_KEYS = Object.freeze([\"hiloRules\", \"parameters\", \"decisions\"]);\n\nfunction transcript(\n  secret: unknown,\n  parameters: HiloParameters,\n  version: HiloRulesVersion,\n): unknown[] {\n  if (\n    !record(secret) ||\n    Object.keys(secret).length !== SECRET_KEYS.length ||\n    SECRET_KEYS.some((key) => !Object.hasOwn(secret, key)) ||\n    secret.hiloRules !== version ||\n    !Array.isArray(secret.decisions) ||\n    secret.decisions.length >= HILO_MAX_DECISIONS ||\n    JSON.stringify(validateHiloParameters(secret.parameters)) !== JSON.stringify(parameters)\n  )\n    throw invalid(\"Invalid active Hilo round\");\n  return secret.decisions;\n}\n\nexport async function replayHilo(\n  input: {\n    stakeMinor: bigint;\n    parameters: unknown;\n    entropy: Entropy;\n    decisions: readonly Decision[];\n  },\n  version: HiloRulesVersion = HILO_RULES_VERSION,\n): Promise<Resolution> {\n  const amount = stake(input.stakeMinor),\n    parameters = validateHiloParameters(input.parameters),\n    seed = entropy(input.entropy);\n  if (!Array.isArray(input.decisions) || input.decisions.length > HILO_MAX_DECISIONS)\n    throw invalid(\"Invalid Hilo decisions\");\n  let state = await begin(parameters, seed, edition(version));\n  for (const entry of input.decisions) state = await advance(state, entry, amount, seed);\n  return publish(amount, state);\n}\n\nexport function reconstructHilo(\n  input: {\n    stakeMinor: bigint;\n    parameters: unknown;\n    result: unknown;\n  },\n  version: HiloRulesVersion = HILO_RULES_VERSION,\n): Resolution {\n  const amount = stake(input.stakeMinor),\n    parameters = validateHiloParameters(input.parameters),\n    result = input.result;\n  if (!record(result)) throw invalid(\"Invalid Hilo result\");\n  const history = own(result, \"history\"),\n    decisions = own(result, \"decisions\");\n  if (\n    own(result, \"hiloRules\") !== edition(version) ||\n    !Array.isArray(history) ||\n    !history.length ||\n    history.length > HILO_MAX_DECISIONS + 1 ||\n    !Array.isArray(decisions) ||\n    decisions.length > HILO_MAX_DECISIONS\n  )\n    throw invalid(\"Invalid Hilo result\");\n  const cards = history.map((entry) => card(record(entry) ? own(entry, \"card\") : null));\n  let state = initial(parameters, parameters.startingCard ?? cards[0], version);\n  for (const entry of decisions) {\n    const planned = plan(state, entry, amount);\n    state = settle(\n      state,\n      planned,\n      amount,\n      planned.draws ? (cards[planned.step + 1] ?? null) : null,\n    );\n  }\n  return publish(amount, state);\n}\n\nfunction hiloRules(version: HiloRulesVersion): GameRules<HiloParameters> {\n  return Object.freeze({\n    game: \"hilo\",\n    version,\n    stateful: true,\n    actions: HILO_ACTIONS,\n    closingActions: HILO_CLOSING_ACTIONS,\n    limits: hiloLimits(version),\n    validateParameters: validateHiloParameters,\n    maximumPayoutMinor(stakeMinor: bigint, parameters: HiloParameters) {\n      stake(stakeMinor);\n      validateHiloParameters(parameters);\n      return hiloLimits(version).maxPayoutMinor;\n    },\n    async resolve(input: ResolveInput<HiloParameters>) {\n      const amount = stake(input.stakeMinor),\n        parameters = validateHiloParameters(input.parameters);\n      return publish(amount, await begin(parameters, entropy(input.entropy), version));\n    },\n    async act(input: ActInput<HiloParameters>) {\n      const amount = stake(input.stakeMinor),\n        parameters = validateHiloParameters(input.parameters),\n        seed = entropy(input.entropy),\n        prior = transcript(input.secret, parameters, version);\n      let state = await begin(parameters, seed, version);\n      for (const entry of prior) {\n        state = await advance(state, entry, amount, seed);\n        if (state.status !== \"ACTIVE\") throw invalid(\"Invalid active Hilo round\");\n      }\n      return publish(amount, await advance(state, input.decision, amount, seed));\n    },\n    reconstruct(\n      input: Readonly<{\n        stakeMinor: bigint;\n        parameters: HiloParameters;\n        result: Record<string, unknown>;\n      }>,\n    ) {\n      return reconstructHilo(input, version);\n    },\n  } satisfies GameRules<HiloParameters>);\n}\n\nexport const hiloRulesModule: GameRules<HiloParameters> = hiloRules(HILO_RULES_VERSION);\nexport const hilo2RulesModule: GameRules<HiloParameters> = hiloRules(HILO_2_RULES_VERSION);\n","sha256":"a834fc44548201229e82533698f55b9f9b4584dbaf1844ea93e90fa173708993"},"src/lib/games/hilo-decimal.ts":{"source":"export type HiloDecimal = Readonly<{ coefficient: bigint; scale: number }>;\nexport type HiloRounding = \"down\" | \"half-up\";\n\nconst syntax = /^(?:(\\d+)(?:\\.(\\d*))?|\\.(\\d+))(?:e([+-]?\\d+))?$/i;\n\nfunction normalized(coefficient: bigint, scale: number): HiloDecimal {\n  if (coefficient < 0n || !Number.isSafeInteger(scale))\n    throw new RangeError(\"Invalid Hilo decimal\");\n  let value = coefficient,\n    places = scale;\n  while (places < 0) {\n    value *= 10n;\n    places++;\n  }\n  while (places > 0 && value % 10n === 0n) {\n    value /= 10n;\n    places--;\n  }\n  return Object.freeze({ coefficient: value, scale: value === 0n ? 0 : places });\n}\n\nexport function hiloDecimal(text: string): HiloDecimal {\n  const match = typeof text === \"string\" ? syntax.exec(text) : null;\n  if (!match) throw new RangeError(\"Invalid Hilo decimal\");\n  const whole = match[1] ?? \"\",\n    fraction = match[2] ?? match[3] ?? \"\",\n    exponent = match[4] === undefined ? 0 : Number(match[4]);\n  if (!Number.isSafeInteger(exponent) || Math.abs(exponent) > 1000)\n    throw new RangeError(\"Invalid Hilo decimal\");\n  return normalized(BigInt(`${whole}${fraction}` || \"0\"), fraction.length - exponent);\n}\n\nexport function hiloDecimalFromNumber(value: number): HiloDecimal {\n  if (typeof value !== \"number\" || !Number.isFinite(value) || value < 0)\n    throw new RangeError(\"Invalid Hilo decimal\");\n  return hiloDecimal(String(value));\n}\n\nexport function hiloDecimalText(value: HiloDecimal): string {\n  const digits = value.coefficient.toString();\n  if (value.scale === 0) return digits;\n  const padded = digits.padStart(value.scale + 1, \"0\");\n  return `${padded.slice(0, -value.scale)}.${padded.slice(-value.scale)}`;\n}\n\nexport function hiloDecimalToNumber(value: HiloDecimal): number {\n  return Number(hiloDecimalText(value));\n}\n\nexport function hiloDecimalTimes(left: HiloDecimal, right: HiloDecimal): HiloDecimal {\n  return normalized(left.coefficient * right.coefficient, left.scale + right.scale);\n}\n\nexport function hiloDecimalCompare(left: HiloDecimal, right: HiloDecimal) {\n  const scale = Math.max(left.scale, right.scale),\n    a = left.coefficient * 10n ** BigInt(scale - left.scale),\n    b = right.coefficient * 10n ** BigInt(scale - right.scale);\n  return a === b ? 0 : a > b ? 1 : -1;\n}\n\nfunction rounded(numerator: bigint, denominator: bigint, places: number, mode: HiloRounding) {\n  const extended = (numerator * 10n ** BigInt(places + 1)) / denominator,\n    kept = extended / 10n;\n  return normalized(mode === \"half-up\" && extended % 10n >= 5n ? kept + 1n : kept, places);\n}\n\nexport function hiloDecimalDivide(\n  dividend: HiloDecimal,\n  divisor: HiloDecimal,\n  places = 20,\n): HiloDecimal {\n  if (divisor.coefficient === 0n) throw new RangeError(\"Hilo division by zero\");\n  return rounded(\n    dividend.coefficient * 10n ** BigInt(divisor.scale),\n    divisor.coefficient * 10n ** BigInt(dividend.scale),\n    places,\n    \"half-up\",\n  );\n}\n\nexport function hiloDecimalFixed(value: HiloDecimal, places: number, mode: HiloRounding): string {\n  if (!Number.isSafeInteger(places) || places < 0 || places > 100)\n    throw new RangeError(\"Invalid Hilo decimal places\");\n  const result = rounded(value.coefficient, 10n ** BigInt(value.scale), places, mode),\n    digits = (result.coefficient * 10n ** BigInt(places - result.scale))\n      .toString()\n      .padStart(places + 1, \"0\");\n  return places === 0 ? digits : `${digits.slice(0, -places)}.${digits.slice(-places)}`;\n}\n","sha256":"c5d9155d844af598a0a7f15c92a4312ef843fe4cc5539f6e6e5600336e9f6f83"},"src/lib/games/rps-rules.ts":{"source":"import { ratio, ratioText, type Ratio, type Resolution } from \"@/games/math\";\nimport { createEntropy } from \"./entropy\";\nimport type { ActInput, Entropy, GameRules, Limits, ResolveInput } from \"./rules\";\nimport { gameLimits, maxWinLimits } from \"./limits\";\nimport { MAX_WIN_END, MAX_WIN_MINOR, admitMaxWinStake, hundredthsRatio } from \"./max-win\";\n\nexport const RPS_RULES_VERSION = \"peeks-rps-1\" as const;\nexport const RPS_2_RULES_VERSION = \"peeks-rps-2\" as const;\nexport const RPS_RULES_VERSIONS = Object.freeze([RPS_RULES_VERSION, RPS_2_RULES_VERSION] as const);\nexport type RpsRulesVersion = (typeof RPS_RULES_VERSIONS)[number];\nexport const RPS_GAME_NAME = \"rock-paper-scissors\" as const;\nexport const RPS_HMAC = \"SHA-256\" as const;\nexport const RPS_HANDS = Object.freeze([\"R\", \"P\", \"S\"] as const);\nexport type RpsHand = (typeof RPS_HANDS)[number];\nexport const RPS_CHOICES = Object.freeze([\"r\", \"p\", \"s\"] as const);\nexport type RpsChoice = (typeof RPS_CHOICES)[number];\nexport const RPS_MAX_ROUNDS = 20;\nexport const RPS_MIN_HANDS = 1;\nexport const RPS_MAX_HANDS = 20;\nexport const RPS_BASE_MULTIPLIER = \"1.96\" as const;\nexport const RPS_MAX_MULTIPLIER = \"10000.00\" as const;\nexport const RPS_2_MAX_MULTIPLIER = \"1027604.48\" as const;\nexport const RPS_TIE_MULTIPLIER = \"0.98\" as const;\nexport const RPS_RTP_PERCENT = 98;\nexport const RPS_MAX_STAKE_MINOR = 1_000_000n;\nexport const RPS_GAME_LIMITS = gameLimits(\"rps\");\nexport const RPS_MAX_PAYOUT_MINOR = RPS_GAME_LIMITS.maxPayoutMinor;\nexport const RPS_2_GAME_LIMITS = maxWinLimits(\"rps\");\nexport const RPS_PROCESSING_DELAYS = Object.freeze({ standard: 700, instant: 0 } as const);\nexport const RPS_ACTIONS = Object.freeze([\"bet\", \"cashout\"] as const);\nexport const RPS_CLOSING_ACTIONS = Object.freeze([\"cashout\"] as const);\nexport const RPS_STREAM_CURSORS = 1024;\nexport const RPS_WORD_LIMIT = 0x1_0000_0000 - (0x1_0000_0000 % RPS_HANDS.length);\n\nexport type RpsAnimationDuration =\n  (typeof RPS_PROCESSING_DELAYS)[keyof typeof RPS_PROCESSING_DELAYS];\nexport type RpsManualParameters = Record<string, never>;\nexport type RpsAutoParameters = {\n  animationDurationMs: RpsAnimationDuration;\n  bets: RpsHand[];\n};\nexport type RpsParameters = RpsManualParameters | RpsAutoParameters;\nexport type RpsRoundResult = \"win\" | \"draw\" | \"lose\";\nexport type RpsEndReason =\n  | \"loss\"\n  | \"cashout\"\n  | \"maxMultiplier\"\n  | \"maxPayout\"\n  | \"maxRounds\"\n  | \"complete\"\n  | typeof MAX_WIN_END;\nexport type RpsRound = {\n  round: number;\n  canCashout: boolean;\n  houseBet: RpsHand;\n  multiplier: string;\n  playerBet: RpsHand;\n  roundResult: RpsRoundResult;\n  streakIndex: number;\n};\nexport type RpsDecision =\n  | { action: \"bet\"; choice: RpsChoice; expectedStep: number }\n  | { action: \"cashout\"; expectedStep: number };\nexport type RpsResult = {\n  rpsRules: RpsRulesVersion;\n  parameters: RpsParameters;\n  mode: \"manual\" | \"auto\";\n  maxRounds: number;\n  multiplierTable: string[];\n  maxMultiplier: string;\n  step: number;\n  streak: number;\n  rpsResult: RpsRound[];\n  multiplier: string;\n  currentPayoutMinor: string;\n  nextMultiplier: string | null;\n  nextPayoutMinor: string | null;\n  canCashout: boolean;\n  gameOver: boolean;\n  endReason: RpsEndReason | null;\n  capped: boolean;\n  uncappedPayoutMinor: string;\n  maxPayoutMinor: string;\n  decisions: RpsDecision[];\n  playerHands: RpsHand[];\n  dealerHand: RpsHand[] | null;\n};\nexport type RpsSecret = {\n  rpsRules: RpsRulesVersion;\n  houseHands: RpsHand[];\n  decisions: RpsDecision[];\n};\nexport type RpsQuote = {\n  multiplier: string;\n  grossMinor: bigint;\n  payoutMinor: bigint;\n  capped: boolean;\n};\n\ntype Progress = {\n  rounds: RpsRound[];\n  streak: number;\n  hasWon: boolean;\n  multiplier: bigint;\n  endReason: RpsEndReason | null;\n  decisions: RpsDecision[];\n};\n\nconst ONE = 100n;\nconst BASE = 196n;\nconst CAP = 1_000_000n;\nconst TIE = 98n;\nconst LADDER_LENGTH = 14;\nconst SECRET_KEYS = Object.freeze([\"rpsRules\", \"houseHands\", \"decisions\"]);\nconst BET_KEYS = Object.freeze([\"action\", \"choice\", \"expectedStep\"]);\nconst CASHOUT_KEYS = Object.freeze([\"action\", \"expectedStep\"]);\nconst AUTO_KEYS = Object.freeze([\"animationDurationMs\", \"bets\"]);\nconst CHOICE_HANDS: Readonly<Record<RpsChoice, RpsHand>> = Object.freeze({\n  r: \"R\",\n  p: \"P\",\n  s: \"S\",\n});\nconst BEATS: Readonly<Record<RpsHand, RpsHand>> = Object.freeze({\n  R: \"S\",\n  P: \"R\",\n  S: \"P\",\n});\n\nconst invalid = (message = \"Invalid Rock Paper Scissors parameters\") => new RangeError(message);\n\nfunction record(value: unknown): value is Record<string, unknown> {\n  return !!value && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction exactKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {\n  const present = Object.keys(value);\n  return present.length === keys.length && keys.every((key) => Object.hasOwn(value, key));\n}\n\nfunction sameJson(left: unknown, right: unknown): boolean {\n  if (Array.isArray(left))\n    return (\n      Array.isArray(right) &&\n      left.length === right.length &&\n      left.every((entry, index) => sameJson(entry, right[index]))\n    );\n  if (record(left))\n    return (\n      record(right) &&\n      exactKeys(right, Object.keys(left)) &&\n      Object.keys(left).every((key) => sameJson(left[key], right[key]))\n    );\n  return left === right;\n}\n\nfunction isHand(value: unknown): value is RpsHand {\n  return typeof value === \"string\" && (RPS_HANDS as readonly string[]).includes(value);\n}\n\nfunction isChoice(value: unknown): value is RpsChoice {\n  return typeof value === \"string\" && (RPS_CHOICES as readonly string[]).includes(value);\n}\n\nfunction handOf(value: unknown, message: string): RpsHand {\n  if (!isHand(value)) throw invalid(message);\n  return value;\n}\n\nfunction handsOf(value: unknown, message: string): RpsHand[] {\n  if (!Array.isArray(value) || value.length !== RPS_MAX_ROUNDS) throw invalid(message);\n  return Array.from(value as unknown[], (hand) => handOf(hand, message));\n}\n\nfunction stakeOf(value: unknown): bigint {\n  if (typeof value !== \"bigint\" || value < 1n || value > RPS_MAX_STAKE_MINOR)\n    throw invalid(\"Invalid Rock Paper Scissors stake\");\n  return value;\n}\n\nfunction roundOf(value: unknown): number {\n  if (\n    typeof value !== \"number\" ||\n    !Number.isSafeInteger(value) ||\n    value < 1 ||\n    value > RPS_MAX_ROUNDS\n  )\n    throw invalid(\"Invalid Rock Paper Scissors round\");\n  return value;\n}\n\nfunction streakOf(value: unknown): number {\n  if (typeof value !== \"number\" || !Number.isSafeInteger(value) || value < 0 || value > 64)\n    throw invalid(\"Invalid Rock Paper Scissors streak\");\n  return value;\n}\n\nfunction entropyOf(value: unknown): Entropy {\n  const entropy = value as Entropy | null;\n  if (\n    !entropy ||\n    typeof entropy !== \"object\" ||\n    typeof entropy.hmac !== \"function\" ||\n    typeof entropy.serverSeed !== \"string\" ||\n    !entropy.serverSeed.trim() ||\n    typeof entropy.clientSeed !== \"string\" ||\n    !entropy.clientSeed.trim() ||\n    typeof entropy.nonce !== \"number\" ||\n    !Number.isSafeInteger(entropy.nonce) ||\n    entropy.nonce < 0\n  )\n    throw invalid(\"Invalid Rock Paper Scissors fairness seed\");\n  return entropy;\n}\n\nfunction fixed(hundredths: bigint): string {\n  return `${hundredths / 100n}.${(hundredths % 100n).toString().padStart(2, \"0\")}`;\n}\n\nfunction hundredthsOf(value: string): bigint {\n  if (typeof value !== \"string\" || !/^(?:0|[1-9][0-9]{0,9})\\.[0-9]{2}$/.test(value))\n    throw invalid(\"Invalid Rock Paper Scissors multiplier\");\n  const [whole, fraction] = value.split(\".\");\n  return BigInt(whole) * 100n + BigInt(fraction);\n}\n\nexport function isRpsRulesVersion(value: unknown): value is RpsRulesVersion {\n  return (RPS_RULES_VERSIONS as readonly unknown[]).includes(value);\n}\n\nfunction edition(version: unknown): RpsRulesVersion {\n  if (!isRpsRulesVersion(version)) throw invalid(\"Invalid Rock Paper Scissors rules\");\n  return version;\n}\n\nfunction paysInFull(version: RpsRulesVersion): boolean {\n  return version !== RPS_RULES_VERSION;\n}\n\nexport function rpsLimits(version: RpsRulesVersion = RPS_RULES_VERSION): Limits {\n  return paysInFull(edition(version)) ? RPS_2_GAME_LIMITS : RPS_GAME_LIMITS;\n}\n\nfunction stakeHundredths(streak: number, version: RpsRulesVersion = RPS_RULES_VERSION): bigint {\n  const raw = streak <= 0 ? BASE : BASE << BigInt(streak);\n  if (paysInFull(version)) return raw;\n  return raw < CAP ? raw : CAP;\n}\n\nfunction gross(\n  stakeMinor: bigint,\n  hundredths: bigint,\n  version: RpsRulesVersion = RPS_RULES_VERSION,\n) {\n  const amount = (stakeMinor * hundredths) / 100n;\n  if (paysInFull(version)) return { grossMinor: amount, payoutMinor: amount, capped: false };\n  return {\n    grossMinor: amount,\n    payoutMinor: amount > RPS_MAX_PAYOUT_MINOR ? RPS_MAX_PAYOUT_MINOR : amount,\n    capped: amount > RPS_MAX_PAYOUT_MINOR,\n  };\n}\n\nfunction overMaxWin(stakeMinor: bigint, hundredths: bigint): boolean {\n  return (stakeMinor * hundredths) / 100n > MAX_WIN_MINOR;\n}\n\nfunction reduce(numerator: bigint, denominator: bigint): Ratio {\n  let a = numerator,\n    b = denominator;\n  while (b) [a, b] = [b, a % b];\n  return a ? ratio(numerator / a, denominator / a) : ratio(0n, 1n);\n}\n\nfunction isAuto(parameters: RpsParameters): parameters is RpsAutoParameters {\n  return Object.hasOwn(parameters, \"bets\");\n}\n\nfunction copyParameters(parameters: RpsParameters): RpsParameters {\n  return isAuto(parameters)\n    ? { animationDurationMs: parameters.animationDurationMs, bets: [...parameters.bets] }\n    : {};\n}\n\nexport const RPS_MULTIPLIER_TABLE: readonly string[] = Object.freeze(\n  Array.from({ length: LADDER_LENGTH }, (_, streak) => fixed(stakeHundredths(streak))),\n);\nexport const RPS_2_MULTIPLIER_TABLE: readonly string[] = Object.freeze(\n  Array.from({ length: RPS_MAX_ROUNDS }, (_, streak) =>\n    fixed(stakeHundredths(streak, RPS_2_RULES_VERSION)),\n  ),\n);\n\nexport function rpsMultiplierTable(version: RpsRulesVersion = RPS_RULES_VERSION) {\n  return paysInFull(edition(version)) ? RPS_2_MULTIPLIER_TABLE : RPS_MULTIPLIER_TABLE;\n}\n\nexport function rpsMaxMultiplierText(version: RpsRulesVersion = RPS_RULES_VERSION) {\n  return paysInFull(edition(version)) ? RPS_2_MAX_MULTIPLIER : RPS_MAX_MULTIPLIER;\n}\n\nexport function rpsStreakMultiplier(\n  streak: number,\n  version: RpsRulesVersion = RPS_RULES_VERSION,\n): string {\n  return fixed(stakeHundredths(streakOf(streak), edition(version)));\n}\n\nexport function rpsRoundResult(player: unknown, house: unknown): RpsRoundResult {\n  const playerHand = handOf(player, \"Invalid Rock Paper Scissors hand\"),\n    houseHand = handOf(house, \"Invalid Rock Paper Scissors hand\");\n  if (playerHand === houseHand) return \"draw\";\n  return BEATS[playerHand] === houseHand ? \"win\" : \"lose\";\n}\n\nexport function rpsHandForChoice(choice: unknown): RpsHand {\n  if (!isChoice(choice)) throw invalid(\"Invalid Rock Paper Scissors hand\");\n  return CHOICE_HANDS[choice];\n}\n\nexport function rpsChoiceForHand(hand: unknown): RpsChoice {\n  return handOf(hand, \"Invalid Rock Paper Scissors hand\").toLowerCase() as RpsChoice;\n}\n\nexport function rpsQuote(\n  stakeMinor: bigint,\n  multiplier: string,\n  version: RpsRulesVersion = RPS_RULES_VERSION,\n): RpsQuote {\n  const value = hundredthsOf(multiplier);\n  return { multiplier: fixed(value), ...gross(stakeOf(stakeMinor), value, edition(version)) };\n}\n\nexport function validateRpsParameters(input: unknown): RpsParameters {\n  if (!record(input)) throw invalid();\n  if (Object.keys(input).length === 0) return {};\n  if (!exactKeys(input, AUTO_KEYS)) throw invalid();\n  const animationDurationMs = input.animationDurationMs;\n  if (\n    animationDurationMs !== RPS_PROCESSING_DELAYS.standard &&\n    animationDurationMs !== RPS_PROCESSING_DELAYS.instant\n  )\n    throw invalid(\"Invalid Rock Paper Scissors animation duration\");\n  const bets = input.bets;\n  if (!Array.isArray(bets) || bets.length < RPS_MIN_HANDS || bets.length > RPS_MAX_HANDS)\n    throw invalid(\"Invalid Rock Paper Scissors hands\");\n  return {\n    animationDurationMs: (animationDurationMs + 0) as RpsAnimationDuration,\n    bets: Array.from(bets as unknown[], (hand) =>\n      handOf(hand, \"Invalid Rock Paper Scissors hands\"),\n    ),\n  };\n}\n\nexport function rpsMaximumMultiplier(\n  parameters: unknown,\n  version: RpsRulesVersion = RPS_RULES_VERSION,\n): string {\n  const validated = validateRpsParameters(parameters);\n  return isAuto(validated)\n    ? fixed(stakeHundredths(validated.bets.length - 1, edition(version)))\n    : rpsMaxMultiplierText(version);\n}\n\nexport function rpsMaximumPayoutMinor(\n  stakeMinor: bigint,\n  parameters: unknown,\n  version: RpsRulesVersion = RPS_RULES_VERSION,\n): bigint {\n  const stake = stakeOf(stakeMinor);\n  if (paysInFull(edition(version))) {\n    const validated = validateRpsParameters(parameters);\n    if (isAuto(validated))\n      return gross(stake, stakeHundredths(validated.bets.length - 1, version), version).payoutMinor;\n    let best = 0n;\n    for (let streak = 0; streak < RPS_MAX_ROUNDS; streak++) {\n      const amount = gross(stake, stakeHundredths(streak, version), version).payoutMinor;\n      if (amount > MAX_WIN_MINOR) break;\n      best = amount;\n    }\n    return best;\n  }\n  return gross(stake, hundredthsOf(rpsMaximumMultiplier(parameters))).payoutMinor;\n}\n\nexport function rpsTopMultiplierHundredths(parameters: unknown): bigint {\n  const validated = validateRpsParameters(parameters);\n  return stakeHundredths(isAuto(validated) ? validated.bets.length - 1 : 0, RPS_2_RULES_VERSION);\n}\n\nexport function admitRpsStake(\n  stakeMinor: bigint,\n  parameters: unknown,\n  version: RpsRulesVersion = RPS_2_RULES_VERSION,\n): void {\n  admitMaxWinStake(\n    rpsLimits(version),\n    stakeMinor,\n    hundredthsRatio(rpsTopMultiplierHundredths(parameters)),\n  );\n}\n\nexport function rpsMessage(\n  clientSeed: string,\n  nonce: number,\n  round: number,\n  cursor: number,\n): string {\n  return `${clientSeed}:${nonce}:${round}:${RPS_GAME_NAME}:${cursor}`;\n}\n\nexport function rpsHandFromWord(word: number): RpsHand | null {\n  if (typeof word !== \"number\" || !Number.isSafeInteger(word) || word < 0 || word > 0xffffffff)\n    throw invalid(\"Invalid Rock Paper Scissors entropy word\");\n  return word < RPS_WORD_LIMIT ? RPS_HANDS[word % RPS_HANDS.length] : null;\n}\n\nexport async function rpsHouseBet(entropy: Entropy, round: number): Promise<RpsHand> {\n  const source = entropyOf(entropy),\n    index = roundOf(round);\n  for (let cursor = 0; cursor < RPS_STREAM_CURSORS; cursor++) {\n    const digest = await source.hmac(\n      RPS_HMAC,\n      source.serverSeed,\n      rpsMessage(source.clientSeed, source.nonce, index, cursor),\n    );\n    if (!(digest instanceof Uint8Array) || digest.byteLength !== 32)\n      throw invalid(\"Invalid Rock Paper Scissors HMAC digest\");\n    for (let offset = 0; offset < digest.byteLength; offset += 4) {\n      const hand = rpsHandFromWord(\n        ((digest[offset] << 24) |\n          (digest[offset + 1] << 16) |\n          (digest[offset + 2] << 8) |\n          digest[offset + 3]) >>>\n          0,\n      );\n      if (hand) return hand;\n    }\n  }\n  throw invalid(\"Rock Paper Scissors entropy stream exhausted\");\n}\n\nexport async function rpsHouseBets(entropy: Entropy): Promise<RpsHand[]> {\n  const source = entropyOf(entropy),\n    hands: RpsHand[] = [];\n  for (let round = 1; round <= RPS_MAX_ROUNDS; round++)\n    hands.push(await rpsHouseBet(source, round));\n  return hands;\n}\n\nexport async function rpsFairness(input: {\n  serverSeed: string;\n  clientSeed: string;\n  nonce: number;\n}): Promise<RpsHand[]> {\n  if (\n    !input ||\n    typeof input.serverSeed !== \"string\" ||\n    !input.serverSeed.trim() ||\n    typeof input.clientSeed !== \"string\" ||\n    !input.clientSeed.trim() ||\n    typeof input.nonce !== \"number\" ||\n    !Number.isSafeInteger(input.nonce) ||\n    input.nonce < 0\n  )\n    throw invalid(\"Invalid Rock Paper Scissors fairness seed\");\n  return rpsHouseBets(\n    createEntropy({\n      serverSeed: input.serverSeed,\n      clientSeed: input.clientSeed,\n      nonce: input.nonce,\n    }),\n  );\n}\n\nfunction start(): Progress {\n  return { rounds: [], streak: 0, hasWon: false, multiplier: ONE, endReason: null, decisions: [] };\n}\n\nfunction decide(input: unknown, step: number): RpsDecision {\n  const message = \"Invalid Rock Paper Scissors action\";\n  if (!record(input)) throw invalid(message);\n  const allowed =\n    input.action === \"bet\" ? BET_KEYS : input.action === \"cashout\" ? CASHOUT_KEYS : null;\n  if (\n    !allowed ||\n    Object.keys(input).some((key) => input[key] !== undefined && !allowed.includes(key))\n  )\n    throw invalid(message);\n  if (input.expectedStep !== undefined) {\n    if (typeof input.expectedStep !== \"number\" || !Number.isSafeInteger(input.expectedStep))\n      throw invalid(message);\n    if (input.expectedStep !== step) throw invalid(\"Rock Paper Scissors round has changed\");\n  }\n  if (input.action === \"cashout\") return { action: \"cashout\", expectedStep: step };\n  if (!isChoice(input.choice)) throw invalid(\"Invalid Rock Paper Scissors hand\");\n  return { action: \"bet\", choice: input.choice, expectedStep: step };\n}\n\nfunction play(\n  stakeMinor: bigint,\n  hands: readonly RpsHand[],\n  progress: Progress,\n  playerBet: RpsHand,\n  decisions: RpsDecision[],\n  version: RpsRulesVersion,\n): Progress {\n  if (paysInFull(version)) return playInFull(stakeMinor, hands, progress, playerBet, decisions);\n  const round = progress.rounds.length + 1,\n    houseBet = hands[round - 1],\n    roundResult = rpsRoundResult(playerBet, houseBet),\n    atStake = stakeHundredths(progress.streak),\n    reachedMax = atStake >= CAP,\n    canCashout = progress.hasWon || roundResult === \"win\";\n  let streak = progress.streak,\n    hasWon = progress.hasWon,\n    multiplier = progress.multiplier;\n  if (roundResult === \"win\") {\n    hasWon = true;\n    streak += 1;\n    multiplier = atStake;\n  }\n  if (roundResult === \"lose\") multiplier = 0n;\n  const rounds = [\n      ...progress.rounds,\n      {\n        round,\n        canCashout,\n        houseBet,\n        multiplier: fixed(atStake),\n        playerBet,\n        roundResult,\n        streakIndex: streak,\n      },\n    ],\n    endReason: RpsEndReason | null =\n      roundResult === \"lose\"\n        ? \"loss\"\n        : reachedMax\n          ? \"maxMultiplier\"\n          : roundResult === \"win\" &&\n              gross(stakeMinor, multiplier).grossMinor >= RPS_MAX_PAYOUT_MINOR\n            ? \"maxPayout\"\n            : rounds.length >= RPS_MAX_ROUNDS\n              ? \"maxRounds\"\n              : null;\n  return {\n    rounds,\n    streak,\n    hasWon,\n    multiplier: endReason !== null && multiplier === ONE ? TIE : multiplier,\n    endReason,\n    decisions,\n  };\n}\n\nfunction playInFull(\n  stakeMinor: bigint,\n  hands: readonly RpsHand[],\n  progress: Progress,\n  playerBet: RpsHand,\n  decisions: RpsDecision[],\n): Progress {\n  const round = progress.rounds.length + 1,\n    houseBet = hands[round - 1],\n    roundResult = rpsRoundResult(playerBet, houseBet),\n    atStake = stakeHundredths(progress.streak, RPS_2_RULES_VERSION),\n    canCashout = progress.hasWon || roundResult === \"win\";\n  let streak = progress.streak,\n    hasWon = progress.hasWon,\n    multiplier = progress.multiplier;\n  if (roundResult === \"win\") {\n    hasWon = true;\n    streak += 1;\n    multiplier = atStake;\n  }\n  if (roundResult === \"lose\") multiplier = 0n;\n  const rounds = [\n      ...progress.rounds,\n      {\n        round,\n        canCashout,\n        houseBet,\n        multiplier: fixed(atStake),\n        playerBet,\n        roundResult,\n        streakIndex: streak,\n      },\n    ],\n    endReason: RpsEndReason | null =\n      roundResult === \"lose\"\n        ? \"loss\"\n        : rounds.length >= RPS_MAX_ROUNDS\n          ? \"maxRounds\"\n          : roundResult === \"win\" &&\n              overMaxWin(stakeMinor, stakeHundredths(streak, RPS_2_RULES_VERSION))\n            ? MAX_WIN_END\n            : null;\n  return {\n    rounds,\n    streak,\n    hasWon,\n    multiplier: endReason !== null && multiplier === ONE ? TIE : multiplier,\n    endReason,\n    decisions,\n  };\n}\n\nfunction apply(\n  stakeMinor: bigint,\n  hands: readonly RpsHand[],\n  progress: Progress,\n  input: unknown,\n  version: RpsRulesVersion,\n): Progress {\n  if (progress.endReason !== null) throw invalid(\"The game you were trying to play already ended\");\n  const decision = decide(input, progress.rounds.length),\n    decisions = [...progress.decisions, decision];\n  if (decision.action === \"cashout\") {\n    if (progress.streak < 1)\n      throw invalid(\"To cash out in manual mode, the player must win at least one round.\");\n    return { ...progress, endReason: \"cashout\", decisions };\n  }\n  return play(stakeMinor, hands, progress, CHOICE_HANDS[decision.choice], decisions, version);\n}\n\nfunction autoplay(\n  stakeMinor: bigint,\n  hands: readonly RpsHand[],\n  bets: readonly RpsHand[],\n  version: RpsRulesVersion,\n): Progress {\n  if (paysInFull(version)) return autoplayInFull(stakeMinor, hands, bets);\n  const rounds: RpsRound[] = [];\n  let streak = 0,\n    hasWon = false,\n    lost = false,\n    lastWin = 0n,\n    stop: RpsEndReason | null = null;\n  for (const [index, playerBet] of bets.entries()) {\n    const houseBet = hands[index],\n      roundResult = rpsRoundResult(playerBet, houseBet),\n      atStake = stakeHundredths(streak),\n      canCashout = hasWon || roundResult === \"win\";\n    let counted = false;\n    if (roundResult !== \"win\" || lost) {\n      if (roundResult === \"lose\") lost = true;\n    } else {\n      hasWon = true;\n      streak += 1;\n      lastWin = atStake;\n      counted = true;\n    }\n    rounds.push({\n      round: index + 1,\n      canCashout,\n      houseBet,\n      multiplier: fixed(atStake),\n      playerBet,\n      roundResult,\n      streakIndex: streak,\n    });\n    if (atStake >= CAP) {\n      stop = \"maxMultiplier\";\n      break;\n    }\n    if (counted && gross(stakeMinor, lastWin).grossMinor >= RPS_MAX_PAYOUT_MINOR) {\n      stop = \"maxPayout\";\n      break;\n    }\n  }\n  rounds[rounds.length - 1] = { ...rounds[rounds.length - 1], canCashout: hasWon };\n  return {\n    rounds,\n    streak,\n    hasWon,\n    multiplier: lost ? 0n : hasWon ? lastWin : TIE,\n    endReason: lost ? \"loss\" : (stop ?? \"complete\"),\n    decisions: [],\n  };\n}\n\nfunction autoplayInFull(\n  stakeMinor: bigint,\n  hands: readonly RpsHand[],\n  bets: readonly RpsHand[],\n): Progress {\n  if (overMaxWin(stakeMinor, stakeHundredths(bets.length - 1, RPS_2_RULES_VERSION)))\n    admitRpsStake(stakeMinor, { animationDurationMs: 0, bets: [...bets] });\n  const rounds: RpsRound[] = [];\n  let streak = 0,\n    hasWon = false,\n    lost = false,\n    lastWin = 0n;\n  for (const [index, playerBet] of bets.entries()) {\n    const houseBet = hands[index],\n      roundResult = rpsRoundResult(playerBet, houseBet),\n      atStake = stakeHundredths(streak, RPS_2_RULES_VERSION),\n      canCashout = hasWon || roundResult === \"win\";\n    if (roundResult !== \"win\" || lost) {\n      if (roundResult === \"lose\") lost = true;\n    } else {\n      hasWon = true;\n      streak += 1;\n      lastWin = atStake;\n    }\n    rounds.push({\n      round: index + 1,\n      canCashout,\n      houseBet,\n      multiplier: fixed(atStake),\n      playerBet,\n      roundResult,\n      streakIndex: streak,\n    });\n  }\n  rounds[rounds.length - 1] = { ...rounds[rounds.length - 1], canCashout: hasWon };\n  return {\n    rounds,\n    streak,\n    hasWon,\n    multiplier: lost ? 0n : hasWon ? lastWin : TIE,\n    endReason: lost ? \"loss\" : \"complete\",\n    decisions: [],\n  };\n}\n\nfunction settle(\n  stakeMinor: bigint,\n  parameters: RpsParameters,\n  hands: readonly RpsHand[],\n  progress: Progress,\n  version: RpsRulesVersion,\n): Resolution {\n  const auto = isAuto(parameters),\n    active = progress.endReason === null,\n    lost = progress.endReason === \"loss\",\n    held = lost ? 0n : progress.multiplier,\n    amount = gross(stakeMinor, held, version),\n    payoutMinor = active || lost ? 0n : amount.payoutMinor,\n    next = active ? stakeHundredths(progress.streak, version) : null,\n    result: RpsResult = {\n      rpsRules: version,\n      parameters: copyParameters(parameters),\n      mode: auto ? \"auto\" : \"manual\",\n      maxRounds: RPS_MAX_ROUNDS,\n      multiplierTable: [...rpsMultiplierTable(version)],\n      maxMultiplier: rpsMaxMultiplierText(version),\n      step: progress.rounds.length,\n      streak: progress.streak,\n      rpsResult: progress.rounds.map((round) => ({ ...round })),\n      multiplier: fixed(held),\n      currentPayoutMinor: amount.payoutMinor.toString(),\n      nextMultiplier: next === null ? null : fixed(next),\n      nextPayoutMinor:\n        next === null ? null : gross(stakeMinor, next, version).payoutMinor.toString(),\n      canCashout: active && progress.streak > 0,\n      gameOver: !active,\n      endReason: progress.endReason,\n      capped: amount.capped,\n      uncappedPayoutMinor: amount.grossMinor.toString(),\n      maxPayoutMinor: rpsLimits(version).maxPayoutMinor.toString(),\n      decisions: progress.decisions.map((decision) => ({ ...decision })),\n      playerHands: progress.rounds.map((round) => round.playerBet),\n      dealerHand: active ? null : [...hands],\n    },\n    secret: RpsSecret | null = active\n      ? {\n          rpsRules: version,\n          houseHands: [...hands],\n          decisions: progress.decisions.map((decision) => ({ ...decision })),\n        }\n      : null;\n  return {\n    game: \"rps\",\n    status: active ? \"ACTIVE\" : payoutMinor > 0n ? \"WON\" : \"LOST\",\n    payoutMinor: payoutMinor.toString(),\n    multiplier: ratioText(ratio(payoutMinor, stakeMinor)),\n    result,\n    ...(secret ? { secret } : {}),\n    rulesVersion: version,\n  };\n}\n\nfunction restore(\n  stakeMinor: bigint,\n  secret: unknown,\n  version: RpsRulesVersion,\n): { hands: RpsHand[]; progress: Progress } {\n  const message = \"Invalid Rock Paper Scissors state\";\n  if (\n    !record(secret) ||\n    !exactKeys(secret, SECRET_KEYS) ||\n    secret.rpsRules !== version ||\n    !Array.isArray(secret.decisions) ||\n    secret.decisions.length >= RPS_MAX_ROUNDS\n  )\n    throw invalid(message);\n  const hands = handsOf(secret.houseHands, message);\n  let progress = start();\n  for (const item of Array.from(secret.decisions as unknown[])) {\n    if (\n      !record(item) ||\n      !exactKeys(item, BET_KEYS) ||\n      item.action !== \"bet\" ||\n      item.expectedStep !== progress.rounds.length\n    )\n      throw invalid(message);\n    try {\n      progress = apply(stakeMinor, hands, progress, item, version);\n    } catch {\n      throw invalid(message);\n    }\n    if (progress.endReason !== null) throw invalid(message);\n  }\n  return { hands, progress };\n}\n\nexport async function resolveRps(\n  input: ResolveInput<RpsParameters>,\n  version: RpsRulesVersion = RPS_RULES_VERSION,\n): Promise<Resolution> {\n  const parameters = validateRpsParameters(input.parameters),\n    stakeMinor = stakeOf(input.stakeMinor),\n    hands = await rpsHouseBets(input.entropy);\n  edition(version);\n  return settle(\n    stakeMinor,\n    parameters,\n    hands,\n    isAuto(parameters) ? autoplay(stakeMinor, hands, parameters.bets, version) : start(),\n    version,\n  );\n}\n\nexport async function actRps(\n  input: ActInput<RpsParameters>,\n  version: RpsRulesVersion = RPS_RULES_VERSION,\n): Promise<Resolution> {\n  const parameters = validateRpsParameters(input.parameters);\n  if (isAuto(parameters)) throw invalid(\"The game you were trying to play already ended\");\n  const stakeMinor = stakeOf(input.stakeMinor),\n    state = restore(stakeMinor, input.secret, edition(version)),\n    shown = input.result;\n  if (\n    !record(shown) ||\n    shown.step !== state.progress.rounds.length ||\n    !sameJson(shown.decisions, state.progress.decisions) ||\n    !sameJson(shown.rpsResult, state.progress.rounds)\n  )\n    throw invalid(\"Invalid Rock Paper Scissors state\");\n  const hands = await rpsHouseBets(input.entropy);\n  if (hands.some((hand, index) => hand !== state.hands[index]))\n    throw invalid(\"Invalid Rock Paper Scissors state\");\n  return settle(\n    stakeMinor,\n    parameters,\n    hands,\n    apply(stakeMinor, hands, state.progress, input.decision, version),\n    version,\n  );\n}\n\nexport function reconstructRps(\n  input: {\n    stakeMinor: bigint;\n    parameters: unknown;\n    result: unknown;\n  },\n  version: RpsRulesVersion = RPS_RULES_VERSION,\n): Resolution {\n  const message = \"Invalid Rock Paper Scissors result\",\n    parameters = validateRpsParameters(input.parameters),\n    stakeMinor = stakeOf(input.stakeMinor),\n    result = input.result;\n  edition(version);\n  if (!record(result) || !Array.isArray(result.decisions)) throw invalid(message);\n  const hands = handsOf(result.dealerHand, message),\n    decisions = Array.from(result.decisions as unknown[]);\n  if (isAuto(parameters)) {\n    if (decisions.length) throw invalid(message);\n    return settle(\n      stakeMinor,\n      parameters,\n      hands,\n      autoplay(stakeMinor, hands, parameters.bets, version),\n      version,\n    );\n  }\n  let progress = start();\n  for (const decision of decisions) {\n    if (progress.endReason !== null) throw invalid(message);\n    progress = apply(stakeMinor, hands, progress, decision, version);\n  }\n  if (progress.endReason === null) throw invalid(message);\n  return settle(stakeMinor, parameters, hands, progress, version);\n}\n\nfunction hundredthsPayout(stakeMinor: bigint | null, hundredths: bigint): bigint {\n  return stakeMinor === null ? hundredths : gross(stakeMinor, hundredths).payoutMinor;\n}\n\nfunction reachesLimit(stakeMinor: bigint | null, hundredths: bigint): boolean {\n  return stakeMinor !== null && gross(stakeMinor, hundredths).grossMinor >= RPS_MAX_PAYOUT_MINOR;\n}\n\nfunction rtpOf(stakeMinor: bigint | null, numerator: bigint, denominator: bigint): Ratio {\n  return reduce(numerator, denominator * (stakeMinor === null ? 100n : stakeMinor));\n}\n\nfunction autoRtpInFull(hands: number, stake: bigint | null): Ratio {\n  const ladder = (streak: number) => stakeHundredths(streak, RPS_2_RULES_VERSION),\n    payout = (hundredths: bigint) =>\n      stake === null ? hundredths : gross(stake, hundredths, RPS_2_RULES_VERSION).payoutMinor;\n  let open = new Map<number, bigint>([[0, 1n]]);\n  for (let hand = 1; hand <= hands; hand++) {\n    const next = new Map<number, bigint>();\n    for (const [streak, count] of open) {\n      next.set(streak + 1, (next.get(streak + 1) ?? 0n) + count);\n      next.set(streak, (next.get(streak) ?? 0n) + count);\n    }\n    open = next;\n  }\n  let numerator = 0n;\n  for (const [streak, count] of open)\n    numerator += count * payout(streak ? ladder(streak - 1) : TIE);\n  return rtpOf(stake, numerator, 3n ** BigInt(hands));\n}\n\nfunction manualRtpInFull(targetWins: number, stake: bigint | null): Ratio {\n  const ladder = (streak: number) => stakeHundredths(streak, RPS_2_RULES_VERSION),\n    payout = (hundredths: bigint) =>\n      stake === null ? hundredths : gross(stake, hundredths, RPS_2_RULES_VERSION).payoutMinor;\n  let open = new Map<number, bigint>([[0, 1n]]),\n    numerator = 0n;\n  for (let round = 1; round <= RPS_MAX_ROUNDS; round++) {\n    const next = new Map<number, bigint>(),\n      weight = 3n ** BigInt(RPS_MAX_ROUNDS - round),\n      last = round === RPS_MAX_ROUNDS;\n    for (const [streak, count] of open) {\n      const atStake = ladder(streak),\n        held = streak ? ladder(streak - 1) : TIE,\n        stops = stake !== null && overMaxWin(stake, ladder(streak + 1));\n      if (last || stops || streak + 1 >= targetWins) numerator += count * weight * payout(atStake);\n      else next.set(streak + 1, (next.get(streak + 1) ?? 0n) + count);\n      if (last) numerator += count * weight * payout(held);\n      else next.set(streak, (next.get(streak) ?? 0n) + count);\n    }\n    open = next;\n  }\n  return rtpOf(stake, numerator, 3n ** BigInt(RPS_MAX_ROUNDS));\n}\n\nexport function rpsAutoRtp(\n  hands: number,\n  stakeMinor: bigint | null = null,\n  version: RpsRulesVersion = RPS_RULES_VERSION,\n): Ratio {\n  if (\n    typeof hands !== \"number\" ||\n    !Number.isSafeInteger(hands) ||\n    hands < RPS_MIN_HANDS ||\n    hands > RPS_MAX_HANDS\n  )\n    throw invalid(\"Invalid Rock Paper Scissors hands\");\n  const stake = stakeMinor === null ? null : stakeOf(stakeMinor);\n  if (paysInFull(edition(version))) return autoRtpInFull(hands, stake);\n  let open = new Map<number, bigint>([[0, 1n]]),\n    numerator = 0n;\n  for (let hand = 1; hand <= hands; hand++) {\n    const next = new Map<number, bigint>(),\n      weight = 3n ** BigInt(hands - hand);\n    for (const [streak, count] of open) {\n      const atStake = stakeHundredths(streak);\n      if (atStake >= CAP) {\n        numerator += count * weight * hundredthsPayout(stake, atStake);\n        numerator += count * weight * hundredthsPayout(stake, stakeHundredths(streak - 1));\n        continue;\n      }\n      if (reachesLimit(stake, atStake))\n        numerator += count * weight * hundredthsPayout(stake, atStake);\n      else next.set(streak + 1, (next.get(streak + 1) ?? 0n) + count);\n      next.set(streak, (next.get(streak) ?? 0n) + count);\n    }\n    open = next;\n  }\n  for (const [streak, count] of open)\n    numerator += count * hundredthsPayout(stake, streak ? stakeHundredths(streak - 1) : TIE);\n  return rtpOf(stake, numerator, 3n ** BigInt(hands));\n}\n\nexport function rpsManualRtp(\n  targetWins: number,\n  stakeMinor: bigint | null = null,\n  version: RpsRulesVersion = RPS_RULES_VERSION,\n): Ratio {\n  const full = paysInFull(edition(version));\n  if (\n    typeof targetWins !== \"number\" ||\n    !Number.isSafeInteger(targetWins) ||\n    targetWins < 1 ||\n    targetWins > (full ? RPS_MAX_ROUNDS : LADDER_LENGTH)\n  )\n    throw invalid(\"Invalid Rock Paper Scissors target\");\n  const stake = stakeMinor === null ? null : stakeOf(stakeMinor);\n  if (full) return manualRtpInFull(targetWins, stake);\n  let open = new Map<number, bigint>([[0, 1n]]),\n    numerator = 0n;\n  for (let round = 1; round <= RPS_MAX_ROUNDS; round++) {\n    const next = new Map<number, bigint>(),\n      weight = 3n ** BigInt(RPS_MAX_ROUNDS - round),\n      last = round === RPS_MAX_ROUNDS;\n    for (const [streak, count] of open) {\n      const atStake = stakeHundredths(streak),\n        held = streak ? stakeHundredths(streak - 1) : TIE;\n      if (atStake >= CAP || reachesLimit(stake, atStake) || last || streak + 1 >= targetWins)\n        numerator += count * weight * hundredthsPayout(stake, atStake);\n      else next.set(streak + 1, (next.get(streak + 1) ?? 0n) + count);\n      if (atStake >= CAP || last) numerator += count * weight * hundredthsPayout(stake, held);\n      else next.set(streak, (next.get(streak) ?? 0n) + count);\n    }\n    open = next;\n  }\n  return rtpOf(stake, numerator, 3n ** BigInt(RPS_MAX_ROUNDS));\n}\n\nexport const rpsRulesModule: GameRules<RpsParameters> = Object.freeze({\n  game: \"rps\",\n  version: RPS_RULES_VERSION,\n  stateful: true,\n  actions: RPS_ACTIONS,\n  closingActions: RPS_CLOSING_ACTIONS,\n  limits: RPS_GAME_LIMITS,\n  validateParameters: validateRpsParameters,\n  maximumPayoutMinor: rpsMaximumPayoutMinor,\n  resolve: resolveRps,\n  act: actRps,\n  reconstruct: reconstructRps,\n});\n\nexport const rps2RulesModule: GameRules<RpsParameters> = Object.freeze({\n  game: \"rps\",\n  version: RPS_2_RULES_VERSION,\n  stateful: true,\n  actions: RPS_ACTIONS,\n  closingActions: RPS_CLOSING_ACTIONS,\n  limits: RPS_2_GAME_LIMITS,\n  admitStake: (stakeMinor: bigint, parameters: RpsParameters) =>\n    admitRpsStake(stakeMinor, parameters, RPS_2_RULES_VERSION),\n  validateParameters: validateRpsParameters,\n  maximumPayoutMinor: (stakeMinor: bigint, parameters: RpsParameters) =>\n    rpsMaximumPayoutMinor(stakeMinor, parameters, RPS_2_RULES_VERSION),\n  resolve: (input: ResolveInput<RpsParameters>) => resolveRps(input, RPS_2_RULES_VERSION),\n  act: (input: ActInput<RpsParameters>) => actRps(input, RPS_2_RULES_VERSION),\n  reconstruct: (input: Parameters<typeof reconstructRps>[0]) =>\n    reconstructRps(input, RPS_2_RULES_VERSION),\n} satisfies GameRules<RpsParameters>);\n","sha256":"e0aeeb40932be7b5801f46f4f816541a9e4a046e22d211ee981d0f0aa29ce036"},"src/lib/games/war-rules.ts":{"source":"import { ratio, ratioText, type Ratio, type Resolution } from \"@/games/math\";\nimport type { ActInput, Decision, Entropy, GameRules, Limits, ResolveInput } from \"./rules\";\nimport { admitStakeRange, gameLimits, maxWinLimits } from \"./limits\";\nimport { MAX_WIN_MINOR, maxBetForPayout, maxBetMessage } from \"./max-win\";\n\nexport const WAR_RULES_VERSION = \"peeks-war-1\" as const;\nexport const WAR_2_RULES_VERSION = \"peeks-war-2\" as const;\nexport const WAR_RULES_VERSIONS = Object.freeze([WAR_RULES_VERSION, WAR_2_RULES_VERSION] as const);\nexport type WarRulesVersion = (typeof WAR_RULES_VERSIONS)[number];\nexport const WAR_SETTLEMENT_POLICY = \"peeks-pc-floor-1\" as const;\nexport const WAR_GAME_NAME = \"war\" as const;\nexport const WAR_HASH = \"SHA-512\" as const;\nexport const WAR_MESSAGE_FORMAT = \"{clientSeed}:{nonce}:war:{cursor}\" as const;\nexport const WAR_DECK_COUNT = 8;\nexport const WAR_SHOE_SIZE = WAR_DECK_COUNT * 52;\nexport const WAR_BURNED_CARDS = 3;\nexport const WAR_MAX_TIES = 4;\nexport const WAR_ROUND_CARDS = 2 * (WAR_BURNED_CARDS + 1);\nexport const WAR_MAX_CARDS = 2 + (WAR_MAX_TIES - 1) * WAR_ROUND_CARDS;\nexport const WAR_MAX_STAKE_MINOR = 1_000_000n;\nexport const WAR_MIN_BET_MINOR = 1n;\nexport const WAR_GAME_LIMITS = gameLimits(\"war\", \"main-bet\");\nexport const WAR_2_GAME_LIMITS = maxWinLimits(\"war\", \"main-bet\");\nexport const WAR_SIDE_BET_MAX_MINOR = gameLimits(\"war\", \"side-bet\").maxStakeMinor;\nexport const WAR_MAX_PAYOUT_MINOR = WAR_GAME_LIMITS.maxPayoutMinor;\nexport const WAR_STREAM_LIMIT = 1024;\n\nexport const WAR_CARD_RANKS = Object.freeze([\n  \"A\",\n  \"2\",\n  \"3\",\n  \"4\",\n  \"5\",\n  \"6\",\n  \"7\",\n  \"8\",\n  \"9\",\n  \"T\",\n  \"J\",\n  \"Q\",\n  \"K\",\n] as const);\nexport const WAR_CARD_SUITS = Object.freeze([\"c\", \"d\", \"h\", \"s\"] as const);\nexport type WarRank = (typeof WAR_CARD_RANKS)[number];\nexport type WarSuit = (typeof WAR_CARD_SUITS)[number];\nexport type WarCard = `${WarRank}${WarSuit}`;\nexport type WarColour = \"black\" | \"red\";\nexport const WAR_CARD_RANK_VALUES: Readonly<Record<WarRank, number>> = Object.freeze({\n  2: 1,\n  3: 2,\n  4: 3,\n  5: 4,\n  6: 5,\n  7: 6,\n  8: 7,\n  9: 8,\n  A: 13,\n  J: 10,\n  K: 12,\n  Q: 11,\n  T: 9,\n});\nexport const WAR_SUIT_COLOURS: Readonly<Record<WarSuit, WarColour>> = Object.freeze({\n  c: \"black\",\n  d: \"red\",\n  h: \"red\",\n  s: \"black\",\n});\n\nexport const WAR_ROUND_STATUS = Object.freeze({\n  PlayerLose: \"player-lose\",\n  PlayerSurrender: \"player-surrender\",\n  PlayerWarLose: \"player-war-lose\",\n  PlayerWarMaxTies: \"player-war-max-ties\",\n  PlayerWarWin: \"player-war-win\",\n  PlayerWin: \"player-win\",\n  WarPending: \"war-pending\",\n} as const);\nexport type WarRoundStatus = (typeof WAR_ROUND_STATUS)[keyof typeof WAR_ROUND_STATUS];\nexport const WAR_PLAYER_ACTIONS = Object.freeze({\n  GoToWar: \"go-to-war\",\n  Surrender: \"surrender\",\n} as const);\nexport const WAR_AUTO_BET_ACTIONS = Object.freeze({\n  GoToWar: WAR_PLAYER_ACTIONS.GoToWar,\n  Stop: \"stop\",\n  Surrender: WAR_PLAYER_ACTIONS.Surrender,\n} as const);\nexport const WAR_CARD_KIND = Object.freeze({\n  Burned: \"burned\",\n  Playing: \"playing\",\n} as const);\nexport const WAR_CARD_RESULT_TYPE = Object.freeze({\n  Lose: \"lose\",\n  Tie: \"tie\",\n  Win: \"win\",\n} as const);\nexport type WarResultType = (typeof WAR_CARD_RESULT_TYPE)[keyof typeof WAR_CARD_RESULT_TYPE];\n\nexport const WAR_ACTIONS = Object.freeze([\"go_to_war\", \"surrender\"] as const);\nexport type WarAction = (typeof WAR_ACTIONS)[number];\nexport const WAR_CLOSING_ACTIONS = Object.freeze([\"surrender\"] as const);\nexport const WAR_BACKEND_ACTIONS: Readonly<\n  Record<WarAction, (typeof WAR_PLAYER_ACTIONS)[keyof typeof WAR_PLAYER_ACTIONS]>\n> = Object.freeze({\n  go_to_war: WAR_PLAYER_ACTIONS.GoToWar,\n  surrender: WAR_PLAYER_ACTIONS.Surrender,\n});\n\nexport const WAR_SIDE_BET_TYPES = Object.freeze([\"tie\", \"coloured_tie\"] as const);\nexport type WarSideBetType = (typeof WAR_SIDE_BET_TYPES)[number];\nexport const WAR_SIDE_BET_LABEL_KEYS: Readonly<Record<WarSideBetType, string>> = Object.freeze({\n  coloured_tie: \"war_coloured_tie\",\n  tie: \"war_tie\",\n});\nexport const WAR_SIDE_BET_PARAMETER_KEYS = Object.freeze({\n  tie: \"tie\",\n  coloured_tie: \"colouredTie\",\n} as const);\nexport const WAR_SIDE_BET_FILLER_LIMITS = Object.freeze({\n  CAD: Object.freeze({ MAX_AMOUNT: 1000, MIN_AMOUNT: 0.1 }),\n  USD: Object.freeze({ MAX_AMOUNT: 1000, MIN_AMOUNT: 0.1 }),\n});\nexport const WAR_SIDE_BET_SUB_TYPES = Object.freeze([\n  \"one_tie\",\n  \"two_ties\",\n  \"three_ties\",\n  \"four_ties\",\n] as const);\nexport type WarSideBetSubType = (typeof WAR_SIDE_BET_SUB_TYPES)[number];\nexport const WAR_SIDE_BET_ODDS: Readonly<Record<WarSideBetType, readonly string[]>> = Object.freeze(\n  {\n    tie: Object.freeze([\"10:1\", \"30:1\", \"60:1\", \"300:1\"]),\n    coloured_tie: Object.freeze([\"20:1\", \"125:1\", \"400:1\", \"1000:1\"]),\n  },\n);\nexport const WAR_SIDE_BET_PROBABILITIES: Readonly<Record<WarSideBetType, readonly string[]>> =\n  Object.freeze({\n    tie: Object.freeze([\"6.911888%\", \"0.516309%\", \"0.038567%\", \"0.003113%\"]),\n    coloured_tie: Object.freeze([\"3.618090%\", \"0.136031%\", \"0.005097%\", \"0.000170%\"]),\n  });\nexport const WAR_SIDE_BET_RTP_LABEL_KEYS: Readonly<Record<WarSideBetType, string>> = Object.freeze({\n  tie: \"sidebet_tie\",\n  coloured_tie: \"sidebet_coloured_tie\",\n});\nexport const WAR_MAIN_BET_ODDS = \"1:1\" as const;\nexport const WAR_MAX_TIES_BONUS_ODDS = \"10:1\" as const;\nexport const WAR_RTP_ENTRIES = Object.freeze([\n  Object.freeze({ labelKey: \"standard\", rtp: \"96.56\" }),\n  Object.freeze({ labelKey: \"sidebet_tie\", rtp: \"95.32\" }),\n  Object.freeze({ labelKey: \"sidebet_coloured_tie\", rtp: \"95.33\" }),\n]);\nexport type WarPayoutRow = Readonly<{\n  description: string;\n  labelKey: string;\n  ties: number | null;\n  value: string;\n}>;\nexport type WarPayoutSection = Readonly<{\n  titleKey: string | null;\n  rows: readonly WarPayoutRow[];\n}>;\nexport const WAR_PAYOUT_SECTIONS: readonly WarPayoutSection[] = Object.freeze([\n  Object.freeze({\n    titleKey: null,\n    rows: Object.freeze([\n      Object.freeze({\n        description: \"\",\n        labelKey: \"main_bet_win\",\n        ties: null,\n        value: WAR_MAIN_BET_ODDS,\n      }),\n    ]),\n  }),\n  ...WAR_SIDE_BET_TYPES.map((type) =>\n    Object.freeze({\n      titleKey: WAR_SIDE_BET_RTP_LABEL_KEYS[type],\n      rows: Object.freeze(\n        WAR_SIDE_BET_ODDS[type].map((value, index) =>\n          Object.freeze({\n            description: WAR_SIDE_BET_PROBABILITIES[type][index],\n            labelKey: WAR_SIDE_BET_RTP_LABEL_KEYS[type],\n            ties: index + 1,\n            value,\n          }),\n        ),\n      ),\n    }),\n  ),\n]);\n\nexport type WarSideBets = { tie?: string; colouredTie?: string };\nexport type WarParameters = { sideBets?: WarSideBets };\nexport type WarDecision = Readonly<{ action: WarAction; expectedStep: number }>;\nexport type WarDraw = Readonly<{ remaining: number; index: number; card: WarCard }>;\nexport type WarTerms = Readonly<{\n  version: WarRulesVersion;\n  parameters: WarParameters;\n  stakeMinor: bigint;\n  mainBetMinor: bigint;\n  tieMinor: bigint;\n  colouredTieMinor: bigint;\n  surrenderReturnMinor: bigint;\n  maximumStakeMinor: bigint;\n  maximumPayoutMinor: bigint;\n  uncappedMaximumPayoutMinor: bigint;\n}>;\nexport type WarOpenDeal = {\n  playerCard: WarCard;\n  dealerCard: WarCard;\n  resultType: WarResultType;\n  coloured: boolean;\n};\nexport type WarRound = {\n  round: number;\n  playerBurnedCards: WarCard[];\n  playerCard: WarCard;\n  dealerBurnedCards: WarCard[];\n  dealerCard: WarCard;\n  resultType: WarResultType;\n  coloured: boolean;\n};\nexport type WarSideBetWager = { type: WarSideBetType; amountMinor: string };\nexport type WarSideBetOutcome = {\n  type: WarSideBetType;\n  amountMinor: string;\n  subType: WarSideBetSubType | null;\n  ties: number;\n  odds: string | null;\n  multiplier: string;\n  payoutMinor: string;\n  won: boolean;\n};\nexport type WarResult = {\n  warRules: WarRulesVersion;\n  settlementPolicy: typeof WAR_SETTLEMENT_POLICY;\n  gameName: typeof WAR_GAME_NAME;\n  decks: number;\n  fairness: { hash: typeof WAR_HASH; messageFormat: typeof WAR_MESSAGE_FORMAT };\n  parameters: WarParameters;\n  step: number;\n  roundStatus: WarRoundStatus;\n  gameOver: boolean;\n  playerWon: boolean | null;\n  actions: WarAction[];\n  openDeal: WarOpenDeal;\n  warRounds: WarRound[];\n  drawnCards: WarCard[];\n  tieCount: number;\n  colouredTieCount: number;\n  initialStakeMinor: string;\n  mainBetMinor: string;\n  goToWarBetMinor: string;\n  surrenderReturnMinor: string;\n  warBetMinor: string;\n  totalStakeMinor: string;\n  sideBets: WarSideBetWager[];\n  sideBetsResult: WarSideBetOutcome[];\n  mainPayoutMinor: string;\n  mainMultiplier: string;\n  sideBetsPayoutMinor: string;\n  grossPayoutMinor: string;\n  payoutMinor: string;\n  capped: boolean;\n  totalMultiplier: string;\n  maxPayoutMinor: string;\n  maximumPayoutMinor: string;\n  payoutTable: { titleKey: string | null; rows: WarPayoutRow[] }[];\n  rtp: { labelKey: string; rtp: string }[];\n  decisions: { action: WarAction; expectedStep: number }[];\n};\n\ntype Round = {\n  terms: WarTerms;\n  cards: WarCard[];\n  status: WarRoundStatus;\n  decisions: WarDecision[];\n};\ntype Secret = Readonly<{\n  parameters: WarParameters;\n  initialStakeMinor: bigint;\n  cards: readonly WarCard[];\n}>;\n\nconst invalid = (message = \"Invalid War parameters\") => new RangeError(message);\nconst CARD_PATTERN = /^[A2-9TJQK][cdhs]$/;\nconst AMOUNT_PATTERN = /^[1-9]\\d{0,6}$/;\nconst STAKE_PATTERN = /^[1-9]\\d{0,15}$/;\nconst SIDE_BET_KEYS = Object.freeze([\"tie\", \"colouredTie\"] as const);\nconst SECRET_KEYS = Object.freeze([\n  \"cards\",\n  \"decisions\",\n  \"initialStakeMinor\",\n  \"parameters\",\n  \"warRules\",\n]);\nconst ZERO: Ratio = Object.freeze({ numerator: 0n, denominator: 1n });\nconst MAIN_RETURNS: Readonly<Record<WarRoundStatus, Ratio>> = Object.freeze({\n  \"player-lose\": ZERO,\n  \"player-surrender\": Object.freeze({ numerator: 1n, denominator: 2n }),\n  \"player-war-lose\": ZERO,\n  \"player-war-max-ties\": Object.freeze({ numerator: 12n, denominator: 1n }),\n  \"player-war-win\": Object.freeze({ numerator: 3n, denominator: 1n }),\n  \"player-win\": Object.freeze({ numerator: 2n, denominator: 1n }),\n  \"war-pending\": ZERO,\n});\nconst SIDE_RETURNS: Readonly<Record<WarSideBetType, readonly bigint[]>> = Object.freeze({\n  tie: Object.freeze(WAR_SIDE_BET_ODDS.tie.map(gross)),\n  coloured_tie: Object.freeze(WAR_SIDE_BET_ODDS.coloured_tie.map(gross)),\n});\n\nfunction gross(odds: string): bigint {\n  const match = /^([1-9]\\d*):1$/.exec(odds);\n  if (!match) throw invalid(\"Invalid War odds\");\n  return BigInt(match[1]) + 1n;\n}\n\nfunction plainObject(value: unknown): value is Record<string, unknown> {\n  if (!value || typeof value !== \"object\" || Array.isArray(value)) return false;\n  const prototype = Object.getPrototypeOf(value);\n  return prototype === Object.prototype || prototype === null;\n}\n\nfunction formatMinor(value: bigint): string {\n  const whole = (value / 100n).toString().replace(/\\B(?=(\\d{3})+(?!\\d))/g, \",\");\n  return `${whole}.${(value % 100n).toString().padStart(2, \"0\")}`;\n}\n\nfunction minimum(a: bigint, b: bigint): bigint {\n  return a < b ? a : b;\n}\n\nfunction fixedHalfUp(numerator: bigint, denominator: bigint, digits: number): string {\n  const scale = 10n ** BigInt(digits),\n    scaled = (numerator * scale * 2n + denominator) / (denominator * 2n);\n  return `${scaled / scale}.${(scaled % scale).toString().padStart(digits, \"0\")}`;\n}\n\nexport function isWarCard(value: unknown): value is WarCard {\n  return typeof value === \"string\" && CARD_PATTERN.test(value);\n}\n\nexport function warCardRank(card: WarCard): WarRank {\n  if (!isWarCard(card)) throw invalid(\"Invalid War card\");\n  return card[0] as WarRank;\n}\n\nexport function warCardSuit(card: WarCard): WarSuit {\n  if (!isWarCard(card)) throw invalid(\"Invalid War card\");\n  return card[1] as WarSuit;\n}\n\nexport function warCardValue(card: WarCard): number {\n  return WAR_CARD_RANK_VALUES[warCardRank(card)];\n}\n\nexport function warCardColour(card: WarCard): WarColour {\n  return WAR_SUIT_COLOURS[warCardSuit(card)];\n}\n\nexport function warCompare(playerCard: WarCard, dealerCard: WarCard): number {\n  return warCardValue(playerCard) - warCardValue(dealerCard);\n}\n\nexport function warIsColouredTie(playerCard: WarCard, dealerCard: WarCard): boolean {\n  return (\n    warCompare(playerCard, dealerCard) === 0 &&\n    warCardColour(playerCard) === warCardColour(dealerCard)\n  );\n}\n\nexport function warResultType(playerCard: WarCard, dealerCard: WarCard): WarResultType {\n  const order = warCompare(playerCard, dealerCard);\n  return order === 0 ? \"tie\" : order > 0 ? \"win\" : \"lose\";\n}\n\nexport function warPlayerWonState(status: WarRoundStatus): boolean | null {\n  switch (status) {\n    case WAR_ROUND_STATUS.PlayerWin:\n    case WAR_ROUND_STATUS.PlayerWarWin:\n      return true;\n    case WAR_ROUND_STATUS.PlayerLose:\n    case WAR_ROUND_STATUS.PlayerSurrender:\n    case WAR_ROUND_STATUS.PlayerWarLose:\n    case WAR_ROUND_STATUS.PlayerWarMaxTies:\n      return false;\n    default:\n      return null;\n  }\n}\n\nexport function warMainReturn(status: WarRoundStatus): Ratio {\n  if (typeof status !== \"string\" || !Object.hasOwn(MAIN_RETURNS, status))\n    throw invalid(\"Invalid War round status\");\n  return MAIN_RETURNS[status];\n}\n\nexport function warSideBetReturn(type: WarSideBetType, count: number): bigint {\n  if (\n    typeof type !== \"string\" ||\n    !Object.hasOwn(SIDE_RETURNS, type) ||\n    !Number.isInteger(count) ||\n    count < 0 ||\n    count > WAR_MAX_TIES\n  )\n    throw invalid(\"Invalid War side bet\");\n  return count === 0 ? 0n : SIDE_RETURNS[type][count - 1];\n}\n\nexport function warShoeCard(position: number): WarCard {\n  if (!Number.isInteger(position) || position < 0 || position >= WAR_SHOE_SIZE)\n    throw invalid(\"Invalid War shoe position\");\n  const code = position % 52;\n  return `${WAR_CARD_RANKS[code % 13]}${WAR_CARD_SUITS[Math.floor(code / 13)]}`;\n}\n\nexport function validateWarParameters(input: unknown): WarParameters {\n  if (!plainObject(input)) throw invalid();\n  const keys = Reflect.ownKeys(input);\n  if (keys.length === 0) return {};\n  if (keys.length !== 1 || keys[0] !== \"sideBets\") throw invalid();\n  const sideBets = input.sideBets;\n  if (!plainObject(sideBets)) throw invalid(\"Invalid War side bets\");\n  const names = Reflect.ownKeys(sideBets);\n  if (\n    names.length === 0 ||\n    names.some(\n      (name) => typeof name !== \"string\" || !(SIDE_BET_KEYS as readonly string[]).includes(name),\n    )\n  )\n    throw invalid(\"Invalid War side bets\");\n  const result: WarSideBets = {};\n  for (const name of SIDE_BET_KEYS) {\n    if (!Object.hasOwn(sideBets, name)) continue;\n    const amount = sideBets[name];\n    if (typeof amount !== \"string\" || !AMOUNT_PATTERN.test(amount))\n      throw invalid(\"Invalid War side bet amount\");\n    if (BigInt(amount) > WAR_SIDE_BET_MAX_MINOR)\n      throw invalid(`Maximum bet is ${formatMinor(WAR_SIDE_BET_MAX_MINOR)}`);\n    result[name] = amount;\n  }\n  return { sideBets: result };\n}\n\nfunction copyParameters(parameters: WarParameters): WarParameters {\n  return parameters.sideBets ? { sideBets: { ...parameters.sideBets } } : {};\n}\n\nexport function isWarRulesVersion(value: unknown): value is WarRulesVersion {\n  return (WAR_RULES_VERSIONS as readonly unknown[]).includes(value);\n}\n\nfunction edition(version: unknown): WarRulesVersion {\n  if (!isWarRulesVersion(version)) throw invalid(\"Invalid War rules\");\n  return version;\n}\n\nfunction paysInFull(version: WarRulesVersion): boolean {\n  return version !== WAR_RULES_VERSION;\n}\n\nexport function warLimits(version: WarRulesVersion = WAR_RULES_VERSION): Limits {\n  return paysInFull(edition(version)) ? WAR_2_GAME_LIMITS : WAR_GAME_LIMITS;\n}\n\nexport function warStakeTerms(\n  stakeMinor: bigint,\n  parameters: unknown,\n  version: WarRulesVersion = WAR_RULES_VERSION,\n): WarTerms {\n  const validated = validateWarParameters(parameters);\n  if (typeof stakeMinor !== \"bigint\" || stakeMinor < WAR_MIN_BET_MINOR)\n    throw invalid(\"Invalid War stake\");\n  if (stakeMinor > WAR_MAX_STAKE_MINOR)\n    throw invalid(`Maximum bet is ${formatMinor(WAR_MAX_STAKE_MINOR)}`);\n  const tieMinor = BigInt(validated.sideBets?.tie ?? \"0\"),\n    colouredTieMinor = BigInt(validated.sideBets?.colouredTie ?? \"0\"),\n    mainBetMinor = stakeMinor - tieMinor - colouredTieMinor;\n  if (mainBetMinor < WAR_MIN_BET_MINOR) throw invalid(\"Side bets require a main bet to play\");\n  const best = MAIN_RETURNS[\"player-war-max-ties\"],\n    uncapped =\n      (mainBetMinor * best.numerator) / best.denominator +\n      tieMinor * warSideBetReturn(\"tie\", WAR_MAX_TIES) +\n      colouredTieMinor * warSideBetReturn(\"coloured_tie\", WAR_MAX_TIES),\n    surrender = MAIN_RETURNS[\"player-surrender\"];\n  return Object.freeze({\n    version: edition(version),\n    parameters: validated,\n    stakeMinor,\n    mainBetMinor,\n    tieMinor,\n    colouredTieMinor,\n    surrenderReturnMinor: (mainBetMinor * surrender.numerator) / surrender.denominator,\n    maximumStakeMinor: stakeMinor + mainBetMinor,\n    maximumPayoutMinor: paysInFull(version) ? uncapped : minimum(uncapped, WAR_MAX_PAYOUT_MINOR),\n    uncappedMaximumPayoutMinor: uncapped,\n  });\n}\n\nexport function admitWarStake(stakeMinor: bigint, parameters: unknown): void {\n  const terms = warStakeTerms(stakeMinor, parameters);\n  admitStakeRange(WAR_GAME_LIMITS, terms.mainBetMinor);\n  const sideLimits = gameLimits(\"war\", \"side-bet\");\n  for (const amount of [terms.tieMinor, terms.colouredTieMinor])\n    if (amount > 0n) admitStakeRange(sideLimits, amount);\n}\n\nexport function admitWar2Stake(stakeMinor: bigint, parameters: unknown): void {\n  admitWarStake(stakeMinor, parameters);\n  const top = warStakeTerms(stakeMinor, parameters, WAR_2_RULES_VERSION).uncappedMaximumPayoutMinor;\n  if (top > MAX_WIN_MINOR) throw invalid(maxBetMessage(maxBetForPayout(stakeMinor, top)));\n}\n\nexport function warMaximumPayoutMinor(\n  stakeMinor: bigint,\n  parameters: unknown,\n  version: WarRulesVersion = WAR_RULES_VERSION,\n): bigint {\n  return warStakeTerms(stakeMinor, parameters, version).maximumPayoutMinor;\n}\n\nexport function warMaximumStakeMinor(stakeMinor: bigint, parameters: unknown): bigint {\n  return warStakeTerms(stakeMinor, parameters).maximumStakeMinor;\n}\n\nfunction checkedEntropy(entropy: Entropy): Entropy {\n  if (\n    !entropy ||\n    typeof entropy.serverSeed !== \"string\" ||\n    !entropy.serverSeed.trim() ||\n    typeof entropy.clientSeed !== \"string\" ||\n    !entropy.clientSeed.trim() ||\n    typeof entropy.nonce !== \"number\" ||\n    !Number.isSafeInteger(entropy.nonce) ||\n    entropy.nonce < 0 ||\n    typeof entropy.hmac !== \"function\"\n  )\n    throw invalid(\"Invalid War fairness seed\");\n  return entropy;\n}\n\nexport function warMessage(entropy: Pick<Entropy, \"clientSeed\" | \"nonce\">): string {\n  return `${entropy.clientSeed}:${entropy.nonce}:${WAR_GAME_NAME}`;\n}\n\nexport async function warShoeDraws(entropy: Entropy, count: number): Promise<readonly WarDraw[]> {\n  const seed = checkedEntropy(entropy);\n  if (!Number.isInteger(count) || count < 0 || count > WAR_SHOE_SIZE)\n    throw invalid(\"Invalid War draw count\");\n  const message = warMessage(seed),\n    words: number[] = [],\n    shoe = Array.from({ length: WAR_SHOE_SIZE }, (_, position) => position),\n    draws: WarDraw[] = [];\n  let cursor = 0,\n    used = 0;\n  const word = async () => {\n    if (used === words.length) {\n      if (cursor >= WAR_STREAM_LIMIT) throw invalid(\"War entropy stream exhausted\");\n      const digest = await seed.hmac(WAR_HASH, seed.serverSeed, `${message}:${cursor}`);\n      if (!(digest instanceof Uint8Array) || digest.byteLength !== 64)\n        throw invalid(\"Invalid War HMAC digest\");\n      for (let offset = 0; offset < 64; offset += 4)\n        words.push(\n          ((digest[offset] << 24) |\n            (digest[offset + 1] << 16) |\n            (digest[offset + 2] << 8) |\n            digest[offset + 3]) >>>\n            0,\n        );\n      cursor++;\n    }\n    return words[used++];\n  };\n  for (let drawn = 0; drawn < count; drawn++) {\n    const remaining = WAR_SHOE_SIZE - drawn,\n      limit = 0x1_0000_0000 - (0x1_0000_0000 % remaining);\n    let value = await word();\n    while (value >= limit) value = await word();\n    const index = value % remaining,\n      last = remaining - 1,\n      selected = shoe[index];\n    shoe[index] = shoe[last];\n    shoe[last] = selected;\n    draws.push(Object.freeze({ remaining, index, card: warShoeCard(selected) }));\n  }\n  return Object.freeze(draws);\n}\n\nexport async function warFairnessShoe(entropy: Entropy): Promise<WarCard[]> {\n  return (await warShoeDraws(entropy, WAR_SHOE_SIZE)).map((draw) => draw.card);\n}\n\nexport function validateWarDecision(input: unknown): WarDecision {\n  if (!plainObject(input)) throw invalid(\"Invalid War decision\");\n  const keys = Object.keys(input).filter((key) => input[key] !== undefined);\n  if (keys.some((key) => key !== \"action\" && key !== \"expectedStep\"))\n    throw invalid(\"Invalid War decision\");\n  const action = input.action,\n    step = input.expectedStep;\n  if (typeof action !== \"string\" || !(WAR_ACTIONS as readonly string[]).includes(action))\n    throw invalid(\"Choose Surrender or Go to War\");\n  if (typeof step !== \"number\" || !Number.isSafeInteger(step) || step < 0)\n    throw invalid(\"War actions require the expected step\");\n  return Object.freeze({ action: action as WarAction, expectedStep: step });\n}\n\nfunction take(round: Round, source: readonly WarCard[], count: number): WarCard[] {\n  const start = round.cards.length;\n  if (start + count > source.length) throw invalid(\"The committed War shoe is exhausted\");\n  const cards = source.slice(start, start + count);\n  for (const card of cards) if (!isWarCard(card)) throw invalid(\"Invalid War card\");\n  round.cards.push(...cards);\n  return cards;\n}\n\nfunction deal(terms: WarTerms, source: readonly WarCard[]): Round {\n  const round: Round = {\n    terms,\n    cards: [],\n    status: WAR_ROUND_STATUS.WarPending,\n    decisions: [],\n  };\n  const [playerCard, dealerCard] = take(round, source, 2),\n    order = warCompare(playerCard, dealerCard);\n  round.status =\n    order === 0\n      ? WAR_ROUND_STATUS.WarPending\n      : order > 0\n        ? WAR_ROUND_STATUS.PlayerWin\n        : WAR_ROUND_STATUS.PlayerLose;\n  return round;\n}\n\nfunction permitted(round: Round, decision: WarDecision) {\n  if (round.status !== WAR_ROUND_STATUS.WarPending)\n    throw invalid(\"This War round is already settled\");\n  if (decision.expectedStep !== round.decisions.length)\n    throw invalid(\"This War round has changed. Refresh your active round.\");\n}\n\nfunction apply(round: Round, input: unknown, source: readonly WarCard[]) {\n  const decision = validateWarDecision(input);\n  permitted(round, decision);\n  round.decisions.push(decision);\n  if (decision.action === \"surrender\") {\n    round.status = WAR_ROUND_STATUS.PlayerSurrender;\n    return;\n  }\n  for (let ties = 1; ties < WAR_MAX_TIES; ties++) {\n    const cards = take(round, source, WAR_ROUND_CARDS),\n      order = warCompare(cards[WAR_BURNED_CARDS], cards[WAR_ROUND_CARDS - 1]);\n    if (order < 0) {\n      round.status = WAR_ROUND_STATUS.PlayerWarLose;\n      return;\n    }\n    if (order > 0) {\n      round.status = WAR_ROUND_STATUS.PlayerWarWin;\n      return;\n    }\n  }\n  round.status = WAR_ROUND_STATUS.PlayerWarMaxTies;\n}\n\nfunction openDealOf(round: Round): WarOpenDeal {\n  const [playerCard, dealerCard] = round.cards;\n  return {\n    playerCard,\n    dealerCard,\n    resultType: warResultType(playerCard, dealerCard),\n    coloured: warIsColouredTie(playerCard, dealerCard),\n  };\n}\n\nfunction warRoundsOf(round: Round): WarRound[] {\n  const rounds: WarRound[] = [];\n  for (let offset = 2; offset < round.cards.length; offset += WAR_ROUND_CARDS) {\n    const cards = round.cards.slice(offset, offset + WAR_ROUND_CARDS),\n      playerCard = cards[WAR_BURNED_CARDS],\n      dealerCard = cards[WAR_ROUND_CARDS - 1];\n    rounds.push({\n      round: rounds.length + 1,\n      playerBurnedCards: cards.slice(0, WAR_BURNED_CARDS),\n      playerCard,\n      dealerBurnedCards: cards.slice(WAR_BURNED_CARDS + 1, WAR_ROUND_CARDS - 1),\n      dealerCard,\n      resultType: warResultType(playerCard, dealerCard),\n      coloured: warIsColouredTie(playerCard, dealerCard),\n    });\n  }\n  return rounds;\n}\n\nfunction sideBetWagers(terms: WarTerms): WarSideBetWager[] {\n  const wagers: WarSideBetWager[] = [];\n  if (terms.tieMinor > 0n) wagers.push({ type: \"tie\", amountMinor: terms.tieMinor.toString() });\n  if (terms.colouredTieMinor > 0n)\n    wagers.push({ type: \"coloured_tie\", amountMinor: terms.colouredTieMinor.toString() });\n  return wagers;\n}\n\nfunction settleSideBets(\n  terms: WarTerms,\n  tieCount: number,\n  colouredTieCount: number,\n): WarSideBetOutcome[] {\n  return sideBetWagers(terms).map((wager) => {\n    const count = wager.type === \"tie\" ? tieCount : colouredTieCount,\n      factor = warSideBetReturn(wager.type, count);\n    return {\n      type: wager.type,\n      amountMinor: wager.amountMinor,\n      subType: count ? WAR_SIDE_BET_SUB_TYPES[count - 1] : null,\n      ties: count,\n      odds: count ? WAR_SIDE_BET_ODDS[wager.type][count - 1] : null,\n      multiplier: factor.toString(),\n      payoutMinor: (BigInt(wager.amountMinor) * factor).toString(),\n      won: count > 0,\n    };\n  });\n}\n\nfunction payoutTable(): WarResult[\"payoutTable\"] {\n  return WAR_PAYOUT_SECTIONS.map((section) => ({\n    titleKey: section.titleKey,\n    rows: section.rows.map((row) => ({ ...row })),\n  }));\n}\n\nfunction resolution(round: Round, acted: boolean): Resolution {\n  const terms = round.terms,\n    over = round.status !== WAR_ROUND_STATUS.WarPending,\n    openDeal = openDealOf(round),\n    warRounds = warRoundsOf(round),\n    contests = [openDeal, ...warRounds],\n    tieCount = contests.filter((entry) => entry.resultType === \"tie\").length,\n    colouredTieCount = contests.filter((entry) => entry.coloured).length,\n    warBetMinor = round.decisions.some((decision) => decision.action === \"go_to_war\")\n      ? terms.mainBetMinor\n      : 0n,\n    totalStakeMinor = terms.stakeMinor + warBetMinor,\n    mainReturn = MAIN_RETURNS[round.status],\n    mainPayoutMinor = (terms.mainBetMinor * mainReturn.numerator) / mainReturn.denominator,\n    sideBetsResult = over ? settleSideBets(terms, tieCount, colouredTieCount) : [],\n    sideBetsPayoutMinor = sideBetsResult.reduce((sum, side) => sum + BigInt(side.payoutMinor), 0n),\n    grossPayoutMinor = mainPayoutMinor + sideBetsPayoutMinor,\n    payoutMinor = paysInFull(terms.version)\n      ? grossPayoutMinor\n      : minimum(grossPayoutMinor, WAR_MAX_PAYOUT_MINOR),\n    decisions = round.decisions.map((decision) => ({ ...decision })),\n    result: WarResult = {\n      warRules: terms.version,\n      settlementPolicy: WAR_SETTLEMENT_POLICY,\n      gameName: WAR_GAME_NAME,\n      decks: WAR_DECK_COUNT,\n      fairness: { hash: WAR_HASH, messageFormat: WAR_MESSAGE_FORMAT },\n      parameters: copyParameters(terms.parameters),\n      step: round.decisions.length,\n      roundStatus: round.status,\n      gameOver: over,\n      playerWon: warPlayerWonState(round.status),\n      actions: over ? [] : [...WAR_ACTIONS],\n      openDeal,\n      warRounds,\n      drawnCards: [...round.cards],\n      tieCount,\n      colouredTieCount,\n      initialStakeMinor: terms.stakeMinor.toString(),\n      mainBetMinor: terms.mainBetMinor.toString(),\n      goToWarBetMinor: terms.mainBetMinor.toString(),\n      surrenderReturnMinor: terms.surrenderReturnMinor.toString(),\n      warBetMinor: warBetMinor.toString(),\n      totalStakeMinor: totalStakeMinor.toString(),\n      sideBets: sideBetWagers(terms),\n      sideBetsResult,\n      mainPayoutMinor: mainPayoutMinor.toString(),\n      mainMultiplier: fixedHalfUp(mainPayoutMinor, terms.mainBetMinor + warBetMinor, 2),\n      sideBetsPayoutMinor: sideBetsPayoutMinor.toString(),\n      grossPayoutMinor: grossPayoutMinor.toString(),\n      payoutMinor: payoutMinor.toString(),\n      capped: grossPayoutMinor > payoutMinor,\n      totalMultiplier: fixedHalfUp(payoutMinor, totalStakeMinor, 2),\n      maxPayoutMinor: warLimits(terms.version).maxPayoutMinor.toString(),\n      maximumPayoutMinor: terms.maximumPayoutMinor.toString(),\n      payoutTable: payoutTable(),\n      rtp: WAR_RTP_ENTRIES.map((entry) => ({ ...entry })),\n      decisions,\n    };\n  return {\n    game: \"war\",\n    status: over ? (payoutMinor > 0n ? \"WON\" : \"LOST\") : \"ACTIVE\",\n    payoutMinor: payoutMinor.toString(),\n    multiplier: ratioText(ratio(payoutMinor, totalStakeMinor)),\n    result,\n    ...(over\n      ? {}\n      : {\n          secret: {\n            warRules: terms.version,\n            parameters: copyParameters(terms.parameters),\n            initialStakeMinor: terms.stakeMinor.toString(),\n            cards: [...round.cards],\n            decisions: decisions.map((decision) => ({ ...decision })),\n          },\n        }),\n    rulesVersion: terms.version,\n    ...(acted ? { additionalStakeMinor: warBetMinor.toString() } : {}),\n  };\n}\n\nfunction cardsOf(input: unknown): WarCard[] {\n  if (!Array.isArray(input) || input.length > WAR_MAX_CARDS)\n    throw invalid(\"Invalid War card sequence\");\n  return Array.from(input, (value: unknown) => {\n    if (!isWarCard(value)) throw invalid(\"Invalid War card sequence\");\n    return value;\n  });\n}\n\nfunction same(a: unknown, b: unknown): boolean {\n  return JSON.stringify(a) === JSON.stringify(b);\n}\n\nfunction readSecret(\n  input: unknown,\n  parameters: unknown,\n  version: WarRulesVersion = WAR_RULES_VERSION,\n): Secret {\n  if (!plainObject(input)) throw invalid(\"Invalid War state\");\n  if (\n    !same(Object.keys(input).sort(), SECRET_KEYS) ||\n    input.warRules !== version ||\n    typeof input.initialStakeMinor !== \"string\" ||\n    !STAKE_PATTERN.test(input.initialStakeMinor) ||\n    !Array.isArray(input.decisions) ||\n    input.decisions.length !== 0\n  )\n    throw invalid(\"Invalid War state\");\n  const validated = validateWarParameters(input.parameters);\n  if (!same(validated, validateWarParameters(parameters)))\n    throw invalid(\"War parameters do not match the active round\");\n  const cards = cardsOf(input.cards);\n  if (cards.length !== 2) throw invalid(\"Invalid War state\");\n  return Object.freeze({\n    parameters: validated,\n    initialStakeMinor: BigInt(input.initialStakeMinor),\n    cards: Object.freeze(cards),\n  });\n}\n\nfunction replay(terms: WarTerms, source: readonly WarCard[], decisions: readonly unknown[]): Round {\n  if (!Array.isArray(decisions) || decisions.length > 1) throw invalid(\"Invalid War decisions\");\n  const round = deal(terms, source);\n  for (const decision of decisions) apply(round, decision, source);\n  return round;\n}\n\nexport function playWar(\n  input: {\n    stakeMinor: bigint;\n    parameters: unknown;\n    cards: readonly string[];\n    decisions?: readonly unknown[];\n  },\n  version: WarRulesVersion = WAR_RULES_VERSION,\n): Resolution {\n  const terms = warStakeTerms(input.stakeMinor, input.parameters, version),\n    decisions = input.decisions ?? [];\n  return resolution(replay(terms, cardsOf(input.cards), decisions), decisions.length > 0);\n}\n\nexport async function warDeal(entropy: Entropy): Promise<readonly WarDraw[]> {\n  return warShoeDraws(entropy, 2);\n}\n\nexport async function resolveWar(\n  input: ResolveInput<WarParameters>,\n  version: WarRulesVersion = WAR_RULES_VERSION,\n): Promise<Resolution> {\n  const terms = warStakeTerms(input.stakeMinor, input.parameters, version),\n    source = (await warDeal(input.entropy)).map((draw) => draw.card);\n  return resolution(deal(terms, source), false);\n}\n\nexport async function actWar(\n  input: ActInput<WarParameters>,\n  version: WarRulesVersion = WAR_RULES_VERSION,\n): Promise<Resolution> {\n  const secret = readSecret(input.secret, input.parameters, version),\n    terms = warStakeTerms(secret.initialStakeMinor, secret.parameters, version);\n  if (typeof input.stakeMinor !== \"bigint\" || input.stakeMinor !== terms.stakeMinor)\n    throw invalid(\"Invalid War stake\");\n  const decision = validateWarDecision(input.decision);\n  permitted(deal(terms, secret.cards), decision);\n  const source = (\n    await warShoeDraws(input.entropy, decision.action === \"go_to_war\" ? WAR_MAX_CARDS : 2)\n  ).map((draw) => draw.card);\n  if (source[0] !== secret.cards[0] || source[1] !== secret.cards[1])\n    throw invalid(\"This War round does not match its committed shoe\");\n  const round = deal(terms, source);\n  apply(round, decision, source);\n  return resolution(round, true);\n}\n\nexport function warAdditionalStakeMinor(\n  input: Readonly<{\n    parameters: WarParameters;\n    secret: Record<string, unknown>;\n    decision: Decision;\n  }>,\n  version: WarRulesVersion = WAR_RULES_VERSION,\n): bigint {\n  const secret = readSecret(input.secret, input.parameters, version),\n    terms = warStakeTerms(secret.initialStakeMinor, secret.parameters, version),\n    decision = validateWarDecision(input.decision);\n  permitted(deal(terms, secret.cards), decision);\n  return decision.action === \"go_to_war\" ? terms.mainBetMinor : 0n;\n}\n\nexport function reconstructWar(\n  input: Readonly<{\n    stakeMinor: bigint;\n    parameters: WarParameters;\n    result: Record<string, unknown>;\n  }>,\n  version: WarRulesVersion = WAR_RULES_VERSION,\n): Resolution {\n  const result = input.result;\n  if (\n    !plainObject(result) ||\n    result.warRules !== edition(version) ||\n    typeof result.initialStakeMinor !== \"string\" ||\n    !STAKE_PATTERN.test(result.initialStakeMinor) ||\n    !Array.isArray(result.decisions)\n  )\n    throw invalid(\"Invalid War result\");\n  const terms = warStakeTerms(BigInt(result.initialStakeMinor), input.parameters, version),\n    source = cardsOf(result.drawnCards),\n    round = replay(terms, source, result.decisions);\n  if (round.cards.length !== source.length) throw invalid(\"Invalid War result\");\n  const outcome = resolution(round, round.decisions.length > 0);\n  if (\n    typeof input.stakeMinor !== \"bigint\" ||\n    input.stakeMinor.toString() !== (outcome.result as WarResult).totalStakeMinor\n  )\n    throw invalid(\"Invalid War stake\");\n  return outcome;\n}\n\nexport const warRulesModule: GameRules<WarParameters> = Object.freeze({\n  game: \"war\",\n  version: WAR_RULES_VERSION,\n  stateful: true,\n  actions: WAR_ACTIONS,\n  closingActions: WAR_CLOSING_ACTIONS,\n  limits: WAR_GAME_LIMITS,\n  admitStake: admitWarStake,\n  validateParameters: validateWarParameters,\n  maximumPayoutMinor: warMaximumPayoutMinor,\n  maximumStakeMinor: warMaximumStakeMinor,\n  resolve: resolveWar,\n  act: actWar,\n  additionalStakeMinor: warAdditionalStakeMinor,\n  reconstruct: reconstructWar,\n});\n\nexport const war2RulesModule: GameRules<WarParameters> = Object.freeze({\n  game: \"war\",\n  version: WAR_2_RULES_VERSION,\n  stateful: true,\n  actions: WAR_ACTIONS,\n  closingActions: WAR_CLOSING_ACTIONS,\n  limits: WAR_2_GAME_LIMITS,\n  admitStake: admitWar2Stake,\n  validateParameters: validateWarParameters,\n  maximumPayoutMinor: (stakeMinor: bigint, parameters: WarParameters) =>\n    warMaximumPayoutMinor(stakeMinor, parameters, WAR_2_RULES_VERSION),\n  maximumStakeMinor: warMaximumStakeMinor,\n  resolve: (input: ResolveInput<WarParameters>) => resolveWar(input, WAR_2_RULES_VERSION),\n  act: (input: ActInput<WarParameters>) => actWar(input, WAR_2_RULES_VERSION),\n  additionalStakeMinor: (\n    input: Readonly<{\n      parameters: WarParameters;\n      secret: Record<string, unknown>;\n      decision: Decision;\n    }>,\n  ) => warAdditionalStakeMinor(input, WAR_2_RULES_VERSION),\n  reconstruct: (\n    input: Readonly<{\n      stakeMinor: bigint;\n      parameters: WarParameters;\n      result: Record<string, unknown>;\n    }>,\n  ) => reconstructWar(input, WAR_2_RULES_VERSION),\n});\n","sha256":"e99a910eebd647afe858e1b18ed823a46a082436a241353a1303288876e6ca30"},"src/lib/games/chicken-rules.ts":{"source":"import { MAX_PAYOUT_MINOR, ratio, ratioText, type Ratio, type Resolution } from \"@/games/math\";\nimport { createEntropy } from \"./entropy\";\nimport type { ActInput, Entropy, GameRules, Limits, ResolveInput } from \"./rules\";\nimport { gameLimits, maxWinLimits } from \"./limits\";\nimport { MAX_WIN_END, MAX_WIN_MINOR, admitMaxWinStake, hundredthsRatio } from \"./max-win\";\n\nexport const CHICKEN_RULES_VERSION = \"peeks-chicken-1\" as const;\nexport const CHICKEN_2_RULES_VERSION = \"peeks-chicken-2\" as const;\nexport const CHICKEN_RULES_VERSIONS = Object.freeze([\n  CHICKEN_RULES_VERSION,\n  CHICKEN_2_RULES_VERSION,\n] as const);\nexport type ChickenRulesVersion = (typeof CHICKEN_RULES_VERSIONS)[number];\nexport const CHICKEN_GAME = \"chicken\" as const;\nexport const CHICKEN_GAME_NAME = \"chicken-cross\" as const;\nexport const CHICKEN_HASH = \"SHA-256\" as const;\nexport const CHICKEN_CURSOR = 0;\nexport const CHICKEN_WORD_INDEX = 0;\nexport const CHICKEN_DIFFICULTIES = Object.freeze([\"easy\", \"medium\", \"hard\", \"expert\"] as const);\nexport type ChickenDifficulty = (typeof CHICKEN_DIFFICULTIES)[number];\nexport const CHICKEN_DEFAULT_DIFFICULTY: ChickenDifficulty = \"easy\";\nexport const CHICKEN_TILES = 25;\nexport const CHICKEN_LANES: Readonly<Record<ChickenDifficulty, number>> = Object.freeze({\n  easy: 24,\n  medium: 22,\n  hard: 20,\n  expert: 15,\n});\nexport const CHICKEN_HAZARDS: Readonly<Record<ChickenDifficulty, number>> = Object.freeze({\n  easy: 1,\n  medium: 3,\n  hard: 5,\n  expert: 10,\n});\nexport const CHICKEN_HOUSE_EDGE = \"0.04\" as const;\nexport const CHICKEN_RTP_PERCENT = 96;\nexport const CHICKEN_OUTCOMES = 0x1_0000_0000n;\nexport const CHICKEN_BIG_DECIMALS = 20;\nexport const CHICKEN_PROCESSING_DELAYS = Object.freeze({\n  standard: 850,\n  auto: 0,\n  instant: 0,\n} as const);\nexport const CHICKEN_SPAM_GUARD_MS = 150;\nexport const CHICKEN_RATE_LIMIT = 60_000 / CHICKEN_SPAM_GUARD_MS;\nexport const CHICKEN_MAX_STAKE_MINOR = 1_000_000n;\nexport const CHICKEN_MAX_PAYOUT_MINOR = MAX_PAYOUT_MINOR;\nexport const CHICKEN_GAME_LIMITS = gameLimits(\"chicken\");\nexport const CHICKEN_2_GAME_LIMITS = maxWinLimits(\"chicken\");\nexport const CHICKEN_ACTIONS = Object.freeze([\"cross\", \"cashout\"] as const);\nexport const CHICKEN_CLOSING_ACTIONS = Object.freeze([\"cashout\"] as const);\n\nexport type ChickenManualParameters = { difficulty: ChickenDifficulty };\nexport type ChickenAutoParameters = {\n  difficulty: ChickenDifficulty;\n  laneIndex: number;\n};\nexport type ChickenParameters = ChickenManualParameters | ChickenAutoParameters;\nexport type ChickenEnd = \"death\" | \"cashout\" | \"complete\" | \"maxPayout\" | typeof MAX_WIN_END;\nexport type ChickenLane = {\n  round: number;\n  canCashout: boolean;\n  multiplier: string;\n  winPercentage: string;\n};\nexport type ChickenRound = {\n  round: number;\n  didWin: boolean;\n  multiplier: string;\n  winPercentage: string;\n};\nexport type ChickenDecision =\n  | { action: \"cross\"; expectedStep: number }\n  | { action: \"cross\"; selection: number; expectedStep: number }\n  | { action: \"cashout\"; expectedStep: number };\nexport type ChickenMaxOutcome = {\n  fairValue: number;\n  maxLane: number;\n  maxMultiplier: string;\n  deathPoint: number | null;\n};\nexport type ChickenResult = {\n  chickenRules: ChickenRulesVersion;\n  parameters: ChickenParameters;\n  difficulty: ChickenDifficulty;\n  mode: \"manual\" | \"auto\";\n  lanes: number;\n  hazards: number;\n  multipliers: string[];\n  step: number;\n  lanesCrossed: number;\n  chickenCrossResult: ChickenLane[];\n  currentMultiplier: string;\n  potentialPayoutMinor: string;\n  nextMultiplier?: string;\n  nextPayoutMinor?: string;\n  canCashout: boolean;\n  gameOver: boolean;\n  endedBy?: ChickenEnd;\n  deathLane?: number;\n  capped: boolean;\n  uncappedPayoutMinor?: string;\n  maxPayoutMinor: string;\n  decisions: ChickenDecision[];\n  maxOutcome?: ChickenMaxOutcome;\n};\nexport type ChickenSecret = {\n  chickenRules: ChickenRulesVersion;\n  difficulty: ChickenDifficulty;\n  fairValue: number;\n  decisions: ChickenDecision[];\n};\nexport type ChickenQuote = {\n  lanes: number;\n  multiplier: string;\n  payoutMinor: bigint;\n  uncappedPayoutMinor: bigint;\n  capped: boolean;\n};\n\ntype Progress = {\n  version: ChickenRulesVersion;\n  lanes: ChickenLane[];\n  crossed: number;\n  decisions: ChickenDecision[];\n  endedBy: ChickenEnd | null;\n};\n\nconst tables: Readonly<Record<ChickenDifficulty, readonly string[]>> = Object.freeze({\n  easy: Object.freeze([\n    \"1.00\",\n    \"1.04\",\n    \"1.09\",\n    \"1.14\",\n    \"1.20\",\n    \"1.26\",\n    \"1.33\",\n    \"1.41\",\n    \"1.50\",\n    \"1.60\",\n    \"1.71\",\n    \"1.85\",\n    \"2.00\",\n    \"2.18\",\n    \"2.40\",\n    \"2.67\",\n    \"3.00\",\n    \"3.43\",\n    \"4.00\",\n    \"4.80\",\n    \"6.00\",\n    \"8.00\",\n    \"12.00\",\n    \"24.00\",\n  ]),\n  medium: Object.freeze([\n    \"1.09\",\n    \"1.25\",\n    \"1.43\",\n    \"1.66\",\n    \"1.94\",\n    \"2.28\",\n    \"2.71\",\n    \"3.25\",\n    \"3.94\",\n    \"4.85\",\n    \"6.07\",\n    \"7.72\",\n    \"10.04\",\n    \"13.38\",\n    \"18.40\",\n    \"26.29\",\n    \"39.43\",\n    \"63.09\",\n    \"110.40\",\n    \"220.80\",\n    \"552.00\",\n    \"2208.00\",\n  ]),\n  hard: Object.freeze([\n    \"1.20\",\n    \"1.52\",\n    \"1.94\",\n    \"2.51\",\n    \"3.29\",\n    \"4.39\",\n    \"5.95\",\n    \"8.24\",\n    \"11.68\",\n    \"16.98\",\n    \"25.48\",\n    \"39.63\",\n    \"64.40\",\n    \"110.40\",\n    \"202.40\",\n    \"404.80\",\n    \"910.80\",\n    \"2428.80\",\n    \"8500.80\",\n    \"51004.80\",\n  ]),\n  expert: Object.freeze([\n    \"1.60\",\n    \"2.74\",\n    \"4.85\",\n    \"8.90\",\n    \"16.98\",\n    \"33.97\",\n    \"71.71\",\n    \"161.35\",\n    \"391.86\",\n    \"1044.96\",\n    \"3134.87\",\n    \"10972.06\",\n    \"47545.60\",\n    \"285273.60\",\n    \"3138009.60\",\n  ]),\n});\n\nconst BIG_SCALE = 10n ** BigInt(CHICKEN_BIG_DECIMALS);\nconst HUNDREDTH_SCALE = BIG_SCALE / 100n;\nconst PERCENT_SCALE = BIG_SCALE / 10_000n;\nconst CROSSING_NUMERATOR = (CHICKEN_OUTCOMES * 96n * BIG_SCALE) / 100n;\nconst SECRET_KEYS = Object.freeze([\"chickenRules\", \"difficulty\", \"fairValue\", \"decisions\"]);\nconst PARAMETER_KEYS = Object.freeze([\"difficulty\", \"laneIndex\"]);\nconst DECISION_KEYS = Object.freeze([\n  \"action\",\n  \"selection\",\n  \"selections\",\n  \"choice\",\n  \"expectedStep\",\n]);\n\nconst invalid = (message = \"Invalid Chicken Cross parameters\") => new RangeError(message);\n\nfunction hundredthsOf(value: string): bigint {\n  const [whole, fraction] = value.split(\".\");\n  return BigInt(whole) * 100n + BigInt(fraction);\n}\n\nfunction fixed(hundredths: bigint): string {\n  return `${hundredths / 100n}.${(hundredths % 100n).toString().padStart(2, \"0\")}`;\n}\n\nfunction halfUp(numerator: bigint, denominator: bigint): bigint {\n  return (2n * numerator + denominator) / (2n * denominator);\n}\n\nfunction winPercentageOf(hundredths: bigint): string {\n  const units = halfUp(9600n * BIG_SCALE, hundredths) / PERCENT_SCALE;\n  return `${units / 10_000n}.${(units % 10_000n).toString().padStart(4, \"0\")}`;\n}\n\nfunction frozenTable<T>(map: (difficulty: ChickenDifficulty) => readonly T[]) {\n  return Object.freeze(\n    Object.fromEntries(\n      CHICKEN_DIFFICULTIES.map((difficulty) => [difficulty, Object.freeze(map(difficulty))]),\n    ) as Record<ChickenDifficulty, readonly T[]>,\n  );\n}\n\nconst cents = frozenTable((difficulty) => tables[difficulty].map(hundredthsOf));\nconst percentages = frozenTable((difficulty) => cents[difficulty].map(winPercentageOf));\n\nfunction record(value: unknown): value is Record<string, unknown> {\n  return !!value && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction fields(value: unknown, allowed: readonly string[]): Record<string, unknown> | null {\n  if (!record(value)) return null;\n  const entries: Record<string, unknown> = {};\n  for (const key of Reflect.ownKeys(value)) {\n    const descriptor = Object.getOwnPropertyDescriptor(value, key);\n    if (\n      typeof key !== \"string\" ||\n      !allowed.includes(key) ||\n      !descriptor?.enumerable ||\n      !Object.hasOwn(descriptor, \"value\")\n    )\n      return null;\n    entries[key] = descriptor.value;\n  }\n  return entries;\n}\n\nfunction same(value: unknown, expected: unknown): boolean {\n  if (Array.isArray(expected))\n    return (\n      Array.isArray(value) &&\n      value.length === expected.length &&\n      expected.every((entry, index) => same(value[index], entry))\n    );\n  if (record(expected)) {\n    if (!record(value)) return false;\n    const keys = Object.keys(expected);\n    return (\n      Object.keys(value).length === keys.length &&\n      keys.every((key) => Object.hasOwn(value, key) && same(value[key], expected[key]))\n    );\n  }\n  return value === expected;\n}\n\nfunction whole(value: unknown, min: number, max: number, message: string): number {\n  if (typeof value !== \"number\" || !Number.isSafeInteger(value) || value < min || value > max)\n    throw invalid(message);\n  return value + 0;\n}\n\nfunction isDifficulty(value: unknown): value is ChickenDifficulty {\n  return typeof value === \"string\" && (CHICKEN_DIFFICULTIES as readonly string[]).includes(value);\n}\n\nfunction difficultyOf(value: unknown): ChickenDifficulty {\n  if (!isDifficulty(value)) throw invalid(\"Invalid Chicken Cross difficulty\");\n  return value;\n}\n\nfunction laneOf(difficulty: ChickenDifficulty, value: unknown): number {\n  return whole(value, 1, CHICKEN_LANES[difficulty], \"Invalid Chicken Cross lane\");\n}\n\nfunction stakeOf(value: unknown): bigint {\n  if (typeof value !== \"bigint\" || value < 1n || value > CHICKEN_MAX_STAKE_MINOR)\n    throw invalid(\"Invalid Chicken Cross stake\");\n  return value;\n}\n\nfunction fairValueOf(value: unknown, message = \"Invalid Chicken Cross fair value\"): number {\n  return whole(value, 0, 0xffff_ffff, message);\n}\n\nfunction entropyOf(value: unknown) {\n  if (!value || typeof value !== \"object\") throw invalid(\"Invalid Chicken Cross fairness seed\");\n  const entropy = value as Entropy,\n    { serverSeed, clientSeed, nonce, hmac } = entropy;\n  if (\n    typeof hmac !== \"function\" ||\n    typeof serverSeed !== \"string\" ||\n    !serverSeed.trim() ||\n    typeof clientSeed !== \"string\" ||\n    !clientSeed.trim() ||\n    typeof nonce !== \"number\" ||\n    !Number.isSafeInteger(nonce) ||\n    nonce < 0\n  )\n    throw invalid(\"Invalid Chicken Cross fairness seed\");\n  return {\n    serverSeed,\n    clientSeed,\n    nonce: nonce + 0,\n    hmac: (message: string) => hmac.call(entropy, CHICKEN_HASH, serverSeed, message),\n  };\n}\n\nfunction reduce(numerator: bigint, denominator: bigint): Ratio {\n  let a = numerator,\n    b = denominator;\n  while (b) [a, b] = [b, a % b];\n  return a ? ratio(numerator / a, denominator / a) : ratio(0n, 1n);\n}\n\nexport function validateChickenParameters(input: unknown): ChickenParameters {\n  const value = fields(input, PARAMETER_KEYS);\n  if (!value || !isDifficulty(value.difficulty)) throw invalid();\n  const difficulty = value.difficulty;\n  if (!Object.hasOwn(value, \"laneIndex\")) return { difficulty };\n  return {\n    difficulty,\n    laneIndex: whole(\n      value.laneIndex,\n      0,\n      CHICKEN_LANES[difficulty] - 1,\n      \"Invalid Chicken Cross parameters\",\n    ),\n  };\n}\n\nexport function chickenConfig(difficulty: unknown) {\n  const value = difficultyOf(difficulty);\n  return {\n    difficulty: value,\n    lanes: CHICKEN_LANES[value],\n    hazards: CHICKEN_HAZARDS[value],\n    tiles: CHICKEN_TILES,\n  };\n}\n\nexport function chickenMultipliers(difficulty: unknown): readonly string[] {\n  return tables[difficultyOf(difficulty)];\n}\n\nexport function chickenWinPercentages(difficulty: unknown): readonly string[] {\n  return percentages[difficultyOf(difficulty)];\n}\n\nexport function chickenMultiplierText(difficulty: unknown, lanes: number): string {\n  const value = difficultyOf(difficulty);\n  return tables[value][laneOf(value, lanes) - 1];\n}\n\nexport function chickenMultiplier(difficulty: unknown, lanes: number): Ratio {\n  const value = difficultyOf(difficulty);\n  return reduce(cents[value][laneOf(value, lanes) - 1], 100n);\n}\n\nexport function chickenMessage(\n  clientSeed: string,\n  nonce: number,\n  cursor: number = CHICKEN_CURSOR,\n): string {\n  return `${clientSeed}:${nonce}:${CHICKEN_GAME_NAME}:${cursor}`;\n}\n\nexport function chickenUint32FromDigest(digest: Uint8Array): number {\n  if (!(digest instanceof Uint8Array) || digest.byteLength !== 32)\n    throw invalid(\"Invalid Chicken Cross HMAC digest\");\n  return ((digest[0] << 24) | (digest[1] << 16) | (digest[2] << 8) | digest[3]) >>> 0;\n}\n\nexport async function chickenFairValue(entropy: Entropy): Promise<number> {\n  const source = entropyOf(entropy);\n  return chickenUint32FromDigest(\n    await source.hmac(chickenMessage(source.clientSeed, source.nonce)),\n  );\n}\n\nexport function chickenCrossingValue(fairValue: number): bigint {\n  return halfUp(CROSSING_NUMERATOR, BigInt(fairValueOf(fairValue)) + 1n);\n}\n\nexport function chickenCrossingText(fairValue: number): string {\n  const value = chickenCrossingValue(fairValue);\n  return `${value / BIG_SCALE}.${(value % BIG_SCALE).toString().padStart(CHICKEN_BIG_DECIMALS, \"0\")}`;\n}\n\nfunction survives(fairValue: number, hundredths: bigint): boolean {\n  return chickenCrossingValue(fairValue) >= hundredths * HUNDREDTH_SCALE;\n}\n\nexport function chickenRound(difficulty: unknown, fairValue: number, round: number): ChickenRound {\n  const value = difficultyOf(difficulty),\n    index = laneOf(value, round),\n    didWin = survives(fairValueOf(fairValue), cents[value][index - 1]);\n  return {\n    round: index,\n    didWin,\n    multiplier: didWin ? tables[value][index - 1] : \"0.00\",\n    winPercentage: percentages[value][index - 1],\n  };\n}\n\nexport function chickenMaxLane(difficulty: unknown, fairValue: number): number {\n  const table = cents[difficultyOf(difficulty)],\n    drawn = fairValueOf(fairValue);\n  let lane = 0;\n  while (lane < table.length && survives(drawn, table[lane])) lane++;\n  return lane;\n}\n\nexport function chickenMaxOutcome(difficulty: unknown, fairValue: number): ChickenMaxOutcome {\n  const value = difficultyOf(difficulty),\n    drawn = fairValueOf(fairValue),\n    maxLane = chickenMaxLane(value, drawn);\n  return {\n    fairValue: drawn,\n    maxLane,\n    maxMultiplier: maxLane ? tables[value][maxLane - 1] : \"0.00\",\n    deathPoint: maxLane < CHICKEN_LANES[value] ? maxLane + 1 : null,\n  };\n}\n\nexport async function chickenFairness(input: {\n  serverSeed: string;\n  clientSeed: string;\n  nonce: number;\n  difficulty: unknown;\n}): Promise<ChickenMaxOutcome> {\n  const difficulty = difficultyOf(input.difficulty);\n  return chickenMaxOutcome(\n    difficulty,\n    await chickenFairValue(\n      createEntropy({\n        serverSeed: input.serverSeed,\n        clientSeed: input.clientSeed,\n        nonce: input.nonce,\n      }),\n    ),\n  );\n}\n\nexport function chickenWinningOutcomes(difficulty: unknown, lanes: number): bigint {\n  const value = difficultyOf(difficulty),\n    target = cents[value][laneOf(value, lanes) - 1] * HUNDREDTH_SCALE,\n    limit = (2n * CROSSING_NUMERATOR) / (2n * target - 1n);\n  return limit < CHICKEN_OUTCOMES ? limit : CHICKEN_OUTCOMES;\n}\n\nexport function chickenNominalRtp(difficulty: unknown, lanes: number): Ratio {\n  const value = difficultyOf(difficulty),\n    count = laneOf(value, lanes);\n  return reduce(\n    chickenWinningOutcomes(value, count) * cents[value][count - 1],\n    CHICKEN_OUTCOMES * 100n,\n  );\n}\n\nexport function isChickenRulesVersion(value: unknown): value is ChickenRulesVersion {\n  return (CHICKEN_RULES_VERSIONS as readonly unknown[]).includes(value);\n}\n\nfunction edition(version: unknown): ChickenRulesVersion {\n  if (!isChickenRulesVersion(version)) throw invalid(\"Invalid Chicken Cross rules\");\n  return version;\n}\n\nfunction paysInFull(version: ChickenRulesVersion): boolean {\n  return version !== CHICKEN_RULES_VERSION;\n}\n\nexport function chickenLimits(version: ChickenRulesVersion = CHICKEN_RULES_VERSION): Limits {\n  return paysInFull(edition(version)) ? CHICKEN_2_GAME_LIMITS : CHICKEN_GAME_LIMITS;\n}\n\nexport function chickenPayout(\n  stakeMinor: bigint,\n  multiplierHundredths: bigint,\n  version: ChickenRulesVersion = CHICKEN_RULES_VERSION,\n) {\n  const uncapped = (stakeOf(stakeMinor) * multiplierHundredths) / 100n,\n    capped = !paysInFull(edition(version)) && uncapped > MAX_PAYOUT_MINOR;\n  return {\n    payoutMinor: capped ? MAX_PAYOUT_MINOR : uncapped,\n    uncappedPayoutMinor: uncapped,\n    capped,\n  };\n}\n\nfunction overMaxWin(stakeMinor: bigint, multiplierHundredths: bigint): boolean {\n  return (stakeMinor * multiplierHundredths) / 100n > MAX_WIN_MINOR;\n}\n\nexport function chickenQuote(\n  stakeMinor: bigint,\n  difficulty: unknown,\n  lanes: number,\n  version: ChickenRulesVersion = CHICKEN_RULES_VERSION,\n): ChickenQuote {\n  const value = difficultyOf(difficulty),\n    count = laneOf(value, lanes);\n  return {\n    lanes: count,\n    multiplier: tables[value][count - 1],\n    ...chickenPayout(stakeMinor, cents[value][count - 1], version),\n  };\n}\n\nexport function chickenStopLane(\n  stakeMinor: bigint,\n  difficulty: unknown,\n  lanes: number,\n  version: ChickenRulesVersion = CHICKEN_RULES_VERSION,\n): number {\n  const value = difficultyOf(difficulty),\n    count = laneOf(value, lanes),\n    stake = stakeOf(stakeMinor);\n  if (paysInFull(edition(version))) {\n    for (let lane = 1; lane < count; lane++) if (overMaxWin(stake, cents[value][lane])) return lane;\n    return count;\n  }\n  for (let lane = 1; lane < count; lane++)\n    if (chickenPayout(stake, cents[value][lane - 1]).uncappedPayoutMinor >= MAX_PAYOUT_MINOR)\n      return lane;\n  return count;\n}\n\nexport function chickenPcRtp(\n  stakeMinor: bigint,\n  difficulty: unknown,\n  lanes: number,\n  version: ChickenRulesVersion = CHICKEN_RULES_VERSION,\n): Ratio {\n  const value = difficultyOf(difficulty),\n    stake = stakeOf(stakeMinor),\n    stop = chickenStopLane(stake, value, lanes, version);\n  return reduce(\n    chickenWinningOutcomes(value, stop) *\n      chickenPayout(stake, cents[value][stop - 1], version).payoutMinor,\n    CHICKEN_OUTCOMES * stake,\n  );\n}\n\nexport function chickenMaximumPayoutMinor(\n  stakeMinor: bigint,\n  parameters: unknown,\n  version: ChickenRulesVersion = CHICKEN_RULES_VERSION,\n): bigint {\n  const validated = validateChickenParameters(parameters),\n    table = cents[validated.difficulty],\n    lane =\n      \"laneIndex\" in validated\n        ? validated.laneIndex\n        : paysInFull(edition(version))\n          ? chickenStopLane(stakeMinor, validated.difficulty, table.length, version) - 1\n          : table.length - 1;\n  return chickenPayout(stakeMinor, table[lane], version).payoutMinor;\n}\n\nexport function chickenTopMultiplierHundredths(parameters: unknown): bigint {\n  const validated = validateChickenParameters(parameters);\n  return cents[validated.difficulty][\"laneIndex\" in validated ? validated.laneIndex : 0];\n}\n\nexport function admitChicken2Stake(stakeMinor: bigint, parameters: unknown): void {\n  admitMaxWinStake(\n    CHICKEN_2_GAME_LIMITS,\n    stakeMinor,\n    hundredthsRatio(chickenTopMultiplierHundredths(parameters)),\n  );\n}\n\nfunction start(version: ChickenRulesVersion = CHICKEN_RULES_VERSION): Progress {\n  return { version, lanes: [], crossed: 0, decisions: [], endedBy: null };\n}\n\nfunction lane(difficulty: ChickenDifficulty, round: number, didWin: boolean): ChickenLane {\n  return {\n    round,\n    canCashout: didWin,\n    multiplier: didWin ? tables[difficulty][round - 1] : \"0.00\",\n    winPercentage: percentages[difficulty][round - 1],\n  };\n}\n\nfunction advance(\n  stakeMinor: bigint,\n  difficulty: ChickenDifficulty,\n  fairValue: number,\n  progress: Progress,\n  target: number,\n  closing = false,\n): Progress {\n  const table = cents[difficulty],\n    lanes = [...progress.lanes];\n  let crossed = progress.crossed,\n    endedBy: ChickenEnd | null = null;\n  for (let round = crossed + 1; round <= target; round++) {\n    if (!survives(fairValue, table[round - 1])) {\n      lanes.push(lane(difficulty, round, false));\n      endedBy = \"death\";\n      break;\n    }\n    lanes.push(lane(difficulty, round, true));\n    crossed = round;\n    if (round === table.length) {\n      endedBy = \"complete\";\n      break;\n    }\n    if (paysInFull(progress.version)) {\n      if (closing && round === target) {\n        endedBy = \"cashout\";\n        break;\n      }\n      if (overMaxWin(stakeMinor, table[round])) {\n        endedBy = MAX_WIN_END;\n        break;\n      }\n    } else if (\n      chickenPayout(stakeMinor, table[round - 1]).uncappedPayoutMinor >= MAX_PAYOUT_MINOR\n    ) {\n      endedBy = \"maxPayout\";\n      break;\n    }\n  }\n  return { version: progress.version, lanes, crossed, decisions: progress.decisions, endedBy };\n}\n\nfunction decide(\n  input: unknown,\n  difficulty: ChickenDifficulty,\n  progress: Progress,\n): ChickenDecision {\n  const value = fields(input, DECISION_KEYS),\n    step = progress.lanes.length;\n  if (\n    !value ||\n    (value.action !== \"cross\" && value.action !== \"cashout\") ||\n    value.selections !== undefined ||\n    value.choice !== undefined\n  )\n    throw invalid(\"Invalid Chicken Cross action\");\n  const { action, selection, expectedStep } = value;\n  if (expectedStep !== undefined) {\n    if (typeof expectedStep !== \"number\" || !Number.isSafeInteger(expectedStep))\n      throw invalid(\"Invalid Chicken Cross action\");\n    if (expectedStep !== step) throw invalid(\"Chicken Cross round has changed\");\n  }\n  if (action === \"cashout\") {\n    if (selection !== undefined) throw invalid(\"Invalid Chicken Cross action\");\n    if (progress.crossed === 0) throw invalid(\"You must select at least one tile to cash out\");\n    return { action: \"cashout\", expectedStep: step };\n  }\n  if (selection === undefined) return { action: \"cross\", expectedStep: step };\n  const target = whole(selection, 0, CHICKEN_LANES[difficulty] - 1, \"Invalid Chicken Cross lane\");\n  if (target < step) throw invalid(\"Invalid Chicken Cross lane\");\n  return { action: \"cross\", selection: target, expectedStep: step };\n}\n\nfunction apply(\n  stakeMinor: bigint,\n  difficulty: ChickenDifficulty,\n  fairValue: number,\n  progress: Progress,\n  input: unknown,\n): Progress {\n  if (progress.endedBy !== null) throw invalid(\"The game you were trying to play already ended\");\n  const decision = decide(input, difficulty, progress),\n    decisions = [...progress.decisions, decision];\n  if (decision.action === \"cashout\") return { ...progress, decisions, endedBy: \"cashout\" };\n  return {\n    ...advance(\n      stakeMinor,\n      difficulty,\n      fairValue,\n      progress,\n      \"selection\" in decision ? decision.selection + 1 : progress.crossed + 1,\n    ),\n    decisions,\n  };\n}\n\nfunction autoplay(\n  stakeMinor: bigint,\n  parameters: ChickenAutoParameters,\n  fairValue: number,\n  version: ChickenRulesVersion = CHICKEN_RULES_VERSION,\n): Progress {\n  const progress = advance(\n    stakeMinor,\n    parameters.difficulty,\n    fairValue,\n    start(version),\n    parameters.laneIndex + 1,\n    true,\n  );\n  return progress.endedBy === null ? { ...progress, endedBy: \"cashout\" } : progress;\n}\n\nfunction settle(\n  stakeMinor: bigint,\n  parameters: ChickenParameters,\n  fairValue: number,\n  progress: Progress,\n): Resolution {\n  const version = progress.version,\n    difficulty = parameters.difficulty,\n    auto = \"laneIndex\" in parameters,\n    table = cents[difficulty],\n    active = progress.endedBy === null,\n    dead = progress.endedBy === \"death\",\n    crossed = progress.crossed,\n    current = crossed > 0 && !dead ? table[crossed - 1] : 0n,\n    quote = chickenPayout(stakeMinor, current, version),\n    payout = active || dead ? 0n : quote.payoutMinor,\n    next = active ? chickenPayout(stakeMinor, table[crossed], version) : null,\n    result: ChickenResult = {\n      chickenRules: version,\n      parameters: auto\n        ? { difficulty, laneIndex: parameters.laneIndex }\n        : { difficulty: parameters.difficulty },\n      difficulty,\n      mode: auto ? \"auto\" : \"manual\",\n      lanes: table.length,\n      hazards: CHICKEN_HAZARDS[difficulty],\n      multipliers: [...tables[difficulty]],\n      step: progress.lanes.length,\n      lanesCrossed: crossed,\n      chickenCrossResult: progress.lanes.map((entry) => ({ ...entry })),\n      currentMultiplier: fixed(current),\n      potentialPayoutMinor: quote.payoutMinor.toString(),\n      ...(next\n        ? { nextMultiplier: fixed(table[crossed]), nextPayoutMinor: next.payoutMinor.toString() }\n        : {}),\n      canCashout: active && crossed > 0,\n      gameOver: !active,\n      ...(progress.endedBy ? { endedBy: progress.endedBy } : {}),\n      ...(dead ? { deathLane: progress.lanes.length } : {}),\n      capped: quote.capped,\n      ...(quote.capped ? { uncappedPayoutMinor: quote.uncappedPayoutMinor.toString() } : {}),\n      maxPayoutMinor: chickenLimits(version).maxPayoutMinor.toString(),\n      decisions: progress.decisions.map((decision) => ({ ...decision })),\n      ...(active ? {} : { maxOutcome: chickenMaxOutcome(difficulty, fairValue) }),\n    },\n    secret: ChickenSecret | null = active\n      ? {\n          chickenRules: version,\n          difficulty,\n          fairValue,\n          decisions: progress.decisions.map((decision) => ({ ...decision })),\n        }\n      : null;\n  return {\n    game: CHICKEN_GAME,\n    status: active ? \"ACTIVE\" : payout > 0n ? \"WON\" : \"LOST\",\n    payoutMinor: payout.toString(),\n    multiplier: ratioText(ratio(payout, stakeMinor)),\n    result,\n    ...(secret ? { secret } : {}),\n    rulesVersion: version,\n  };\n}\n\nfunction restore(\n  stakeMinor: bigint,\n  difficulty: ChickenDifficulty,\n  secret: unknown,\n  version: ChickenRulesVersion = CHICKEN_RULES_VERSION,\n): { fairValue: number; progress: Progress } {\n  const message = \"Invalid Chicken Cross state\";\n  if (\n    !record(secret) ||\n    Object.keys(secret).length !== SECRET_KEYS.length ||\n    SECRET_KEYS.some((key) => !Object.hasOwn(secret, key)) ||\n    secret.chickenRules !== version ||\n    secret.difficulty !== difficulty ||\n    !Array.isArray(secret.decisions) ||\n    secret.decisions.length >= CHICKEN_LANES[difficulty]\n  )\n    throw invalid(message);\n  const fairValue = fairValueOf(secret.fairValue, message);\n  let progress = start(version);\n  for (const item of Array.from(secret.decisions as unknown[])) {\n    let next: Progress;\n    try {\n      next = apply(stakeMinor, difficulty, fairValue, progress, item);\n    } catch {\n      throw invalid(message);\n    }\n    const recorded = next.decisions.at(-1);\n    if (next.endedBy !== null || recorded?.action !== \"cross\" || !same(item, recorded))\n      throw invalid(message);\n    progress = next;\n  }\n  return { fairValue, progress };\n}\n\nexport async function resolveChicken(\n  input: ResolveInput<ChickenParameters>,\n  version: ChickenRulesVersion = CHICKEN_RULES_VERSION,\n): Promise<Resolution> {\n  const parameters = validateChickenParameters(input.parameters),\n    stakeMinor = stakeOf(input.stakeMinor),\n    fairValue = await chickenFairValue(input.entropy);\n  return settle(\n    stakeMinor,\n    parameters,\n    fairValue,\n    \"laneIndex\" in parameters\n      ? autoplay(stakeMinor, parameters, fairValue, edition(version))\n      : start(edition(version)),\n  );\n}\n\nexport async function actChicken(\n  input: ActInput<ChickenParameters>,\n  version: ChickenRulesVersion = CHICKEN_RULES_VERSION,\n): Promise<Resolution> {\n  const parameters = validateChickenParameters(input.parameters);\n  if (\"laneIndex\" in parameters) throw invalid(\"The game you were trying to play already ended\");\n  const stakeMinor = stakeOf(input.stakeMinor),\n    state = restore(stakeMinor, parameters.difficulty, input.secret, edition(version));\n  if (\n    !same(input.result, settle(stakeMinor, parameters, state.fairValue, state.progress).result) ||\n    (await chickenFairValue(input.entropy)) !== state.fairValue\n  )\n    throw invalid(\"Invalid Chicken Cross state\");\n  return settle(\n    stakeMinor,\n    parameters,\n    state.fairValue,\n    apply(stakeMinor, parameters.difficulty, state.fairValue, state.progress, input.decision),\n  );\n}\n\nexport function reconstructChicken(\n  input: {\n    stakeMinor: bigint;\n    parameters: unknown;\n    result: unknown;\n  },\n  version: ChickenRulesVersion = CHICKEN_RULES_VERSION,\n): Resolution {\n  const parameters = validateChickenParameters(input.parameters),\n    stakeMinor = stakeOf(input.stakeMinor),\n    result = input.result,\n    message = \"Invalid Chicken Cross result\";\n  if (!record(result) || !Array.isArray(result.decisions) || !record(result.maxOutcome))\n    throw invalid(message);\n  const fairValue = fairValueOf(result.maxOutcome.fairValue, message),\n    decisions = Array.from(result.decisions as unknown[]);\n  if (\"laneIndex\" in parameters) {\n    if (decisions.length) throw invalid(message);\n    return settle(\n      stakeMinor,\n      parameters,\n      fairValue,\n      autoplay(stakeMinor, parameters, fairValue, edition(version)),\n    );\n  }\n  let progress = start(edition(version));\n  for (const decision of decisions) {\n    if (progress.endedBy !== null) throw invalid(message);\n    progress = apply(stakeMinor, parameters.difficulty, fairValue, progress, decision);\n  }\n  if (progress.endedBy === null) throw invalid(message);\n  return settle(stakeMinor, parameters, fairValue, progress);\n}\n\nexport const chickenRulesModule: GameRules<ChickenParameters> = Object.freeze({\n  game: CHICKEN_GAME,\n  version: CHICKEN_RULES_VERSION,\n  stateful: true,\n  actions: CHICKEN_ACTIONS,\n  closingActions: CHICKEN_CLOSING_ACTIONS,\n  rateLimitPerMinute: CHICKEN_RATE_LIMIT,\n  limits: CHICKEN_GAME_LIMITS,\n  validateParameters: validateChickenParameters,\n  maximumPayoutMinor: chickenMaximumPayoutMinor,\n  resolve: resolveChicken,\n  act: actChicken,\n  reconstruct: reconstructChicken,\n});\n\nexport const chicken2RulesModule: GameRules<ChickenParameters> = Object.freeze({\n  game: CHICKEN_GAME,\n  version: CHICKEN_2_RULES_VERSION,\n  stateful: true,\n  actions: CHICKEN_ACTIONS,\n  closingActions: CHICKEN_CLOSING_ACTIONS,\n  rateLimitPerMinute: CHICKEN_RATE_LIMIT,\n  limits: CHICKEN_2_GAME_LIMITS,\n  admitStake: admitChicken2Stake,\n  validateParameters: validateChickenParameters,\n  maximumPayoutMinor: (stakeMinor: bigint, parameters: ChickenParameters) =>\n    chickenMaximumPayoutMinor(stakeMinor, parameters, CHICKEN_2_RULES_VERSION),\n  resolve: (input: ResolveInput<ChickenParameters>) =>\n    resolveChicken(input, CHICKEN_2_RULES_VERSION),\n  act: (input: ActInput<ChickenParameters>) => actChicken(input, CHICKEN_2_RULES_VERSION),\n  reconstruct: (\n    input: Readonly<{\n      stakeMinor: bigint;\n      parameters: ChickenParameters;\n      result: Record<string, unknown>;\n    }>,\n  ) => reconstructChicken(input, CHICKEN_2_RULES_VERSION),\n});\n","sha256":"932941283323581e24ff1974e070da3d21a5525dc7eacb0bfb19aaaf32bf697d"},"src/games/verify.ts":{"source":"import { createEntropy } from \"@/lib/games/entropy\";\nimport { presentRulesVersions, rulesForVersion, sameRulesVersion } from \"@/lib/games/registry\";\nimport type { GameRules } from \"@/lib/games/rules\";\nimport { normalizeDecision } from \"@/lib/receipt\";\nimport {\n  evaluateBet,\n  actOnGame,\n  RULES_VERSION,\n  type GameSlug,\n  type RandomSource,\n  type GameDecision,\n  type Resolution,\n} from \"./math\";\nexport type GameProof = {\n  game: GameSlug;\n  rulesVersion?: string;\n  amountMinor: string;\n  parameters: Record<string, unknown>;\n  serverSeed: string;\n  serverSeedHash: string;\n  clientSeed: string;\n  nonce: number;\n  initialStakeMinor?: string;\n  decisions?: (\n    | GameDecision\n    | {\n        action: string;\n        selection?: number;\n        selections?: number[];\n        choice?: string;\n        expectedStep?: number;\n      }\n  )[];\n  expected?: Record<string, unknown>;\n};\nexport const PROOF_DECISIONS = 4096;\nfunction matches(expected: unknown, actual: unknown): boolean {\n  if (expected === null || typeof expected !== \"object\") return expected === actual;\n  if (!actual || typeof actual !== \"object\" || Array.isArray(expected) !== Array.isArray(actual))\n    return false;\n  if (Array.isArray(expected) && expected.length !== (actual as unknown[]).length) return false;\n  return Object.entries(expected).every(([key, value]) =>\n    matches(value, (actual as Record<string, unknown>)[key]),\n  );\n}\nfunction stakeOf(value: unknown): bigint {\n  if (typeof value !== \"string\" || !/^[1-9][0-9]{0,15}$/.test(value))\n    throw new Error(\"Invalid proof stake\");\n  return BigInt(value);\n}\nasync function verifyRulesModuleProof(rules: GameRules, proof: GameProof): Promise<Resolution> {\n  const decisions = proof.decisions ?? [];\n  if (!Array.isArray(decisions) || decisions.length > PROOF_DECISIONS)\n    throw new Error(\"Invalid proof decisions\");\n  const amount = stakeOf(proof.amountMinor),\n    parameters = rules.validateParameters(proof.parameters),\n    entropy = createEntropy({\n      serverSeed: proof.serverSeed,\n      clientSeed: proof.clientSeed,\n      nonce: proof.nonce,\n    });\n  let stakeMinor = stakeOf(proof.initialStakeMinor ?? proof.amountMinor),\n    outcome = await rules.resolve({ stakeMinor, parameters, entropy });\n  for (const entry of decisions) {\n    if (!outcome.secret || outcome.status !== \"ACTIVE\" || !rules.act)\n      throw new Error(\"Proof contains actions after settlement\");\n    const decision = normalizeDecision(entry),\n      secret = JSON.parse(JSON.stringify(outcome.secret)) as Record<string, unknown>,\n      extra = rules.additionalStakeMinor\n        ? rules.additionalStakeMinor({ parameters, secret, decision })\n        : 0n;\n    if (typeof extra !== \"bigint\" || extra < 0n) throw new Error(\"Invalid proof additional stake\");\n    outcome = await rules.act({\n      stakeMinor,\n      parameters,\n      secret,\n      result: JSON.parse(JSON.stringify(outcome.result)) as Record<string, unknown>,\n      decision,\n      entropy,\n    });\n    if (\n      outcome.additionalStakeMinor !== undefined &&\n      BigInt(outcome.additionalStakeMinor) !== extra\n    )\n      throw new Error(\"Proof additional stake does not match the recorded decision\");\n    stakeMinor += extra;\n  }\n  if (stakeMinor !== amount) throw new Error(\"Proof stake does not match the recorded amount\");\n  return outcome;\n}\nexport async function verifyProof(proof: GameProof) {\n  const version = proof.rulesVersion === undefined ? RULES_VERSION : proof.rulesVersion;\n  const rules = version === RULES_VERSION ? undefined : rulesForVersion(version);\n  if (version !== RULES_VERSION && (!rules || rules.game !== proof.game))\n    throw new Error(\"Unsupported game rules version\");\n  if (\n    proof.expected &&\n    Object.hasOwn(proof.expected, \"rulesVersion\") &&\n    !sameRulesVersion(proof.expected.rulesVersion, version)\n  )\n    throw new Error(\"Proof rules version does not match the recorded result\");\n  if (\n    rules &&\n    !rules.stateful &&\n    proof.initialStakeMinor !== undefined &&\n    proof.initialStakeMinor !== proof.amountMinor\n  )\n    throw new Error(\"Proof stake does not match the recorded amount\");\n  const encoder = new TextEncoder();\n  const digest = await crypto.subtle.digest(\"SHA-256\", encoder.encode(proof.serverSeed));\n  const hash = Array.from(new Uint8Array(digest), (byte) =>\n    byte.toString(16).padStart(2, \"0\"),\n  ).join(\"\");\n  if (hash !== proof.serverSeedHash)\n    throw new Error(\"The revealed seed does not match its commitment\");\n  let outcome: Resolution;\n  if (rules) outcome = await verifyRulesModuleProof(rules, proof);\n  else {\n    let stakeMinor = BigInt(proof.initialStakeMinor ?? proof.amountMinor);\n    const key = await crypto.subtle.importKey(\n      \"raw\",\n      encoder.encode(proof.serverSeed),\n      { name: \"HMAC\", hash: \"SHA-256\" },\n      false,\n      [\"sign\"],\n    );\n    const words = await Promise.all(\n      Array.from({ length: 512 }, async (_, cursor) =>\n        new DataView(\n          await crypto.subtle.sign(\n            \"HMAC\",\n            key,\n            encoder.encode(\n              `${RULES_VERSION}:${proof.game}:${proof.clientSeed}:${proof.nonce}:${cursor}`,\n            ),\n          ),\n        ).getUint32(0),\n      ),\n    );\n    let cursor = 0;\n    const random: RandomSource = {\n      integer(max) {\n        const ceiling = Math.floor(0x1_0000_0000 / max) * max;\n        let value: number;\n        do {\n          if (cursor >= words.length) throw new Error(\"Verification draw budget exceeded\");\n          value = words[cursor++];\n        } while (value >= ceiling);\n        return value % max;\n      },\n    };\n    outcome = evaluateBet(\n      {\n        game: proof.game,\n        stakeMinor,\n        parameters: proof.parameters,\n      },\n      random,\n    );\n    if (\n      proof.decisions !== undefined &&\n      (!Array.isArray(proof.decisions) || proof.decisions.length > 312)\n    )\n      throw new Error(\"Invalid proof decisions\");\n    for (const decision of (proof.decisions ?? []) as GameDecision[]) {\n      if (!outcome.secret || outcome.status !== \"ACTIVE\")\n        throw new Error(\"Proof contains actions after settlement\");\n      outcome = actOnGame({\n        game: proof.game,\n        stakeMinor,\n        secret: outcome.secret,\n        action: decision.action,\n        selection: decision.selection,\n        expectedStep: decision.expectedStep,\n      });\n      stakeMinor += BigInt(outcome.additionalStakeMinor ?? \"0\");\n    }\n  }\n  if (\n    proof.expected &&\n    !matches(presentRulesVersions(proof.expected), presentRulesVersions(outcome))\n  )\n    throw new Error(\"The reconstructed outcome does not match the recorded result\");\n  return outcome;\n}\n","sha256":"a3bde88bd29e5f6a30b9649819dd4f0f0b4b5dbccb2bdd9ca05039c78a99542e"},"src/lib/games/registry.ts":{"source":"import { GAME_SLUGS, MAX_PAYOUT_MINOR, RULES_VERSION, type GameSlug } from \"@/games/math\";\nimport { ACTION_PATTERN } from \"@/lib/receipt\";\nimport type { ActInput, GameRules, GameParameters } from \"./rules\";\nimport { PLATFORM_LIMITS, admitStakeRange } from \"./limits\";\nimport {\n  isPeeksRulesId,\n  isRulesName,\n  outsideRulesName,\n  peeksRulesName,\n  relabelRules,\n} from \"./rules-versions\";\nimport { PLINKO_RATE_LIMIT, plinkoNormalRulesModule } from \"./plinko-normal\";\nimport { plinko2RulesModule, plinko3RulesModule } from \"./plinko-2\";\nimport { PLINKO_RAIN_RULES_VERSION } from \"./plinko-rain\";\nimport { dice2RulesModule, diceRulesModule } from \"./dice-rules\";\nimport { limbo2RulesModule, limboRulesModule } from \"./limbo-rules\";\nimport { keno2RulesModule, kenoRulesModule } from \"./keno-rules\";\nimport { wheel2RulesModule, wheelRulesModule } from \"./wheel-rules\";\nimport { roulette2RulesModule, rouletteRulesModule } from \"./roulette-rules\";\nimport { mines2RulesModule, minesRulesModule } from \"./mines-rules\";\nimport { tower2RulesModule, towerRulesModule } from \"./tower-rules\";\nimport { blackjack2RulesModule, blackjackRulesModule } from \"./blackjack-rules\";\nimport { baccarat2RulesModule, baccaratRulesModule } from \"./baccarat-rules\";\nimport { hilo2RulesModule, hiloRulesModule } from \"./hilo-rules\";\nimport { war2RulesModule, warRulesModule } from \"./war-rules\";\nimport { rps2RulesModule, rpsRulesModule } from \"./rps-rules\";\nimport { chicken2RulesModule, chickenRulesModule } from \"./chicken-rules\";\nimport { coinflip3RulesModule, coinflipRulesModule } from \"./coinflip-rules\";\nimport { moles3RulesModule, molesRulesModule } from \"./moles-rules\";\n\nexport const LEGACY_STATEFUL_GAMES: readonly GameSlug[] = Object.freeze([\n  \"mines\",\n  \"tower\",\n  \"blackjack\",\n  \"hilo\",\n  \"chicken\",\n  \"moles\",\n]);\nexport const DEFAULT_RATE_LIMIT = 180;\nexport const LEGACY_ACTION_RATE_LIMIT = 180;\nexport const DEFAULT_ACTION_RATE_LIMIT = 400;\nexport const DEFAULT_CLOSING_ACTIONS: readonly string[] = Object.freeze([\n  \"cashout\",\n  \"stand\",\n  \"surrender\",\n]);\nexport const MAX_STAKE_CEILING_MINOR = 8_000_000n;\n\nexport const NESTED_RULES_VERSIONS: readonly string[] = Object.freeze([PLINKO_RAIN_RULES_VERSION]);\nconst LABEL_CACHE_LIMIT = 256;\n\nconst versions = new Map<string, GameRules>();\nconst games = new Map<GameSlug, GameRules[]>();\nconst canonicalOf = new WeakMap<GameRules, GameRules>();\nconst successors = new Map<string, string>();\nconst formerNames = new Map<string, string>();\nconst labels = new Map<string, GameRules>();\nconst peeksOnly = new Set<string>();\n\nfunction assertRules(rules: GameRules) {\n  const fail = (reason: string): never => {\n    throw new Error(`Invalid rules module: ${reason}`);\n  };\n  if (!rules || typeof rules !== \"object\") fail(\"not an object\");\n  if (!(GAME_SLUGS as readonly string[]).includes(rules.game)) fail(\"unknown game\");\n  if (!isPeeksRulesId(rules.version) || rules.version === RULES_VERSION) fail(\"invalid version\");\n  if (\n    rules.formerly !== undefined &&\n    (!isRulesName(rules.formerly) || rules.formerly === peeksRulesName(rules.version))\n  )\n    fail(\"invalid former name\");\n  if (typeof rules.stateful !== \"boolean\") fail(\"invalid stateful flag\");\n  if (\n    !Array.isArray(rules.actions) ||\n    rules.actions.some((action) => typeof action !== \"string\" || !ACTION_PATTERN.test(action)) ||\n    new Set(rules.actions).size !== rules.actions.length\n  )\n    fail(\"invalid actions\");\n  if (rules.stateful && (!rules.actions.length || typeof rules.act !== \"function\"))\n    fail(\"stateful modules require actions and act\");\n  if (!rules.stateful && (rules.actions.length || rules.act !== undefined))\n    fail(\"stateless modules cannot declare actions\");\n  if (\n    rules.closingActions !== undefined &&\n    (!Array.isArray(rules.closingActions) ||\n      rules.closingActions.some((action) => !rules.actions.includes(action)))\n  )\n    fail(\"invalid closing actions\");\n  for (const limit of [rules.rateLimitPerMinute, rules.actionRateLimitPerMinute])\n    if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1 || limit > 10000))\n      fail(\"invalid rate limit\");\n  if (rules.actionRateLimitPerMinute !== undefined && !rules.stateful)\n    fail(\"stateless modules cannot declare action rate limits\");\n  if (rules.limits !== undefined) {\n    const { minStakeMinor, maxStakeMinor, maxPayoutMinor } = rules.limits ?? {};\n    if (\n      typeof minStakeMinor !== \"bigint\" ||\n      typeof maxStakeMinor !== \"bigint\" ||\n      typeof maxPayoutMinor !== \"bigint\" ||\n      minStakeMinor < 1n ||\n      maxStakeMinor < minStakeMinor ||\n      maxStakeMinor > PLATFORM_LIMITS.maxStakeMinor ||\n      maxPayoutMinor < 1n ||\n      maxPayoutMinor > MAX_PAYOUT_MINOR\n    )\n      fail(\"invalid limits\");\n  }\n  for (const name of [\"validateParameters\", \"maximumPayoutMinor\", \"resolve\"] as const)\n    if (typeof rules[name] !== \"function\") fail(`missing ${name}`);\n  for (const name of [\n    \"maximumStakeMinor\",\n    \"additionalStakeMinor\",\n    \"reconstruct\",\n    \"admitStake\",\n  ] as const)\n    if (rules[name] !== undefined && typeof rules[name] !== \"function\") fail(`invalid ${name}`);\n}\n\nfunction reindex() {\n  successors.clear();\n  formerNames.clear();\n  labels.clear();\n  for (const rules of versions.values())\n    if (rules.formerly !== undefined) successors.set(rules.formerly, rules.version);\n  for (const id of [...versions.keys(), ...NESTED_RULES_VERSIONS]) {\n    const name = peeksRulesName(id);\n    if (versions.get(id)?.formerly === undefined && !peeksOnly.has(id) && !successors.has(name))\n      successors.set(name, id);\n  }\n  for (const [name, id] of successors) formerNames.set(id, name);\n}\n\nfunction successorOf(id: string): string | undefined {\n  const name = outsideRulesName(id);\n  return name === undefined ? undefined : successors.get(name);\n}\n\nexport function canonicalRulesVersion<T>(id: T): T {\n  return typeof id === \"string\" ? ((successorOf(id) ?? id) as T) : id;\n}\n\nexport function sameRulesVersion(left: unknown, right: unknown): boolean {\n  return (\n    left === right ||\n    (typeof left === \"string\" &&\n      typeof right === \"string\" &&\n      canonicalRulesVersion(left) === canonicalRulesVersion(right))\n  );\n}\n\nexport function presentRulesVersions<T>(value: T): T {\n  return relabelRules(value, canonicalRulesVersion);\n}\n\nfunction labelled<P extends GameParameters>(rules: GameRules<P>, label: string): GameRules<P> {\n  const prefix = label.slice(0, label.indexOf(\"-\") + 1),\n    inbound = (id: string) => canonicalRulesVersion(id),\n    outbound = (id: string) => {\n      const name = formerNames.get(id);\n      return name === undefined ? id : `${prefix}${name}`;\n    };\n  const wrapper: GameRules<P> = Object.freeze({\n    ...rules,\n    version: label,\n    async resolve(input) {\n      return relabelRules(await rules.resolve(input), outbound);\n    },\n    ...(rules.act\n      ? {\n          async act(input: ActInput<P>) {\n            return relabelRules(\n              await rules.act!({\n                ...input,\n                secret: relabelRules(input.secret, inbound),\n                result: relabelRules(input.result, inbound),\n              }),\n              outbound,\n            );\n          },\n        }\n      : {}),\n    ...(rules.additionalStakeMinor\n      ? {\n          additionalStakeMinor(\n            input: Readonly<{\n              parameters: P;\n              secret: Record<string, unknown>;\n              decision: ActInput<P>[\"decision\"];\n            }>,\n          ) {\n            return rules.additionalStakeMinor!({\n              ...input,\n              secret: relabelRules(input.secret, inbound),\n            });\n          },\n        }\n      : {}),\n    ...(rules.reconstruct\n      ? {\n          reconstruct(\n            input: Readonly<{ stakeMinor: bigint; parameters: P; result: Record<string, unknown> }>,\n          ) {\n            return relabelRules(\n              rules.reconstruct!({ ...input, result: relabelRules(input.result, inbound) }),\n              outbound,\n            );\n          },\n        }\n      : {}),\n  });\n  canonicalOf.set(wrapper, rules);\n  return wrapper;\n}\n\nfunction install(rules: GameRules, outsideName = true) {\n  assertRules(rules);\n  if (\n    versions.has(rules.version) ||\n    NESTED_RULES_VERSIONS.includes(rules.version) ||\n    (rules.formerly !== undefined &&\n      [...versions.values()].some((entry) => entry.formerly === rules.formerly)) ||\n    (!outsideName && rules.formerly !== undefined)\n  )\n    throw new Error(\"Duplicate rules version\");\n  versions.set(rules.version, rules);\n  if (!outsideName) peeksOnly.add(rules.version);\n  games.set(rules.game, [...(games.get(rules.game) ?? []), rules]);\n  reindex();\n}\n\ninstall(plinkoNormalRulesModule);\ninstall(plinko2RulesModule);\ninstall(diceRulesModule);\ninstall(limboRulesModule);\ninstall(kenoRulesModule);\ninstall(wheelRulesModule);\ninstall(rouletteRulesModule);\ninstall(minesRulesModule);\ninstall(towerRulesModule);\ninstall(blackjackRulesModule);\ninstall(baccaratRulesModule);\ninstall(hiloRulesModule);\ninstall(warRulesModule);\ninstall(rpsRulesModule);\ninstall(chickenRulesModule);\ninstall(coinflipRulesModule);\ninstall(molesRulesModule);\nfor (const rules of [\n  plinko3RulesModule,\n  dice2RulesModule,\n  limbo2RulesModule,\n  keno2RulesModule,\n  wheel2RulesModule,\n  roulette2RulesModule,\n  mines2RulesModule,\n  tower2RulesModule,\n  blackjack2RulesModule,\n  baccarat2RulesModule,\n  hilo2RulesModule,\n  war2RulesModule,\n  rps2RulesModule,\n  chicken2RulesModule,\n  coinflip3RulesModule,\n  moles3RulesModule,\n])\n  install(rules, false);\n\nexport function rulesForVersion(version: string): GameRules | undefined {\n  if (typeof version !== \"string\") return undefined;\n  const found = versions.get(version);\n  if (found) return found;\n  const successor = successorOf(version),\n    rules = successor === undefined ? undefined : versions.get(successor);\n  if (!rules) return undefined;\n  let wrapper = labels.get(version);\n  if (!wrapper) {\n    if (labels.size >= LABEL_CACHE_LIMIT) labels.clear();\n    wrapper = labelled(rules, version);\n    labels.set(version, wrapper);\n  }\n  return wrapper;\n}\n\nexport function currentRules(game: GameSlug): GameRules | undefined {\n  return games.get(game)?.at(-1);\n}\n\nexport function rulesForGame(game: GameSlug): readonly GameRules[] {\n  return [...(games.get(game) ?? [])].reverse();\n}\n\nexport function isStatefulRound(game: GameSlug, version: string): boolean {\n  if (version === RULES_VERSION) return LEGACY_STATEFUL_GAMES.includes(game);\n  const rules = rulesForVersion(version);\n  return !!rules && rules.game === game && rules.stateful;\n}\n\nexport function guardsActiveRounds(game: GameSlug): boolean {\n  return !!currentRules(game)?.stateful || LEGACY_STATEFUL_GAMES.includes(game);\n}\n\nexport function rateLimitFor(game: GameSlug): number {\n  return (\n    currentRules(game)?.rateLimitPerMinute ??\n    (game === \"plinko\" ? PLINKO_RATE_LIMIT : DEFAULT_RATE_LIMIT)\n  );\n}\n\nexport function actionRateLimitFor(rules: GameRules | undefined): number {\n  return rules\n    ? (rules.actionRateLimitPerMinute ?? DEFAULT_ACTION_RATE_LIMIT)\n    : LEGACY_ACTION_RATE_LIMIT;\n}\n\nexport function admitStake<P extends GameParameters>(\n  rules: GameRules<P>,\n  stakeMinor: bigint,\n  parameters: P,\n): void {\n  if (rules.admitStake) rules.admitStake(stakeMinor, parameters);\n  else if (rules.limits) admitStakeRange(rules.limits, stakeMinor);\n}\n\nexport function closingActionsFor(rules: GameRules): readonly string[] {\n  return (\n    rules.closingActions ??\n    rules.actions.filter((action) => DEFAULT_CLOSING_ACTIONS.includes(action))\n  );\n}\n\nexport function canonicalRules(rules: GameRules): GameRules {\n  return canonicalOf.get(rules) ?? rules;\n}\n\nexport function registerRulesForTesting(rules: GameRules): () => void {\n  if (process.env.NODE_ENV !== \"test\")\n    throw new Error(\"Rules can only be registered while testing\");\n  const savedVersions = new Map(versions),\n    savedGames = new Map<GameSlug, GameRules[]>(\n      [...games].map(([game, list]) => [game, [...list]]),\n    ),\n    replaced = typeof rules?.version === \"string\" ? versions.get(rules.version) : undefined;\n  const restore = () => {\n    versions.clear();\n    for (const [version, entry] of savedVersions) versions.set(version, entry);\n    games.clear();\n    for (const [game, list] of savedGames) games.set(game, list);\n    reindex();\n  };\n  if (replaced) {\n    versions.delete(replaced.version);\n    games.set(\n      replaced.game,\n      (games.get(replaced.game) ?? []).filter((entry) => entry !== replaced),\n    );\n  }\n  try {\n    install(rules);\n  } catch (error) {\n    restore();\n    throw error;\n  }\n  let active = true;\n  return () => {\n    if (active) {\n      active = false;\n      restore();\n    }\n  };\n}\n","sha256":"2c9c603720d1fac4ea64ebc856ee20c1118b84e734d59d06a73660f9a1e32156"},"src/lib/receipt.ts":{"source":"import { MAX_PAYOUT_MINOR, type GameSlug } from \"@/games/math\";\nimport type { Decision, GameRules, GameParameters } from \"./games/rules\";\n\nexport const ACTION_PATTERN = /^[a-z][a-z_]{0,23}$/;\nexport const CHOICE_PATTERN = /^[a-z0-9_-]{1,32}$/;\nexport const SELECTION_MAX = 4095;\nexport const SELECTIONS_MAX = 64;\nexport const STEP_MAX = 10000;\n\nexport type Funding = \"PLAYER\" | \"BONUS\";\nexport type BetCommand<P extends GameParameters = GameParameters> = {\n  path: \"/api/games/bet\";\n  body: {\n    game: GameSlug;\n    amountMinor: string;\n    parameters: P;\n    idempotencyKey: string;\n    funding?: Funding;\n    fundingAwardId?: string;\n  };\n};\nexport type ActionCommand = {\n  path: \"/api/games/action\";\n  body: {\n    gameId: string;\n    idempotencyKey: string;\n    action: string;\n    selection?: number;\n    selections?: number[];\n    choice?: string;\n    expectedStep?: number;\n  };\n};\nexport type Command<P extends GameParameters = GameParameters> = BetCommand<P> | ActionCommand;\nexport type GameRound<P extends GameParameters = GameParameters> = {\n  id: string;\n  game: GameSlug;\n  status: \"WON\" | \"LOST\" | \"ACTIVE\";\n  stakeMinor: string;\n  amountMinor: string;\n  payoutMinor: string;\n  multiplier: string;\n  parameters: P;\n  rulesVersion: string;\n  serverSeedHash: string;\n  clientSeed: string;\n  nonce: number;\n  createdAt: string;\n  funding: Funding;\n  fundingAwardId: string | null;\n  result: Record<string, unknown>;\n};\n\nconst uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;\nconst decisionKeys = Object.freeze([\"action\", \"selection\", \"selections\", \"choice\", \"expectedStep\"]);\nconst betKeys = Object.freeze([\n  \"game\",\n  \"amountMinor\",\n  \"parameters\",\n  \"idempotencyKey\",\n  \"funding\",\n  \"fundingAwardId\",\n]);\nconst actionKeys = Object.freeze([\"gameId\", \"idempotencyKey\", ...decisionKeys]);\nconst roundKeys = Object.freeze([\n  \"id\",\n  \"game\",\n  \"status\",\n  \"stakeMinor\",\n  \"amountMinor\",\n  \"payoutMinor\",\n  \"multiplier\",\n  \"parameters\",\n  \"rulesVersion\",\n  \"serverSeedHash\",\n  \"clientSeed\",\n  \"nonce\",\n  \"createdAt\",\n  \"funding\",\n  \"fundingAwardId\",\n  \"result\",\n]);\nconst record = (value: unknown): value is Record<string, unknown> =>\n  !!value && typeof value === \"object\" && !Array.isArray(value);\nconst exactKeys = (value: Record<string, unknown>, keys: readonly string[]) =>\n  Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key));\nconst validId = (value: unknown): value is string => typeof value === \"string\" && uuid.test(value);\nconst integer = (value: unknown, min: number, max: number): value is number =>\n  typeof value === \"number\" && Number.isSafeInteger(value) && value >= min && value <= max;\nconst minor = (value: unknown): value is string =>\n  typeof value === \"string\" && /^(?:0|[1-9][0-9]{0,15})$/.test(value);\nconst invalidCommand = () => new Error(\"Invalid saved wager. Retry the original request.\");\nconst invalidReceipt = () =>\n  new Error(\"The result could not be confirmed. Retry the same request.\");\n\nfunction sameValue(value: unknown, expected: unknown): boolean {\n  if (Array.isArray(expected))\n    return (\n      Array.isArray(value) &&\n      value.length === expected.length &&\n      expected.every((entry, index) => sameValue(value[index], entry))\n    );\n  if (record(expected))\n    return (\n      record(value) &&\n      exactKeys(value, Object.keys(expected)) &&\n      Object.entries(expected).every(([key, entry]) => sameValue(value[key], entry))\n    );\n  return value === expected;\n}\n\nfunction attempt<T>(callback: () => T, failure: () => Error): T {\n  try {\n    return callback();\n  } catch {\n    throw failure();\n  }\n}\n\nfunction json<T>(value: T): T {\n  return JSON.parse(JSON.stringify(value) ?? \"null\") as T;\n}\n\nexport function normalizeDecision(value: unknown): Decision {\n  if (!record(value)) throw new RangeError(\"Invalid game action\");\n  const present = Object.keys(value).filter((key) => value[key] !== undefined);\n  if (\n    present.some((key) => !decisionKeys.includes(key)) ||\n    typeof value.action !== \"string\" ||\n    !ACTION_PATTERN.test(value.action) ||\n    (value.selection !== undefined && !integer(value.selection, 0, SELECTION_MAX)) ||\n    (value.selections !== undefined &&\n      (!Array.isArray(value.selections) ||\n        value.selections.length > SELECTIONS_MAX ||\n        Array.from(value.selections as unknown[]).some(\n          (entry) => !integer(entry, 0, SELECTION_MAX),\n        ))) ||\n    (value.choice !== undefined &&\n      (typeof value.choice !== \"string\" || !CHOICE_PATTERN.test(value.choice))) ||\n    (value.expectedStep !== undefined && !integer(value.expectedStep, 0, STEP_MAX))\n  )\n    throw new RangeError(\"Invalid game action\");\n  return Object.freeze({\n    action: value.action,\n    ...(value.selection === undefined ? {} : { selection: (value.selection as number) + 0 }),\n    ...(value.selections === undefined\n      ? {}\n      : {\n          selections: Object.freeze(Array.from(value.selections as number[], (entry) => entry + 0)),\n        }),\n    ...(value.choice === undefined ? {} : { choice: value.choice as string }),\n    ...(value.expectedStep === undefined\n      ? {}\n      : { expectedStep: (value.expectedStep as number) + 0 }),\n  });\n}\n\nfunction fundingOf(body: Record<string, unknown>) {\n  if (\n    (body.funding !== undefined && body.funding !== \"PLAYER\" && body.funding !== \"BONUS\") ||\n    (body.funding === \"BONUS\" ? !validId(body.fundingAwardId) : body.fundingAwardId !== undefined)\n  )\n    throw invalidCommand();\n  return {\n    ...(body.funding === undefined ? {} : { funding: body.funding as Funding }),\n    ...(body.fundingAwardId === undefined ? {} : { fundingAwardId: body.fundingAwardId as string }),\n  };\n}\n\nexport function validateBetCommand<P extends GameParameters>(\n  rules: GameRules<P>,\n  value: unknown,\n): BetCommand<P> {\n  if (!record(value) || !exactKeys(value, [\"path\", \"body\"]) || !record(value.body))\n    throw invalidCommand();\n  const body = value.body;\n  if (\n    value.path !== \"/api/games/bet\" ||\n    body.game !== rules.game ||\n    typeof body.amountMinor !== \"string\" ||\n    !/^[1-9][0-9]{0,6}$/.test(body.amountMinor) ||\n    BigInt(body.amountMinor) > 1_000_000n ||\n    !validId(body.idempotencyKey) ||\n    Object.keys(body).some((key) => !betKeys.includes(key))\n  )\n    throw invalidCommand();\n  const funding = fundingOf(body),\n    parameters = attempt(() => json(rules.validateParameters(body.parameters)), invalidCommand);\n  if (!sameValue(body.parameters, parameters)) throw invalidCommand();\n  return {\n    path: \"/api/games/bet\",\n    body: {\n      game: rules.game,\n      amountMinor: body.amountMinor,\n      parameters,\n      idempotencyKey: body.idempotencyKey,\n      ...funding,\n    },\n  };\n}\n\nexport function validateActionCommand(\n  rules: Pick<GameRules, \"stateful\" | \"actions\">,\n  value: unknown,\n): ActionCommand {\n  if (!record(value) || !exactKeys(value, [\"path\", \"body\"]) || !record(value.body))\n    throw invalidCommand();\n  const body = value.body;\n  if (\n    value.path !== \"/api/games/action\" ||\n    !validId(body.gameId) ||\n    !validId(body.idempotencyKey) ||\n    Object.keys(body).some((key) => !actionKeys.includes(key))\n  )\n    throw invalidCommand();\n  const decision = attempt(\n    () =>\n      normalizeDecision(Object.fromEntries(decisionKeys.map((key) => [key, body[key]] as const))),\n    invalidCommand,\n  );\n  if (!rules.stateful || !rules.actions.includes(decision.action)) throw invalidCommand();\n  return {\n    path: \"/api/games/action\",\n    body: {\n      gameId: body.gameId,\n      idempotencyKey: body.idempotencyKey,\n      action: decision.action,\n      ...(decision.selection === undefined ? {} : { selection: decision.selection }),\n      ...(decision.selections === undefined ? {} : { selections: [...decision.selections] }),\n      ...(decision.choice === undefined ? {} : { choice: decision.choice }),\n      ...(decision.expectedStep === undefined ? {} : { expectedStep: decision.expectedStep }),\n    },\n  };\n}\n\nexport function validateRound<P extends GameParameters>(\n  rules: GameRules<P>,\n  value: unknown,\n): GameRound<P> {\n  if (\n    !record(value) ||\n    !exactKeys(value, roundKeys) ||\n    !validId(value.id) ||\n    value.game !== rules.game ||\n    value.rulesVersion !== rules.version ||\n    (value.status !== \"WON\" &&\n      value.status !== \"LOST\" &&\n      !(value.status === \"ACTIVE\" && rules.stateful)) ||\n    !minor(value.stakeMinor) ||\n    value.stakeMinor === \"0\" ||\n    value.amountMinor !== value.stakeMinor ||\n    !minor(value.payoutMinor) ||\n    BigInt(value.payoutMinor) > MAX_PAYOUT_MINOR ||\n    (value.status === \"ACTIVE\" && value.payoutMinor !== \"0\") ||\n    typeof value.multiplier !== \"string\" ||\n    !/^[0-9]{1,16}(?:\\.[0-9]{1,8})?$/.test(value.multiplier) ||\n    typeof value.serverSeedHash !== \"string\" ||\n    !/^[0-9a-f]{64}$/.test(value.serverSeedHash) ||\n    typeof value.clientSeed !== \"string\" ||\n    !/^[A-Za-z0-9_-]{1,64}$/.test(value.clientSeed) ||\n    !integer(value.nonce, 0, 2_147_483_645) ||\n    typeof value.createdAt !== \"string\" ||\n    !/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$/.test(value.createdAt) ||\n    !Number.isFinite(Date.parse(value.createdAt)) ||\n    new Date(value.createdAt).toISOString() !== value.createdAt ||\n    (value.funding !== \"PLAYER\" && value.funding !== \"BONUS\") ||\n    (value.funding === \"BONUS\" ? !validId(value.fundingAwardId) : value.fundingAwardId !== null) ||\n    !record(value.result) ||\n    (value.result.refunded !== undefined && typeof value.result.refunded !== \"boolean\") ||\n    (value.result.refunded === true && value.payoutMinor !== value.stakeMinor) ||\n    (value.result.initialStakeMinor !== undefined &&\n      (!minor(value.result.initialStakeMinor) ||\n        value.result.initialStakeMinor === \"0\" ||\n        BigInt(value.result.initialStakeMinor) > BigInt(value.stakeMinor))) ||\n    (value.result.decisions !== undefined && !Array.isArray(value.result.decisions))\n  )\n    throw invalidReceipt();\n  const result = value.result,\n    parameters = attempt(() => json(rules.validateParameters(value.parameters)), invalidReceipt);\n  if (!sameValue(value.parameters, parameters)) throw invalidReceipt();\n  if (rules.reconstruct && value.status !== \"ACTIVE\") {\n    const expected = attempt(\n      () =>\n        json(\n          rules.reconstruct!({\n            stakeMinor: BigInt(value.stakeMinor as string),\n            parameters,\n            result: structuredClone(result),\n          }),\n        ),\n      invalidReceipt,\n    );\n    if (\n      expected.game !== rules.game ||\n      expected.rulesVersion !== rules.version ||\n      expected.status !== value.status ||\n      expected.payoutMinor !== value.payoutMinor ||\n      expected.multiplier !== value.multiplier ||\n      !sameValue(result, expected.result)\n    )\n      throw invalidReceipt();\n  }\n  return {\n    id: value.id,\n    game: rules.game,\n    status: value.status as GameRound[\"status\"],\n    stakeMinor: value.stakeMinor,\n    amountMinor: value.stakeMinor,\n    payoutMinor: value.payoutMinor,\n    multiplier: value.multiplier,\n    parameters: structuredClone(parameters),\n    rulesVersion: rules.version,\n    serverSeedHash: value.serverSeedHash,\n    clientSeed: value.clientSeed,\n    nonce: value.nonce,\n    createdAt: value.createdAt,\n    funding: value.funding,\n    fundingAwardId: value.fundingAwardId as string | null,\n    result: structuredClone(result),\n  };\n}\n\nexport function validateReceipt<P extends GameParameters>(\n  rules: GameRules<P>,\n  value: unknown,\n  input: unknown,\n): GameRound<P> {\n  const body = validateBetCommand(rules, input).body,\n    round = validateRound(rules, value);\n  if (\n    round.stakeMinor !== body.amountMinor ||\n    !sameValue(round.parameters, body.parameters) ||\n    round.funding !== (body.funding ?? \"PLAYER\") ||\n    (body.funding === \"BONUS\"\n      ? round.fundingAwardId?.toLowerCase() !== body.fundingAwardId!.toLowerCase()\n      : round.fundingAwardId !== null) ||\n    (round.result.initialStakeMinor !== undefined &&\n      round.result.initialStakeMinor !== body.amountMinor) ||\n    (Array.isArray(round.result.decisions) && round.result.decisions.length > 0)\n  )\n    throw invalidReceipt();\n  return round;\n}\n\nexport function validateActionReceipt<P extends GameParameters>(\n  rules: GameRules<P>,\n  value: unknown,\n  input: unknown,\n  previous?: unknown,\n  options: { replayed?: boolean } = {},\n): GameRound<P> {\n  const body = validateActionCommand(rules, input).body,\n    round = validateRound(rules, value);\n  if (round.id.toLowerCase() !== body.gameId.toLowerCase()) throw invalidReceipt();\n  const decisions = round.result.decisions;\n  if (!Array.isArray(decisions) || decisions.length === 0) throw invalidReceipt();\n  const last = attempt(() => normalizeDecision(decisions.at(-1)), invalidReceipt);\n  for (const key of [\"action\", \"selection\", \"selections\", \"choice\"] as const)\n    if (!sameValue(last[key], body[key])) throw invalidReceipt();\n  if (body.expectedStep !== undefined && last.expectedStep !== body.expectedStep)\n    throw invalidReceipt();\n  if (previous !== undefined) {\n    const before = validateRound(rules, previous);\n    const earlier = before.result.decisions;\n    if (!Array.isArray(earlier)) throw invalidReceipt();\n    const shared = Math.min(earlier.length, decisions.length);\n    if (\n      before.id !== round.id ||\n      before.serverSeedHash !== round.serverSeedHash ||\n      before.clientSeed !== round.clientSeed ||\n      before.nonce !== round.nonce ||\n      before.createdAt !== round.createdAt ||\n      before.funding !== round.funding ||\n      before.fundingAwardId !== round.fundingAwardId ||\n      !sameValue(round.parameters, before.parameters) ||\n      !sameValue(earlier.slice(0, shared), decisions.slice(0, shared)) ||\n      (options.replayed\n        ? earlier.length === decisions.length\n          ? before.status !== round.status ||\n            before.stakeMinor !== round.stakeMinor ||\n            before.payoutMinor !== round.payoutMinor ||\n            before.multiplier !== round.multiplier ||\n            !sameValue(before.result, round.result)\n          : earlier.length < decisions.length\n            ? BigInt(round.stakeMinor) < BigInt(before.stakeMinor)\n            : BigInt(round.stakeMinor) > BigInt(before.stakeMinor)\n        : before.status !== \"ACTIVE\" ||\n          decisions.length !== earlier.length + 1 ||\n          BigInt(round.stakeMinor) < BigInt(before.stakeMinor))\n    )\n      throw invalidReceipt();\n  }\n  return round;\n}\n\nexport function validateHistoryReceipt<P extends GameParameters>(\n  rules: GameRules<P>,\n  value: unknown,\n): GameRound<P> {\n  return validateRound(rules, value);\n}\n\nexport function savedCommand<P extends GameParameters>(\n  rules: GameRules<P>,\n  raw: string | null,\n): Command<P> | null {\n  if (raw === null) return null;\n  try {\n    const value: unknown = JSON.parse(raw);\n    return record(value) && value.path === \"/api/games/action\"\n      ? validateActionCommand(rules, value)\n      : validateBetCommand(rules, value);\n  } catch {\n    throw invalidCommand();\n  }\n}\n","sha256":"13f4a9edac75ec4cfa9334c329813681fda996831d3a3ca370c9f292ba7e5401"},"src/lib/games/verifier-preview.ts":{"source":"import type { GameSlug } from \"@/games/math\";\nimport { createEntropy } from \"./entropy\";\nimport { DICE_RULES_VERSIONS, diceDraw, diceRollText } from \"./dice-rules\";\nimport {\n  LIMBO_RULES_VERSIONS,\n  limboDraw,\n  limboResultHundredths,\n  limboMultiplierText,\n} from \"./limbo-rules\";\nimport { KENO_RULES_VERSIONS, kenoDraw, kenoGridSize, type KenoDifficulty } from \"./keno-rules\";\nimport { ROULETTE_RULES_VERSIONS, drawRoulette, roulettePocket } from \"./roulette-rules\";\nimport { MINES_RULES_VERSIONS, minesDraw } from \"./mines-rules\";\nimport { MOLES_RULES_VERSIONS, molesFairness } from \"./moles-rules\";\nimport { TOWER_RULES_VERSIONS, towerFairness } from \"./tower-rules\";\nimport { WHEEL_RULES_VERSIONS, wheelFairnessRounds } from \"./wheel-rules\";\nimport { COINFLIP_RULES_VERSIONS, coinflipFairnessResults } from \"./coinflip-rules\";\nimport { RPS_RULES_VERSIONS, rpsFairness } from \"./rps-rules\";\nimport { CHICKEN_RULES_VERSIONS } from \"./chicken-rules\";\nimport { chickenVerifierOutcome } from \"./chicken-fairness\";\nimport { BLACKJACK_RULES_VERSIONS } from \"./blackjack-rules\";\nimport { blackjackVerifierCards } from \"./blackjack-shoe\";\nimport { BACCARAT_RULES_VERSIONS, baccaratDeal } from \"./baccarat-rules\";\nimport { WAR_RULES_VERSIONS, warFairnessShoe } from \"./war-rules\";\nimport { HILO_RULES_VERSIONS, hiloCardAt } from \"./hilo-rules\";\nimport { PLINKO_NORMAL_RULES_VERSION, plinkoPath, plinkoScheduleDecimal } from \"./plinko-rules\";\nimport { PLINKO_2_RULES_VERSION, PLINKO_3_RULES_VERSION } from \"./plinko-2\";\n\nexport type VerifierPreview =\n  | { kind: \"number\"; value: string }\n  | {\n      kind: \"cards\";\n      cards: { rank: string; suit: string }[];\n      groups?: { label: string; count: number; total: number }[];\n      winner?: \"player\" | \"banker\" | \"tie\";\n    }\n  | { kind: \"numbers\"; values: number[]; columns: number; total: number }\n  | { kind: \"grid\"; rows: { cells: { label: string; active: boolean }[] }[] }\n  | {\n      kind: \"sequence\";\n      values: {\n        label: string;\n        image?: string;\n        baseMultiplier?: string;\n        index?: number;\n        round?: number;\n      }[];\n    }\n  | {\n      kind: \"plinko\";\n      rows: number;\n      risk: \"low\" | \"medium\" | \"high\";\n      path: (0 | 1)[];\n      bucket: number;\n      multiplier: string;\n    };\n\nexport type VerifierPreviewInput = {\n  game: GameSlug;\n  version: string;\n  clientSeed: string;\n  serverSeed: string;\n  nonce: number;\n  parameters: Record<string, string | number>;\n};\n\nconst versions: Readonly<Record<GameSlug, readonly string[]>> = Object.freeze({\n  dice: DICE_RULES_VERSIONS,\n  plinko: [PLINKO_NORMAL_RULES_VERSION, PLINKO_2_RULES_VERSION, PLINKO_3_RULES_VERSION],\n  limbo: LIMBO_RULES_VERSIONS,\n  keno: KENO_RULES_VERSIONS,\n  wheel: WHEEL_RULES_VERSIONS,\n  roulette: ROULETTE_RULES_VERSIONS,\n  mines: MINES_RULES_VERSIONS,\n  tower: TOWER_RULES_VERSIONS,\n  blackjack: BLACKJACK_RULES_VERSIONS,\n  baccarat: BACCARAT_RULES_VERSIONS,\n  hilo: HILO_RULES_VERSIONS,\n  rps: RPS_RULES_VERSIONS,\n  war: WAR_RULES_VERSIONS,\n  chicken: CHICKEN_RULES_VERSIONS,\n  coinflip: COINFLIP_RULES_VERSIONS,\n  moles: MOLES_RULES_VERSIONS,\n});\n\nfunction parameters(value: unknown, keys: readonly string[]) {\n  if (\n    !value ||\n    typeof value !== \"object\" ||\n    Array.isArray(value) ||\n    Object.keys(value).length !== keys.length ||\n    keys.some((key) => !Object.hasOwn(value, key))\n  )\n    throw new RangeError(\"Invalid verifier parameters\");\n  return value as Record<string, unknown>;\n}\n\nfunction option<const T extends readonly string[]>(value: unknown, allowed: T): T[number] {\n  if (typeof value !== \"string\" || !allowed.includes(value))\n    throw new RangeError(\"Invalid verifier parameter\");\n  return value;\n}\n\nfunction integer(value: unknown, minimum: number, maximum: number) {\n  if (\n    typeof value !== \"number\" ||\n    !Number.isSafeInteger(value) ||\n    value < minimum ||\n    value > maximum\n  )\n    throw new RangeError(\"Invalid verifier parameter\");\n  return value;\n}\n\nfunction card(value: string) {\n  const rank = value.slice(0, -1);\n  return { rank: rank === \"T\" ? \"10\" : rank, suit: value.slice(-1) };\n}\n\nfunction grid(active: readonly boolean[]) {\n  return { cells: active.map((value, index) => ({ label: String(index + 1), active: value })) };\n}\n\nexport async function previewVerifier(input: VerifierPreviewInput): Promise<VerifierPreview> {\n  if (\n    !input ||\n    !Object.hasOwn(versions, input.game) ||\n    !versions[input.game].includes(input.version)\n  )\n    throw new RangeError(\"Use a recorded receipt to verify this rules version.\");\n  if (\n    typeof input.serverSeed !== \"string\" ||\n    !input.serverSeed.trim() ||\n    input.serverSeed.length > 4096 ||\n    typeof input.clientSeed !== \"string\" ||\n    !input.clientSeed.trim() ||\n    input.clientSeed.length > 4096\n  )\n    throw new RangeError(\"Invalid fairness seed\");\n  const entropy = createEntropy(input);\n  const seeds = { serverSeed: input.serverSeed, clientSeed: input.clientSeed, nonce: input.nonce };\n  switch (input.game) {\n    case \"dice\": {\n      parameters(input.parameters, []);\n      return {\n        kind: \"number\",\n        value: diceRollText((await diceDraw(entropy)).rollResult),\n      };\n    }\n    case \"limbo\": {\n      parameters(input.parameters, []);\n      return {\n        kind: \"number\",\n        value: `${limboMultiplierText(limboResultHundredths(await limboDraw(entropy)))}x`,\n      };\n    }\n    case \"roulette\": {\n      parameters(input.parameters, []);\n      return {\n        kind: \"number\",\n        value: String(roulettePocket(await drawRoulette(entropy))),\n      };\n    }\n    case \"plinko\": {\n      const value = parameters(input.parameters, [\"riskType\", \"rowCount\"]);\n      if (value.riskType === \"rain\")\n        throw new RangeError(\n          \"Rain seed preview is unavailable while its source policy remains unresolved.\",\n        );\n      const risk = option(value.riskType, [\"low\", \"medium\", \"high\"] as const);\n      const rows = integer(value.rowCount, 8, 16);\n      const path = await plinkoPath({ ...seeds, rows });\n      const bucket = path.reduce<number>((sum, bit) => sum + bit, 0);\n      return {\n        kind: \"plinko\",\n        rows,\n        risk,\n        path,\n        bucket,\n        multiplier: plinkoScheduleDecimal({ rows, risk })[bucket],\n      };\n    }\n    case \"keno\": {\n      const value = parameters(input.parameters, [\"riskType\"]);\n      const risk: KenoDifficulty = option(value.riskType, [\n        \"classic\",\n        \"low\",\n        \"medium\",\n        \"high\",\n        \"extreme\",\n      ] as const);\n      return {\n        kind: \"numbers\",\n        values: await kenoDraw(entropy, risk),\n        columns: 8,\n        total: kenoGridSize(risk),\n      };\n    }\n    case \"mines\": {\n      const value = parameters(input.parameters, [\"gridSize\", \"minesCount\"]);\n      const size = integer(value.gridSize, 25, 64);\n      if (![25, 36, 49, 64].includes(size)) throw new RangeError(\"Invalid verifier grid\");\n      const mines = integer(value.minesCount, 0, size - 1);\n      const board = await minesDraw(entropy, { tiles: size, mines });\n      return { kind: \"grid\", rows: [grid(board.board.map((cell) => cell === \"M\"))] };\n    }\n    case \"moles\": {\n      const value = parameters(input.parameters, [\"moleCount\"]);\n      const moleCount = integer(value.moleCount, 1, 6);\n      const rounds = await molesFairness({ ...seeds, moleCount });\n      return {\n        kind: \"grid\",\n        rows: rounds.map((round) =>\n          grid(Array.from({ length: 7 }, (_, index) => round.moleIndexes.includes(index))),\n        ),\n      };\n    }\n    case \"tower\": {\n      const value = parameters(input.parameters, [\"riskType\"]);\n      const difficulty = option(value.riskType, [\n        \"easy\",\n        \"medium\",\n        \"hard\",\n        \"expert\",\n        \"master\",\n      ] as const);\n      return {\n        kind: \"grid\",\n        rows: (await towerFairness({ ...seeds, difficulty })).map((round) =>\n          grid(round.roundTileResult),\n        ),\n      };\n    }\n    case \"wheel\": {\n      const value = parameters(input.parameters, [\"riskType\"]);\n      const risk = option(value.riskType, [\"low\", \"medium\", \"high\", \"risky\"] as const);\n      return {\n        kind: \"sequence\",\n        values: (await wheelFairnessRounds(entropy, risk)).map((round) => ({\n          label: `${round.compoundingMultiplier}x`,\n          baseMultiplier: round.baseMultiplier,\n          index: round.hitIndex,\n          round: round.round,\n        })),\n      };\n    }\n    case \"chicken\": {\n      const value = parameters(input.parameters, [\"difficulty\"]);\n      const difficulty = option(value.difficulty, [\"easy\", \"medium\", \"hard\", \"expert\"] as const);\n      const outcome = await chickenVerifierOutcome({ ...seeds, difficulty });\n      if (!outcome) throw new RangeError(\"Invalid fairness seed\");\n      return { kind: \"number\", value: `${outcome.maxMultiplier}x` };\n    }\n    case \"coinflip\": {\n      parameters(input.parameters, []);\n      return {\n        kind: \"sequence\",\n        values: (await coinflipFairnessResults(entropy)).map((label) => ({ label })),\n      };\n    }\n    case \"rps\": {\n      parameters(input.parameters, []);\n      return {\n        kind: \"sequence\",\n        values: (await rpsFairness(seeds)).map((label) => ({ label })),\n      };\n    }\n    case \"blackjack\": {\n      parameters(input.parameters, []);\n      return { kind: \"cards\", cards: (await blackjackVerifierCards(entropy)).map(card) };\n    }\n    case \"war\": {\n      parameters(input.parameters, []);\n      return { kind: \"cards\", cards: (await warFairnessShoe(entropy)).map(card) };\n    }\n    case \"hilo\": {\n      parameters(input.parameters, []);\n      const cards = [];\n      for (let round = 1; round <= 99; round++)\n        cards.push(card((await hiloCardAt(entropy, round)).join(\"\")));\n      return { kind: \"cards\", cards };\n    }\n    case \"baccarat\": {\n      parameters(input.parameters, []);\n      const { round } = await baccaratDeal(entropy);\n      return {\n        kind: \"cards\",\n        cards: [...round.playerCards, ...round.bankerCards].map(card),\n        groups: [\n          { label: \"Player\", count: round.playerCards.length, total: round.playerTotal },\n          { label: \"Banker\", count: round.bankerCards.length, total: round.bankerTotal },\n        ],\n        winner: round.winner,\n      };\n    }\n  }\n}\n","sha256":"cba92b4c7b09ea1aeceb1ddca9e285adbd55882d7c9a70f8f485e31a2dc4ae0a"},"src/lib/games/chicken-fairness.ts":{"source":"import { createEntropy } from \"./entropy\";\nimport type { Entropy } from \"./rules\";\nimport {\n  CHICKEN_DEFAULT_DIFFICULTY,\n  CHICKEN_DIFFICULTIES,\n  CHICKEN_GAME_NAME,\n  chickenFairValue,\n  chickenMaxOutcome,\n  type ChickenDifficulty,\n  type ChickenMaxOutcome,\n} from \"./chicken-rules\";\n\nexport const CHICKEN_VERIFIER_PATH = `/provably-fair/game-verifier/${CHICKEN_GAME_NAME}` as const;\nexport const CHICKEN_VERIFIER_QUERY = \"chickenCrossDifficulty\" as const;\nexport const CHICKEN_VERIFIER_INPUT = \"chicken_cross_difficulty\" as const;\nexport const CHICKEN_VERIFIER_COPY =\n  \"The maximum multiplier this game round could have paid out.\" as const;\nexport const CHICKEN_DEMO_NONCE = 1;\nexport const CHICKEN_DEMO_STAKE_MINOR = 1n;\n\nexport type ChickenVerifierInput = Readonly<{\n  serverSeed: unknown;\n  clientSeed: unknown;\n  nonce: unknown;\n  difficulty: unknown;\n}>;\n\nexport type ChickenDemoSeeds = Readonly<{\n  serverSeed: string;\n  clientSeed: string;\n  nonce: typeof CHICKEN_DEMO_NONCE;\n}>;\n\nfunction filled(value: unknown): value is string {\n  return typeof value === \"string\" && !!value.trim();\n}\n\nfunction validNonce(value: unknown): value is number {\n  return typeof value === \"number\" && Number.isSafeInteger(value) && value >= 0;\n}\n\nfunction isDifficulty(value: unknown): value is ChickenDifficulty {\n  return typeof value === \"string\" && (CHICKEN_DIFFICULTIES as readonly string[]).includes(value);\n}\n\nexport function chickenSeedsValid(\n  serverSeed: unknown,\n  clientSeed: unknown,\n  nonce: unknown,\n): boolean {\n  return filled(serverSeed) && filled(clientSeed) && validNonce(nonce);\n}\n\nexport function chickenParseNonce(value: unknown): number | undefined {\n  if (value === null || value === undefined) return undefined;\n  if (typeof value === \"number\") return validNonce(value) ? value + 0 : undefined;\n  if (typeof value !== \"string\") return undefined;\n  const text = value.trim();\n  if (!text) return undefined;\n  const nonce = Number(text);\n  return validNonce(nonce) ? nonce + 0 : undefined;\n}\n\nexport function chickenVerifierDifficulty(value: unknown): ChickenDifficulty {\n  return isDifficulty(value) ? value : CHICKEN_DEFAULT_DIFFICULTY;\n}\n\nexport function chickenVerifierQuery(\n  difficulty: unknown,\n): Partial<Record<typeof CHICKEN_VERIFIER_QUERY, ChickenDifficulty>> {\n  const value = chickenVerifierDifficulty(difficulty);\n  return value === CHICKEN_DEFAULT_DIFFICULTY ? {} : { [CHICKEN_VERIFIER_QUERY]: value };\n}\n\nexport async function chickenVerifierOutcome(\n  input: ChickenVerifierInput,\n): Promise<ChickenMaxOutcome | null> {\n  const nonce = chickenParseNonce(input.nonce);\n  if (\n    nonce === undefined ||\n    !chickenSeedsValid(input.serverSeed, input.clientSeed, nonce) ||\n    !isDifficulty(input.difficulty)\n  )\n    return null;\n  return chickenMaxOutcome(\n    input.difficulty,\n    await chickenFairValue(\n      createEntropy({\n        serverSeed: input.serverSeed as string,\n        clientSeed: input.clientSeed as string,\n        nonce,\n      }),\n    ),\n  );\n}\n\nexport async function chickenVerifierText(input: ChickenVerifierInput): Promise<string> {\n  const outcome = await chickenVerifierOutcome(input);\n  return `${outcome ? outcome.maxMultiplier : \"0.00\"}x`;\n}\n\nexport function chickenDemoSeeds(\n  uuid: () => string = () => globalThis.crypto.randomUUID(),\n): ChickenDemoSeeds {\n  const serverSeed = uuid(),\n    clientSeed = uuid();\n  if (!chickenSeedsValid(serverSeed, clientSeed, CHICKEN_DEMO_NONCE))\n    throw new RangeError(\"Invalid Chicken Cross demo seed\");\n  return Object.freeze({ serverSeed, clientSeed, nonce: CHICKEN_DEMO_NONCE });\n}\n\nexport function chickenDemoEntropy(seeds: ChickenDemoSeeds): Entropy {\n  return createEntropy(seeds);\n}\n","sha256":"3954256a5c022474d74370f3f2c3b5d38670af82daa238935fbef8d6edeece8e"}}}
