mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-14 11:10:51 +01:00
Track kcd2_dice_solver app and add run.sh dependency installer
The Vite/React/TS dice solver had no git history yet. Commit it alongside a run.sh that ensures Node/pnpm are present, installs deps, and starts the dev server with a single command. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
ea270db04f
commit
3babda92d2
@ -0,0 +1,15 @@
|
||||
{
|
||||
"title": "Commit the KCD2 Dice Solver app and add run.sh",
|
||||
"objective": "Bring the previously-untracked kcd2_dice_solver Vite/React/TypeScript app under git version control, and add a run.sh that installs its dependencies and starts its dev server with a single command.",
|
||||
"acceptance_criteria": [
|
||||
"kcd2_dice_solver/ source, tests, and config files are tracked in git (node_modules/dist/coverage remain ignored)",
|
||||
"run.sh ensures Node >=22 and pnpm are available, runs `pnpm install`, then starts `pnpm dev`",
|
||||
"run.sh is executable, shellcheck-clean, and verified to serve HTTP 200 on http://localhost:5174",
|
||||
"pre-commit passes on all staged files"
|
||||
],
|
||||
"out_of_scope": [
|
||||
"No changes to the solver's game logic, scoring, or UI behavior",
|
||||
"No CI workflow added for this app"
|
||||
],
|
||||
"verifier": "pre-commit run --files <staged files>; manual run of ./run.sh with curl against http://localhost:5174"
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
{
|
||||
"intent": "Add a run.sh to kcd2_dice_solver that installs dependencies and starts the dice solver's dev server with a single command.",
|
||||
"scope": ["kcd2_dice_solver/run.sh (new file)", "No changes to app source, tests, or build config"],
|
||||
"changes": [
|
||||
"run.sh ensures Node >=22 (installing the LTS via nvm if missing/too old) and pnpm (via corepack) if missing",
|
||||
"run.sh runs `pnpm install` then `exec pnpm dev` from the script's own directory"
|
||||
],
|
||||
"verification": [
|
||||
{
|
||||
"command": "shellcheck kcd2_dice_solver/run.sh",
|
||||
"result": "pass",
|
||||
"evidence": "No warnings after fixing one SC2155 (declare/assign separately)"
|
||||
},
|
||||
{
|
||||
"command": "./run.sh (foreground, timeout 25s)",
|
||||
"result": "pass",
|
||||
"evidence": "Installed deps, then 'VITE v6.4.3 ready in 132 ms' / Local: http://localhost:5174/"
|
||||
},
|
||||
{
|
||||
"command": "./run.sh (background) + curl -s -o /dev/null -w '%{http_code}' http://localhost:5174/",
|
||||
"result": "pass",
|
||||
"evidence": "HTTP 200"
|
||||
},
|
||||
{
|
||||
"command": "pre-commit run --files kcd2_dice_solver/run.sh",
|
||||
"result": "pass",
|
||||
"evidence": "All applicable hooks Passed (shellcheck, executables-have-shebangs, ai-evidence-contract, etc.)"
|
||||
}
|
||||
],
|
||||
"risks": ["If nvm is not installed on a machine, run.sh exits with an explicit error instead of installing nvm itself (out of scope)"],
|
||||
"rollback": ["Delete kcd2_dice_solver/run.sh", "No other files depend on it"]
|
||||
}
|
||||
4
kcd2_dice_solver/.gitignore
vendored
Normal file
4
kcd2_dice_solver/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
*.tsbuildinfo
|
||||
80
kcd2_dice_solver/README.md
Normal file
80
kcd2_dice_solver/README.md
Normal file
@ -0,0 +1,80 @@
|
||||
# KCD2 Dice Solver
|
||||
|
||||
Pick which six dice and which badge to bring to a game of dice in
|
||||
*Kingdom Come: Deliverance II*. Enter what you own; the solver evaluates every
|
||||
loadout your inventory allows and reports the best one with the numbers behind
|
||||
it.
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev # http://localhost:5174
|
||||
pnpm test # full suite
|
||||
pnpm coverage # 100% statements / branches / functions / lines
|
||||
pnpm lint # tsc --noEmit + eslint (strictTypeChecked)
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## Entering an inventory
|
||||
|
||||
Three ways, all wired to the same state:
|
||||
|
||||
- **Click** a die's row to add one.
|
||||
- **Scroll** the mouse wheel over its counter to step it up or down.
|
||||
- **Search** by name — a fuzzy match, so `wei` finds *Weighted die* and
|
||||
`paintb` finds *Painter's die B*. The same box filters the badge list.
|
||||
|
||||
The inventory is saved to `localStorage`, so it survives a reload.
|
||||
|
||||
## How the recommendation is computed
|
||||
|
||||
**Scoring** (`src/core/scoring.ts`) is the wiki's table: 100 per one, 50 per
|
||||
five, `1000` for three ones and `100 × face` otherwise, doubled for each die
|
||||
beyond three, and 500/750/1500 for the three straights. A roll scores the best
|
||||
*partition* of its dice, which is a small memoised search.
|
||||
|
||||
**Expected value** (`src/core/evaluate.ts`) is exact, not sampled. Scoring
|
||||
depends only on the multiset of faces, so the six dice are convolved into a
|
||||
distribution over count vectors — at most 924 of them, against 6⁶ = 46,656
|
||||
ordered outcomes. Those vectors stay packed as integers end to end, so the
|
||||
scorer's memo is keyed on them directly. `evaluate.test.ts` checks the result
|
||||
against a naive full enumeration that shares none of that machinery.
|
||||
|
||||
**The search** (`src/core/search.ts`) evaluates whole sets, because a Farkle
|
||||
set's value is not the sum of its dice — ranking dice individually and taking
|
||||
the top six is simply wrong. Dice with identical distributions are pooled
|
||||
first (nine of the game's dice are plain uniform dice under different names).
|
||||
Small inventories are enumerated exhaustively and reported as *provably
|
||||
optimal*; larger ones use a multi-start steepest-ascent local search and are
|
||||
reported as *not proven*.
|
||||
|
||||
**Turn value** (`src/core/simulate.ts`) is a Monte Carlo simulation, and unlike
|
||||
the above it depends on how you play. The policy is stated explicitly in that
|
||||
file and its knobs are exposed rather than buried: hold the subset maximising
|
||||
`points + 60 × dice-left`, bank at 300.
|
||||
|
||||
**Badges** (`src/core/badgeValue.ts`) split in two. The five that change the
|
||||
scoring table are valued on the exact EV delta after re-running the whole dice
|
||||
search with the rule on — so equipping one can change which dice you should
|
||||
bring. The rest are simulated one charge at a time, with each turn drawing from
|
||||
its own seeded stream so the comparison is paired and the measured difference is
|
||||
the badge rather than a diverged random sequence.
|
||||
|
||||
Nothing uses `Math.random`; every random source is a seeded `mulberry32`, so
|
||||
every number here is reproducible.
|
||||
|
||||
## What the numbers are not
|
||||
|
||||
- **Balatro's die** publishes no face probabilities. Every face is a wildcard
|
||||
("you get to choose how it's counted"), so it is modelled as always resolving
|
||||
in your favour — which means it always wins if you own it.
|
||||
- **The three "Advantage" formations** (Carpenter's Cut, Executioner's Gallows,
|
||||
Priest's Eye) and the **Headstart** point leads have no published values. The
|
||||
constants used are marked `UNVERIFIED` in `src/data/badges.ts`. Correct them
|
||||
from the game's help screen if you have it open.
|
||||
- **Badge point-per-game figures** are estimates for ranking badges against each
|
||||
other, not a prediction of a scoreline.
|
||||
- The wiki and [Inara](https://inara.cz/kingdom-come-2/items-dice/) **disagree**
|
||||
on about six dice (Ci/Fer/Lu, Painted, Trinity, Holy Trinity, Devil's head).
|
||||
The [fandom wiki](https://kingdom-come-deliverance.fandom.com/wiki/Dice/KCD2)
|
||||
is treated as canonical; the Inara figures sit in a comment beside each
|
||||
conflicting entry.
|
||||
29
kcd2_dice_solver/eslint.config.js
Normal file
29
kcd2_dice_solver/eslint.config.js
Normal file
@ -0,0 +1,29 @@
|
||||
import js from '@eslint/js';
|
||||
import reactHooksPlugin from 'eslint-plugin-react-hooks';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'coverage'] },
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
{
|
||||
plugins: {
|
||||
'react-hooks': reactHooksPlugin,
|
||||
},
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: './tsconfig.json',
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
...reactHooksPlugin.configs.recommended.rules,
|
||||
'@typescript-eslint/restrict-template-expressions': ['error', { allowNumber: true }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['*.js', '*.mjs', '*.cjs'],
|
||||
...tseslint.configs.disableTypeChecked,
|
||||
},
|
||||
);
|
||||
12
kcd2_dice_solver/index.html
Normal file
12
kcd2_dice_solver/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>KCD2 Dice Solver</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
44
kcd2_dice_solver/package.json
Normal file
44
kcd2_dice_solver/package.json
Normal file
@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "kcd2-dice-solver",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "tsc --noEmit && eslint src",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"coverage": "vitest run --coverage"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22",
|
||||
"pnpm": ">=10"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"eslint": "^10.6.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.62.0",
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
2755
kcd2_dice_solver/pnpm-lock.yaml
Normal file
2755
kcd2_dice_solver/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
65
kcd2_dice_solver/run.sh
Executable file
65
kcd2_dice_solver/run.sh
Executable file
@ -0,0 +1,65 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================================
|
||||
# Installs dependencies (Node via nvm, pnpm via corepack, then npm packages)
|
||||
# and starts the KCD2 Dice Solver dev server.
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
readonly SCRIPT_DIR
|
||||
readonly NODE_MIN_MAJOR=22
|
||||
readonly NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||
|
||||
ensure_node() {
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
local major
|
||||
major="$(node --version | sed -E 's/^v([0-9]+).*/\1/')"
|
||||
if (( major >= NODE_MIN_MAJOR )); then
|
||||
return
|
||||
fi
|
||||
echo "Node $(node --version) is older than required v${NODE_MIN_MAJOR}; installing a newer version via nvm..."
|
||||
else
|
||||
echo "Node not found; installing via nvm..."
|
||||
fi
|
||||
|
||||
if [[ ! -s "${NVM_DIR}/nvm.sh" ]]; then
|
||||
echo "Error: nvm not found at ${NVM_DIR}/nvm.sh; install nvm first." >&2
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck source=/dev/null
|
||||
source "${NVM_DIR}/nvm.sh"
|
||||
nvm install --lts
|
||||
nvm use --lts
|
||||
}
|
||||
|
||||
ensure_pnpm() {
|
||||
if command -v pnpm >/dev/null 2>&1; then
|
||||
return
|
||||
fi
|
||||
echo "pnpm not found; enabling via corepack..."
|
||||
corepack enable
|
||||
corepack prepare pnpm@latest --activate
|
||||
}
|
||||
|
||||
main() {
|
||||
# nvm.sh may not be sourced yet in a fresh shell, so load it before the
|
||||
# version check even if `node` already resolves from a different source.
|
||||
if [[ -s "${NVM_DIR}/nvm.sh" ]]; then
|
||||
# shellcheck source=/dev/null
|
||||
source "${NVM_DIR}/nvm.sh"
|
||||
fi
|
||||
|
||||
ensure_node
|
||||
ensure_pnpm
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
echo "Installing dependencies..."
|
||||
pnpm install
|
||||
|
||||
echo "Starting dev server (http://localhost:5174)..."
|
||||
exec pnpm dev
|
||||
}
|
||||
|
||||
main "$@"
|
||||
298
kcd2_dice_solver/src/App.test.tsx
Normal file
298
kcd2_dice_solver/src/App.test.tsx
Normal file
@ -0,0 +1,298 @@
|
||||
/**
|
||||
* End-to-end tests for the app, driving the real flow: search for a die, click
|
||||
* to add it, scroll the wheel over a counter, and get a real recommendation.
|
||||
*
|
||||
* The worker is replaced with a port that runs the *real* solver synchronously —
|
||||
* jsdom has no module-worker support, but nothing about the solver is stubbed,
|
||||
* so these exercise the actual recommendation path.
|
||||
*/
|
||||
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { App, STORAGE_KEY, loadInventory } from "./App.tsx";
|
||||
import type { SavedInventory } from "./App.tsx";
|
||||
import { solve } from "./core/solve.ts";
|
||||
import type { SolverPort } from "./hooks/useSolver.ts";
|
||||
import type { WorkerReply, WorkerRequest } from "./worker/solver.worker.ts";
|
||||
|
||||
/**
|
||||
* A solver port that runs the real solver in-process.
|
||||
*
|
||||
* @returns A port usable in place of the Web Worker.
|
||||
*/
|
||||
function createInlinePort(): SolverPort {
|
||||
const listeners = new Set<(event: MessageEvent<WorkerReply>) => void>();
|
||||
return {
|
||||
postMessage: ({ id, request }: WorkerRequest) => {
|
||||
let reply: WorkerReply;
|
||||
try {
|
||||
// Keep the simulation short: these tests are about the wiring.
|
||||
reply = { id, ok: true, response: solve({ ...request, simulationTurns: 200 }) };
|
||||
} catch (error) {
|
||||
reply = { id, ok: false, error: error instanceof Error ? error.message : "failed" };
|
||||
}
|
||||
for (const listener of listeners) {
|
||||
listener({ data: reply } as MessageEvent<WorkerReply>);
|
||||
}
|
||||
},
|
||||
addEventListener: (_type, listener) => listeners.add(listener),
|
||||
removeEventListener: (_type, listener) => listeners.delete(listener),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* An in-memory Storage stand-in, so tests do not share persisted state.
|
||||
*
|
||||
* @param initial - Optional starting contents.
|
||||
* @returns A minimal storage object.
|
||||
*/
|
||||
function memoryStorage(initial?: string): Pick<Storage, "getItem" | "setItem"> {
|
||||
const store = new Map<string, string>();
|
||||
if (initial !== undefined) {
|
||||
store.set(STORAGE_KEY, initial);
|
||||
}
|
||||
return {
|
||||
getItem: (key) => store.get(key) ?? null,
|
||||
setItem: (key, value) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read back what the app persisted, typed so assertions are not on `any`.
|
||||
*
|
||||
* @param storage - The storage the app was given.
|
||||
* @returns The parsed inventory.
|
||||
*/
|
||||
function readSaved(storage: Pick<Storage, "getItem">): SavedInventory {
|
||||
return JSON.parse(storage.getItem(STORAGE_KEY) ?? "{}") as SavedInventory;
|
||||
}
|
||||
|
||||
const renderApp = (initial?: string) =>
|
||||
render(<App createPort={createInlinePort} storage={memoryStorage(initial)} />);
|
||||
|
||||
describe("loadInventory", () => {
|
||||
it("starts empty when nothing is saved", () => {
|
||||
expect(loadInventory(memoryStorage())).toEqual({ diceCounts: {}, badgeIds: [] });
|
||||
});
|
||||
|
||||
it("restores a saved inventory", () => {
|
||||
const saved = JSON.stringify({ diceCounts: { weighted: 3 }, badgeIds: ["tin_might"] });
|
||||
expect(loadInventory(memoryStorage(saved))).toEqual({
|
||||
diceCounts: { weighted: 3 },
|
||||
badgeIds: ["tin_might"],
|
||||
});
|
||||
});
|
||||
|
||||
it("survives a corrupt entry rather than taking the page down with it", () => {
|
||||
expect(loadInventory(memoryStorage("{not json"))).toEqual({ diceCounts: {}, badgeIds: [] });
|
||||
});
|
||||
|
||||
it("survives an entry of the wrong shape", () => {
|
||||
expect(loadInventory(memoryStorage("42"))).toEqual({ diceCounts: {}, badgeIds: [] });
|
||||
expect(loadInventory(memoryStorage("null"))).toEqual({ diceCounts: {}, badgeIds: [] });
|
||||
expect(loadInventory(memoryStorage('{"diceCounts":"nope","badgeIds":"nope"}'))).toEqual({
|
||||
diceCounts: {},
|
||||
badgeIds: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("App", () => {
|
||||
it("asks for dice before anything is entered", () => {
|
||||
renderApp();
|
||||
expect(screen.getByText(/Add at least six dice/)).toBeInTheDocument();
|
||||
expect(screen.getByText("0 dice")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("finds a die by fuzzy search, adds six by clicking, and solves", async () => {
|
||||
renderApp();
|
||||
|
||||
await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei");
|
||||
const row = screen.getAllByRole("listitem")[0];
|
||||
const add = within(row).getByRole("button", { name: "Weighted die" });
|
||||
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
await userEvent.click(add);
|
||||
}
|
||||
|
||||
expect(screen.getByText("6 dice")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Best loadout")).toBeInTheDocument();
|
||||
});
|
||||
// Six Weighted dice is the right answer and the panel must name it.
|
||||
expect(screen.getByText("6×")).toBeInTheDocument();
|
||||
expect(screen.getByText("Weighted die")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Provably optimal/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds dice by scrolling the wheel over the counter", async () => {
|
||||
renderApp();
|
||||
await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei");
|
||||
const counter = screen.getByLabelText("How many Weighted die");
|
||||
const stepper = counter.parentElement;
|
||||
if (!stepper) {
|
||||
throw new Error("no stepper");
|
||||
}
|
||||
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
fireEvent.wheel(stepper, { deltaY: -120 });
|
||||
}
|
||||
expect(screen.getByLabelText("How many Weighted die")).toHaveValue(6);
|
||||
expect(screen.getByText("6 dice")).toBeInTheDocument();
|
||||
|
||||
fireEvent.wheel(stepper, { deltaY: 120 });
|
||||
expect(screen.getByLabelText("How many Weighted die")).toHaveValue(5);
|
||||
expect(screen.getByText("5 dice")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("accumulates a rapid burst of clicks and wheel ticks", async () => {
|
||||
// Driving the real app in headless Chrome caught this: six fast clicks used
|
||||
// to land as one, because each handler computed from the count captured at
|
||||
// render time. React batches faster than it re-renders.
|
||||
renderApp();
|
||||
await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei");
|
||||
|
||||
const row = screen.getAllByRole("listitem")[0];
|
||||
const add = within(row).getByRole("button", { name: "Weighted die" });
|
||||
act(() => {
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
fireEvent.click(add);
|
||||
}
|
||||
});
|
||||
expect(screen.getByLabelText("How many Weighted die")).toHaveValue(4);
|
||||
|
||||
const stepper = screen.getByLabelText("How many Weighted die").parentElement;
|
||||
if (!stepper) {
|
||||
throw new Error("no stepper");
|
||||
}
|
||||
act(() => {
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
fireEvent.wheel(stepper, { deltaY: -120 });
|
||||
}
|
||||
});
|
||||
expect(screen.getByLabelText("How many Weighted die")).toHaveValue(6);
|
||||
expect(screen.getByText("6 dice")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ranks the badges you own alongside the dice", async () => {
|
||||
const saved = JSON.stringify({
|
||||
diceCounts: { weighted: 6 },
|
||||
badgeIds: ["gold_emperors", "gold_defence"],
|
||||
});
|
||||
renderApp(saved);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Best badge per tier")).toBeInTheDocument();
|
||||
});
|
||||
// The name also appears in the ownership checkbox, so scope to the result.
|
||||
const results = document.querySelector(".result");
|
||||
if (!results) {
|
||||
throw new Error("no result panel");
|
||||
}
|
||||
expect(within(results as HTMLElement).getByText("Gold Emperor's badge")).toBeInTheDocument();
|
||||
// The Defence badge is owned too, and must be reported as situational
|
||||
// rather than given an invented number.
|
||||
expect(within(results as HTMLElement).getByText(/situational/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restores a saved inventory on load", () => {
|
||||
renderApp(JSON.stringify({ diceCounts: { weighted: 4 }, badgeIds: [] }));
|
||||
expect(screen.getByText("4 dice")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("persists changes for next time", async () => {
|
||||
const storage = memoryStorage();
|
||||
render(<App createPort={createInlinePort} storage={storage} />);
|
||||
await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei");
|
||||
await userEvent.click(screen.getByLabelText("Add one Weighted die"));
|
||||
|
||||
expect(readSaved(storage)).toEqual({ diceCounts: { weighted: 1 }, badgeIds: [] });
|
||||
});
|
||||
|
||||
it("clears everything on request", async () => {
|
||||
renderApp(JSON.stringify({ diceCounts: { weighted: 6 }, badgeIds: ["tin_might"] }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Best loadout")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Clear" }));
|
||||
expect(screen.getByText("0 dice")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Add at least six dice/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to the browser's own storage when none is injected", () => {
|
||||
window.localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ diceCounts: { weighted: 2 }, badgeIds: [] }),
|
||||
);
|
||||
render(<App createPort={createInlinePort} />);
|
||||
expect(screen.getByText("2 dice")).toBeInTheDocument();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("disables Clear when there is nothing to clear", () => {
|
||||
renderApp();
|
||||
expect(screen.getByRole("button", { name: "Clear" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("drops a die from the inventory when its count reaches zero", async () => {
|
||||
const storage = memoryStorage(
|
||||
JSON.stringify({ diceCounts: { weighted: 1 }, badgeIds: [] }),
|
||||
);
|
||||
render(<App createPort={createInlinePort} storage={storage} />);
|
||||
await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei");
|
||||
await userEvent.click(screen.getByLabelText("Remove one Weighted die"));
|
||||
|
||||
expect(readSaved(storage)).toEqual({ diceCounts: {}, badgeIds: [] });
|
||||
});
|
||||
|
||||
it("toggles a badge on and off", async () => {
|
||||
const storage = memoryStorage();
|
||||
render(<App createPort={createInlinePort} storage={storage} />);
|
||||
await userEvent.type(screen.getByLabelText("Search dice and badges"), "emperor");
|
||||
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
await userEvent.click(checkbox);
|
||||
expect(readSaved(storage).badgeIds).toEqual(["gold_emperors"]);
|
||||
|
||||
await userEvent.click(checkbox);
|
||||
expect(readSaved(storage).badgeIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses the singular for a single die", async () => {
|
||||
renderApp();
|
||||
await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei");
|
||||
await userEvent.click(screen.getByLabelText("Add one Weighted die"));
|
||||
expect(screen.getByText("1 die")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces a solver error rather than showing nothing", async () => {
|
||||
// A port that always fails, standing in for a worker that threw.
|
||||
const failingPort = (): SolverPort => {
|
||||
const listeners = new Set<(event: MessageEvent<WorkerReply>) => void>();
|
||||
return {
|
||||
postMessage: ({ id }: WorkerRequest) => {
|
||||
for (const listener of listeners) {
|
||||
listener({ data: { id, ok: false, error: "solver exploded" } } as MessageEvent<
|
||||
WorkerReply
|
||||
>);
|
||||
}
|
||||
},
|
||||
addEventListener: (_type, listener) => listeners.add(listener),
|
||||
removeEventListener: (_type, listener) => listeners.delete(listener),
|
||||
};
|
||||
};
|
||||
render(
|
||||
<App
|
||||
createPort={failingPort}
|
||||
storage={memoryStorage(JSON.stringify({ diceCounts: { weighted: 6 }, badgeIds: [] }))}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("solver exploded")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
198
kcd2_dice_solver/src/App.tsx
Normal file
198
kcd2_dice_solver/src/App.tsx
Normal file
@ -0,0 +1,198 @@
|
||||
/**
|
||||
* KCD2 Dice Solver.
|
||||
*
|
||||
* Enter what dice and badges you own; get the best six to bring and the best
|
||||
* badge for each tier you hold.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { JSX } from "react";
|
||||
import { BadgePicker } from "./components/BadgePicker.tsx";
|
||||
import { DiceList } from "./components/DiceList.tsx";
|
||||
import { ResultPanel } from "./components/ResultPanel.tsx";
|
||||
import type { CountUpdater } from "./components/QuantityStepper.tsx";
|
||||
import { SET_SIZE } from "./core/search.ts";
|
||||
import type { SolveRequest } from "./core/solve.ts";
|
||||
import { useSolver } from "./hooks/useSolver.ts";
|
||||
import type { SolverPort } from "./hooks/useSolver.ts";
|
||||
|
||||
/** localStorage key for the saved inventory. */
|
||||
export const STORAGE_KEY = "kcd2-dice-solver.inventory";
|
||||
|
||||
/** What gets persisted between visits. */
|
||||
export interface SavedInventory {
|
||||
readonly diceCounts: Record<string, number>;
|
||||
readonly badgeIds: string[];
|
||||
}
|
||||
|
||||
const EMPTY: SavedInventory = { diceCounts: {}, badgeIds: [] };
|
||||
|
||||
/**
|
||||
* Read the saved inventory, tolerating anything unparsable.
|
||||
*
|
||||
* @param storage - Where to read from; injectable for tests.
|
||||
* @returns The saved inventory, or an empty one.
|
||||
*/
|
||||
export function loadInventory(storage: Pick<Storage, "getItem">): SavedInventory {
|
||||
const raw = storage.getItem(STORAGE_KEY);
|
||||
if (raw === null) {
|
||||
return EMPTY;
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (typeof parsed !== "object" || parsed === null) {
|
||||
return EMPTY;
|
||||
}
|
||||
const { diceCounts, badgeIds } = parsed as Partial<SavedInventory>;
|
||||
return {
|
||||
diceCounts: typeof diceCounts === "object" ? { ...diceCounts } : {},
|
||||
badgeIds: Array.isArray(badgeIds) ? [...badgeIds] : [],
|
||||
};
|
||||
} catch {
|
||||
// A corrupt entry should cost the saved inventory, not the whole page.
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AppProps {
|
||||
/** Overridden in tests to avoid depending on jsdom worker support. */
|
||||
readonly createPort?: () => SolverPort;
|
||||
/** Overridden in tests to isolate persistence. */
|
||||
readonly storage?: Pick<Storage, "getItem" | "setItem">;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole application.
|
||||
*
|
||||
* @param props - Optional injection points for tests.
|
||||
* @returns The app element.
|
||||
*/
|
||||
export function App({ createPort, storage }: AppProps = {}): JSX.Element {
|
||||
const store = storage ?? window.localStorage;
|
||||
const [query, setQuery] = useState("");
|
||||
const [saved, setSaved] = useState<SavedInventory>(() => loadInventory(store));
|
||||
|
||||
useEffect(() => {
|
||||
store.setItem(STORAGE_KEY, JSON.stringify(saved));
|
||||
}, [saved, store]);
|
||||
|
||||
const setDieCount = useCallback((id: string, update: CountUpdater): void => {
|
||||
setSaved((previous) => {
|
||||
// Applied to the previous state, not to a value captured at render time,
|
||||
// so a burst of clicks or wheel ticks accumulates instead of collapsing.
|
||||
const count = update(previous.diceCounts[id] ?? 0);
|
||||
// Rebuilt rather than mutated so a count of zero simply never makes it
|
||||
// into the saved object.
|
||||
const diceCounts = Object.fromEntries(
|
||||
Object.entries(previous.diceCounts).filter(([key]) => key !== id),
|
||||
);
|
||||
if (count > 0) {
|
||||
diceCounts[id] = count;
|
||||
}
|
||||
return { ...previous, diceCounts };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleBadge = useCallback((id: string, owned: boolean): void => {
|
||||
setSaved((previous) => ({
|
||||
...previous,
|
||||
badgeIds: owned
|
||||
? [...previous.badgeIds, id]
|
||||
: previous.badgeIds.filter((existing) => existing !== id),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const total = Object.values(saved.diceCounts).reduce((sum, count) => sum + count, 0);
|
||||
|
||||
const request = useMemo<SolveRequest | null>(
|
||||
() =>
|
||||
total < SET_SIZE
|
||||
? null
|
||||
: { diceCounts: saved.diceCounts, badgeIds: saved.badgeIds },
|
||||
[saved, total],
|
||||
);
|
||||
|
||||
const { result, error, solving } = useSolver(request, createPort);
|
||||
const ownedBadges = useMemo(() => new Set(saved.badgeIds), [saved.badgeIds]);
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header>
|
||||
<h1>KCD2 Dice Solver</h1>
|
||||
<p className="subtitle">
|
||||
Pick what you own; get the best six dice and a badge for each tier.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="columns">
|
||||
<section className="inventory">
|
||||
<div className="toolbar">
|
||||
<input
|
||||
className="search"
|
||||
type="search"
|
||||
placeholder="Search dice and badges…"
|
||||
aria-label="Search dice and badges"
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<span className="count">
|
||||
{total} {total === 1 ? "die" : "dice"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="clear"
|
||||
disabled={total === 0 && saved.badgeIds.length === 0}
|
||||
onClick={() => {
|
||||
setSaved(EMPTY);
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="hint">
|
||||
Click a die to add one, or scroll the wheel over its counter.
|
||||
</p>
|
||||
|
||||
<DiceList query={query} counts={saved.diceCounts} onChange={setDieCount} />
|
||||
|
||||
<h2>Badges</h2>
|
||||
<BadgePicker query={query} owned={ownedBadges} onToggle={toggleBadge} />
|
||||
</section>
|
||||
|
||||
<section className="results">
|
||||
<ResultPanel result={result} error={error} solving={solving} />
|
||||
|
||||
<footer className="caveats">
|
||||
<h3>What these numbers are</h3>
|
||||
<ul>
|
||||
<li>
|
||||
Expected score and bust chance are <strong>exact</strong>, computed over
|
||||
every possible outcome of the six dice — not sampled.
|
||||
</li>
|
||||
<li>
|
||||
Points per turn is a <strong>simulation</strong> of a push-your-luck turn
|
||||
that banks at 300 points. Change how you play and it changes.
|
||||
</li>
|
||||
<li>
|
||||
Badge values are <strong>estimates</strong> for ranking badges against each
|
||||
other, not a prediction of a scoreline.
|
||||
</li>
|
||||
<li>
|
||||
Balatro’s die is modelled as always counting in your favour, so it will
|
||||
always be picked if you own it.
|
||||
</li>
|
||||
<li>
|
||||
The three “Advantage” formations and the Headstart leads have{" "}
|
||||
<strong>no published point values</strong>; the figures used are marked
|
||||
UNVERIFIED in the source.
|
||||
</li>
|
||||
</ul>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
kcd2_dice_solver/src/components/BadgePicker.tsx
Normal file
63
kcd2_dice_solver/src/components/BadgePicker.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Badge ownership, grouped by tier.
|
||||
*
|
||||
* Badges are owned or not owned — there is no quantity — so these are toggles
|
||||
* rather than steppers. They are grouped by tier because that is the axis the
|
||||
* recommendation is reported on: a badge only takes effect when your opponent
|
||||
* brings one of the same tier, and you cannot know theirs in advance.
|
||||
*/
|
||||
|
||||
import type { JSX } from "react";
|
||||
import { BADGES, TIERS } from "../data/badges.ts";
|
||||
import { fuzzyFilter } from "../lib/fuzzy.ts";
|
||||
|
||||
export interface BadgePickerProps {
|
||||
readonly query: string;
|
||||
readonly owned: ReadonlySet<string>;
|
||||
readonly onToggle: (id: string, owned: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tier-grouped badge checkboxes, filtered by the same fuzzy search.
|
||||
*
|
||||
* @param props - Query, owned ids, and a toggle handler.
|
||||
* @returns The picker element.
|
||||
*/
|
||||
export function BadgePicker({ query, owned, onToggle }: BadgePickerProps): JSX.Element {
|
||||
const matches = fuzzyFilter(query, BADGES, (badge) => badge.name).map((m) => m.item);
|
||||
const matched = new Set(matches.map((badge) => badge.id));
|
||||
|
||||
return (
|
||||
<div className="badge-picker">
|
||||
{TIERS.map((tier) => {
|
||||
const inTier = BADGES.filter(
|
||||
(badge) => badge.tier === tier && matched.has(badge.id),
|
||||
);
|
||||
if (inTier.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<section key={tier}>
|
||||
<h3 className={`tier tier-${tier}`}>{tier}</h3>
|
||||
<ul className="badge-list">
|
||||
{inTier.map((badge) => (
|
||||
<li key={badge.id}>
|
||||
<label title={badge.description}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={owned.has(badge.id)}
|
||||
onChange={(event) => {
|
||||
onToggle(badge.id, event.target.checked);
|
||||
}}
|
||||
/>
|
||||
<span>{badge.name}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
kcd2_dice_solver/src/components/DiceList.tsx
Normal file
45
kcd2_dice_solver/src/components/DiceList.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
/**
|
||||
* The searchable list of every die in the game, with owned quantities.
|
||||
*/
|
||||
|
||||
import type { JSX } from "react";
|
||||
import { DICE } from "../data/dice.ts";
|
||||
import { fuzzyFilter } from "../lib/fuzzy.ts";
|
||||
import { DieRow } from "./DieRow.tsx";
|
||||
import type { CountUpdater } from "./QuantityStepper.tsx";
|
||||
|
||||
export interface DiceListProps {
|
||||
readonly query: string;
|
||||
readonly counts: Readonly<Record<string, number>>;
|
||||
readonly onChange: (id: string, update: CountUpdater) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuzzy-filtered list of dice.
|
||||
*
|
||||
* @param props - The search query, current counts, and a change handler.
|
||||
* @returns The list element, or an empty-state message.
|
||||
*/
|
||||
export function DiceList({ query, counts, onChange }: DiceListProps): JSX.Element {
|
||||
const matches = fuzzyFilter(query, DICE, (die) => die.name);
|
||||
|
||||
if (matches.length === 0) {
|
||||
return <p className="empty">No die matches “{query}”.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="die-list">
|
||||
{matches.map(({ item, indices }) => (
|
||||
<DieRow
|
||||
key={item.id}
|
||||
die={item}
|
||||
count={counts[item.id] ?? 0}
|
||||
highlight={indices}
|
||||
onChange={(update) => {
|
||||
onChange(item.id, update);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
96
kcd2_dice_solver/src/components/DieRow.tsx
Normal file
96
kcd2_dice_solver/src/components/DieRow.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
/**
|
||||
* One die in the inventory list: name, face distribution, and a quantity.
|
||||
*/
|
||||
|
||||
import type { JSX } from "react";
|
||||
import type { Die } from "../data/dice.ts";
|
||||
import { QuantityStepper } from "./QuantityStepper.tsx";
|
||||
import type { CountUpdater } from "./QuantityStepper.tsx";
|
||||
|
||||
/** Most of any single die the game lets you carry into one match. */
|
||||
export const MAX_PER_DIE = 6;
|
||||
|
||||
export interface DieRowProps {
|
||||
readonly die: Die;
|
||||
readonly count: number;
|
||||
readonly highlight: readonly number[];
|
||||
readonly onChange: (update: CountUpdater) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a name with the fuzzy-matched characters marked.
|
||||
*
|
||||
* Adjacent characters are grouped into runs rather than emitted one element per
|
||||
* character: it keeps the DOM small and, more usefully, leaves the unmatched
|
||||
* parts of the name as whole text nodes.
|
||||
*
|
||||
* @param name - The die's display name.
|
||||
* @param highlight - Indices in the name that the search query matched.
|
||||
* @returns The name as alternating plain and marked runs.
|
||||
*/
|
||||
export function highlightName(name: string, highlight: readonly number[]): JSX.Element {
|
||||
const marked = new Set(highlight);
|
||||
const runs: { text: string; hit: boolean }[] = [];
|
||||
for (let index = 0; index < name.length; index += 1) {
|
||||
const hit = marked.has(index);
|
||||
const last = runs.at(-1);
|
||||
if (last?.hit === hit) {
|
||||
last.text += name[index];
|
||||
} else {
|
||||
runs.push({ text: name[index], hit });
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{runs.map((run, index) =>
|
||||
run.hit ? (
|
||||
// Index keys are correct here: the runs come from a fixed string.
|
||||
<mark key={index}>{run.text}</mark>
|
||||
) : (
|
||||
<span key={index}>{run.text}</span>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single inventory row. Clicking anywhere on the row adds one die.
|
||||
*
|
||||
* @param props - The die, its current count, search highlights, and a handler.
|
||||
* @returns The row element.
|
||||
*/
|
||||
export function DieRow({ die, count, highlight, onChange }: DieRowProps): JSX.Element {
|
||||
const isWild = die.wildcardFaces.length > 0;
|
||||
return (
|
||||
<li className={count > 0 ? "die-row owned" : "die-row"}>
|
||||
<button
|
||||
type="button"
|
||||
className="die-main"
|
||||
title={die.description}
|
||||
// The stepper's "+" already owns "Add one <die>"; this control needs a
|
||||
// distinct accessible name so the two are not ambiguous.
|
||||
aria-label={die.name}
|
||||
onClick={() => {
|
||||
onChange((previous) => Math.min(MAX_PER_DIE, previous + 1));
|
||||
}}
|
||||
>
|
||||
<span className="die-name">{highlightName(die.name, highlight)}</span>
|
||||
<span className="die-faces">
|
||||
{die.weights.map((weight, face) => (
|
||||
<span
|
||||
className="die-face"
|
||||
key={face}
|
||||
title={`${face + 1}: ${(weight * 100).toFixed(1)}%`}
|
||||
>
|
||||
<span className="die-face-bar" style={{ height: `${weight * 100 * 1.6}%` }} />
|
||||
<span className="die-face-label">{face + 1}</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
{isWild && <span className="die-tag">wild</span>}
|
||||
</button>
|
||||
<QuantityStepper label={die.name} value={count} max={MAX_PER_DIE} onChange={onChange} />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
103
kcd2_dice_solver/src/components/QuantityStepper.tsx
Normal file
103
kcd2_dice_solver/src/components/QuantityStepper.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
/**
|
||||
* The quantity control for one inventory row.
|
||||
*
|
||||
* Three ways to change a count, because all three were asked for: the −/+
|
||||
* buttons, typing a number, and scrolling the mouse wheel over the control.
|
||||
*/
|
||||
|
||||
import type { JSX } from "react";
|
||||
|
||||
/**
|
||||
* How a count changes: a function of the previous count, never an absolute
|
||||
* value computed from a stale render. Wheel events and rapid clicks arrive
|
||||
* faster than React re-renders, so `onChange(value + 1)` silently collapses a
|
||||
* burst of six into one — which is exactly what happened the first time this
|
||||
* was driven in a real browser.
|
||||
*/
|
||||
export type CountUpdater = (previous: number) => number;
|
||||
|
||||
export interface QuantityStepperProps {
|
||||
readonly label: string;
|
||||
readonly value: number;
|
||||
readonly max: number;
|
||||
readonly onChange: (update: CountUpdater) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp a count into the allowed range, treating anything unparsable as zero.
|
||||
*
|
||||
* @param value - Proposed count.
|
||||
* @param max - Largest allowed count.
|
||||
* @returns The clamped whole number.
|
||||
*/
|
||||
export function clampCount(value: number, max: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(max, Math.max(0, Math.floor(value)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stepper for how many of a die or badge the player owns.
|
||||
*
|
||||
* @param props - Current value, bounds, and the change handler.
|
||||
* @returns The stepper element.
|
||||
*/
|
||||
export function QuantityStepper({
|
||||
label,
|
||||
value,
|
||||
max,
|
||||
onChange,
|
||||
}: QuantityStepperProps): JSX.Element {
|
||||
const step = (delta: number): void => {
|
||||
onChange((previous) => clampCount(previous + delta, max));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="stepper"
|
||||
onWheel={(event) => {
|
||||
// Scrolling up adds one, down removes one. The row is not scrollable
|
||||
// itself, so nothing is stolen from the page.
|
||||
event.preventDefault();
|
||||
step(event.deltaY < 0 ? 1 : -1);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="stepper-button"
|
||||
aria-label={`Remove one ${label}`}
|
||||
disabled={value === 0}
|
||||
onClick={() => {
|
||||
step(-1);
|
||||
}}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
className="stepper-value"
|
||||
type="number"
|
||||
min={0}
|
||||
max={max}
|
||||
value={value}
|
||||
aria-label={`How many ${label}`}
|
||||
onChange={(event) => {
|
||||
// Typing is the one case that really is an absolute value.
|
||||
const typed = Number(event.target.value);
|
||||
onChange(() => clampCount(typed, max));
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="stepper-button"
|
||||
aria-label={`Add one ${label}`}
|
||||
disabled={value >= max}
|
||||
onClick={() => {
|
||||
step(1);
|
||||
}}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
171
kcd2_dice_solver/src/components/ResultPanel.tsx
Normal file
171
kcd2_dice_solver/src/components/ResultPanel.tsx
Normal file
@ -0,0 +1,171 @@
|
||||
/**
|
||||
* The recommendation: six dice, the numbers behind them, and a badge per tier.
|
||||
*/
|
||||
|
||||
import type { JSX } from "react";
|
||||
import type { Die } from "../data/dice.ts";
|
||||
import type { SolveResponse } from "../core/solve.ts";
|
||||
|
||||
export interface ResultPanelProps {
|
||||
readonly result: SolveResponse | null;
|
||||
readonly error: string | null;
|
||||
readonly solving: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse a loadout into "3x Weighted die, 2x Pie die" form.
|
||||
*
|
||||
* @param dice - The six dice of a loadout.
|
||||
* @returns One entry per distinct die, in descending count order.
|
||||
*/
|
||||
export function summariseDice(dice: readonly Die[]): { die: Die; count: number }[] {
|
||||
const byId = new Map<string, { die: Die; count: number }>();
|
||||
for (const die of dice) {
|
||||
const existing = byId.get(die.id);
|
||||
if (existing) {
|
||||
existing.count += 1;
|
||||
} else {
|
||||
byId.set(die.id, { die, count: 1 });
|
||||
}
|
||||
}
|
||||
return [...byId.values()].sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a point figure for display.
|
||||
*
|
||||
* @param value - The number of points.
|
||||
* @returns The value rounded to whole points with thousands separators.
|
||||
*/
|
||||
function points(value: number): string {
|
||||
return Math.round(value).toLocaleString("en-GB");
|
||||
}
|
||||
|
||||
/**
|
||||
* The recommendation panel.
|
||||
*
|
||||
* @param props - The latest solve, any error, and whether one is in flight.
|
||||
* @returns The panel element.
|
||||
*/
|
||||
export function ResultPanel({ result, error, solving }: ResultPanelProps): JSX.Element {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="result">
|
||||
<p className="empty">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!result) {
|
||||
return (
|
||||
<div className="result">
|
||||
<p className="empty">
|
||||
{solving ? "Solving…" : "Add at least six dice to get a recommendation."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={solving ? "result stale" : "result"}>
|
||||
<h2>Best loadout</h2>
|
||||
<ul className="loadout">
|
||||
{summariseDice(result.dice).map(({ die, count }) => (
|
||||
<li key={die.id}>
|
||||
<span className="loadout-count">{count}×</span>
|
||||
<span className="loadout-name">{die.name}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<dl className="stats">
|
||||
<div>
|
||||
<dt>Expected score per throw</dt>
|
||||
<dd>{points(result.evaluation.ev)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Bust chance</dt>
|
||||
<dd>{(result.evaluation.bustProbability * 100).toFixed(2)}%</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Good throw (90th pct)</dt>
|
||||
<dd>{points(result.evaluation.p90)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Simulated points per turn</dt>
|
||||
<dd>
|
||||
{points(result.simulation.meanPerTurn)}
|
||||
<small> ± {result.simulation.standardError.toFixed(1)}</small>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p className="provenance">
|
||||
{result.optimal
|
||||
? "Provably optimal: every possible loadout was evaluated."
|
||||
: "Best found by local search — near-optimal, not proven."}{" "}
|
||||
Chosen from {result.inventorySize} dice.
|
||||
</p>
|
||||
|
||||
{result.alternatives.length > 0 && (
|
||||
<details className="alternatives">
|
||||
<summary>Runners-up</summary>
|
||||
<ul>
|
||||
{result.alternatives.map((alternative, index) => (
|
||||
// Alternatives are a fixed ranked list; the index is their rank.
|
||||
<li key={index}>
|
||||
<span className="alt-ev">{points(alternative.evaluation.ev)}</span>
|
||||
{summariseDice(alternative.dice)
|
||||
.map(({ die, count }) => `${count}× ${die.name}`)
|
||||
.join(", ")}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<h2>Best badge per tier</h2>
|
||||
{result.badges.length === 0 ? (
|
||||
<p className="empty">Tick the badges you own to have them ranked.</p>
|
||||
) : (
|
||||
result.badges.map((recommendation) => {
|
||||
const [best, ...rest] = recommendation.ranked;
|
||||
return (
|
||||
<section key={recommendation.tier} className="badge-result">
|
||||
<h3 className={`tier tier-${recommendation.tier}`}>{recommendation.tier}</h3>
|
||||
<p className="badge-pick">
|
||||
<strong>{best.badge.name}</strong>
|
||||
{best.pointsPerGame !== null && (
|
||||
<span className="badge-value"> ~{points(best.pointsPerGame)} pts/game</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="badge-reason">{best.reason}</p>
|
||||
{best.dice && (
|
||||
<p className="badge-reason">
|
||||
With this badge, bring:{" "}
|
||||
{summariseDice(best.dice)
|
||||
.map(({ die, count }) => `${count}× ${die.name}`)
|
||||
.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
{rest.length > 0 && (
|
||||
<details>
|
||||
<summary>Other {recommendation.tier} badges</summary>
|
||||
<ul>
|
||||
{rest.map((valuation) => (
|
||||
<li key={valuation.badge.id}>
|
||||
{valuation.badge.name}
|
||||
{valuation.pointsPerGame === null
|
||||
? " — situational"
|
||||
: ` — ~${points(valuation.pointsPerGame)} pts/game`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
385
kcd2_dice_solver/src/components/components.test.tsx
Normal file
385
kcd2_dice_solver/src/components/components.test.tsx
Normal file
@ -0,0 +1,385 @@
|
||||
/**
|
||||
* Component tests for the three ways of entering an inventory: clicking a die,
|
||||
* scrolling the wheel over its counter, and fuzzy-searching by name.
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Die } from "../data/dice.ts";
|
||||
import type { SolveResponse } from "../core/solve.ts";
|
||||
import { BadgePicker } from "./BadgePicker.tsx";
|
||||
import { DiceList } from "./DiceList.tsx";
|
||||
import { DieRow, MAX_PER_DIE, highlightName } from "./DieRow.tsx";
|
||||
import { QuantityStepper, clampCount } from "./QuantityStepper.tsx";
|
||||
import { ResultPanel, summariseDice } from "./ResultPanel.tsx";
|
||||
|
||||
/**
|
||||
* Apply the updater a component emitted, to assert on the resulting count.
|
||||
*
|
||||
* @param mock - The mocked onChange.
|
||||
* @param previous - The count it should be applied to.
|
||||
* @param call - Which call to read, counting from the end by default.
|
||||
* @returns The count the component asked for.
|
||||
*/
|
||||
function applied(
|
||||
mock: { mock: { calls: unknown[][] } },
|
||||
previous: number,
|
||||
call = mock.mock.calls.length - 1,
|
||||
): number {
|
||||
const update = mock.mock.calls[call][0] as (p: number) => number;
|
||||
return update(previous);
|
||||
}
|
||||
|
||||
const ordinary: Die = {
|
||||
id: "ordinary",
|
||||
name: "Ordinary die",
|
||||
description: "An ordinary playing die.",
|
||||
weights: [1 / 6, 1 / 6, 1 / 6, 1 / 6, 1 / 6, 1 / 6],
|
||||
wildcardFaces: [],
|
||||
};
|
||||
|
||||
describe("clampCount", () => {
|
||||
it("keeps a value inside the range", () => {
|
||||
expect(clampCount(3, 6)).toBe(3);
|
||||
expect(clampCount(-2, 6)).toBe(0);
|
||||
expect(clampCount(99, 6)).toBe(6);
|
||||
});
|
||||
|
||||
it("floors fractions and rejects nonsense", () => {
|
||||
expect(clampCount(2.9, 6)).toBe(2);
|
||||
expect(clampCount(Number.NaN, 6)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("QuantityStepper", () => {
|
||||
it("adds one when the plus button is pressed", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<QuantityStepper label="Ordinary die" value={2} max={6} onChange={onChange} />);
|
||||
await userEvent.click(screen.getByLabelText("Add one Ordinary die"));
|
||||
expect(applied(onChange, 2)).toBe(3);
|
||||
});
|
||||
|
||||
it("removes one when the minus button is pressed", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<QuantityStepper label="Ordinary die" value={2} max={6} onChange={onChange} />);
|
||||
await userEvent.click(screen.getByLabelText("Remove one Ordinary die"));
|
||||
expect(applied(onChange, 2)).toBe(1);
|
||||
});
|
||||
|
||||
it("disables the buttons at the ends of the range", () => {
|
||||
const { rerender } = render(
|
||||
<QuantityStepper label="Ordinary die" value={0} max={6} onChange={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByLabelText("Remove one Ordinary die")).toBeDisabled();
|
||||
rerender(<QuantityStepper label="Ordinary die" value={6} max={6} onChange={vi.fn()} />);
|
||||
expect(screen.getByLabelText("Add one Ordinary die")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("increments on a wheel scroll up and decrements on a scroll down", () => {
|
||||
const onChange = vi.fn();
|
||||
const { container } = render(
|
||||
<QuantityStepper label="Ordinary die" value={2} max={6} onChange={onChange} />,
|
||||
);
|
||||
const stepper = container.querySelector(".stepper");
|
||||
if (!stepper) {
|
||||
throw new Error("no stepper");
|
||||
}
|
||||
fireEvent.wheel(stepper, { deltaY: -120 });
|
||||
expect(applied(onChange, 2)).toBe(3);
|
||||
fireEvent.wheel(stepper, { deltaY: 120 });
|
||||
expect(applied(onChange, 2)).toBe(1);
|
||||
});
|
||||
|
||||
it("accumulates a burst of wheel ticks rather than collapsing them", () => {
|
||||
// Wheel events arrive far faster than React re-renders. Emitting an
|
||||
// absolute `value + 1` made six ticks land as one; each emitted updater
|
||||
// must therefore compose.
|
||||
const onChange = vi.fn();
|
||||
const { container } = render(
|
||||
<QuantityStepper label="Ordinary die" value={0} max={6} onChange={onChange} />,
|
||||
);
|
||||
const stepper = container.querySelector(".stepper");
|
||||
if (!stepper) {
|
||||
throw new Error("no stepper");
|
||||
}
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
fireEvent.wheel(stepper, { deltaY: -120 });
|
||||
}
|
||||
expect(onChange).toHaveBeenCalledTimes(6);
|
||||
const total = onChange.mock.calls.reduce(
|
||||
(count: number, [update]) => (update as (p: number) => number)(count),
|
||||
0,
|
||||
);
|
||||
expect(total).toBe(6);
|
||||
});
|
||||
|
||||
it("clamps a typed value", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<QuantityStepper label="Ordinary die" value={0} max={6} onChange={onChange} />);
|
||||
await userEvent.type(screen.getByLabelText("How many Ordinary die"), "9");
|
||||
expect(applied(onChange, 0)).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("highlightName", () => {
|
||||
it("marks the matched characters and leaves the rest alone", () => {
|
||||
const { container } = render(<p>{highlightName("Die", [0])}</p>);
|
||||
expect(container.querySelectorAll("mark")).toHaveLength(1);
|
||||
expect(container.textContent).toBe("Die");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DieRow", () => {
|
||||
it("adds one die when the row is clicked", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<DieRow die={ordinary} count={0} highlight={[]} onChange={onChange} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Ordinary die" }));
|
||||
expect(applied(onChange, 0)).toBe(1);
|
||||
});
|
||||
|
||||
it("stops at the maximum when clicked repeatedly", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<DieRow die={ordinary} count={MAX_PER_DIE} highlight={[]} onChange={onChange} />,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Ordinary die" }));
|
||||
expect(applied(onChange, MAX_PER_DIE)).toBe(MAX_PER_DIE);
|
||||
});
|
||||
|
||||
it("marks a row you own", () => {
|
||||
const { container } = render(
|
||||
<DieRow die={ordinary} count={2} highlight={[]} onChange={vi.fn()} />,
|
||||
);
|
||||
expect(container.querySelector(".die-row")).toHaveClass("owned");
|
||||
});
|
||||
|
||||
it("tags the wildcard dice", () => {
|
||||
const balatro: Die = { ...ordinary, id: "balatro", wildcardFaces: [1, 2, 3, 4, 5, 6] };
|
||||
render(<DieRow die={balatro} count={0} highlight={[]} onChange={vi.fn()} />);
|
||||
expect(screen.getByText("wild")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DiceList", () => {
|
||||
it("shows every die when the query is empty", () => {
|
||||
render(<DiceList query="" counts={{}} onChange={vi.fn()} />);
|
||||
expect(screen.getAllByRole("listitem")).toHaveLength(43);
|
||||
});
|
||||
|
||||
it("narrows to the Weighted die on a three-letter query", () => {
|
||||
render(<DiceList query="wei" counts={{}} onChange={vi.fn()} />);
|
||||
const rows = screen.getAllByRole("listitem");
|
||||
// The name is split into highlighted and plain runs, so match on the row
|
||||
// button's accessible name rather than on a single text node.
|
||||
expect(within(rows[0]).getByRole("button", { name: "Weighted die" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("highlights the characters the query matched", () => {
|
||||
render(<DiceList query="wei" counts={{}} onChange={vi.fn()} />);
|
||||
const rows = screen.getAllByRole("listitem");
|
||||
expect(within(rows[0]).getByText("Wei").tagName).toBe("MARK");
|
||||
});
|
||||
|
||||
it("finds a die by a fragment from the middle of its name", () => {
|
||||
render(<DiceList query="grozav" counts={{}} onChange={vi.fn()} />);
|
||||
expect(screen.getAllByRole("listitem")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("says so when nothing matches", () => {
|
||||
render(<DiceList query="qqqqq" counts={{}} onChange={vi.fn()} />);
|
||||
expect(screen.getByText(/No die matches/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes the owned count through to the row", () => {
|
||||
render(<DiceList query="wei" counts={{ weighted: 4 }} onChange={vi.fn()} />);
|
||||
expect(screen.getByLabelText("How many Weighted die")).toHaveValue(4);
|
||||
});
|
||||
|
||||
it("reports which die changed", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<DiceList query="wei" counts={{}} onChange={onChange} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Weighted die" }));
|
||||
expect(onChange.mock.calls[0][0]).toBe("weighted");
|
||||
expect((onChange.mock.calls[0][1] as (p: number) => number)(0)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BadgePicker", () => {
|
||||
it("groups badges under their tier", () => {
|
||||
render(<BadgePicker query="" owned={new Set()} onToggle={vi.fn()} />);
|
||||
expect(screen.getByRole("heading", { name: "tin" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "silver" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "gold" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides a tier with no matching badge", () => {
|
||||
render(<BadgePicker query="emperor" owned={new Set()} onToggle={vi.fn()} />);
|
||||
expect(screen.queryByRole("heading", { name: "tin" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "gold" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reflects which badges are owned", () => {
|
||||
render(<BadgePicker query="emperor" owned={new Set(["gold_emperors"])} onToggle={vi.fn()} />);
|
||||
expect(screen.getByRole("checkbox")).toBeChecked();
|
||||
});
|
||||
|
||||
it("reports a toggle in both directions", async () => {
|
||||
const onToggle = vi.fn();
|
||||
const { rerender } = render(
|
||||
<BadgePicker query="emperor" owned={new Set()} onToggle={onToggle} />,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("checkbox"));
|
||||
expect(onToggle).toHaveBeenLastCalledWith("gold_emperors", true);
|
||||
|
||||
rerender(
|
||||
<BadgePicker query="emperor" owned={new Set(["gold_emperors"])} onToggle={onToggle} />,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("checkbox"));
|
||||
expect(onToggle).toHaveBeenLastCalledWith("gold_emperors", false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("summariseDice", () => {
|
||||
it("collapses duplicates and orders by count", () => {
|
||||
const weighted: Die = { ...ordinary, id: "weighted", name: "Weighted die" };
|
||||
expect(summariseDice([ordinary, weighted, weighted, weighted])).toEqual([
|
||||
{ die: weighted, count: 3 },
|
||||
{ die: ordinary, count: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
const response: SolveResponse = {
|
||||
dice: new Array<Die>(6).fill(ordinary),
|
||||
evaluation: { ev: 399.1, bustProbability: 0.0309, p90: 900 },
|
||||
simulation: {
|
||||
meanPerTurn: 412.5,
|
||||
standardError: 3.2,
|
||||
bustRate: 0.2,
|
||||
throwsPerTurn: 1.4,
|
||||
turns: 20000,
|
||||
},
|
||||
optimal: true,
|
||||
alternatives: [],
|
||||
badges: [],
|
||||
inventorySize: 6,
|
||||
};
|
||||
|
||||
describe("ResultPanel", () => {
|
||||
it("prompts for dice before the first solve", () => {
|
||||
render(<ResultPanel result={null} error={null} solving={false} />);
|
||||
expect(screen.getByText(/Add at least six dice/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says it is working while a solve is in flight", () => {
|
||||
render(<ResultPanel result={null} error={null} solving />);
|
||||
expect(screen.getByText("Solving…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an error instead of a stale answer", () => {
|
||||
render(<ResultPanel result={null} error="Need at least 6 dice" solving={false} />);
|
||||
expect(screen.getByText("Need at least 6 dice")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the loadout and its numbers", () => {
|
||||
render(<ResultPanel result={response} error={null} solving={false} />);
|
||||
expect(screen.getByText("6×")).toBeInTheDocument();
|
||||
expect(screen.getByText("Ordinary die")).toBeInTheDocument();
|
||||
expect(screen.getByText("399")).toBeInTheDocument();
|
||||
expect(screen.getByText("3.09%")).toBeInTheDocument();
|
||||
expect(screen.getByText("900")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says when the answer is proven rather than merely good", () => {
|
||||
render(<ResultPanel result={response} error={null} solving={false} />);
|
||||
expect(screen.getByText(/Provably optimal/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("admits when the answer is only a local search result", () => {
|
||||
render(
|
||||
<ResultPanel result={{ ...response, optimal: false }} error={null} solving={false} />,
|
||||
);
|
||||
expect(screen.getByText(/not proven/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists runners-up when there are any", () => {
|
||||
const withAlternatives: SolveResponse = {
|
||||
...response,
|
||||
alternatives: [
|
||||
{ dice: new Array<Die>(6).fill(ordinary), evaluation: response.evaluation },
|
||||
],
|
||||
};
|
||||
render(<ResultPanel result={withAlternatives} error={null} solving={false} />);
|
||||
expect(screen.getByText("Runners-up")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("asks for badges when none are owned", () => {
|
||||
render(<ResultPanel result={response} error={null} solving={false} />);
|
||||
expect(screen.getByText(/Tick the badges you own/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the pick, its value and its reasoning for each tier", () => {
|
||||
const withBadges: SolveResponse = {
|
||||
...response,
|
||||
badges: [
|
||||
{
|
||||
tier: "gold",
|
||||
ranked: [
|
||||
{
|
||||
badge: {
|
||||
id: "gold_emperors",
|
||||
name: "Gold Emperor's badge",
|
||||
description: "",
|
||||
tier: "gold",
|
||||
effect: { kind: "scoring", rules: { emperorTriple: true } },
|
||||
},
|
||||
pointsPerGame: 1234,
|
||||
reason: "Changes the scoring table.",
|
||||
dice: new Array<Die>(6).fill(ordinary),
|
||||
},
|
||||
{
|
||||
badge: {
|
||||
id: "gold_warlord",
|
||||
name: "Gold Warlord badge",
|
||||
description: "",
|
||||
tier: "gold",
|
||||
effect: { kind: "multiplier", factor: 2, uses: 1 },
|
||||
},
|
||||
pointsPerGame: 567,
|
||||
reason: "Doubles a good turn.",
|
||||
dice: null,
|
||||
},
|
||||
{
|
||||
badge: {
|
||||
id: "gold_defence",
|
||||
name: "Gold Defence badge",
|
||||
description: "",
|
||||
tier: "gold",
|
||||
effect: { kind: "defence" },
|
||||
},
|
||||
pointsPerGame: null,
|
||||
reason: "Situational.",
|
||||
dice: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
render(<ResultPanel result={withBadges} error={null} solving={false} />);
|
||||
expect(screen.getByText("Gold Emperor's badge")).toBeInTheDocument();
|
||||
expect(screen.getByText(/~1,234 pts\/game/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Changes the scoring table.")).toBeInTheDocument();
|
||||
expect(screen.getByText(/With this badge, bring/)).toBeInTheDocument();
|
||||
// The runners-up list renders both a valued badge and a situational one.
|
||||
expect(screen.getByText(/Gold Warlord badge — ~567 pts\/game/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Gold Defence badge — situational/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("dims the panel while a fresh solve is running", () => {
|
||||
const { container } = render(
|
||||
<ResultPanel result={response} error={null} solving />,
|
||||
);
|
||||
expect(container.querySelector(".result")).toHaveClass("stale");
|
||||
});
|
||||
});
|
||||
198
kcd2_dice_solver/src/core/badgeValue.test.ts
Normal file
198
kcd2_dice_solver/src/core/badgeValue.test.ts
Normal file
@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Tests for badge valuation.
|
||||
*
|
||||
* Every figure here is an estimate built on the simulator's stated policy, so
|
||||
* these tests assert the *properties* the model must have — an optional badge
|
||||
* can never be worth less than nothing, a bigger tier is worth at least as much
|
||||
* as a smaller one — rather than pinning arbitrary point totals.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BADGES_BY_ID, DEFAULT_FORMATION_VALUES } from "../data/badges.ts";
|
||||
import type { Badge } from "../data/badges.ts";
|
||||
import { DICE_BY_ID } from "../data/dice.ts";
|
||||
import type { Die } from "../data/dice.ts";
|
||||
import {
|
||||
bestThrowsValue,
|
||||
measureBaseline,
|
||||
rankValue,
|
||||
recommendBadges,
|
||||
singleCharge,
|
||||
valueBadge,
|
||||
} from "./badgeValue.ts";
|
||||
import type { BadgeContext } from "./badgeValue.ts";
|
||||
import { NO_CHARGES, DEFAULT_POLICY } from "./simulate.ts";
|
||||
import type { InventoryEntry } from "./search.ts";
|
||||
|
||||
const die = (id: string): Die => {
|
||||
const found = DICE_BY_ID.get(id);
|
||||
if (!found) {
|
||||
throw new Error(`no such die: ${id}`);
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
const badge = (id: string): Badge => {
|
||||
const found = BADGES_BY_ID.get(id);
|
||||
if (!found) {
|
||||
throw new Error(`no such badge: ${id}`);
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
const inventory: InventoryEntry[] = [
|
||||
{ die: die("ordinary"), count: 6 },
|
||||
{ die: die("lucky"), count: 6 },
|
||||
{ die: die("weighted"), count: 2 },
|
||||
];
|
||||
|
||||
const context: BadgeContext = {
|
||||
inventory,
|
||||
baselineDice: new Array<Die>(6).fill(die("ordinary")),
|
||||
formationValues: DEFAULT_FORMATION_VALUES,
|
||||
policy: DEFAULT_POLICY,
|
||||
seed: 0x5eed,
|
||||
searchOptions: {},
|
||||
};
|
||||
|
||||
const baseline = measureBaseline(context);
|
||||
|
||||
describe("measureBaseline", () => {
|
||||
it("measures the loadout once, for every badge to share", () => {
|
||||
expect(baseline.turn).toBeGreaterThan(0);
|
||||
expect(baseline.ev).toBeGreaterThan(0);
|
||||
expect(baseline.throwsPerTurn).toBeGreaterThanOrEqual(1);
|
||||
expect(baseline.p90).toBeGreaterThan(0);
|
||||
expect(baseline.distribution.at(0.9)).toBe(baseline.p90);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bestThrowsValue", () => {
|
||||
const distribution = { at: (fraction: number): number => fraction * 1000 };
|
||||
|
||||
it("values later charges at lower percentiles", () => {
|
||||
// One charge is worth the best throw; three charges are worth the best
|
||||
// three, which is less than three times the best.
|
||||
const one = bestThrowsValue(distribution, 1, 10);
|
||||
const three = bestThrowsValue(distribution, 3, 10);
|
||||
expect(three).toBeGreaterThan(one);
|
||||
expect(three).toBeLessThan(one * 3);
|
||||
});
|
||||
|
||||
it("never dips below the median, however short the game", () => {
|
||||
expect(bestThrowsValue(distribution, 1, 1)).toBe(500);
|
||||
});
|
||||
|
||||
it("is zero for a badge with no charges", () => {
|
||||
expect(bestThrowsValue(distribution, 0, 10)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("singleCharge", () => {
|
||||
it("maps each simulated effect onto one charge", () => {
|
||||
expect(singleCharge(badge("tin_might"))).toEqual({ ...NO_CHARGES, extraDice: 1 });
|
||||
expect(singleCharge(badge("tin_resurrection"))).toEqual({ ...NO_CHARGES, antibust: 1 });
|
||||
expect(singleCharge(badge("gold_fortune"))).toEqual({
|
||||
...NO_CHARGES,
|
||||
reroll: 1,
|
||||
rerollDice: 3,
|
||||
});
|
||||
expect(singleCharge(badge("gold_transmutation"))).toEqual({
|
||||
...NO_CHARGES,
|
||||
setDie: 1,
|
||||
setDieValue: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("gives no charges to badges that are valued analytically", () => {
|
||||
expect(singleCharge(badge("gold_warlord"))).toEqual(NO_CHARGES);
|
||||
expect(singleCharge(badge("gold_headstart"))).toEqual(NO_CHARGES);
|
||||
});
|
||||
});
|
||||
|
||||
describe("valueBadge", () => {
|
||||
it("never values an optional badge below zero", () => {
|
||||
// A badge you may simply decline to use cannot cost you points, so a
|
||||
// negative estimate would be a modelling error, not a finding.
|
||||
for (const id of BADGES_BY_ID.keys()) {
|
||||
const valuation = valueBadge(badge(id), context, baseline);
|
||||
if (valuation.pointsPerGame !== null) {
|
||||
expect(valuation.pointsPerGame).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("gives every badge a stated reason", () => {
|
||||
for (const id of BADGES_BY_ID.keys()) {
|
||||
expect(valueBadge(badge(id), context, baseline).reason.length).toBeGreaterThan(10);
|
||||
}
|
||||
});
|
||||
|
||||
it("reports defence badges as situational rather than inventing a number", () => {
|
||||
const valuation = valueBadge(badge("gold_defence"), context, baseline);
|
||||
expect(valuation.pointsPerGame).toBeNull();
|
||||
expect(valuation.reason).toMatch(/situational/i);
|
||||
});
|
||||
|
||||
it("values headstart at exactly its point lead", () => {
|
||||
expect(valueBadge(badge("tin_headstart"), context, baseline).pointsPerGame).toBe(250);
|
||||
expect(valueBadge(badge("gold_headstart"), context, baseline).pointsPerGame).toBe(1000);
|
||||
});
|
||||
|
||||
it("ranks a higher tier at least as high as a lower one", () => {
|
||||
const tin = valueBadge(badge("tin_might"), context, baseline).pointsPerGame ?? 0;
|
||||
const gold = valueBadge(badge("gold_might"), context, baseline).pointsPerGame ?? 0;
|
||||
expect(gold).toBeGreaterThanOrEqual(tin);
|
||||
});
|
||||
|
||||
it("returns a re-optimised loadout for scoring badges only", () => {
|
||||
expect(valueBadge(badge("gold_emperors"), context, baseline).dice).toHaveLength(6);
|
||||
expect(valueBadge(badge("gold_might"), context, baseline).dice).toBeNull();
|
||||
});
|
||||
|
||||
it("measures its own baseline when not given one", () => {
|
||||
const valuation = valueBadge(badge("tin_headstart"), context);
|
||||
expect(valuation.pointsPerGame).toBe(250);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rankValue", () => {
|
||||
it("ranks a scored badge by its points", () => {
|
||||
expect(
|
||||
rankValue({ badge: badge("tin_headstart"), pointsPerGame: 250, reason: "", dice: null }),
|
||||
).toBe(250);
|
||||
});
|
||||
|
||||
it("sinks a situational badge below every scored one", () => {
|
||||
expect(
|
||||
rankValue({ badge: badge("tin_defence"), pointsPerGame: null, reason: "", dice: null }),
|
||||
).toBe(-Infinity);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recommendBadges", () => {
|
||||
it("returns one ranked list per owned tier", () => {
|
||||
const recommendations = recommendBadges(
|
||||
new Set(["tin_might", "tin_headstart", "gold_warlord"]),
|
||||
context,
|
||||
);
|
||||
expect(recommendations.map((r) => r.tier)).toEqual(["tin", "gold"]);
|
||||
expect(recommendations[0].ranked).toHaveLength(2);
|
||||
expect(recommendations[1].ranked).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("orders each tier best first", () => {
|
||||
const [tin] = recommendBadges(
|
||||
new Set(["tin_might", "tin_headstart", "tin_fortune", "tin_defence"]),
|
||||
context,
|
||||
);
|
||||
const values = tin.ranked.map((v) => v.pointsPerGame ?? -Infinity);
|
||||
expect(values).toEqual([...values].sort((a, b) => b - a));
|
||||
// The situational defence badge sorts last rather than being dropped.
|
||||
expect(tin.ranked[tin.ranked.length - 1].badge.id).toBe("tin_defence");
|
||||
});
|
||||
|
||||
it("returns nothing when no badges are owned", () => {
|
||||
expect(recommendBadges(new Set(), context)).toEqual([]);
|
||||
});
|
||||
});
|
||||
333
kcd2_dice_solver/src/core/badgeValue.ts
Normal file
333
kcd2_dice_solver/src/core/badgeValue.ts
Normal file
@ -0,0 +1,333 @@
|
||||
/**
|
||||
* Estimating what a badge is worth, in points gained over a whole game.
|
||||
*
|
||||
* Badges fall into two families and are valued differently, because pretending
|
||||
* one model fits both would be wrong in a way the number would hide:
|
||||
*
|
||||
* Scoring badges (Carpenter, Executioner, Priest, Emperor, Tyche) change the
|
||||
* scoring table. They are valued by re-running the *entire dice search* with
|
||||
* their rule active and comparing the resulting turn value — so equipping one
|
||||
* can change which six dice you should bring.
|
||||
*
|
||||
* Turn-level badges (Might, Fortune, Wedding, Resurrection, Swap-out,
|
||||
* Transmutation, Doppelganger, Warlord, Headstart, Bird King) do not change
|
||||
* what a roll is worth, only what you can do about it. Each charge is
|
||||
* simulated once and the per-charge uplift multiplied by the number of uses.
|
||||
*
|
||||
* Defence badges are purely reactive — their worth is exactly whatever the
|
||||
* opponent's badge would have done, which is unknowable at pick time. They
|
||||
* are reported as situational rather than given a fabricated number.
|
||||
*
|
||||
* Every number here is an estimate built on the simulator's stated policy. They
|
||||
* are meant to rank badges against each other, not to predict a scoreline.
|
||||
*/
|
||||
|
||||
import type { Badge, BadgeTier, FormationValues, ScoringRules } from "../data/badges.ts";
|
||||
import { BADGES, BASE_RULES, DEFAULT_FORMATION_VALUES, TIERS } from "../data/badges.ts";
|
||||
import type { Die } from "../data/dice.ts";
|
||||
import { evaluateSet, scoreDistribution } from "./evaluate.ts";
|
||||
import type { ScoreDistribution } from "./evaluate.ts";
|
||||
import { Scorer } from "./scoring.ts";
|
||||
import { findBestSet } from "./search.ts";
|
||||
import type { InventoryEntry, SearchOptions } from "./search.ts";
|
||||
import { DEFAULT_POLICY, NO_CHARGES, simulateTurns } from "./simulate.ts";
|
||||
import type { BadgeCharges, TurnPolicy } from "./simulate.ts";
|
||||
|
||||
/** How many turns a game is assumed to last, for scaling per-turn uplifts. */
|
||||
export const TURNS_PER_GAME = 8;
|
||||
|
||||
/** Turns simulated when valuing a badge. Enough to rank, cheap enough to feel instant. */
|
||||
export const BADGE_SIMULATION_TURNS = 4000;
|
||||
|
||||
export interface BadgeValuation {
|
||||
readonly badge: Badge;
|
||||
/** Estimated points gained over a game, or null for situational badges. */
|
||||
readonly pointsPerGame: number | null;
|
||||
/** One-line explanation of where the number came from. */
|
||||
readonly reason: string;
|
||||
/** The dice this badge implies, when it changes the optimal loadout. */
|
||||
readonly dice: readonly Die[] | null;
|
||||
}
|
||||
|
||||
export interface BadgeRecommendation {
|
||||
readonly tier: BadgeTier;
|
||||
readonly ranked: readonly BadgeValuation[];
|
||||
}
|
||||
|
||||
/** Inputs shared by every badge valuation in one solve. */
|
||||
export interface BadgeContext {
|
||||
readonly inventory: readonly InventoryEntry[];
|
||||
readonly baselineDice: readonly Die[];
|
||||
readonly formationValues: FormationValues;
|
||||
readonly policy: TurnPolicy;
|
||||
readonly seed: number;
|
||||
readonly searchOptions: SearchOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mean banked points per turn for a set under a rule set.
|
||||
*
|
||||
* @param dice - The six dice played.
|
||||
* @param scorer - Memoised scorer for the active rules.
|
||||
* @param policy - Push-your-luck policy.
|
||||
* @param charges - Badge charges available that turn.
|
||||
* @param seed - Random seed, held constant across comparisons so that
|
||||
* differences reflect the badge and not simulation noise.
|
||||
* @returns Mean points banked per turn.
|
||||
*/
|
||||
function turnValue(
|
||||
dice: readonly Die[],
|
||||
scorer: Scorer,
|
||||
policy: TurnPolicy,
|
||||
charges: BadgeCharges,
|
||||
seed: number,
|
||||
): number {
|
||||
return simulateTurns(dice, scorer, BADGE_SIMULATION_TURNS, policy, charges, seed).meanPerTurn;
|
||||
}
|
||||
|
||||
/**
|
||||
* The baseline every badge is measured against: the no-badge loadout's scorer,
|
||||
* its simulated turn value, and its exact throw evaluation.
|
||||
*
|
||||
* Computed once per solve rather than once per badge — recomputing a 4,000-turn
|
||||
* simulation thirty-three times was the single largest cost in the first
|
||||
* version of this module.
|
||||
*/
|
||||
export interface BadgeBaseline {
|
||||
readonly scorer: Scorer;
|
||||
readonly turn: number;
|
||||
readonly p90: number;
|
||||
/** Exact expected score of one throw of the baseline loadout. */
|
||||
readonly ev: number;
|
||||
/** Mean throws per turn, used to convert a per-throw delta into a per-game one. */
|
||||
readonly throwsPerTurn: number;
|
||||
/** Exact single-throw score distribution of the baseline loadout. */
|
||||
readonly distribution: ScoreDistribution;
|
||||
}
|
||||
|
||||
/**
|
||||
* Total value of the best `uses` throws a player will see in a game.
|
||||
*
|
||||
* A badge with three charges is not worth three 90th-percentile throws: over the
|
||||
* ten-or-so throws in a game you get roughly one 90th-percentile throw, one
|
||||
* 80th, one 70th. Valuing each charge at the same optimistic percentile
|
||||
* over-rated the three-charge gold badges noticeably.
|
||||
*
|
||||
* @param distribution - Single-throw score distribution.
|
||||
* @param uses - How many charges the badge has.
|
||||
* @param throwsPerGame - How many throws the player expects to make.
|
||||
* @returns Summed value of the `uses` best throws.
|
||||
*/
|
||||
export function bestThrowsValue(
|
||||
distribution: ScoreDistribution,
|
||||
uses: number,
|
||||
throwsPerGame: number,
|
||||
): number {
|
||||
let total = 0;
|
||||
for (let k = 1; k <= uses; k += 1) {
|
||||
// The k-th best of N throws sits near the (1 - k/N) quantile. Clamped so a
|
||||
// short game cannot push the estimate below the median.
|
||||
const fraction = Math.min(0.99, Math.max(0.5, 1 - k / Math.max(1, throwsPerGame)));
|
||||
total += distribution.at(fraction);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure the no-badge baseline for a loadout.
|
||||
*
|
||||
* @param context - Inventory, baseline loadout, and simulation settings.
|
||||
* @returns The scorer, turn value, and 90th-percentile throw score.
|
||||
*/
|
||||
export function measureBaseline(context: BadgeContext): BadgeBaseline {
|
||||
const scorer = new Scorer(BASE_RULES, context.formationValues);
|
||||
const simulation = simulateTurns(
|
||||
context.baselineDice,
|
||||
scorer,
|
||||
BADGE_SIMULATION_TURNS,
|
||||
context.policy,
|
||||
NO_CHARGES,
|
||||
context.seed,
|
||||
);
|
||||
const evaluation = evaluateSet(context.baselineDice, scorer);
|
||||
return {
|
||||
scorer,
|
||||
turn: simulation.meanPerTurn,
|
||||
throwsPerTurn: simulation.throwsPerTurn,
|
||||
p90: evaluation.p90,
|
||||
ev: evaluation.ev,
|
||||
distribution: scoreDistribution(context.baselineDice, scorer),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Value one badge in points gained per game.
|
||||
*
|
||||
* @param badge - The badge to value.
|
||||
* @param context - Inventory, baseline loadout, and simulation settings.
|
||||
* @param baseline - The no-badge baseline to compare against.
|
||||
* @returns The valuation, including any change to the recommended dice.
|
||||
*/
|
||||
export function valueBadge(
|
||||
badge: Badge,
|
||||
context: BadgeContext,
|
||||
baseline: BadgeBaseline = measureBaseline(context),
|
||||
): BadgeValuation {
|
||||
const { baselineDice, formationValues, policy, seed } = context;
|
||||
const { scorer: baseScorer, turn: baseTurn } = baseline;
|
||||
const effect = badge.effect;
|
||||
|
||||
switch (effect.kind) {
|
||||
case "scoring": {
|
||||
const rules: ScoringRules = { ...BASE_RULES, ...effect.rules };
|
||||
const scorer = new Scorer(rules, formationValues);
|
||||
// The rule change can make different dice optimal, so re-run the search.
|
||||
const search = findBestSet(context.inventory, scorer, context.searchOptions);
|
||||
// Valued on the *exact* per-throw EV delta rather than by simulation.
|
||||
// Adding a formation can only ever raise a roll's best score, so this
|
||||
// difference is provably non-negative — whereas comparing two simulated
|
||||
// turn values let noise report a strictly-better rule as a loss.
|
||||
const delta = search.evaluation.ev - baseline.ev;
|
||||
return {
|
||||
badge,
|
||||
pointsPerGame: delta * baseline.throwsPerTurn * TURNS_PER_GAME,
|
||||
reason: `Changes the scoring table: +${format(delta)} expected points per throw (${format(baseline.throwsPerTurn)} throws/turn, ${TURNS_PER_GAME} turns/game).`,
|
||||
dice: search.dice,
|
||||
};
|
||||
}
|
||||
case "headstart": {
|
||||
return {
|
||||
badge,
|
||||
pointsPerGame: effect.points,
|
||||
reason: `Flat lead of ${effect.points} points (UNVERIFIED: the game only says "small"/"moderate"/"large").`,
|
||||
dice: null,
|
||||
};
|
||||
}
|
||||
case "multiplier": {
|
||||
// Saved for a good turn, so valued against a 90th-percentile throw run
|
||||
// rather than an average one.
|
||||
const good = baseline.p90 + baseTurn;
|
||||
const gain = (effect.factor - 1) * good * effect.uses;
|
||||
return {
|
||||
badge,
|
||||
pointsPerGame: gain,
|
||||
reason: `x${effect.factor} on ${effect.uses} good turn(s), valued against a 90th-percentile turn.`,
|
||||
dice: null,
|
||||
};
|
||||
}
|
||||
case "doubleThrow": {
|
||||
const throwsPerGame = baseline.throwsPerTurn * TURNS_PER_GAME;
|
||||
const gain = bestThrowsValue(baseline.distribution, effect.uses, throwsPerGame);
|
||||
return {
|
||||
badge,
|
||||
pointsPerGame: gain,
|
||||
reason: `Repeats your ${effect.uses} best throw(s) out of roughly ${Math.round(throwsPerGame)} in a game.`,
|
||||
dice: null,
|
||||
};
|
||||
}
|
||||
case "extraDice":
|
||||
case "antibust":
|
||||
case "reroll":
|
||||
case "setDie": {
|
||||
const charges = singleCharge(badge);
|
||||
const perCharge =
|
||||
turnValue(baselineDice, baseScorer, policy, charges, seed) - baseTurn;
|
||||
const { uses } = effect;
|
||||
return {
|
||||
badge,
|
||||
pointsPerGame: perCharge * uses,
|
||||
reason: `Simulated uplift of ${format(perCharge)} points per charge, ${uses} charge(s) per game.`,
|
||||
dice: null,
|
||||
};
|
||||
}
|
||||
case "defence": {
|
||||
return {
|
||||
badge,
|
||||
pointsPerGame: null,
|
||||
reason: "Situational: worth exactly whatever the opponent's badge would have done.",
|
||||
dice: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the charge set that gives a badge exactly one use, for per-charge
|
||||
* measurement.
|
||||
*
|
||||
* @param badge - The badge whose effect to model.
|
||||
* @returns Charges with one use of that badge and nothing else.
|
||||
*/
|
||||
export function singleCharge(badge: Badge): BadgeCharges {
|
||||
const effect = badge.effect;
|
||||
switch (effect.kind) {
|
||||
case "extraDice":
|
||||
return { ...NO_CHARGES, extraDice: 1 };
|
||||
case "antibust":
|
||||
return { ...NO_CHARGES, antibust: 1 };
|
||||
case "reroll":
|
||||
return { ...NO_CHARGES, reroll: 1, rerollDice: effect.dice };
|
||||
case "setDie":
|
||||
return { ...NO_CHARGES, setDie: 1, setDieValue: effect.value };
|
||||
default:
|
||||
// Scoring, multiplier, doubleThrow, headstart and defence badges are
|
||||
// valued analytically rather than simulated, so they carry no charges.
|
||||
return NO_CHARGES;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Round a point figure for display inside a reason string.
|
||||
*
|
||||
* @param value - The number to format.
|
||||
* @returns The value rounded to one decimal place.
|
||||
*/
|
||||
function format(value: number): string {
|
||||
return value.toFixed(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort key for a valuation: situational badges rank below every scored one
|
||||
* rather than being dropped from the list.
|
||||
*
|
||||
* @param valuation - The valuation to rank.
|
||||
* @returns Its points per game, or negative infinity when situational.
|
||||
*/
|
||||
export function rankValue(valuation: BadgeValuation): number {
|
||||
return valuation.pointsPerGame ?? -Infinity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank the badges the player owns, one list per tier.
|
||||
*
|
||||
* @param ownedBadgeIds - Ids of badges in the player's inventory.
|
||||
* @param context - Inventory, baseline loadout, and simulation settings.
|
||||
* @returns One recommendation per tier that has at least one owned badge.
|
||||
*/
|
||||
export function recommendBadges(
|
||||
ownedBadgeIds: ReadonlySet<string>,
|
||||
context: BadgeContext,
|
||||
): BadgeRecommendation[] {
|
||||
const recommendations: BadgeRecommendation[] = [];
|
||||
const baseline = measureBaseline(context);
|
||||
for (const tier of TIERS) {
|
||||
const owned = BADGES.filter(
|
||||
(badge) => badge.tier === tier && ownedBadgeIds.has(badge.id),
|
||||
);
|
||||
if (owned.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const ranked = owned
|
||||
.map((badge) => valueBadge(badge, context, baseline))
|
||||
.sort((a, b) => rankValue(b) - rankValue(a));
|
||||
recommendations.push({ tier, ranked });
|
||||
}
|
||||
return recommendations;
|
||||
}
|
||||
|
||||
/** Defaults used when the caller does not care. */
|
||||
export const DEFAULT_BADGE_CONTEXT = {
|
||||
formationValues: DEFAULT_FORMATION_VALUES,
|
||||
policy: DEFAULT_POLICY,
|
||||
seed: 0x5eed,
|
||||
} as const;
|
||||
66
kcd2_dice_solver/src/core/counts.ts
Normal file
66
kcd2_dice_solver/src/core/counts.ts
Normal file
@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Packed representation of a roll.
|
||||
*
|
||||
* A roll is only ever needed as a *multiset* of faces, so it is stored as seven
|
||||
* counts — faces 1-6 plus the wildcard slot — and those seven counts are packed
|
||||
* into one integer. That packing is the reason the solver is fast: adding a die
|
||||
* to a category is a single integer addition, and the scorer's memo can be keyed
|
||||
* on the packed value directly, so a warm run never touches an array at all.
|
||||
*/
|
||||
|
||||
/** Number of die faces. */
|
||||
export const FACES = 6;
|
||||
|
||||
/** Length of a count vector: six faces plus the wildcard slot. */
|
||||
export const CATEGORIES = 7;
|
||||
|
||||
/** Index of the wildcard ("substitute") slot in a count vector. */
|
||||
export const WILD = 6;
|
||||
|
||||
/**
|
||||
* Base used for packing. Base 8 holds up to 7 dice in any one category, which is
|
||||
* the most a hand can ever have: six dice plus the one a Might badge adds.
|
||||
*/
|
||||
export const RADIX = 8;
|
||||
|
||||
/**
|
||||
* A roll expressed as counts per category: indices 0-5 are faces 1-6 and index
|
||||
* 6 is the number of wildcard dice.
|
||||
*/
|
||||
export type CountVector = readonly number[];
|
||||
|
||||
/** How much a packed key changes when one die joins a category. */
|
||||
export const SLOT_STEP: readonly number[] = Array.from(
|
||||
{ length: CATEGORIES },
|
||||
(_, slot) => RADIX ** (CATEGORIES - 1 - slot),
|
||||
);
|
||||
|
||||
/**
|
||||
* Pack a count vector into an integer key.
|
||||
*
|
||||
* @param counts - Count vector to encode.
|
||||
* @returns A non-negative integer uniquely identifying the vector.
|
||||
*/
|
||||
export function encode(counts: CountVector): number {
|
||||
let key = 0;
|
||||
for (const count of counts) {
|
||||
key = key * RADIX + count;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpack an integer key back into a count vector.
|
||||
*
|
||||
* @param key - Key produced by {@link encode} or by summing {@link SLOT_STEP}.
|
||||
* @returns The seven-element count vector it stands for.
|
||||
*/
|
||||
export function decode(key: number): number[] {
|
||||
const counts = new Array<number>(CATEGORIES).fill(0);
|
||||
let rest = key;
|
||||
for (let i = CATEGORIES - 1; i >= 0; i -= 1) {
|
||||
counts[i] = rest % RADIX;
|
||||
rest = Math.floor(rest / RADIX);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
112
kcd2_dice_solver/src/core/distribution.ts
Normal file
112
kcd2_dice_solver/src/core/distribution.ts
Normal file
@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Exact outcome distribution for a set of loaded dice.
|
||||
*
|
||||
* Naively a six-dice set has 6^6 = 46,656 ordered outcomes, and the search
|
||||
* evaluates tens of thousands of candidate sets. But scoring depends only on the
|
||||
* *multiset* of faces, and the dice are independent, so we convolve them into a
|
||||
* distribution over count vectors instead. After six dice there are at most
|
||||
* C(12, 6) = 924 distinct count vectors — roughly fifty times less work, and,
|
||||
* unlike sampling, exact.
|
||||
*
|
||||
* The count vectors stay packed as integers throughout (see `counts.ts`), so a
|
||||
* convolution step is an integer addition and the scorer can look the result up
|
||||
* without ever building an array.
|
||||
*/
|
||||
|
||||
import type { Die, Face } from "../data/dice.ts";
|
||||
import { CATEGORIES, SLOT_STEP, WILD, decode } from "./counts.ts";
|
||||
|
||||
/** One reachable outcome: a count vector and the probability of reaching it. */
|
||||
export interface Outcome {
|
||||
readonly counts: readonly number[];
|
||||
readonly probability: number;
|
||||
}
|
||||
|
||||
/** The same distribution with count vectors left packed. */
|
||||
export interface PackedDistribution {
|
||||
/** Packed count vectors, one per reachable outcome. */
|
||||
readonly keys: readonly number[];
|
||||
/** Probability of each, index-aligned with `keys`. Sums to 1. */
|
||||
readonly probabilities: readonly number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse a die's face weights into the seven scoring categories.
|
||||
*
|
||||
* Faces flagged as wildcards contribute their probability to the wildcard slot
|
||||
* rather than to their printed face, because that is how they score.
|
||||
*
|
||||
* @param die - The die to convert.
|
||||
* @returns A seven-element probability vector, faces 1-6 then wildcard.
|
||||
*/
|
||||
export function categoryWeights(die: Die): number[] {
|
||||
const weights = new Array<number>(CATEGORIES).fill(0);
|
||||
for (let face = 0; face < 6; face += 1) {
|
||||
const isWild = die.wildcardFaces.includes((face + 1) as Face);
|
||||
const slot = isWild ? WILD : face;
|
||||
weights[slot] += die.weights[face];
|
||||
}
|
||||
return weights;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the zero-probability categories from a die's weights.
|
||||
*
|
||||
* Several dice have a 0% face and the Pie die has two. Skipping them keeps the
|
||||
* convolution from branching into states that can never happen.
|
||||
*
|
||||
* @param weights - Category weights of one die.
|
||||
* @returns Key step and probability for each category the die can actually roll.
|
||||
*/
|
||||
function transitions(weights: readonly number[]): { step: number; weight: number }[] {
|
||||
const result: { step: number; weight: number }[] = [];
|
||||
for (let slot = 0; slot < CATEGORIES; slot += 1) {
|
||||
const weight = weights[slot];
|
||||
if (weight > 0) {
|
||||
result.push({ step: SLOT_STEP[slot], weight });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convolve a set of dice into the exact distribution over packed count vectors.
|
||||
*
|
||||
* @param dice - The dice being rolled together, in any order.
|
||||
* @returns Packed keys with their probabilities, which sum to 1.
|
||||
*/
|
||||
export function packedDistribution(dice: readonly Die[]): PackedDistribution {
|
||||
let keys: number[] = [0];
|
||||
let probabilities: number[] = [1];
|
||||
|
||||
for (const die of dice) {
|
||||
const steps = transitions(categoryWeights(die));
|
||||
const merged = new Map<number, number>();
|
||||
for (let i = 0; i < keys.length; i += 1) {
|
||||
const key = keys[i];
|
||||
const probability = probabilities[i];
|
||||
for (const { step, weight } of steps) {
|
||||
const nextKey = key + step;
|
||||
merged.set(nextKey, (merged.get(nextKey) ?? 0) + probability * weight);
|
||||
}
|
||||
}
|
||||
keys = [...merged.keys()];
|
||||
probabilities = [...merged.values()];
|
||||
}
|
||||
|
||||
return { keys, probabilities };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convolve a set of dice into the exact distribution over count vectors.
|
||||
*
|
||||
* @param dice - The dice being rolled together, in any order.
|
||||
* @returns Every reachable outcome with its probability. Probabilities sum to 1.
|
||||
*/
|
||||
export function rollDistribution(dice: readonly Die[]): Outcome[] {
|
||||
const { keys, probabilities } = packedDistribution(dice);
|
||||
return keys.map((key, index) => ({
|
||||
counts: decode(key),
|
||||
probability: probabilities[index],
|
||||
}));
|
||||
}
|
||||
187
kcd2_dice_solver/src/core/evaluate.test.ts
Normal file
187
kcd2_dice_solver/src/core/evaluate.test.ts
Normal file
@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Cross-checks for the convolution.
|
||||
*
|
||||
* The whole speed story — packed integer keys, pooling identical dice, skipping
|
||||
* zero-weight faces, routing wildcards into a seventh slot — is exactly the kind
|
||||
* of optimisation that can produce subtly wrong probabilities while still
|
||||
* looking plausible. So the distribution is checked against a naive enumeration
|
||||
* of all 6^6 ordered outcomes, which shares none of that machinery.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BASE_RULES } from "../data/badges.ts";
|
||||
import { DICE, DICE_BY_ID } from "../data/dice.ts";
|
||||
import type { Die } from "../data/dice.ts";
|
||||
import { CATEGORIES, WILD } from "./counts.ts";
|
||||
import { categoryWeights, packedDistribution, rollDistribution } from "./distribution.ts";
|
||||
import { evaluateQuick, evaluateSet, percentile, scoreDistribution } from "./evaluate.ts";
|
||||
import { Scorer } from "./scoring.ts";
|
||||
|
||||
/**
|
||||
* Expected score and bust probability by brute-force enumeration.
|
||||
*
|
||||
* Walks all 6^n ordered face combinations directly, with no packing, no pooling
|
||||
* and no zero-weight pruning — deliberately the slow, obvious implementation.
|
||||
*
|
||||
* @param dice - The dice thrown together.
|
||||
* @param scorer - Scorer to evaluate each outcome with.
|
||||
* @returns Expected score and bust probability.
|
||||
*/
|
||||
function bruteForce(
|
||||
dice: readonly Die[],
|
||||
scorer: Scorer,
|
||||
): { ev: number; bustProbability: number } {
|
||||
const weights = dice.map(categoryWeights);
|
||||
let ev = 0;
|
||||
let bustProbability = 0;
|
||||
|
||||
const walk = (index: number, counts: number[], probability: number): void => {
|
||||
if (index === dice.length) {
|
||||
const score = scorer.score(counts);
|
||||
ev += probability * score;
|
||||
if (score === 0) {
|
||||
bustProbability += probability;
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (let slot = 0; slot < CATEGORIES; slot += 1) {
|
||||
const weight = weights[index][slot];
|
||||
if (weight === 0) {
|
||||
continue;
|
||||
}
|
||||
counts[slot] += 1;
|
||||
walk(index + 1, counts, probability * weight);
|
||||
counts[slot] -= 1;
|
||||
}
|
||||
};
|
||||
|
||||
walk(0, new Array<number>(CATEGORIES).fill(0), 1);
|
||||
return { ev, bustProbability };
|
||||
}
|
||||
|
||||
const die = (id: string): Die => {
|
||||
const found = DICE_BY_ID.get(id);
|
||||
if (!found) {
|
||||
throw new Error(`no such die: ${id}`);
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
describe("convolution vs brute force", () => {
|
||||
const scorer = new Scorer(BASE_RULES);
|
||||
|
||||
it("agrees on six identical fair dice", () => {
|
||||
const set = new Array<Die>(6).fill(die("ordinary"));
|
||||
const expected = bruteForce(set, scorer);
|
||||
const actual = evaluateQuick(set, scorer);
|
||||
expect(actual.ev).toBeCloseTo(expected.ev, 9);
|
||||
expect(actual.bustProbability).toBeCloseTo(expected.bustProbability, 9);
|
||||
});
|
||||
|
||||
it("agrees on six different loaded dice", () => {
|
||||
const set = [
|
||||
die("weighted"),
|
||||
die("grozav"),
|
||||
die("favourable"),
|
||||
die("mathematician"),
|
||||
die("wagoner"),
|
||||
die("lucky"),
|
||||
];
|
||||
const expected = bruteForce(set, scorer);
|
||||
const actual = evaluateQuick(set, scorer);
|
||||
expect(actual.ev).toBeCloseTo(expected.ev, 9);
|
||||
expect(actual.bustProbability).toBeCloseTo(expected.bustProbability, 9);
|
||||
});
|
||||
|
||||
it("agrees on dice with zero-probability faces", () => {
|
||||
// Pie die has 0% fives and 0% sixes; Favourable die has 0% twos. These are
|
||||
// the faces the convolution prunes, so they need explicit cover.
|
||||
const set = [
|
||||
die("pie"),
|
||||
die("pie"),
|
||||
die("pie"),
|
||||
die("favourable"),
|
||||
die("favourable"),
|
||||
die("favourable"),
|
||||
];
|
||||
const expected = bruteForce(set, scorer);
|
||||
const actual = evaluateQuick(set, scorer);
|
||||
expect(actual.ev).toBeCloseTo(expected.ev, 9);
|
||||
expect(actual.bustProbability).toBeCloseTo(expected.bustProbability, 9);
|
||||
});
|
||||
|
||||
it("agrees on a mix including both wildcard dice", () => {
|
||||
const set = [
|
||||
die("balatro"),
|
||||
die("devils_head"),
|
||||
die("devils_head"),
|
||||
die("ordinary"),
|
||||
die("weighted"),
|
||||
die("pie"),
|
||||
];
|
||||
const expected = bruteForce(set, scorer);
|
||||
const actual = evaluateQuick(set, scorer);
|
||||
expect(actual.ev).toBeCloseTo(expected.ev, 9);
|
||||
expect(actual.bustProbability).toBeCloseTo(expected.bustProbability, 9);
|
||||
});
|
||||
});
|
||||
|
||||
describe("distribution mechanics", () => {
|
||||
it("produces probabilities that sum to one", () => {
|
||||
for (const target of DICE) {
|
||||
const { probabilities } = packedDistribution(new Array<Die>(6).fill(target));
|
||||
const total = probabilities.reduce((sum, value) => sum + value, 0);
|
||||
expect(total).toBeCloseTo(1, 12);
|
||||
}
|
||||
});
|
||||
|
||||
it("routes wildcard faces into the wildcard slot", () => {
|
||||
const weights = categoryWeights(die("devils_head"));
|
||||
// The devil's head replaces the one, so face 1 must be empty.
|
||||
expect(weights[0]).toBe(0);
|
||||
expect(weights[WILD]).toBeCloseTo(1 / 6, 9);
|
||||
});
|
||||
|
||||
it("routes every Balatro face into the wildcard slot", () => {
|
||||
const weights = categoryWeights(die("balatro"));
|
||||
expect(weights[WILD]).toBeCloseTo(1, 12);
|
||||
});
|
||||
|
||||
it("decodes count vectors that sum to the number of dice", () => {
|
||||
const outcomes = rollDistribution(new Array<Die>(3).fill(die("ordinary")));
|
||||
for (const outcome of outcomes) {
|
||||
const total = outcome.counts.reduce((sum, value) => sum + value, 0);
|
||||
expect(total).toBe(3);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("percentiles", () => {
|
||||
const scorer = new Scorer(BASE_RULES);
|
||||
|
||||
it("returns the smallest score reaching the cumulative fraction", () => {
|
||||
const outcomes = [
|
||||
{ score: 0, probability: 0.5 },
|
||||
{ score: 100, probability: 0.3 },
|
||||
{ score: 900, probability: 0.2 },
|
||||
];
|
||||
expect(percentile(outcomes, 0.4)).toBe(0);
|
||||
expect(percentile(outcomes, 0.7)).toBe(100);
|
||||
expect(percentile(outcomes, 0.9)).toBe(900);
|
||||
});
|
||||
|
||||
it("falls back to the largest score when probabilities fall short", () => {
|
||||
// A truncated distribution summing below the requested fraction.
|
||||
expect(percentile([{ score: 700, probability: 0.5 }], 0.9)).toBe(700);
|
||||
});
|
||||
|
||||
it("returns zero for an empty distribution", () => {
|
||||
expect(percentile([], 0.5)).toBe(0);
|
||||
});
|
||||
|
||||
it("exposes the same percentile through scoreDistribution", () => {
|
||||
const set = new Array<Die>(6).fill(die("ordinary"));
|
||||
const distribution = scoreDistribution(set, scorer);
|
||||
expect(distribution.at(0.9)).toBe(evaluateSet(set, scorer).p90);
|
||||
});
|
||||
});
|
||||
137
kcd2_dice_solver/src/core/evaluate.ts
Normal file
137
kcd2_dice_solver/src/core/evaluate.ts
Normal file
@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Exact single-roll evaluation of a dice set.
|
||||
*
|
||||
* This is the solver's default objective. It is policy-free: it asks only "if I
|
||||
* throw these six dice, what is the expected value of the best combination on
|
||||
* the table, and how often do I bust?". No push-your-luck assumptions are baked
|
||||
* in, so the number is a property of the dice rather than of a playstyle.
|
||||
*
|
||||
* Bust probability is reported alongside because expected value alone
|
||||
* over-rewards swingy sets — a set that busts a fifth of the time loses games
|
||||
* that its average score says it should win.
|
||||
*
|
||||
* Two entry points on purpose: the search runs `evaluateQuick` tens of thousands
|
||||
* of times and only needs the mean and the bust rate, while `evaluateSet` also
|
||||
* sorts the distribution for a percentile and is called a handful of times on
|
||||
* the results that are actually reported.
|
||||
*/
|
||||
|
||||
import type { Die } from "../data/dice.ts";
|
||||
import { packedDistribution } from "./distribution.ts";
|
||||
import type { Scorer } from "./scoring.ts";
|
||||
|
||||
/** What the search needs: the mean and the downside. */
|
||||
export interface QuickEvaluation {
|
||||
/** Expected best-combination score of a single throw of the whole set. */
|
||||
readonly ev: number;
|
||||
/** Probability the throw scores nothing at all. */
|
||||
readonly bustProbability: number;
|
||||
}
|
||||
|
||||
export interface Evaluation extends QuickEvaluation {
|
||||
/**
|
||||
* 90th-percentile throw score. Badges you save for a good moment (Warlord,
|
||||
* Doppelganger) are worth what a *good* throw is worth, not an average one.
|
||||
*/
|
||||
readonly p90: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a dice set exactly, without the percentile.
|
||||
*
|
||||
* @param dice - The dice thrown together, normally six of them.
|
||||
* @param scorer - Memoised scorer carrying the active badge rules.
|
||||
* @returns Expected score and bust probability for one throw.
|
||||
*/
|
||||
export function evaluateQuick(dice: readonly Die[], scorer: Scorer): QuickEvaluation {
|
||||
const { keys, probabilities } = packedDistribution(dice);
|
||||
let ev = 0;
|
||||
let bustProbability = 0;
|
||||
for (let i = 0; i < keys.length; i += 1) {
|
||||
const probability = probabilities[i];
|
||||
const score = scorer.scoreKey(keys[i]);
|
||||
ev += probability * score;
|
||||
if (score === 0) {
|
||||
bustProbability += probability;
|
||||
}
|
||||
}
|
||||
return { ev, bustProbability };
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a dice set exactly, including the 90th-percentile throw score.
|
||||
*
|
||||
* @param dice - The dice thrown together, normally six of them.
|
||||
* @param scorer - Memoised scorer carrying the active badge rules.
|
||||
* @returns Expected score, bust probability, and the 90th-percentile score.
|
||||
*/
|
||||
export function evaluateSet(dice: readonly Die[], scorer: Scorer): Evaluation {
|
||||
const { keys, probabilities } = packedDistribution(dice);
|
||||
let ev = 0;
|
||||
let bustProbability = 0;
|
||||
const outcomes: { score: number; probability: number }[] = [];
|
||||
|
||||
for (let i = 0; i < keys.length; i += 1) {
|
||||
const probability = probabilities[i];
|
||||
const score = scorer.scoreKey(keys[i]);
|
||||
ev += probability * score;
|
||||
if (score === 0) {
|
||||
bustProbability += probability;
|
||||
}
|
||||
outcomes.push({ score, probability });
|
||||
}
|
||||
|
||||
return { ev, bustProbability, p90: percentile(outcomes, 0.9) };
|
||||
}
|
||||
|
||||
/** A score distribution you can take arbitrary percentiles of. */
|
||||
export interface ScoreDistribution {
|
||||
/** Exact percentile of the single-throw score, for a fraction in [0, 1]. */
|
||||
readonly at: (fraction: number) => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the exact single-throw score distribution of a dice set.
|
||||
*
|
||||
* @param dice - The dice thrown together.
|
||||
* @param scorer - Memoised scorer carrying the active badge rules.
|
||||
* @returns A distribution supporting percentile queries.
|
||||
*/
|
||||
export function scoreDistribution(
|
||||
dice: readonly Die[],
|
||||
scorer: Scorer,
|
||||
): ScoreDistribution {
|
||||
const { keys, probabilities } = packedDistribution(dice);
|
||||
const outcomes = keys.map((key, index) => ({
|
||||
score: scorer.scoreKey(key),
|
||||
probability: probabilities[index],
|
||||
}));
|
||||
outcomes.sort((a, b) => a.score - b.score);
|
||||
return { at: (fraction) => percentile(outcomes, fraction) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact percentile of a discrete score distribution.
|
||||
*
|
||||
* @param outcomes - Score/probability pairs; probabilities must sum to 1.
|
||||
* @param fraction - Percentile to take, in [0, 1].
|
||||
* @returns The smallest score whose cumulative probability reaches `fraction`.
|
||||
*/
|
||||
export function percentile(
|
||||
outcomes: readonly { score: number; probability: number }[],
|
||||
fraction: number,
|
||||
): number {
|
||||
const sorted = outcomes.toSorted((a, b) => a.score - b.score);
|
||||
let cumulative = 0;
|
||||
let last = 0;
|
||||
for (const outcome of sorted) {
|
||||
cumulative += outcome.probability;
|
||||
last = outcome.score;
|
||||
if (cumulative >= fraction) {
|
||||
return outcome.score;
|
||||
}
|
||||
}
|
||||
// Only reached when the probabilities sum below `fraction`, i.e. an empty or
|
||||
// truncated distribution; the largest score seen is the honest answer.
|
||||
return last;
|
||||
}
|
||||
159
kcd2_dice_solver/src/core/scoring.test.ts
Normal file
159
kcd2_dice_solver/src/core/scoring.test.ts
Normal file
@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Every row of the wiki's scoring table, asserted directly.
|
||||
*
|
||||
* https://kingdom-come-deliverance.fandom.com/wiki/Dice/KCD2
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BASE_RULES, DEFAULT_FORMATION_VALUES } from "../data/badges.ts";
|
||||
import type { ScoringRules } from "../data/badges.ts";
|
||||
import { Scorer, ofAKindValue, tripleBase } from "./scoring.ts";
|
||||
|
||||
const plain = (): Scorer => new Scorer(BASE_RULES);
|
||||
const withRules = (rules: Partial<ScoringRules>): Scorer =>
|
||||
new Scorer({ ...BASE_RULES, ...rules }, DEFAULT_FORMATION_VALUES);
|
||||
|
||||
describe("wiki scoring table", () => {
|
||||
it("scores a single one and a single five", () => {
|
||||
expect(plain().scoreFaces([1, 2, 2, 3, 4, 4])).toBe(100);
|
||||
expect(plain().scoreFaces([5, 2, 2, 3, 4, 4])).toBe(50);
|
||||
expect(plain().scoreFaces([1, 5, 2, 2, 4, 4])).toBe(150);
|
||||
});
|
||||
|
||||
it("scores three of a kind for every face", () => {
|
||||
const scorer = plain();
|
||||
// Padded with 2s and 4s, which score nothing on their own.
|
||||
expect(scorer.scoreFaces([1, 1, 1, 2, 4, 4])).toBe(1000);
|
||||
expect(scorer.scoreFaces([2, 2, 2, 4, 4, 3])).toBe(200);
|
||||
expect(scorer.scoreFaces([3, 3, 3, 2, 4, 4])).toBe(300);
|
||||
expect(scorer.scoreFaces([4, 4, 4, 2, 2, 3])).toBe(400);
|
||||
expect(scorer.scoreFaces([5, 5, 5, 2, 4, 4])).toBe(500);
|
||||
expect(scorer.scoreFaces([6, 6, 6, 2, 4, 4])).toBe(600);
|
||||
});
|
||||
|
||||
it("doubles the value for each die beyond three", () => {
|
||||
const scorer = plain();
|
||||
// The wiki illustrates this on twos: 200 -> 400 -> 800 -> 1600.
|
||||
expect(scorer.scoreFaces([2, 2, 2, 2, 4, 3])).toBe(400);
|
||||
expect(scorer.scoreFaces([2, 2, 2, 2, 2, 3])).toBe(800);
|
||||
expect(scorer.scoreFaces([2, 2, 2, 2, 2, 2])).toBe(1600);
|
||||
// And on ones, where the base is 1000.
|
||||
expect(scorer.scoreFaces([1, 1, 1, 1, 4, 3])).toBe(2000);
|
||||
expect(scorer.scoreFaces([1, 1, 1, 1, 1, 3])).toBe(4000);
|
||||
expect(scorer.scoreFaces([1, 1, 1, 1, 1, 1])).toBe(8000);
|
||||
});
|
||||
|
||||
it("scores the three straights", () => {
|
||||
const scorer = plain();
|
||||
// The sixth die is a dead 3: the straight already consumed the only 1 and
|
||||
// the only 5, so there is nothing left to add.
|
||||
expect(scorer.scoreFaces([1, 2, 3, 4, 5, 3])).toBe(500);
|
||||
expect(scorer.scoreFaces([2, 3, 4, 5, 6, 3])).toBe(750);
|
||||
expect(scorer.scoreFaces([1, 2, 3, 4, 5, 6])).toBe(1500);
|
||||
});
|
||||
|
||||
it("reports a bust as zero", () => {
|
||||
expect(plain().scoreFaces([2, 2, 3, 3, 4, 6])).toBe(0);
|
||||
});
|
||||
|
||||
it("picks the best partition rather than the first one found", () => {
|
||||
// Six 1s could be scored as two tripled... no: as 3+3 (1000+1000) or as
|
||||
// six-of-a-kind (8000). The maximisation must find the latter.
|
||||
expect(plain().scoreFaces([1, 1, 1, 1, 1, 1])).toBe(8000);
|
||||
// 1,1,1,5,5,5 is two triples, not a triple plus singles.
|
||||
expect(plain().scoreFaces([1, 1, 1, 5, 5, 5])).toBe(1500);
|
||||
});
|
||||
|
||||
it("exposes the combination formulas", () => {
|
||||
expect(tripleBase(1)).toBe(1000);
|
||||
expect(tripleBase(4)).toBe(400);
|
||||
expect(ofAKindValue(3, 3)).toBe(300);
|
||||
expect(ofAKindValue(3, 5)).toBe(1200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wildcards", () => {
|
||||
it("lets a wildcard complete a straight", () => {
|
||||
// 2,3,4,5,6 plus one wildcard becomes the full 1-6 straight.
|
||||
expect(plain().scoreFaces([2, 3, 4, 5, 6], 1)).toBe(1500);
|
||||
});
|
||||
|
||||
it("lets a wildcard complete a triple", () => {
|
||||
// 6,6 + wildcard-as-6 is 600, but 2,3,4,6 + wildcard-as-5 is the 2-6
|
||||
// straight at 750 — the scorer must take the better of the two.
|
||||
expect(plain().scoreFaces([6, 6, 2, 3, 4], 1)).toBe(750);
|
||||
// Without the 2-6 straight available, the triple is the best use.
|
||||
expect(plain().scoreFaces([6, 6, 2, 2, 4], 1)).toBe(600);
|
||||
});
|
||||
|
||||
it("resolves six wildcards to the best possible roll", () => {
|
||||
// Balatro's die: every face is a wildcard, so six of them are six ones.
|
||||
expect(plain().scoreFaces([], 6)).toBe(8000);
|
||||
});
|
||||
|
||||
it("never makes a roll worse", () => {
|
||||
const scorer = plain();
|
||||
expect(scorer.scoreFaces([2, 2, 3, 3, 4], 1)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("badge scoring rules", () => {
|
||||
it("adds the Carpenter's Cut", () => {
|
||||
// 3+5 alone is worth 50 normally (the five); the Cut makes it 150.
|
||||
expect(plain().scoreFaces([3, 5, 2, 2, 4, 4])).toBe(50);
|
||||
expect(withRules({ cut: true }).scoreFaces([3, 5, 2, 2, 4, 4])).toBe(150);
|
||||
});
|
||||
|
||||
it("adds the Executioner's Gallows", () => {
|
||||
// Padded so neither a straight (no 3) nor a triple is available.
|
||||
expect(plain().scoreFaces([4, 5, 6, 2, 2, 6])).toBe(50);
|
||||
expect(withRules({ gallows: true }).scoreFaces([4, 5, 6, 2, 2, 6])).toBe(250);
|
||||
});
|
||||
|
||||
it("adds the Priest's Eye", () => {
|
||||
// Padded so no straight is available (no 2, no 4).
|
||||
expect(plain().scoreFaces([1, 3, 5, 6, 6, 3])).toBe(150);
|
||||
expect(withRules({ eye: true }).scoreFaces([1, 3, 5, 6, 6, 3])).toBe(300);
|
||||
});
|
||||
|
||||
it("triples the Emperor's 1+1+1 but not its extensions", () => {
|
||||
expect(withRules({ emperorTriple: true }).scoreFaces([1, 1, 1, 2, 4, 4])).toBe(3000);
|
||||
// Four ones: either the plain four-of-a-kind (2000) or a tripled triple plus
|
||||
// a single one (3000 + 100). The better reading wins on its own merits.
|
||||
expect(withRules({ emperorTriple: true }).scoreFaces([1, 1, 1, 1, 2, 4])).toBe(3100);
|
||||
});
|
||||
|
||||
it("doubles the Tyche three sixes", () => {
|
||||
expect(withRules({ tycheDouble: true }).scoreFaces([6, 6, 6, 2, 4, 4])).toBe(1200);
|
||||
});
|
||||
|
||||
it("leaves other faces alone under Emperor and Tyche", () => {
|
||||
const scorer = withRules({ emperorTriple: true, tycheDouble: true });
|
||||
expect(scorer.scoreFaces([3, 3, 3, 2, 4, 4])).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scoreUsingAll", () => {
|
||||
it("requires every die to be part of a combination", () => {
|
||||
const scorer = plain();
|
||||
expect(scorer.scoreUsingAll([1, 0, 0, 0, 0, 0, 0])).toBe(100);
|
||||
// A lone 2 cannot score, so no legal hold uses it.
|
||||
expect(scorer.scoreUsingAll([0, 1, 0, 0, 0, 0, 0])).toBe(-Infinity);
|
||||
// A 1 plus a dead 2 is still illegal as a hold.
|
||||
expect(scorer.scoreUsingAll([1, 1, 0, 0, 0, 0, 0])).toBe(-Infinity);
|
||||
});
|
||||
|
||||
it("scores an empty hold as zero", () => {
|
||||
expect(plain().scoreUsingAll([0, 0, 0, 0, 0, 0, 0])).toBe(0);
|
||||
});
|
||||
|
||||
it("resolves wildcards in a hold", () => {
|
||||
expect(plain().scoreUsingAll([0, 0, 0, 0, 0, 0, 1])).toBe(100);
|
||||
});
|
||||
|
||||
it("memoises repeated queries", () => {
|
||||
const scorer = plain();
|
||||
const first = scorer.scoreUsingAll([3, 0, 0, 0, 0, 0, 0]);
|
||||
expect(scorer.scoreUsingAll([3, 0, 0, 0, 0, 0, 0])).toBe(first);
|
||||
});
|
||||
});
|
||||
282
kcd2_dice_solver/src/core/scoring.ts
Normal file
282
kcd2_dice_solver/src/core/scoring.ts
Normal file
@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Farkle scoring as played in Kingdom Come: Deliverance II.
|
||||
*
|
||||
* Transcribed from the scoring table at
|
||||
* https://kingdom-come-deliverance.fandom.com/wiki/Dice/KCD2:
|
||||
*
|
||||
* each 1 100
|
||||
* each 5 50
|
||||
* three of a kind 1000 for ones, otherwise 100 * face
|
||||
* each die beyond three doubles the set's value
|
||||
* straight 1-5 500
|
||||
* straight 2-6 750
|
||||
* straight 1-6 1500
|
||||
*
|
||||
* The wiki's "Four of a kind 400 / Five of a kind 800 / Six of a kind 1600" row
|
||||
* is the doubling rule illustrated on twos (200 -> 400 -> 800 -> 1600), which is
|
||||
* why it is expressed here as a formula rather than a lookup.
|
||||
*
|
||||
* A roll's score is the best partition of its dice into combinations; dice left
|
||||
* over simply score nothing. That maximisation is what `bestScore` computes.
|
||||
*/
|
||||
|
||||
import type { ScoringRules, FormationValues } from "../data/badges.ts";
|
||||
import { DEFAULT_FORMATION_VALUES } from "../data/badges.ts";
|
||||
import { CATEGORIES, FACES, WILD, decode, encode } from "./counts.ts";
|
||||
import type { CountVector } from "./counts.ts";
|
||||
|
||||
export { CATEGORIES, FACES, WILD } from "./counts.ts";
|
||||
export type { CountVector } from "./counts.ts";
|
||||
|
||||
/**
|
||||
* Base value of a three-of-a-kind of the given face.
|
||||
*
|
||||
* @param face - Die face, 1-6.
|
||||
* @returns 1000 for ones, otherwise 100 times the face value.
|
||||
*/
|
||||
export function tripleBase(face: number): number {
|
||||
return face === 1 ? 1000 : face * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Value of an n-of-a-kind, applying the "each additional die doubles" rule.
|
||||
*
|
||||
* @param face - Die face, 1-6.
|
||||
* @param n - How many dice of that face are used, must be at least 3.
|
||||
* @returns The combination's point value.
|
||||
*/
|
||||
export function ofAKindValue(face: number, n: number): number {
|
||||
return tripleBase(face) * 2 ** (n - 3);
|
||||
}
|
||||
|
||||
/** Everything `bestScore` needs beyond the roll itself. */
|
||||
export interface ScoringConfig {
|
||||
readonly rules: ScoringRules;
|
||||
readonly formationValues: FormationValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* A memoised scorer bound to one rule set.
|
||||
*
|
||||
* The reachable state space is tiny (a few thousand count vectors), so the cache
|
||||
* is built lazily once per rule set and then reused across every candidate dice
|
||||
* set the search evaluates.
|
||||
*/
|
||||
export class Scorer {
|
||||
private readonly cache = new Map<number, number>();
|
||||
|
||||
private readonly strictCache = new Map<number, number>();
|
||||
|
||||
private readonly config: ScoringConfig;
|
||||
|
||||
/**
|
||||
* @param rules - Which badge-granted scoring rules are active.
|
||||
* @param formationValues - Point values for the badge formations.
|
||||
*/
|
||||
constructor(
|
||||
rules: ScoringRules,
|
||||
formationValues: FormationValues = DEFAULT_FORMATION_VALUES,
|
||||
) {
|
||||
this.config = { rules, formationValues };
|
||||
}
|
||||
|
||||
/**
|
||||
* Best achievable score for a roll.
|
||||
*
|
||||
* @param counts - Count vector of the roll (faces 1-6 plus wildcards).
|
||||
* @returns The maximum total over all legal partitions; 0 means a bust.
|
||||
*/
|
||||
score(counts: CountVector): number {
|
||||
return this.scoreKey(encode(counts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Best achievable score for a roll already packed into a key.
|
||||
*
|
||||
* The hot path uses this: on a cache hit no count vector is ever materialised,
|
||||
* which is what keeps a full search in the tens of milliseconds.
|
||||
*
|
||||
* @param key - Packed count vector, see `counts.ts`.
|
||||
* @returns The maximum total over all legal partitions; 0 means a bust.
|
||||
*/
|
||||
scoreKey(key: number): number {
|
||||
const hit = this.cache.get(key);
|
||||
if (hit !== undefined) {
|
||||
return hit;
|
||||
}
|
||||
const value = this.compute(decode(key));
|
||||
this.cache.set(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score a plain list of face values, for tests and the simulator.
|
||||
*
|
||||
* @param faces - Face values rolled, each 1-6.
|
||||
* @param wildcards - How many of the dice are wildcards.
|
||||
* @returns The best achievable score.
|
||||
*/
|
||||
scoreFaces(faces: readonly number[], wildcards = 0): number {
|
||||
const counts = new Array<number>(CATEGORIES).fill(0);
|
||||
for (const face of faces) {
|
||||
counts[face - 1] += 1;
|
||||
}
|
||||
counts[WILD] = wildcards;
|
||||
return this.score(counts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best score for a set of dice in which *every* die must be part of a
|
||||
* combination.
|
||||
*
|
||||
* This is what "holding" dice means in the mini-game: you may only set aside
|
||||
* dice that actually score, so a legal hold is exactly a sub-multiset with a
|
||||
* finite value here. The plain `score` cannot answer that, because it is free
|
||||
* to ignore dead dice.
|
||||
*
|
||||
* @param counts - Count vector of the dice being held.
|
||||
* @returns The best score using all of them, or `-Infinity` if impossible.
|
||||
*/
|
||||
scoreUsingAll(counts: CountVector): number {
|
||||
const key = encode(counts);
|
||||
const hit = this.strictCache.get(key);
|
||||
if (hit !== undefined) {
|
||||
return hit;
|
||||
}
|
||||
const value = this.computeStrict(counts);
|
||||
this.strictCache.set(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uncached recursion for {@link scoreUsingAll}.
|
||||
*
|
||||
* @param counts - Count vector of the remaining dice.
|
||||
* @returns Best score consuming every die, or `-Infinity`.
|
||||
*/
|
||||
private computeStrict(counts: CountVector): number {
|
||||
const remaining = counts.reduce((sum, count) => sum + count, 0);
|
||||
if (remaining === 0) {
|
||||
return 0;
|
||||
}
|
||||
let best = -Infinity;
|
||||
for (const { value, rest } of this.moves(counts)) {
|
||||
const tail = this.scoreUsingAll(rest);
|
||||
if (tail > -Infinity) {
|
||||
best = Math.max(best, value + tail);
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uncached recursive maximisation over partitions.
|
||||
*
|
||||
* @param counts - Count vector of the remaining dice.
|
||||
* @returns The best score obtainable from those dice.
|
||||
*/
|
||||
private compute(counts: CountVector): number {
|
||||
// Leaving dice unscored is always legal, so 0 is the floor.
|
||||
let best = 0;
|
||||
for (const { value, rest } of this.moves(counts)) {
|
||||
best = Math.max(best, value + this.score(rest));
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every legal single step from a position: take one combination (or resolve
|
||||
* one wildcard) and hand the leftovers back to the recursion.
|
||||
*
|
||||
* Both scorers share this so the rules are stated exactly once.
|
||||
*
|
||||
* @param counts - Count vector of the remaining dice.
|
||||
* @returns Each move's point value and the dice left afterwards.
|
||||
*/
|
||||
private moves(counts: CountVector): { value: number; rest: number[] }[] {
|
||||
const wild = counts[WILD];
|
||||
if (wild > 0) {
|
||||
// A wildcard is free to become any face, so try each in turn. Resolving
|
||||
// one at a time is sufficient: further wildcards are handled by the
|
||||
// recursive call, and every assignment is reachable that way.
|
||||
const options: { value: number; rest: number[] }[] = [];
|
||||
for (let face = 0; face < FACES; face += 1) {
|
||||
const rest = counts.slice();
|
||||
rest[WILD] = wild - 1;
|
||||
rest[face] += 1;
|
||||
options.push({ value: 0, rest });
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
const options: { value: number; rest: number[] }[] = [];
|
||||
const { rules, formationValues } = this.config;
|
||||
|
||||
const take = (faces: readonly number[], value: number): void => {
|
||||
const rest = counts.slice();
|
||||
for (const face of faces) {
|
||||
if (rest[face - 1] === 0) {
|
||||
return;
|
||||
}
|
||||
rest[face - 1] -= 1;
|
||||
}
|
||||
options.push({ value, rest });
|
||||
};
|
||||
|
||||
// Straights.
|
||||
take([1, 2, 3, 4, 5, 6], 1500);
|
||||
take([2, 3, 4, 5, 6], 750);
|
||||
take([1, 2, 3, 4, 5], 500);
|
||||
|
||||
// Badge formations.
|
||||
if (rules.cut) {
|
||||
take([3, 5], formationValues.cut);
|
||||
}
|
||||
if (rules.gallows) {
|
||||
take([4, 5, 6], formationValues.gallows);
|
||||
}
|
||||
if (rules.eye) {
|
||||
take([1, 3, 5], formationValues.eye);
|
||||
}
|
||||
|
||||
// N-of-a-kind, for every face and every usable size.
|
||||
for (let face = 0; face < FACES; face += 1) {
|
||||
const available = counts[face];
|
||||
for (let n = 3; n <= available; n += 1) {
|
||||
const rest = counts.slice();
|
||||
rest[face] = available - n;
|
||||
options.push({ value: this.combinationValue(face + 1, n), rest });
|
||||
}
|
||||
}
|
||||
|
||||
// Singles: only ones and fives score on their own.
|
||||
take([1], 100);
|
||||
take([5], 50);
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Value of an n-of-a-kind under the active badge rules.
|
||||
*
|
||||
* @param face - Die face, 1-6.
|
||||
* @param n - Number of dice in the set.
|
||||
* @returns The point value, including the Emperor and Tyche multipliers.
|
||||
*/
|
||||
private combinationValue(face: number, n: number): number {
|
||||
const base = ofAKindValue(face, n);
|
||||
// Both badges name a specific three-die combination — "every 1+1+1" and
|
||||
// "three sixes" — so they multiply the triple only, not the four-, five- and
|
||||
// six-of-a-kind extensions of it. Applying the multiplier to those too made
|
||||
// the Emperor badge look like a 27,000-point swing in a game played to a few
|
||||
// thousand. The partition search still considers splitting six ones into two
|
||||
// tripled triples, so the better reading wins on its own merits.
|
||||
if (n === 3 && face === 1 && this.config.rules.emperorTriple) {
|
||||
return base * 3;
|
||||
}
|
||||
if (n === 3 && face === 6 && this.config.rules.tycheDouble) {
|
||||
return base * 2;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
}
|
||||
223
kcd2_dice_solver/src/core/search.test.ts
Normal file
223
kcd2_dice_solver/src/core/search.test.ts
Normal file
@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Ground truth for the search.
|
||||
*
|
||||
* The exhaustive path is checked against an independent brute-force enumeration,
|
||||
* and the heuristic path is checked against the exhaustive one on a problem
|
||||
* small enough that the true optimum is known.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BASE_RULES } from "../data/badges.ts";
|
||||
import { DICE_BY_ID } from "../data/dice.ts";
|
||||
import type { Die } from "../data/dice.ts";
|
||||
import { evaluateQuick } from "./evaluate.ts";
|
||||
import { Scorer } from "./scoring.ts";
|
||||
import { SET_SIZE, countCandidates, findBestSet, groupInventory } from "./search.ts";
|
||||
import type { InventoryEntry } from "./search.ts";
|
||||
|
||||
const die = (id: string): Die => {
|
||||
const found = DICE_BY_ID.get(id);
|
||||
if (!found) {
|
||||
throw new Error(`no such die: ${id}`);
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
/**
|
||||
* Best six-dice set by exhaustive enumeration, written independently of the
|
||||
* search's own combinatorics.
|
||||
*
|
||||
* @param inventory - Owned dice with counts.
|
||||
* @param scorer - Scorer to evaluate with.
|
||||
* @returns The best set's expected value.
|
||||
*/
|
||||
function bruteForceBest(inventory: readonly InventoryEntry[], scorer: Scorer): number {
|
||||
const pool: Die[] = [];
|
||||
for (const entry of inventory) {
|
||||
for (let n = 0; n < entry.count; n += 1) {
|
||||
pool.push(entry.die);
|
||||
}
|
||||
}
|
||||
|
||||
let best = -Infinity;
|
||||
const choose = (start: number, picked: Die[]): void => {
|
||||
if (picked.length === SET_SIZE) {
|
||||
best = Math.max(best, evaluateQuick(picked, scorer).ev);
|
||||
return;
|
||||
}
|
||||
for (let i = start; i < pool.length; i += 1) {
|
||||
picked.push(pool[i]);
|
||||
choose(i + 1, picked);
|
||||
picked.pop();
|
||||
}
|
||||
};
|
||||
choose(0, []);
|
||||
return best;
|
||||
}
|
||||
|
||||
describe("inventory grouping", () => {
|
||||
it("pools dice whose distributions are identical", () => {
|
||||
// Six of the game's dice are plain uniform 16.7% dice under different names.
|
||||
const groups = groupInventory([
|
||||
{ die: die("ordinary"), count: 2 },
|
||||
{ die: die("hugo"), count: 2 },
|
||||
{ die: die("molar"), count: 1 },
|
||||
]);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].available).toBe(5);
|
||||
expect(groups[0].members).toEqual(["ordinary", "hugo", "molar"]);
|
||||
});
|
||||
|
||||
it("keeps genuinely different dice apart", () => {
|
||||
const groups = groupInventory([
|
||||
{ die: die("ordinary"), count: 1 },
|
||||
{ die: die("weighted"), count: 1 },
|
||||
]);
|
||||
expect(groups).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("ignores entries with no dice", () => {
|
||||
expect(groupInventory([{ die: die("ordinary"), count: 0 }])).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("caps a group at six, since a loadout is only six dice", () => {
|
||||
const groups = groupInventory([{ die: die("ordinary"), count: 99 }]);
|
||||
expect(groups[0].available).toBe(SET_SIZE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("candidate counting", () => {
|
||||
it("counts one loadout when exactly six dice are owned", () => {
|
||||
expect(countCandidates(groupInventory([{ die: die("ordinary"), count: 6 }]))).toBe(1);
|
||||
});
|
||||
|
||||
it("counts every split between two groups", () => {
|
||||
// Six from each of two groups: 0/6, 1/5, ... 6/0 is seven loadouts.
|
||||
const groups = groupInventory([
|
||||
{ die: die("ordinary"), count: 6 },
|
||||
{ die: die("weighted"), count: 6 },
|
||||
]);
|
||||
expect(countCandidates(groups)).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exhaustive search", () => {
|
||||
const scorer = new Scorer(BASE_RULES);
|
||||
|
||||
it("matches an independent brute force", () => {
|
||||
const inventory: InventoryEntry[] = [
|
||||
{ die: die("ordinary"), count: 2 },
|
||||
{ die: die("weighted"), count: 2 },
|
||||
{ die: die("grozav"), count: 2 },
|
||||
{ die: die("misfortune"), count: 2 },
|
||||
];
|
||||
const result = findBestSet(inventory, scorer);
|
||||
expect(result.optimal).toBe(true);
|
||||
expect(result.evaluation.ev).toBeCloseTo(bruteForceBest(inventory, scorer), 9);
|
||||
});
|
||||
|
||||
it("reports distinct runner-up loadouts", () => {
|
||||
const result = findBestSet(
|
||||
[
|
||||
{ die: die("ordinary"), count: 6 },
|
||||
{ die: die("weighted"), count: 6 },
|
||||
{ die: die("lucky"), count: 6 },
|
||||
],
|
||||
scorer,
|
||||
{ alternatives: 3 },
|
||||
);
|
||||
const signatures = [result, ...result.alternatives].map((entry) =>
|
||||
entry.dice
|
||||
.map((d) => d.id)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.join(","),
|
||||
);
|
||||
expect(new Set(signatures).size).toBe(signatures.length);
|
||||
// And they must be ordered worst-last.
|
||||
const values = result.alternatives.map((a) => a.evaluation.ev);
|
||||
expect(values).toEqual([...values].sort((a, b) => b - a));
|
||||
expect(result.evaluation.ev).toBeGreaterThanOrEqual(values[0]);
|
||||
});
|
||||
|
||||
it("names the dice that were pooled into the pick", () => {
|
||||
const result = findBestSet(
|
||||
[
|
||||
{ die: die("ordinary"), count: 3 },
|
||||
{ die: die("hugo"), count: 3 },
|
||||
],
|
||||
scorer,
|
||||
);
|
||||
expect(result.equivalentIds).toEqual(["ordinary", "hugo"]);
|
||||
});
|
||||
|
||||
it("refuses an inventory smaller than a loadout", () => {
|
||||
expect(() => findBestSet([{ die: die("ordinary"), count: 5 }], scorer)).toThrow(
|
||||
/at least 6 dice/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("heuristic search", () => {
|
||||
const scorer = new Scorer(BASE_RULES);
|
||||
|
||||
const inventory: InventoryEntry[] = [
|
||||
{ die: die("ordinary"), count: 6 },
|
||||
{ die: die("weighted"), count: 6 },
|
||||
{ die: die("grozav"), count: 6 },
|
||||
{ die: die("misfortune"), count: 6 },
|
||||
{ die: die("lucky"), count: 6 },
|
||||
{ die: die("pie"), count: 6 },
|
||||
{ die: die("monk"), count: 6 },
|
||||
];
|
||||
|
||||
it("finds the true optimum on a problem the exhaustive path can also solve", () => {
|
||||
const truth = findBestSet(inventory, scorer);
|
||||
expect(truth.optimal).toBe(true);
|
||||
|
||||
// Same inventory, but forced down the hill-climb path.
|
||||
const heuristic = findBestSet(inventory, scorer, { exhaustiveLimit: 0 });
|
||||
expect(heuristic.optimal).toBe(false);
|
||||
expect(heuristic.evaluation.ev).toBeCloseTo(truth.evaluation.ev, 9);
|
||||
});
|
||||
|
||||
it("stays within one percent of optimal from a single random restart", () => {
|
||||
const truth = findBestSet(inventory, scorer);
|
||||
for (const seed of [1, 2, 3, 12345]) {
|
||||
const heuristic = findBestSet(inventory, scorer, {
|
||||
exhaustiveLimit: 0,
|
||||
restarts: 1,
|
||||
seed,
|
||||
});
|
||||
expect(heuristic.evaluation.ev).toBeGreaterThan(truth.evaluation.ev * 0.99);
|
||||
}
|
||||
});
|
||||
|
||||
it("always returns exactly six dice", () => {
|
||||
// The hill climb once drove a group's count negative and produced sets of
|
||||
// the wrong size; this pins the invariant.
|
||||
for (const seed of [0, 7, 99, 4242]) {
|
||||
const result = findBestSet(inventory, scorer, {
|
||||
exhaustiveLimit: 0,
|
||||
restarts: 3,
|
||||
seed,
|
||||
});
|
||||
expect(result.dice).toHaveLength(SET_SIZE);
|
||||
for (const alternative of result.alternatives) {
|
||||
expect(alternative.dice).toHaveLength(SET_SIZE);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("respects how many of each die the player owns", () => {
|
||||
const result = findBestSet(
|
||||
[
|
||||
{ die: die("weighted"), count: 2 },
|
||||
{ die: die("ordinary"), count: 6 },
|
||||
],
|
||||
scorer,
|
||||
{ exhaustiveLimit: 0 },
|
||||
);
|
||||
const weighted = result.dice.filter((d) => d.id === "weighted");
|
||||
expect(weighted).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
417
kcd2_dice_solver/src/core/search.ts
Normal file
417
kcd2_dice_solver/src/core/search.ts
Normal file
@ -0,0 +1,417 @@
|
||||
/**
|
||||
* Search for the best six-dice loadout that an inventory can field.
|
||||
*
|
||||
* A set's value is not the sum of its dice's individual values — Farkle scores
|
||||
* combinations *across* the six dice (triples, straights, the doubling rule), so
|
||||
* ranking dice one at a time and taking the top six is simply wrong. The search
|
||||
* therefore evaluates whole sets.
|
||||
*
|
||||
* Two strategies, picked automatically by problem size:
|
||||
*
|
||||
* exhaustive every size-6 multiset the inventory allows; provably optimal
|
||||
* hillClimb multi-start local search over single-die swaps; labelled as
|
||||
* heuristic in the result so it is never mistaken for a proof
|
||||
*
|
||||
* Before either runs, dice whose *scoring* behaviour is identical are merged.
|
||||
* Nine of the game's dice are plain uniform 16.7% dice under different names,
|
||||
* and the three Painter's dice share one distribution, so this alone removes a
|
||||
* large amount of duplicate work.
|
||||
*/
|
||||
|
||||
import type { Die } from "../data/dice.ts";
|
||||
import { categoryWeights } from "./distribution.ts";
|
||||
import { evaluateQuick, evaluateSet } from "./evaluate.ts";
|
||||
import type { Evaluation, QuickEvaluation } from "./evaluate.ts";
|
||||
import type { Scorer } from "./scoring.ts";
|
||||
import { mulberry32, randomInt } from "../lib/rng.ts";
|
||||
|
||||
/** How many dice go into a loadout. */
|
||||
export const SET_SIZE = 6;
|
||||
|
||||
/**
|
||||
* Largest number of candidate sets we are willing to enumerate exhaustively.
|
||||
* At roughly 4k floating-point operations per candidate this stays well under a
|
||||
* second in a worker.
|
||||
*/
|
||||
export const EXHAUSTIVE_LIMIT = 300_000;
|
||||
|
||||
/** One line of the user's inventory. */
|
||||
export interface InventoryEntry {
|
||||
readonly die: Die;
|
||||
readonly count: number;
|
||||
}
|
||||
|
||||
/** A group of dice that score identically, pooled into one searchable type. */
|
||||
interface DiceGroup {
|
||||
/** Representative die, used for evaluation. */
|
||||
readonly die: Die;
|
||||
/** Every die id in the group, so the UI can explain the substitution. */
|
||||
readonly members: readonly string[];
|
||||
/** Total number of interchangeable dice available across the group. */
|
||||
readonly available: number;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
/** The recommended six dice, as concrete dice. */
|
||||
readonly dice: readonly Die[];
|
||||
/** Exact evaluation of that set. */
|
||||
readonly evaluation: Evaluation;
|
||||
/** True when the whole space was enumerated, so the result is provably best. */
|
||||
readonly optimal: boolean;
|
||||
/** Runner-up sets, best first, for context in the UI. */
|
||||
readonly alternatives: readonly SetCandidate[];
|
||||
/** Ids that were pooled because they score identically to the pick. */
|
||||
readonly equivalentIds: readonly string[];
|
||||
}
|
||||
|
||||
export interface SetCandidate {
|
||||
readonly dice: readonly Die[];
|
||||
readonly evaluation: Evaluation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge dice whose category-weight vectors are identical.
|
||||
*
|
||||
* @param inventory - Owned dice with their counts; zero counts are ignored.
|
||||
* @returns One group per distinct scoring behaviour.
|
||||
*/
|
||||
export function groupInventory(inventory: readonly InventoryEntry[]): DiceGroup[] {
|
||||
const groups = new Map<string, { die: Die; members: string[]; available: number }>();
|
||||
for (const entry of inventory) {
|
||||
if (entry.count <= 0) {
|
||||
continue;
|
||||
}
|
||||
// Round to 9 decimals so renormalisation noise cannot split a real group.
|
||||
const key = categoryWeights(entry.die)
|
||||
.map((weight) => weight.toFixed(9))
|
||||
.join(",");
|
||||
const existing = groups.get(key);
|
||||
if (existing) {
|
||||
existing.members.push(entry.die.id);
|
||||
existing.available += entry.count;
|
||||
} else {
|
||||
groups.set(key, { die: entry.die, members: [entry.die.id], available: entry.count });
|
||||
}
|
||||
}
|
||||
return [...groups.values()].map((group) => ({
|
||||
die: group.die,
|
||||
members: group.members,
|
||||
// Never search past six of a kind: a loadout is only six dice.
|
||||
available: Math.min(group.available, SET_SIZE),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Count how many size-6 multisets an inventory admits.
|
||||
*
|
||||
* Used to decide between exhaustive and heuristic search without ever building
|
||||
* the list, so an enormous inventory cannot blow up memory just to be measured.
|
||||
*
|
||||
* @param groups - Pooled dice groups.
|
||||
* @returns The number of distinct loadouts, capped at `Number.MAX_SAFE_INTEGER`.
|
||||
*/
|
||||
export function countCandidates(groups: readonly DiceGroup[]): number {
|
||||
// ways[k] = number of ways to choose k dice from the groups seen so far.
|
||||
let ways = new Array<number>(SET_SIZE + 1).fill(0);
|
||||
ways[0] = 1;
|
||||
for (const group of groups) {
|
||||
const next = new Array<number>(SET_SIZE + 1).fill(0);
|
||||
for (let taken = 0; taken <= SET_SIZE; taken += 1) {
|
||||
const base = ways[taken];
|
||||
for (let take = 0; take <= group.available && taken + take <= SET_SIZE; take += 1) {
|
||||
next[taken + take] += base;
|
||||
}
|
||||
}
|
||||
ways = next;
|
||||
}
|
||||
return ways[SET_SIZE];
|
||||
}
|
||||
|
||||
/** A candidate expressed as how many dice are taken from each group. */
|
||||
type Selection = number[];
|
||||
|
||||
/**
|
||||
* Expand a per-group selection into the concrete dice it stands for.
|
||||
*
|
||||
* @param groups - Pooled dice groups, in the same order as the selection.
|
||||
* @param selection - How many dice to take from each group.
|
||||
* @returns The six dice of the loadout.
|
||||
*/
|
||||
function expand(groups: readonly DiceGroup[], selection: Selection): Die[] {
|
||||
const dice: Die[] = [];
|
||||
groups.forEach((group, index) => {
|
||||
for (let n = 0; n < selection[index]; n += 1) {
|
||||
dice.push(group.die);
|
||||
}
|
||||
});
|
||||
return dice;
|
||||
}
|
||||
|
||||
/** Keeps the best few candidates seen, so the UI can show runners-up. */
|
||||
class Leaderboard {
|
||||
private readonly entries: { selection: Selection; evaluation: QuickEvaluation }[] = [];
|
||||
|
||||
/** Signatures already offered, so one loadout cannot occupy several slots. */
|
||||
private readonly seen = new Set<string>();
|
||||
|
||||
/**
|
||||
* @param capacity - How many candidates to retain, best first.
|
||||
*/
|
||||
constructor(private readonly capacity: number) {}
|
||||
|
||||
/**
|
||||
* Offer a candidate to the leaderboard.
|
||||
*
|
||||
* @param selection - Per-group counts of the candidate.
|
||||
* @param evaluation - Its exact evaluation.
|
||||
*/
|
||||
offer(selection: Selection, evaluation: QuickEvaluation): void {
|
||||
// The hill climb revisits the same optimum from several restarts, so
|
||||
// without this the "alternatives" list is five copies of the winner.
|
||||
const signature = selection.join(",");
|
||||
if (this.seen.has(signature)) {
|
||||
return;
|
||||
}
|
||||
if (this.entries.length >= this.capacity) {
|
||||
const worst = this.entries[this.entries.length - 1];
|
||||
if (evaluation.ev <= worst.evaluation.ev) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Recorded only once a candidate makes the board, which keeps the set small
|
||||
// during an exhaustive enumeration of tens of thousands of loadouts.
|
||||
this.seen.add(signature);
|
||||
this.entries.push({ selection: selection.slice(), evaluation });
|
||||
this.entries.sort((a, b) => b.evaluation.ev - a.evaluation.ev);
|
||||
if (this.entries.length > this.capacity) {
|
||||
this.entries.length = this.capacity;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns The retained candidates, best first.
|
||||
*/
|
||||
all(): readonly { selection: Selection; evaluation: QuickEvaluation }[] {
|
||||
return this.entries;
|
||||
}
|
||||
}
|
||||
|
||||
/** Options controlling the search. */
|
||||
export interface SearchOptions {
|
||||
/** Override the exhaustive/heuristic cutoff; used by tests. */
|
||||
readonly exhaustiveLimit?: number;
|
||||
/** Random restarts for the hill climb. */
|
||||
readonly restarts?: number;
|
||||
/** Seed for those restarts. */
|
||||
readonly seed?: number;
|
||||
/** How many runner-up sets to report. */
|
||||
readonly alternatives?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best six-dice loadout an inventory can field.
|
||||
*
|
||||
* @param inventory - Owned dice with counts.
|
||||
* @param scorer - Memoised scorer carrying the active badge rules.
|
||||
* @param options - Optional tuning; the defaults are what the UI uses.
|
||||
* @returns The recommended set, its evaluation, and runners-up.
|
||||
* @throws If the inventory holds fewer than six dice in total.
|
||||
*/
|
||||
export function findBestSet(
|
||||
inventory: readonly InventoryEntry[],
|
||||
scorer: Scorer,
|
||||
options: SearchOptions = {},
|
||||
): SearchResult {
|
||||
const groups = groupInventory(inventory);
|
||||
const total = groups.reduce((sum, group) => sum + group.available, 0);
|
||||
if (total < SET_SIZE) {
|
||||
throw new Error(`Need at least ${SET_SIZE} dice, inventory has ${total}`);
|
||||
}
|
||||
|
||||
const limit = options.exhaustiveLimit ?? EXHAUSTIVE_LIMIT;
|
||||
const board = new Leaderboard((options.alternatives ?? 4) + 1);
|
||||
const evaluate = (selection: Selection): QuickEvaluation =>
|
||||
evaluateQuick(expand(groups, selection), scorer);
|
||||
|
||||
const optimal = countCandidates(groups) <= limit;
|
||||
if (optimal) {
|
||||
enumerateAll(groups, (selection) => {
|
||||
board.offer(selection, evaluate(selection));
|
||||
});
|
||||
} else {
|
||||
hillClimb(groups, evaluate, board, options);
|
||||
}
|
||||
|
||||
// The inventory-size check above guarantees at least one candidate was
|
||||
// offered, so `all()` is never empty here.
|
||||
const [best, ...rest] = board.all();
|
||||
|
||||
const usedGroups = groups.filter((_, index) => best.selection[index] > 0);
|
||||
const bestDice = expand(groups, best.selection);
|
||||
return {
|
||||
dice: bestDice,
|
||||
// Only the handful of sets we actually report pay for the percentile.
|
||||
evaluation: evaluateSet(bestDice, scorer),
|
||||
optimal,
|
||||
alternatives: rest.map((entry) => {
|
||||
const dice = expand(groups, entry.selection);
|
||||
return { dice, evaluation: evaluateSet(dice, scorer) };
|
||||
}),
|
||||
equivalentIds: usedGroups.flatMap((group) => group.members),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate every legal size-6 selection.
|
||||
*
|
||||
* @param groups - Pooled dice groups.
|
||||
* @param visit - Called once per complete selection.
|
||||
*/
|
||||
function enumerateAll(
|
||||
groups: readonly DiceGroup[],
|
||||
visit: (selection: Selection) => void,
|
||||
): void {
|
||||
const selection: Selection = new Array<number>(groups.length).fill(0);
|
||||
|
||||
const recurse = (index: number, remaining: number): void => {
|
||||
if (remaining === 0) {
|
||||
visit(selection);
|
||||
return;
|
||||
}
|
||||
if (index >= groups.length) {
|
||||
return;
|
||||
}
|
||||
const max = Math.min(groups[index].available, remaining);
|
||||
for (let take = max; take >= 0; take -= 1) {
|
||||
selection[index] = take;
|
||||
recurse(index + 1, remaining - take);
|
||||
}
|
||||
selection[index] = 0;
|
||||
};
|
||||
|
||||
recurse(0, SET_SIZE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-start hill climb over single-die swaps.
|
||||
*
|
||||
* Each restart begins from a legal selection and repeatedly tries moving one die
|
||||
* from one group to another, keeping any move that raises expected value, until
|
||||
* no single swap helps.
|
||||
*
|
||||
* @param groups - Pooled dice groups.
|
||||
* @param evaluate - Exact evaluator for a selection.
|
||||
* @param board - Leaderboard collecting the best selections seen.
|
||||
* @param options - Restart count and seed.
|
||||
*/
|
||||
function hillClimb(
|
||||
groups: readonly DiceGroup[],
|
||||
evaluate: (selection: Selection) => QuickEvaluation,
|
||||
board: Leaderboard,
|
||||
options: SearchOptions,
|
||||
): void {
|
||||
const random = mulberry32(options.seed ?? 0x5eed);
|
||||
const restarts = options.restarts ?? 8;
|
||||
|
||||
for (let restart = 0; restart < restarts; restart += 1) {
|
||||
// Restart 0 starts greedy (fill from the highest solo-EV group down); the
|
||||
// rest start from random legal selections to escape local optima.
|
||||
const selection =
|
||||
restart === 0 ? greedySeed(groups, evaluate) : randomSeed(groups, random);
|
||||
let current = evaluate(selection);
|
||||
board.offer(selection, current);
|
||||
|
||||
// Steepest ascent: score every legal single-die swap against the *current*
|
||||
// selection, then apply only the best one and rescan. Applying swaps as they
|
||||
// are found while continuing to iterate would mutate the selection out from
|
||||
// under the loop bounds — an earlier version did exactly that and could
|
||||
// drive a group's count negative, producing seven-dice "sets" whose value
|
||||
// rises without limit and a search that never terminates.
|
||||
for (;;) {
|
||||
let bestMove: { from: number; to: number; evaluation: QuickEvaluation } | null = null;
|
||||
|
||||
for (let from = 0; from < groups.length; from += 1) {
|
||||
if (selection[from] === 0) {
|
||||
continue;
|
||||
}
|
||||
for (let to = 0; to < groups.length; to += 1) {
|
||||
if (to === from || selection[to] >= groups[to].available) {
|
||||
continue;
|
||||
}
|
||||
selection[from] -= 1;
|
||||
selection[to] += 1;
|
||||
const candidate = evaluate(selection);
|
||||
selection[from] += 1;
|
||||
selection[to] -= 1;
|
||||
|
||||
if (candidate.ev > current.ev && candidate.ev > (bestMove?.evaluation.ev ?? -Infinity)) {
|
||||
bestMove = { from, to, evaluation: candidate };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestMove) {
|
||||
break;
|
||||
}
|
||||
selection[bestMove.from] -= 1;
|
||||
selection[bestMove.to] += 1;
|
||||
current = bestMove.evaluation;
|
||||
board.offer(selection, current);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a starting selection by taking as many of the best solo group as
|
||||
* allowed, then the next best, and so on.
|
||||
*
|
||||
* @param groups - Pooled dice groups.
|
||||
* @param evaluate - Exact evaluator, used here on single-group selections.
|
||||
* @returns A legal size-6 selection.
|
||||
*/
|
||||
function greedySeed(
|
||||
groups: readonly DiceGroup[],
|
||||
evaluate: (selection: Selection) => QuickEvaluation,
|
||||
): Selection {
|
||||
const solo = groups.map((group, index) => {
|
||||
const probe: Selection = new Array<number>(groups.length).fill(0);
|
||||
const take = Math.min(group.available, SET_SIZE);
|
||||
probe[index] = take;
|
||||
// Per-die value, so a group that cannot fill all six slots is still
|
||||
// comparable with one that can.
|
||||
return { index, ev: evaluate(probe).ev / take };
|
||||
});
|
||||
solo.sort((a, b) => b.ev - a.ev);
|
||||
|
||||
const selection: Selection = new Array<number>(groups.length).fill(0);
|
||||
let remaining = SET_SIZE;
|
||||
for (const { index } of solo) {
|
||||
if (remaining === 0) {
|
||||
break;
|
||||
}
|
||||
const take = Math.min(groups[index].available, remaining);
|
||||
selection[index] = take;
|
||||
remaining -= take;
|
||||
}
|
||||
return selection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a random legal starting selection.
|
||||
*
|
||||
* @param groups - Pooled dice groups.
|
||||
* @param random - Seeded random source.
|
||||
* @returns A legal size-6 selection.
|
||||
*/
|
||||
function randomSeed(groups: readonly DiceGroup[], random: () => number): Selection {
|
||||
const selection: Selection = new Array<number>(groups.length).fill(0);
|
||||
let remaining = SET_SIZE;
|
||||
while (remaining > 0) {
|
||||
const index = randomInt(random, groups.length);
|
||||
if (selection[index] < groups[index].available) {
|
||||
selection[index] += 1;
|
||||
remaining -= 1;
|
||||
}
|
||||
}
|
||||
return selection;
|
||||
}
|
||||
260
kcd2_dice_solver/src/core/simulate.test.ts
Normal file
260
kcd2_dice_solver/src/core/simulate.test.ts
Normal file
@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Tests for the turn simulator.
|
||||
*
|
||||
* The simulator encodes a *policy*, not a fact, so these tests pin the policy's
|
||||
* stated rules rather than asserting that its output is "correct" play.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BASE_RULES } from "../data/badges.ts";
|
||||
import { DICE_BY_ID } from "../data/dice.ts";
|
||||
import type { Die } from "../data/dice.ts";
|
||||
import { mulberry32 } from "../lib/rng.ts";
|
||||
import { Scorer } from "./scoring.ts";
|
||||
import {
|
||||
DEFAULT_POLICY,
|
||||
NO_CHARGES,
|
||||
applySetDie,
|
||||
chooseHold,
|
||||
simulateTurn,
|
||||
simulateTurns,
|
||||
subMultisets,
|
||||
} from "./simulate.ts";
|
||||
|
||||
const die = (id: string): Die => {
|
||||
const found = DICE_BY_ID.get(id);
|
||||
if (!found) {
|
||||
throw new Error(`no such die: ${id}`);
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
const scorer = new Scorer(BASE_RULES);
|
||||
const sixOrdinary = new Array<Die>(6).fill(die("ordinary"));
|
||||
|
||||
/**
|
||||
* A die that always rolls the given face, for pinning deterministic behaviour.
|
||||
*
|
||||
* @param face - The face it always shows, 1-6.
|
||||
* @returns Six copies of that die.
|
||||
*/
|
||||
const alwaysRolls = (face: number): Die[] =>
|
||||
new Array<Die>(6).fill({
|
||||
id: `always-${face}`,
|
||||
name: `always ${face}`,
|
||||
description: "",
|
||||
weights: [1, 2, 3, 4, 5, 6].map((f) => (f === face ? 1 : 0)) as unknown as Die["weights"],
|
||||
wildcardFaces: [],
|
||||
});
|
||||
|
||||
const alwaysTwo = alwaysRolls(2);
|
||||
const alwaysThree = alwaysRolls(3);
|
||||
|
||||
describe("subMultisets", () => {
|
||||
it("enumerates every sub-multiset including the empty one", () => {
|
||||
// Two categories holding one die each: 2 x 2 = 4 sub-multisets.
|
||||
const counts = [1, 1, 0, 0, 0, 0, 0];
|
||||
expect(subMultisets(counts)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("scales with the count in each category", () => {
|
||||
// Three of one face: 0, 1, 2 or 3 of them.
|
||||
expect(subMultisets([3, 0, 0, 0, 0, 0, 0])).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chooseHold", () => {
|
||||
it("returns null on a bust", () => {
|
||||
expect(chooseHold([0, 2, 2, 1, 0, 1, 0], scorer, DEFAULT_POLICY)).toBeNull();
|
||||
});
|
||||
|
||||
it("only ever holds dice that score", () => {
|
||||
// One 1 and five dead dice: the only legal hold is that single 1.
|
||||
const hold = chooseHold([1, 2, 2, 1, 0, 0, 0], scorer, DEFAULT_POLICY);
|
||||
expect(hold).toEqual({ points: 100, used: 1 });
|
||||
});
|
||||
|
||||
it("declines a low-value die to keep more dice in hand", () => {
|
||||
// A 1 (100) and a 5 (50) with four dead dice. Taking both scores 150 but
|
||||
// leaves four dice; taking just the 1 scores 100 and leaves five. With the
|
||||
// default 60-point-per-die bonus the policy keeps the extra die.
|
||||
const hold = chooseHold([1, 2, 2, 0, 1, 0, 0], scorer, DEFAULT_POLICY);
|
||||
expect(hold?.used).toBe(1);
|
||||
expect(hold?.points).toBe(100);
|
||||
});
|
||||
|
||||
it("takes everything when dice in hand are worth nothing", () => {
|
||||
const greedy = { bankThreshold: 300, dieValueBonus: 0 };
|
||||
const hold = chooseHold([1, 2, 2, 0, 1, 0, 0], scorer, greedy);
|
||||
expect(hold?.used).toBe(2);
|
||||
expect(hold?.points).toBe(150);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applySetDie", () => {
|
||||
it("converts the die that gains the most", () => {
|
||||
// Two 1s and a dead 2: turning the 2 into a 1 completes the 1000 triple.
|
||||
const counts = [2, 1, 0, 1, 0, 1, 0];
|
||||
expect(applySetDie(counts, 1, scorer)).toBe(true);
|
||||
expect(counts[0]).toBe(3);
|
||||
});
|
||||
|
||||
it("declines to spend the charge when nothing improves", () => {
|
||||
// Six 1s are already worth 8000; converting any of them to a 3 is a loss.
|
||||
const counts = [6, 0, 0, 0, 0, 0, 0];
|
||||
expect(applySetDie(counts, 3, scorer)).toBe(false);
|
||||
expect(counts[0]).toBe(6);
|
||||
});
|
||||
|
||||
it("declines when there is no other die to convert", () => {
|
||||
const counts = [0, 0, 3, 0, 0, 0, 0];
|
||||
expect(applySetDie(counts, 3, scorer)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("simulateTurn", () => {
|
||||
it("banks once the threshold is reached", () => {
|
||||
const random = mulberry32(1);
|
||||
const banked = simulateTurn(sixOrdinary, scorer, DEFAULT_POLICY, NO_CHARGES, random);
|
||||
expect(banked).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("is reproducible for a given seed", () => {
|
||||
const first = simulateTurn(sixOrdinary, scorer, DEFAULT_POLICY, NO_CHARGES, mulberry32(7));
|
||||
const second = simulateTurn(sixOrdinary, scorer, DEFAULT_POLICY, NO_CHARGES, mulberry32(7));
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it("counts the throws it made", () => {
|
||||
const counter = { throws: 0 };
|
||||
simulateTurn(sixOrdinary, scorer, DEFAULT_POLICY, NO_CHARGES, mulberry32(3), counter);
|
||||
expect(counter.throws).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("brings the whole set back when every die scores", () => {
|
||||
// Six weighted dice are ones about two thirds of the time, and with no
|
||||
// per-die bonus the policy holds every scoring die — so all six get set
|
||||
// aside and the hot-dice branch has to refill the hand. A threshold above
|
||||
// one throw's typical score keeps the turn going long enough to see it.
|
||||
const weighted = new Array<Die>(6).fill(die("weighted"));
|
||||
const counter = { throws: 0 };
|
||||
simulateTurn(
|
||||
weighted,
|
||||
scorer,
|
||||
{ bankThreshold: 20_000, dieValueBonus: 0 },
|
||||
NO_CHARGES,
|
||||
mulberry32(5),
|
||||
counter,
|
||||
);
|
||||
expect(counter.throws).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("always terminates, even for dice that can never bust", () => {
|
||||
// Six dice that always roll a 2 always score (six of a kind, 1600). Before
|
||||
// banking was checked ahead of the hot-dice re-roll, this looped forever.
|
||||
const banked = simulateTurn(
|
||||
alwaysTwo,
|
||||
scorer,
|
||||
DEFAULT_POLICY,
|
||||
NO_CHARGES,
|
||||
mulberry32(1),
|
||||
);
|
||||
expect(banked).toBe(1600);
|
||||
});
|
||||
});
|
||||
|
||||
describe("badge charges in a turn", () => {
|
||||
const misfortune = new Array<Die>(6).fill(die("misfortune"));
|
||||
|
||||
it("an anti-bust charge can only raise the mean", () => {
|
||||
const base = simulateTurns(misfortune, scorer, 2000, DEFAULT_POLICY, NO_CHARGES, 11);
|
||||
const rescued = simulateTurns(
|
||||
misfortune,
|
||||
scorer,
|
||||
2000,
|
||||
DEFAULT_POLICY,
|
||||
{ ...NO_CHARGES, antibust: 1 },
|
||||
11,
|
||||
);
|
||||
expect(rescued.meanPerTurn).toBeGreaterThanOrEqual(base.meanPerTurn);
|
||||
expect(rescued.bustRate).toBeLessThanOrEqual(base.bustRate);
|
||||
});
|
||||
|
||||
it("a re-roll charge can only raise the mean", () => {
|
||||
const base = simulateTurns(misfortune, scorer, 2000, DEFAULT_POLICY, NO_CHARGES, 13);
|
||||
const rerolled = simulateTurns(
|
||||
misfortune,
|
||||
scorer,
|
||||
2000,
|
||||
DEFAULT_POLICY,
|
||||
{ ...NO_CHARGES, reroll: 1, rerollDice: 3 },
|
||||
13,
|
||||
);
|
||||
expect(rerolled.meanPerTurn).toBeGreaterThanOrEqual(base.meanPerTurn);
|
||||
});
|
||||
|
||||
it("an extra die can only raise the mean", () => {
|
||||
const base = simulateTurns(sixOrdinary, scorer, 2000, DEFAULT_POLICY, NO_CHARGES, 17);
|
||||
const bigger = simulateTurns(
|
||||
sixOrdinary,
|
||||
scorer,
|
||||
2000,
|
||||
DEFAULT_POLICY,
|
||||
{ ...NO_CHARGES, extraDice: 1 },
|
||||
17,
|
||||
);
|
||||
expect(bigger.meanPerTurn).toBeGreaterThanOrEqual(base.meanPerTurn);
|
||||
});
|
||||
|
||||
it("a transmutation charge can only raise the mean", () => {
|
||||
const base = simulateTurns(sixOrdinary, scorer, 2000, DEFAULT_POLICY, NO_CHARGES, 19);
|
||||
const transmuted = simulateTurns(
|
||||
sixOrdinary,
|
||||
scorer,
|
||||
2000,
|
||||
DEFAULT_POLICY,
|
||||
{ ...NO_CHARGES, setDie: 1, setDieValue: 1 },
|
||||
19,
|
||||
);
|
||||
expect(transmuted.meanPerTurn).toBeGreaterThanOrEqual(base.meanPerTurn);
|
||||
});
|
||||
});
|
||||
|
||||
describe("simulateTurns", () => {
|
||||
it("reports a mean, an error bar, a bust rate and a throw count", () => {
|
||||
const result = simulateTurns(sixOrdinary, scorer, 1000);
|
||||
expect(result.turns).toBe(1000);
|
||||
expect(result.meanPerTurn).toBeGreaterThan(0);
|
||||
expect(result.standardError).toBeGreaterThan(0);
|
||||
expect(result.bustRate).toBeGreaterThan(0);
|
||||
expect(result.bustRate).toBeLessThan(1);
|
||||
expect(result.throwsPerTurn).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("is reproducible for a given seed", () => {
|
||||
const a = simulateTurns(sixOrdinary, scorer, 500, DEFAULT_POLICY, NO_CHARGES, 42);
|
||||
const b = simulateTurns(sixOrdinary, scorer, 500, DEFAULT_POLICY, NO_CHARGES, 42);
|
||||
expect(a.meanPerTurn).toBe(b.meanPerTurn);
|
||||
});
|
||||
|
||||
it("reports zero variance when every turn is identical", () => {
|
||||
const result = simulateTurns(alwaysTwo, scorer, 50);
|
||||
expect(result.meanPerTurn).toBe(1600);
|
||||
expect(result.standardError).toBe(0);
|
||||
expect(result.bustRate).toBe(0);
|
||||
});
|
||||
|
||||
it("reports a total bust for dice that can never score", () => {
|
||||
// Two each of 2, 3 and 4 every single throw: no 1, no 5, no three of a
|
||||
// kind and no straight, so every turn busts.
|
||||
const noScore = [
|
||||
...alwaysTwo.slice(0, 2),
|
||||
...alwaysThree.slice(0, 2),
|
||||
...alwaysRolls(4).slice(0, 2),
|
||||
];
|
||||
const result = simulateTurns(noScore, scorer, 20);
|
||||
expect(result.meanPerTurn).toBe(0);
|
||||
expect(result.standardError).toBe(0);
|
||||
expect(result.bustRate).toBe(1);
|
||||
});
|
||||
});
|
||||
384
kcd2_dice_solver/src/core/simulate.ts
Normal file
384
kcd2_dice_solver/src/core/simulate.ts
Normal file
@ -0,0 +1,384 @@
|
||||
/**
|
||||
* Monte Carlo simulation of a full push-your-luck turn.
|
||||
*
|
||||
* This is the solver's *secondary* objective. Unlike the exact single-roll EV in
|
||||
* `evaluate.ts`, a turn's value depends on how the player plays it, and the
|
||||
* policy below is an assumption rather than a fact — so it is stated explicitly
|
||||
* and its knobs are exposed rather than buried:
|
||||
*
|
||||
* 1. Only holds in which every set-aside die scores are legal (game rule).
|
||||
* 2. Among those, take the hold maximising `points + dieValueBonus * diceLeft`
|
||||
* — i.e. a die still in your hand is worth something, so do not scoop up a
|
||||
* lone 50-point five just because you can.
|
||||
* 3. Roll again while the turn total is below `bankThreshold`; bank once it
|
||||
* reaches it.
|
||||
* 4. If every die scored, take the free re-roll of all six ("hot dice"),
|
||||
* regardless of the threshold — it costs nothing to be holding zero dice.
|
||||
*
|
||||
* Turn-level badges are simulated here too, since their whole value is in how
|
||||
* they change this loop.
|
||||
*/
|
||||
|
||||
import type { Die } from "../data/dice.ts";
|
||||
import { categoryWeights } from "./distribution.ts";
|
||||
import { CATEGORIES, WILD } from "./scoring.ts";
|
||||
import type { Scorer } from "./scoring.ts";
|
||||
import { mulberry32 } from "../lib/rng.ts";
|
||||
import type { Random } from "../lib/rng.ts";
|
||||
|
||||
/** The push-your-luck policy the simulator plays. */
|
||||
export interface TurnPolicy {
|
||||
/** Bank as soon as the turn total reaches this many points. */
|
||||
readonly bankThreshold: number;
|
||||
/** Notional worth of keeping one die in hand, used when choosing a hold. */
|
||||
readonly dieValueBonus: number;
|
||||
}
|
||||
|
||||
/** Default policy. 300 is the conventional Farkle banking threshold. */
|
||||
export const DEFAULT_POLICY: TurnPolicy = {
|
||||
bankThreshold: 300,
|
||||
dieValueBonus: 60,
|
||||
};
|
||||
|
||||
/** Badge charges available to the simulated player, all optional. */
|
||||
export interface BadgeCharges {
|
||||
/** Throws on which one extra die joins the roll. */
|
||||
readonly extraDice: number;
|
||||
/** Re-throws of the whole hand after a bust. */
|
||||
readonly antibust: number;
|
||||
/** Throws on which one die is forced to `setDieValue`. */
|
||||
readonly setDie: number;
|
||||
/** Face a forced die takes, 1-6. */
|
||||
readonly setDieValue: number;
|
||||
/** Re-rolls of the worst `rerollDice` dice, used to rescue a bust. */
|
||||
readonly reroll: number;
|
||||
/** How many dice a single re-roll charge may replace. */
|
||||
readonly rerollDice: number;
|
||||
}
|
||||
|
||||
/** No badge equipped. */
|
||||
export const NO_CHARGES: BadgeCharges = {
|
||||
extraDice: 0,
|
||||
antibust: 0,
|
||||
setDie: 0,
|
||||
setDieValue: 1,
|
||||
reroll: 0,
|
||||
rerollDice: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Sample one category (face index, or the wildcard slot) from a die.
|
||||
*
|
||||
* @param weights - Category weights summing to 1.
|
||||
* @param random - Seeded random source.
|
||||
* @returns The index of the sampled category.
|
||||
*/
|
||||
function sampleCategory(weights: readonly number[], random: Random): number {
|
||||
let target = random();
|
||||
let slot = 0;
|
||||
// Stop one short: the final category absorbs any floating-point drift that
|
||||
// would otherwise let `target` survive the whole sweep.
|
||||
for (; slot < CATEGORIES - 1; slot += 1) {
|
||||
target -= weights[slot];
|
||||
if (target <= 0) {
|
||||
return slot;
|
||||
}
|
||||
}
|
||||
return WILD;
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll a hand of dice into a count vector.
|
||||
*
|
||||
* @param hand - Pre-computed category weights, one entry per die.
|
||||
* @param random - Seeded random source.
|
||||
* @returns The resulting count vector.
|
||||
*/
|
||||
function roll(hand: readonly (readonly number[])[], random: Random): number[] {
|
||||
const counts = new Array<number>(CATEGORIES).fill(0);
|
||||
for (const weights of hand) {
|
||||
counts[sampleCategory(weights, random)] += 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every sub-multiset of a roll, as candidate holds.
|
||||
*
|
||||
* @param counts - Count vector of the roll.
|
||||
* @returns All sub-multisets, including the empty one.
|
||||
*/
|
||||
export function subMultisets(counts: readonly number[]): number[][] {
|
||||
let results: number[][] = [[]];
|
||||
for (const available of counts) {
|
||||
const next: number[][] = [];
|
||||
for (const prefix of results) {
|
||||
for (let take = 0; take <= available; take += 1) {
|
||||
next.push([...prefix, take]);
|
||||
}
|
||||
}
|
||||
results = next;
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/** The hold the policy chose, plus what it is worth. */
|
||||
interface Hold {
|
||||
readonly points: number;
|
||||
readonly used: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose which dice to set aside, per rules 1 and 2 of the policy.
|
||||
*
|
||||
* @param counts - Count vector of the roll.
|
||||
* @param scorer - Memoised scorer carrying the active badge rules.
|
||||
* @param policy - The push-your-luck policy in force.
|
||||
* @returns The chosen hold, or `null` when nothing scores (a bust).
|
||||
*/
|
||||
export function chooseHold(
|
||||
counts: readonly number[],
|
||||
scorer: Scorer,
|
||||
policy: TurnPolicy,
|
||||
): Hold | null {
|
||||
const total = counts.reduce((sum, value) => sum + value, 0);
|
||||
let best: Hold | null = null;
|
||||
let bestUtility = -Infinity;
|
||||
|
||||
for (const candidate of subMultisets(counts)) {
|
||||
const used = candidate.reduce((sum, value) => sum + value, 0);
|
||||
if (used === 0) {
|
||||
continue;
|
||||
}
|
||||
const points = scorer.scoreUsingAll(candidate);
|
||||
if (points === -Infinity) {
|
||||
continue;
|
||||
}
|
||||
const utility = points + policy.dieValueBonus * (total - used);
|
||||
if (utility > bestUtility) {
|
||||
bestUtility = utility;
|
||||
best = { points, used };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force one die in a roll to a chosen face, spending a Transmutation charge.
|
||||
*
|
||||
* Every candidate conversion is tried and the best one kept, and the charge is
|
||||
* only spent if it actually raises the roll's score. A player would never burn a
|
||||
* Tin Transmutation to turn a 1 into a 3, so the model must not either — an
|
||||
* earlier version always converted the "least useful" die and consequently
|
||||
* valued the tin badge at *minus* 95 points.
|
||||
*
|
||||
* @param counts - Count vector of the roll, mutated in place when it helps.
|
||||
* @param value - The face the die becomes, 1-6.
|
||||
* @param scorer - Memoised scorer carrying the active badge rules.
|
||||
* @returns True if the charge was spent.
|
||||
*/
|
||||
export function applySetDie(counts: number[], value: number, scorer: Scorer): boolean {
|
||||
const before = scorer.score(counts);
|
||||
let bestFace = -1;
|
||||
let bestScore = before;
|
||||
|
||||
for (let face = 1; face <= 6; face += 1) {
|
||||
if (face === value || counts[face - 1] === 0) {
|
||||
continue;
|
||||
}
|
||||
counts[face - 1] -= 1;
|
||||
counts[value - 1] += 1;
|
||||
const after = scorer.score(counts);
|
||||
counts[face - 1] += 1;
|
||||
counts[value - 1] -= 1;
|
||||
if (after > bestScore) {
|
||||
bestScore = after;
|
||||
bestFace = face;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestFace === -1) {
|
||||
return false;
|
||||
}
|
||||
counts[bestFace - 1] -= 1;
|
||||
counts[value - 1] += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Play one turn and return the points banked.
|
||||
*
|
||||
* @param dice - The six dice being played.
|
||||
* @param scorer - Memoised scorer carrying the active badge rules.
|
||||
* @param policy - The push-your-luck policy.
|
||||
* @param charges - Badge charges available for this turn.
|
||||
* @param random - Seeded random source.
|
||||
* @param counter - Optional tally of how many throws the turn took.
|
||||
* @returns Points banked; 0 if the turn ended in a bust.
|
||||
*/
|
||||
export function simulateTurn(
|
||||
dice: readonly Die[],
|
||||
scorer: Scorer,
|
||||
policy: TurnPolicy,
|
||||
charges: BadgeCharges,
|
||||
random: Random,
|
||||
counter: { throws: number } = { throws: 0 },
|
||||
): number {
|
||||
const allWeights = dice.map(categoryWeights);
|
||||
// The extra die a Might badge grants is modelled as another copy of the first
|
||||
// die in the loadout — the loadout is uniform in practice, and the game does
|
||||
// not say which die it adds.
|
||||
const [spare] = allWeights;
|
||||
|
||||
let inHand = allWeights.length;
|
||||
let turnTotal = 0;
|
||||
let extraDice = charges.extraDice;
|
||||
let antibust = charges.antibust;
|
||||
let setDie = charges.setDie;
|
||||
let reroll = charges.reroll;
|
||||
|
||||
for (;;) {
|
||||
const hand = allWeights.slice(0, inHand);
|
||||
if (extraDice > 0) {
|
||||
hand.push(spare);
|
||||
extraDice -= 1;
|
||||
}
|
||||
let counts = roll(hand, random);
|
||||
counter.throws += 1;
|
||||
|
||||
if (setDie > 0 && applySetDie(counts, charges.setDieValue, scorer)) {
|
||||
setDie -= 1;
|
||||
}
|
||||
|
||||
let hold = chooseHold(counts, scorer, policy);
|
||||
|
||||
if (hold === null && reroll > 0) {
|
||||
// Re-roll the least useful dice rather than accept the bust.
|
||||
reroll -= 1;
|
||||
const replaced = Math.min(charges.rerollDice, hand.length);
|
||||
counts = rerollWorst(counts, replaced, hand, random);
|
||||
hold = chooseHold(counts, scorer, policy);
|
||||
}
|
||||
if (hold === null && antibust > 0) {
|
||||
antibust -= 1;
|
||||
continue;
|
||||
}
|
||||
if (hold === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
turnTotal += hold.points;
|
||||
const remaining = hand.length - hold.used;
|
||||
|
||||
// Banking is checked before the hot-dice re-roll, not after. Hot dice are
|
||||
// often described as "free", but they are not: throwing six fresh dice can
|
||||
// bust and forfeit the entire turn total. Re-rolling unconditionally also
|
||||
// made the loop non-terminating for a die that always scores.
|
||||
if (turnTotal >= policy.bankThreshold) {
|
||||
return turnTotal;
|
||||
}
|
||||
// Every continue adds at least 50 points, so the turn always terminates.
|
||||
inHand = remaining === 0 ? allWeights.length : remaining;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-roll the least useful dice of a busted roll.
|
||||
*
|
||||
* @param counts - Count vector of the busted roll.
|
||||
* @param howMany - How many dice to replace.
|
||||
* @param hand - Category weights of the dice in hand.
|
||||
* @param random - Seeded random source.
|
||||
* @returns A new count vector with those dice re-rolled.
|
||||
*/
|
||||
function rerollWorst(
|
||||
counts: readonly number[],
|
||||
howMany: number,
|
||||
hand: readonly (readonly number[])[],
|
||||
random: Random,
|
||||
): number[] {
|
||||
const next = counts.slice();
|
||||
const preference = [2, 3, 4, 6, 5, 1];
|
||||
let removed = 0;
|
||||
for (const face of preference) {
|
||||
while (removed < howMany && next[face - 1] > 0) {
|
||||
next[face - 1] -= 1;
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < removed; i += 1) {
|
||||
next[sampleCategory(hand[i], random)] += 1;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Result of a Monte Carlo run. */
|
||||
export interface SimulationResult {
|
||||
/** Mean points banked per turn. */
|
||||
readonly meanPerTurn: number;
|
||||
/** Standard error of that mean, so noise is visible rather than implied. */
|
||||
readonly standardError: number;
|
||||
/** Fraction of turns that ended in a bust. */
|
||||
readonly bustRate: number;
|
||||
/** Mean number of throws made per turn. */
|
||||
readonly throwsPerTurn: number;
|
||||
/** How many turns were played. */
|
||||
readonly turns: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Golden-ratio increment used to derive a distinct stream per turn.
|
||||
*
|
||||
* Each turn is simulated from its own seed rather than from one long shared
|
||||
* stream. That makes badge comparisons *paired*: a turn in which the badge never
|
||||
* triggers plays out bit-for-bit identically with and without it, so the
|
||||
* measured difference is the badge's effect and not a diverged random stream.
|
||||
* With a shared stream the noise swamped the signal — badges that can only ever
|
||||
* help were coming out slightly negative.
|
||||
*/
|
||||
const TURN_SEED_STEP = 0x9e3779b9;
|
||||
|
||||
/**
|
||||
* Estimate a dice set's mean banked points per turn.
|
||||
*
|
||||
* @param dice - The six dice being played.
|
||||
* @param scorer - Memoised scorer carrying the active badge rules.
|
||||
* @param turns - How many turns to simulate.
|
||||
* @param policy - The push-your-luck policy.
|
||||
* @param charges - Badge charges available each turn.
|
||||
* @param seed - Seed for the random source, so runs are reproducible.
|
||||
* @returns Mean, standard error, and bust rate.
|
||||
*/
|
||||
export function simulateTurns(
|
||||
dice: readonly Die[],
|
||||
scorer: Scorer,
|
||||
turns: number,
|
||||
policy: TurnPolicy = DEFAULT_POLICY,
|
||||
charges: BadgeCharges = NO_CHARGES,
|
||||
seed = 0x5eed,
|
||||
): SimulationResult {
|
||||
let sum = 0;
|
||||
let sumSquares = 0;
|
||||
let busts = 0;
|
||||
let throws = 0;
|
||||
for (let turn = 0; turn < turns; turn += 1) {
|
||||
const random = mulberry32((seed + turn * TURN_SEED_STEP) >>> 0);
|
||||
const counter = { throws: 0 };
|
||||
const banked = simulateTurn(dice, scorer, policy, charges, random, counter);
|
||||
sum += banked;
|
||||
sumSquares += banked * banked;
|
||||
throws += counter.throws;
|
||||
if (banked === 0) {
|
||||
busts += 1;
|
||||
}
|
||||
}
|
||||
const mean = sum / turns;
|
||||
const variance = Math.max(0, sumSquares / turns - mean * mean);
|
||||
return {
|
||||
meanPerTurn: mean,
|
||||
standardError: Math.sqrt(variance / turns),
|
||||
bustRate: busts / turns,
|
||||
throwsPerTurn: throws / turns,
|
||||
turns,
|
||||
};
|
||||
}
|
||||
69
kcd2_dice_solver/src/core/solve.demo.test.ts
Normal file
69
kcd2_dice_solver/src/core/solve.demo.test.ts
Normal file
@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Sanity oracles for the solver.
|
||||
*
|
||||
* These are the "would a human immediately call this wrong?" checks: a die that
|
||||
* is obviously the best choice must be chosen, a die that is obviously worse
|
||||
* than a plain one must be refused, and the counter-intuitive case (a die loaded
|
||||
* onto a worthless face still beating a fair die, because six of a kind pays
|
||||
* 1600) must come out right — that last one is what proves the search scores
|
||||
* whole sets rather than ranking dice individually.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { solve } from "./solve.ts";
|
||||
|
||||
/** Six of the same die id, for comparing against a solve result. */
|
||||
const sixOf = (id: string): string[] => new Array<string>(6).fill(id);
|
||||
|
||||
describe("solver sanity oracles", () => {
|
||||
it("takes six Weighted dice when they are available", () => {
|
||||
// Weighted die rolls a 1 (100 points, and the 1000-point triple) 66.7% of
|
||||
// the time. Nothing in the game beats six of them.
|
||||
const result = solve({
|
||||
diceCounts: { weighted: 6, ordinary: 20 },
|
||||
badgeIds: [],
|
||||
simulationTurns: 2000,
|
||||
});
|
||||
expect(result.dice.map((die) => die.id)).toEqual(sixOf("weighted"));
|
||||
expect(result.evaluation.ev).toBeGreaterThan(2000);
|
||||
expect(result.evaluation.bustProbability).toBeLessThan(0.01);
|
||||
});
|
||||
|
||||
it("rejects the Die of misfortune in favour of plain ordinary dice", () => {
|
||||
// Spread across 2/3/4/5 with only 4.5% ones and 4.5% sixes, it scores worse
|
||||
// than an ordinary die, so a correct solver must refuse it.
|
||||
const result = solve({
|
||||
diceCounts: { misfortune: 6, ordinary: 20 },
|
||||
badgeIds: [],
|
||||
simulationTurns: 2000,
|
||||
});
|
||||
expect(result.dice.map((die) => die.id)).toEqual(sixOf("ordinary"));
|
||||
});
|
||||
|
||||
it("prefers a die concentrated on any one face over a fair die", () => {
|
||||
// Grozav's die is 66.7% twos. A two is worth nothing on its own, but six of
|
||||
// a kind is 1600 points, so concentration beats fairness.
|
||||
const result = solve({
|
||||
diceCounts: { grozav: 6, ordinary: 20 },
|
||||
badgeIds: [],
|
||||
simulationTurns: 2000,
|
||||
});
|
||||
expect(result.dice.map((die) => die.id)).toEqual(sixOf("grozav"));
|
||||
});
|
||||
|
||||
it("reports a provably optimal answer for a small inventory", () => {
|
||||
const result = solve({
|
||||
diceCounts: { weighted: 3, ordinary: 3, lucky: 2 },
|
||||
badgeIds: [],
|
||||
simulationTurns: 500,
|
||||
});
|
||||
expect(result.optimal).toBe(true);
|
||||
expect(result.dice).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("refuses to solve an inventory of fewer than six dice", () => {
|
||||
expect(() => solve({ diceCounts: { ordinary: 5 }, badgeIds: [] })).toThrow(
|
||||
/at least 6 dice/,
|
||||
);
|
||||
});
|
||||
});
|
||||
76
kcd2_dice_solver/src/core/solve.test.ts
Normal file
76
kcd2_dice_solver/src/core/solve.test.ts
Normal file
@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Tests for the solver entrypoint: inventory in, recommendation out.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_FORMATION_VALUES } from "../data/badges.ts";
|
||||
import { DEFAULT_POLICY } from "./simulate.ts";
|
||||
import { solve, toInventory } from "./solve.ts";
|
||||
|
||||
describe("toInventory", () => {
|
||||
it("resolves known die ids", () => {
|
||||
const entries = toInventory({ weighted: 2 });
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].die.name).toBe("Weighted die");
|
||||
expect(entries[0].count).toBe(2);
|
||||
});
|
||||
|
||||
it("drops ids that are not dice in the game", () => {
|
||||
// A saved inventory from an older build could name a die that no longer
|
||||
// exists; that must not crash the solve.
|
||||
expect(toInventory({ not_a_real_die: 4 })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("drops entries with no dice", () => {
|
||||
expect(toInventory({ weighted: 0 })).toHaveLength(0);
|
||||
expect(toInventory({ weighted: -3 })).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("solve", () => {
|
||||
it("fills in every default when only an inventory is given", () => {
|
||||
const result = solve({ diceCounts: { ordinary: 6 }, badgeIds: [] });
|
||||
expect(result.dice).toHaveLength(6);
|
||||
expect(result.simulation.turns).toBeGreaterThan(1000);
|
||||
expect(result.inventorySize).toBe(6);
|
||||
expect(result.badges).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts explicit settings", () => {
|
||||
const result = solve({
|
||||
diceCounts: { ordinary: 6, weighted: 6 },
|
||||
badgeIds: ["tin_headstart"],
|
||||
formationValues: DEFAULT_FORMATION_VALUES,
|
||||
policy: DEFAULT_POLICY,
|
||||
simulationTurns: 500,
|
||||
searchOptions: { alternatives: 2 },
|
||||
});
|
||||
expect(result.simulation.turns).toBe(500);
|
||||
expect(result.alternatives).toHaveLength(2);
|
||||
expect(result.badges).toHaveLength(1);
|
||||
expect(result.badges[0].tier).toBe("tin");
|
||||
});
|
||||
|
||||
it("echoes the inventory size back for the status line", () => {
|
||||
const result = solve({
|
||||
diceCounts: { ordinary: 4, weighted: 3, lucky: 2 },
|
||||
badgeIds: [],
|
||||
simulationTurns: 200,
|
||||
});
|
||||
expect(result.inventorySize).toBe(9);
|
||||
});
|
||||
|
||||
it("solves an inventory containing the wildcard dice", () => {
|
||||
// Balatro's die is six wildcards and the Devil's head die is one; both take
|
||||
// a different path through the scorer and the simulator's sampler.
|
||||
const result = solve({
|
||||
diceCounts: { balatro: 3, devils_head: 3, ordinary: 6 },
|
||||
badgeIds: [],
|
||||
simulationTurns: 500,
|
||||
});
|
||||
expect(result.dice).toHaveLength(6);
|
||||
expect(result.simulation.meanPerTurn).toBeGreaterThan(0);
|
||||
// Six wildcards always resolve to six ones, so nothing can beat Balatro.
|
||||
expect(result.dice.filter((die) => die.id === "balatro")).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
116
kcd2_dice_solver/src/core/solve.ts
Normal file
116
kcd2_dice_solver/src/core/solve.ts
Normal file
@ -0,0 +1,116 @@
|
||||
/**
|
||||
* The one entrypoint the UI calls: inventory in, recommendation out.
|
||||
*
|
||||
* Kept free of any DOM or React reference so it runs unchanged on the main
|
||||
* thread (in tests) and inside the Web Worker (in the app).
|
||||
*/
|
||||
|
||||
import type { FormationValues } from "../data/badges.ts";
|
||||
import { BASE_RULES, DEFAULT_FORMATION_VALUES } from "../data/badges.ts";
|
||||
import { DICE_BY_ID } from "../data/dice.ts";
|
||||
import type { Die } from "../data/dice.ts";
|
||||
import { recommendBadges } from "./badgeValue.ts";
|
||||
import type { BadgeRecommendation } from "./badgeValue.ts";
|
||||
import type { Evaluation } from "./evaluate.ts";
|
||||
import { Scorer } from "./scoring.ts";
|
||||
import { findBestSet } from "./search.ts";
|
||||
import type { InventoryEntry, SearchOptions } from "./search.ts";
|
||||
import { DEFAULT_POLICY, NO_CHARGES, simulateTurns } from "./simulate.ts";
|
||||
import type { SimulationResult, TurnPolicy } from "./simulate.ts";
|
||||
|
||||
/** What the UI sends to the solver. */
|
||||
export interface SolveRequest {
|
||||
/** Owned dice, as die id to count. */
|
||||
readonly diceCounts: Readonly<Record<string, number>>;
|
||||
/** Owned badge ids. */
|
||||
readonly badgeIds: readonly string[];
|
||||
/** UNVERIFIED formation point values, editable in the UI. */
|
||||
readonly formationValues?: FormationValues;
|
||||
/** Push-your-luck policy for the simulated turn EV. */
|
||||
readonly policy?: TurnPolicy;
|
||||
/** Turns to simulate for the headline turn EV figure. */
|
||||
readonly simulationTurns?: number;
|
||||
/** Search tuning; the UI leaves this at its defaults. */
|
||||
readonly searchOptions?: SearchOptions;
|
||||
}
|
||||
|
||||
/** What the solver sends back. */
|
||||
export interface SolveResponse {
|
||||
/** The recommended six dice. */
|
||||
readonly dice: readonly Die[];
|
||||
/** Exact single-roll evaluation of that set. */
|
||||
readonly evaluation: Evaluation;
|
||||
/** Simulated full-turn value of that set. */
|
||||
readonly simulation: SimulationResult;
|
||||
/** True when the whole search space was enumerated. */
|
||||
readonly optimal: boolean;
|
||||
/** Runner-up loadouts, best first. */
|
||||
readonly alternatives: readonly { dice: readonly Die[]; evaluation: Evaluation }[];
|
||||
/** Best badge per owned tier. */
|
||||
readonly badges: readonly BadgeRecommendation[];
|
||||
/** Total dice in the inventory, echoed back for the UI's status line. */
|
||||
readonly inventorySize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a die-id-to-count record into inventory entries, dropping unknown ids.
|
||||
*
|
||||
* @param counts - Die ids mapped to how many are owned.
|
||||
* @returns Inventory entries for ids that exist in the game data.
|
||||
*/
|
||||
export function toInventory(counts: Readonly<Record<string, number>>): InventoryEntry[] {
|
||||
const entries: InventoryEntry[] = [];
|
||||
for (const [id, count] of Object.entries(counts)) {
|
||||
const die = DICE_BY_ID.get(id);
|
||||
if (die && count > 0) {
|
||||
entries.push({ die, count });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Turns simulated for the headline turn-EV figure. */
|
||||
export const DEFAULT_SIMULATION_TURNS = 20_000;
|
||||
|
||||
/**
|
||||
* Solve an inventory: pick the best six dice and rank the owned badges.
|
||||
*
|
||||
* @param request - Inventory and options.
|
||||
* @returns The recommended loadout with both objectives and badge advice.
|
||||
* @throws If the inventory holds fewer than six dice.
|
||||
*/
|
||||
export function solve(request: SolveRequest): SolveResponse {
|
||||
const inventory = toInventory(request.diceCounts);
|
||||
const formationValues = request.formationValues ?? DEFAULT_FORMATION_VALUES;
|
||||
const policy = request.policy ?? DEFAULT_POLICY;
|
||||
const searchOptions = request.searchOptions ?? {};
|
||||
|
||||
const scorer = new Scorer(BASE_RULES, formationValues);
|
||||
const search = findBestSet(inventory, scorer, searchOptions);
|
||||
const simulation = simulateTurns(
|
||||
search.dice,
|
||||
scorer,
|
||||
request.simulationTurns ?? DEFAULT_SIMULATION_TURNS,
|
||||
policy,
|
||||
NO_CHARGES,
|
||||
);
|
||||
|
||||
const badges = recommendBadges(new Set(request.badgeIds), {
|
||||
inventory,
|
||||
baselineDice: search.dice,
|
||||
formationValues,
|
||||
policy,
|
||||
seed: 0x5eed,
|
||||
searchOptions,
|
||||
});
|
||||
|
||||
return {
|
||||
dice: search.dice,
|
||||
evaluation: search.evaluation,
|
||||
simulation,
|
||||
optimal: search.optimal,
|
||||
alternatives: search.alternatives,
|
||||
badges,
|
||||
inventorySize: inventory.reduce((sum, entry) => sum + entry.count, 0),
|
||||
};
|
||||
}
|
||||
369
kcd2_dice_solver/src/data/badges.ts
Normal file
369
kcd2_dice_solver/src/data/badges.ts
Normal file
@ -0,0 +1,369 @@
|
||||
/**
|
||||
* The 33 dice badges of Kingdom Come: Deliverance II.
|
||||
*
|
||||
* Source: https://kingdom-come-deliverance.fandom.com/wiki/Dice/KCD2
|
||||
*
|
||||
* Badges only take effect when the opponent also brings a badge of the same
|
||||
* tier, so the solver recommends one badge per tier rather than a single
|
||||
* overall winner.
|
||||
*
|
||||
* UNVERIFIED CONSTANTS
|
||||
* --------------------
|
||||
* Neither the fandom wiki nor Inara publishes numeric values for the three
|
||||
* "Advantage" formations or for the Headstart point leads — the in-game text is
|
||||
* qualitative ("a small point lead"). The constants below are explicit,
|
||||
* editable guesses rather than invented data buried in a table; the UI exposes
|
||||
* them so they can be corrected from the game's own help screen.
|
||||
*/
|
||||
|
||||
export type BadgeTier = "tin" | "silver" | "gold";
|
||||
|
||||
/**
|
||||
* Extra scoring formations and multipliers granted by the five badges that
|
||||
* change the scoring rules themselves. These are threaded through `bestScore`,
|
||||
* so enabling one can change which six dice are optimal.
|
||||
*/
|
||||
export interface ScoringRules {
|
||||
/** Carpenter's Advantage: the "Cut", a 3 and a 5 together. */
|
||||
readonly cut: boolean;
|
||||
/** Executioner's Advantage: "The Gallows", a 4, a 5 and a 6. */
|
||||
readonly gallows: boolean;
|
||||
/** Priest's Advantage: "The Eye", a 1, a 3 and a 5. */
|
||||
readonly eye: boolean;
|
||||
/** Gold Emperor's badge: triple points for every 1+1+1. */
|
||||
readonly emperorTriple: boolean;
|
||||
/** Gold Tyche badge: double points for three sixes. */
|
||||
readonly tycheDouble: boolean;
|
||||
}
|
||||
|
||||
/** Rule set with every scoring badge switched off. */
|
||||
export const BASE_RULES: ScoringRules = {
|
||||
cut: false,
|
||||
gallows: false,
|
||||
eye: false,
|
||||
emperorTriple: false,
|
||||
tycheDouble: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Point values of the three badge-granted formations.
|
||||
*
|
||||
* UNVERIFIED. Defaulted to what the constituent dice are worth under the
|
||||
* ordinary rules, i.e. the formation lets you *use* dice that would otherwise
|
||||
* be dead but grants no bonus on top:
|
||||
* Cut 3+5 -> 50 (the 5) is the only ordinary value, rounded up to 150
|
||||
* Gallows 4+5+6 -> 50 (the 5), rounded up to 250
|
||||
* Eye 1+3+5 -> 150 (the 1 and the 5), rounded up to 300
|
||||
*/
|
||||
export interface FormationValues {
|
||||
readonly cut: number;
|
||||
readonly gallows: number;
|
||||
readonly eye: number;
|
||||
}
|
||||
|
||||
/** Default, UNVERIFIED formation point values. Editable in the UI. */
|
||||
export const DEFAULT_FORMATION_VALUES: FormationValues = {
|
||||
cut: 150,
|
||||
gallows: 250,
|
||||
eye: 300,
|
||||
};
|
||||
|
||||
/**
|
||||
* Point leads granted by the Headstart badges.
|
||||
*
|
||||
* UNVERIFIED. The wiki only says "small" / "moderate" / "large".
|
||||
*/
|
||||
export const HEADSTART_POINTS: Readonly<Record<BadgeTier, number>> = {
|
||||
tin: 250,
|
||||
silver: 500,
|
||||
gold: 1000,
|
||||
};
|
||||
|
||||
/** What a badge actually does, as a discriminated union. */
|
||||
export type BadgeEffect =
|
||||
/** Changes the scoring table. Evaluated jointly with the dice set. */
|
||||
| { readonly kind: "scoring"; readonly rules: Partial<ScoringRules> }
|
||||
/** Roll `dice` extra dice, `uses` times per game. */
|
||||
| { readonly kind: "extraDice"; readonly dice: number; readonly uses: number }
|
||||
/** Reroll up to `dice` dice of your choosing, `uses` times per game. */
|
||||
| { readonly kind: "reroll"; readonly dice: number; readonly uses: number }
|
||||
/** Re-throw after a bust, `uses` times per game. */
|
||||
| { readonly kind: "antibust"; readonly uses: number }
|
||||
/** Double the point value of your last throw, `uses` times per game. */
|
||||
| { readonly kind: "doubleThrow"; readonly uses: number }
|
||||
/** Multiply the whole turn's score by `factor`, `uses` times per game. */
|
||||
| { readonly kind: "multiplier"; readonly factor: number; readonly uses: number }
|
||||
/** Start the game `points` ahead. */
|
||||
| { readonly kind: "headstart"; readonly points: number }
|
||||
/** Change a die of your choosing to `value`, `uses` times per game. */
|
||||
| { readonly kind: "setDie"; readonly value: number; readonly uses: number }
|
||||
/** Purely reactive: cancels an opponent badge of the same tier. */
|
||||
| { readonly kind: "defence" };
|
||||
|
||||
export interface Badge {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly tier: BadgeTier;
|
||||
readonly effect: BadgeEffect;
|
||||
}
|
||||
|
||||
export const BADGES: readonly Badge[] = [
|
||||
// ---- Tin ---------------------------------------------------------------
|
||||
{
|
||||
id: "carpenters_advantage",
|
||||
name: "Carpenter's Advantage badge",
|
||||
description: "You gain a new dice formation called the Cut, consisting of 3+5.",
|
||||
tier: "tin",
|
||||
effect: { kind: "scoring", rules: { cut: true } },
|
||||
},
|
||||
{
|
||||
id: "tin_defence",
|
||||
name: "Tin Defence badge",
|
||||
description: "Use to cancel the effect of your opponent's tin badge.",
|
||||
tier: "tin",
|
||||
effect: { kind: "defence" },
|
||||
},
|
||||
{
|
||||
id: "tin_doppelganger",
|
||||
name: "Tin Doppelganger badge",
|
||||
description: "Using it will double the score of your last roll. Can be used once per game.",
|
||||
tier: "tin",
|
||||
effect: { kind: "doubleThrow", uses: 1 },
|
||||
},
|
||||
{
|
||||
id: "tin_fortune",
|
||||
name: "Tin Fortune badge",
|
||||
description:
|
||||
"After your throw, you can reroll a die of your choosing. Can be used once per game.",
|
||||
tier: "tin",
|
||||
effect: { kind: "reroll", dice: 1, uses: 1 },
|
||||
},
|
||||
{
|
||||
id: "tin_headstart",
|
||||
name: "Tin Headstart badge",
|
||||
description: "You gain a small point lead at the start of the game.",
|
||||
tier: "tin",
|
||||
effect: { kind: "headstart", points: HEADSTART_POINTS.tin },
|
||||
},
|
||||
{
|
||||
id: "tin_might",
|
||||
name: "Tin Might badge",
|
||||
description: "Use it to add one extra die to your throw. Can be used once per game.",
|
||||
tier: "tin",
|
||||
effect: { kind: "extraDice", dice: 1, uses: 1 },
|
||||
},
|
||||
{
|
||||
id: "tin_resurrection",
|
||||
name: "Tin Resurrection badge",
|
||||
description: "After an unlucky throw, use this badge to throw again. Can be used once per game.",
|
||||
tier: "tin",
|
||||
effect: { kind: "antibust", uses: 1 },
|
||||
},
|
||||
{
|
||||
id: "tin_transmutation",
|
||||
name: "Tin Transmutation badge",
|
||||
description:
|
||||
"After your throw, you can change a die of your choosing to a 3. Can be used once per game.",
|
||||
tier: "tin",
|
||||
effect: { kind: "setDie", value: 3, uses: 1 },
|
||||
},
|
||||
{
|
||||
id: "tin_warlord",
|
||||
name: "Tin Warlord badge",
|
||||
description: "Used to gain a quarter more points from your turn. Can be used once per game.",
|
||||
tier: "tin",
|
||||
effect: { kind: "multiplier", factor: 1.25, uses: 1 },
|
||||
},
|
||||
|
||||
// ---- Silver ------------------------------------------------------------
|
||||
{
|
||||
id: "bird_kings",
|
||||
name: "Bird king's badge",
|
||||
description:
|
||||
"The badge of the rightful king of the birds will allow you to roll an additional die. Can be used twice per game.",
|
||||
tier: "silver",
|
||||
effect: { kind: "extraDice", dice: 1, uses: 2 },
|
||||
},
|
||||
{
|
||||
id: "executioners_advantage",
|
||||
name: "Executioner's Advantage badge",
|
||||
description:
|
||||
"You gain a new dice combination called The Gallows, which consists of 4, 5 and 6.",
|
||||
tier: "silver",
|
||||
effect: { kind: "scoring", rules: { gallows: true } },
|
||||
},
|
||||
{
|
||||
id: "silver_defence",
|
||||
name: "Silver Defence badge",
|
||||
description: "Use to cancel the effect of your opponent's Silver badge in the game.",
|
||||
tier: "silver",
|
||||
effect: { kind: "defence" },
|
||||
},
|
||||
{
|
||||
id: "silver_doppelganger",
|
||||
name: "Silver Doppelganger badge",
|
||||
description: "You double the score of your last throw. Can be used twice per game.",
|
||||
tier: "silver",
|
||||
effect: { kind: "doubleThrow", uses: 2 },
|
||||
},
|
||||
{
|
||||
id: "silver_fortune",
|
||||
name: "Silver Fortune badge",
|
||||
description: "After your throw, you can reroll up to 2 dice. Can be used once per game.",
|
||||
tier: "silver",
|
||||
effect: { kind: "reroll", dice: 2, uses: 1 },
|
||||
},
|
||||
{
|
||||
id: "silver_headstart",
|
||||
name: "Silver Headstart badge",
|
||||
description: "Use it to get a moderate point lead at the start of the game.",
|
||||
tier: "silver",
|
||||
effect: { kind: "headstart", points: HEADSTART_POINTS.silver },
|
||||
},
|
||||
{
|
||||
id: "silver_might",
|
||||
name: "Silver Might badge",
|
||||
description: "Using it will allow you to roll one extra die. Can be used twice per game.",
|
||||
tier: "silver",
|
||||
effect: { kind: "extraDice", dice: 1, uses: 2 },
|
||||
},
|
||||
{
|
||||
id: "silver_resurrection",
|
||||
name: "Silver Resurrection badge",
|
||||
description:
|
||||
"When a throw doesn't go your way, use this badge to throw again. Can be used twice per game.",
|
||||
tier: "silver",
|
||||
effect: { kind: "antibust", uses: 2 },
|
||||
},
|
||||
{
|
||||
id: "silver_swap_out",
|
||||
name: "Silver Swap-out badge",
|
||||
description:
|
||||
"After your throw, you can reroll a die of your choosing. Can be used once per game.",
|
||||
tier: "silver",
|
||||
effect: { kind: "reroll", dice: 1, uses: 1 },
|
||||
},
|
||||
{
|
||||
id: "silver_transmutation",
|
||||
name: "Silver Transmutation badge",
|
||||
description:
|
||||
"After your throw, you can change a die of your choosing to a five. Can be used once per game.",
|
||||
tier: "silver",
|
||||
effect: { kind: "setDie", value: 5, uses: 1 },
|
||||
},
|
||||
{
|
||||
id: "silver_warlord",
|
||||
name: "Silver Warlord badge",
|
||||
description:
|
||||
"Using this badge will grant you 50% more points this round. Can be used once per game.",
|
||||
tier: "silver",
|
||||
effect: { kind: "multiplier", factor: 1.5, uses: 1 },
|
||||
},
|
||||
|
||||
// ---- Gold --------------------------------------------------------------
|
||||
{
|
||||
id: "gold_defence",
|
||||
name: "Gold Defence badge",
|
||||
description: "Use to cancel the effect of your opponent's gold badge.",
|
||||
tier: "gold",
|
||||
effect: { kind: "defence" },
|
||||
},
|
||||
{
|
||||
id: "gold_doppelganger",
|
||||
name: "Gold Doppelganger badge",
|
||||
description:
|
||||
"Using this badge will double the point value of your last throw. Can be used three times per game.",
|
||||
tier: "gold",
|
||||
effect: { kind: "doubleThrow", uses: 3 },
|
||||
},
|
||||
{
|
||||
id: "gold_emperors",
|
||||
name: "Gold Emperor's badge",
|
||||
description:
|
||||
"Using this badge, you will gain triple points for every 1+1+1 dice combination. Emperors don't lose.",
|
||||
tier: "gold",
|
||||
effect: { kind: "scoring", rules: { emperorTriple: true } },
|
||||
},
|
||||
{
|
||||
id: "gold_fortune",
|
||||
name: "Gold Fortune badge",
|
||||
description: "After your throw, you can reroll up to three dice. Can be used once per game.",
|
||||
tier: "gold",
|
||||
effect: { kind: "reroll", dice: 3, uses: 1 },
|
||||
},
|
||||
{
|
||||
id: "gold_headstart",
|
||||
name: "Gold Headstart badge",
|
||||
description: "Using this badge will give you a large point lead at the start of the game.",
|
||||
tier: "gold",
|
||||
effect: { kind: "headstart", points: HEADSTART_POINTS.gold },
|
||||
},
|
||||
{
|
||||
id: "gold_might",
|
||||
name: "Gold Might badge",
|
||||
description: "Using it will allow you to roll one extra die. Can be used three times per game.",
|
||||
tier: "gold",
|
||||
effect: { kind: "extraDice", dice: 1, uses: 3 },
|
||||
},
|
||||
{
|
||||
id: "gold_resurrection",
|
||||
name: "Gold Resurrection badge",
|
||||
description: "After an unlucky throw, you can throw again. Can be used three times per game.",
|
||||
tier: "gold",
|
||||
effect: { kind: "antibust", uses: 3 },
|
||||
},
|
||||
{
|
||||
id: "gold_swap_out",
|
||||
name: "Gold Swap-out badge",
|
||||
description:
|
||||
"After your throw, you can reroll two dice with the same value. Can be used once per game.",
|
||||
tier: "gold",
|
||||
effect: { kind: "reroll", dice: 2, uses: 1 },
|
||||
},
|
||||
{
|
||||
id: "gold_transmutation",
|
||||
name: "Gold Transmutation badge",
|
||||
description: "After your throw, change a die of your choosing to a 1. Can be used once per game.",
|
||||
tier: "gold",
|
||||
effect: { kind: "setDie", value: 1, uses: 1 },
|
||||
},
|
||||
{
|
||||
id: "gold_tyche",
|
||||
name: "Gold Tyche badge",
|
||||
description: "If you roll three sixes in a game, you'll always earn double points for them.",
|
||||
tier: "gold",
|
||||
effect: { kind: "scoring", rules: { tycheDouble: true } },
|
||||
},
|
||||
{
|
||||
id: "gold_warlord",
|
||||
name: "Gold Warlord badge",
|
||||
description: "You double the score of your turn. Can be used once per game.",
|
||||
tier: "gold",
|
||||
effect: { kind: "multiplier", factor: 2, uses: 1 },
|
||||
},
|
||||
{
|
||||
id: "gold_wedding",
|
||||
name: "Gold Wedding badge",
|
||||
description:
|
||||
"A memento of Agnes and Olda's big day. Using it allows you to reroll up to three dice. Can be used once per game.",
|
||||
tier: "gold",
|
||||
effect: { kind: "reroll", dice: 3, uses: 1 },
|
||||
},
|
||||
{
|
||||
id: "priests_advantage",
|
||||
name: "Priest's Advantage badge",
|
||||
description:
|
||||
"You gain a new dice formation called The Eye, consisting of the values 1, 3 and 5.",
|
||||
tier: "gold",
|
||||
effect: { kind: "scoring", rules: { eye: true } },
|
||||
},
|
||||
];
|
||||
|
||||
/** Lookup from badge id to badge. */
|
||||
export const BADGES_BY_ID: ReadonlyMap<string, Badge> = new Map(
|
||||
BADGES.map((badge) => [badge.id, badge]),
|
||||
);
|
||||
|
||||
/** Tiers in ascending power order, for grouping in the UI and the results. */
|
||||
export const TIERS: readonly BadgeTier[] = ["tin", "silver", "gold"];
|
||||
139
kcd2_dice_solver/src/data/data.test.ts
Normal file
139
kcd2_dice_solver/src/data/data.test.ts
Normal file
@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Guards on the transcribed game data.
|
||||
*
|
||||
* The wiki publishes percentages rounded to one decimal place, so nothing here
|
||||
* sums to exactly 100 before normalisation. These tests make sure the
|
||||
* normalisation actually happened and that nothing was dropped in transcription.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
BADGES,
|
||||
BADGES_BY_ID,
|
||||
BASE_RULES,
|
||||
DEFAULT_FORMATION_VALUES,
|
||||
HEADSTART_POINTS,
|
||||
TIERS,
|
||||
} from "./badges.ts";
|
||||
import { DICE, DICE_BY_ID, normalise } from "./dice.ts";
|
||||
|
||||
describe("dice data", () => {
|
||||
it("has all 43 dice from the wiki", () => {
|
||||
expect(DICE).toHaveLength(43);
|
||||
});
|
||||
|
||||
it("normalises every distribution to sum to one", () => {
|
||||
for (const die of DICE) {
|
||||
const total = die.weights.reduce((sum, weight) => sum + weight, 0);
|
||||
expect(total).toBeCloseTo(1, 12);
|
||||
}
|
||||
});
|
||||
|
||||
it("has no negative weights", () => {
|
||||
for (const die of DICE) {
|
||||
for (const weight of die.weights) {
|
||||
expect(weight).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("uses unique ids and names", () => {
|
||||
expect(new Set(DICE.map((die) => die.id)).size).toBe(DICE.length);
|
||||
expect(new Set(DICE.map((die) => die.name)).size).toBe(DICE.length);
|
||||
});
|
||||
|
||||
it("indexes every die by id", () => {
|
||||
expect(DICE_BY_ID.size).toBe(DICE.length);
|
||||
expect(DICE_BY_ID.get("weighted")?.name).toBe("Weighted die");
|
||||
expect(DICE_BY_ID.get("nope")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is sorted by display name", () => {
|
||||
const names = DICE.map((die) => die.name);
|
||||
expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b)));
|
||||
});
|
||||
|
||||
it("marks exactly the two wildcard dice", () => {
|
||||
const wild = DICE.filter((die) => die.wildcardFaces.length > 0);
|
||||
expect(wild.map((die) => die.id).sort((a, b) => a.localeCompare(b))).toEqual([
|
||||
"balatro",
|
||||
"devils_head",
|
||||
]);
|
||||
expect(DICE_BY_ID.get("devils_head")?.wildcardFaces).toEqual([1]);
|
||||
expect(DICE_BY_ID.get("balatro")?.wildcardFaces).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("keeps the dice with a zero-probability face", () => {
|
||||
// Favourable die never rolls a 2; Pie die never rolls a 5 or a 6.
|
||||
expect(DICE_BY_ID.get("favourable")?.weights[1]).toBe(0);
|
||||
expect(DICE_BY_ID.get("pie")?.weights[4]).toBe(0);
|
||||
expect(DICE_BY_ID.get("pie")?.weights[5]).toBe(0);
|
||||
});
|
||||
|
||||
it("normalises a raw weight vector", () => {
|
||||
expect(normalise([1, 1, 1, 1, 1, 1])).toEqual([
|
||||
1 / 6, 1 / 6, 1 / 6, 1 / 6, 1 / 6, 1 / 6,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("badge data", () => {
|
||||
it("has all 33 badges from the wiki", () => {
|
||||
expect(BADGES).toHaveLength(33);
|
||||
});
|
||||
|
||||
it("uses unique ids and names", () => {
|
||||
expect(new Set(BADGES.map((badge) => badge.id)).size).toBe(BADGES.length);
|
||||
expect(new Set(BADGES.map((badge) => badge.name)).size).toBe(BADGES.length);
|
||||
});
|
||||
|
||||
it("indexes every badge by id", () => {
|
||||
expect(BADGES_BY_ID.size).toBe(BADGES.length);
|
||||
expect(BADGES_BY_ID.get("gold_emperors")?.tier).toBe("gold");
|
||||
});
|
||||
|
||||
it("assigns every badge to a known tier", () => {
|
||||
for (const badge of BADGES) {
|
||||
expect(TIERS).toContain(badge.tier);
|
||||
}
|
||||
});
|
||||
|
||||
it("gives every tier a defence badge", () => {
|
||||
for (const tier of TIERS) {
|
||||
const defence = BADGES.filter(
|
||||
(badge) => badge.tier === tier && badge.effect.kind === "defence",
|
||||
);
|
||||
expect(defence).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("has the five scoring badges", () => {
|
||||
const scoring = BADGES.filter((badge) => badge.effect.kind === "scoring");
|
||||
expect(scoring.map((badge) => badge.id).sort((a, b) => a.localeCompare(b))).toEqual([
|
||||
"carpenters_advantage",
|
||||
"executioners_advantage",
|
||||
"gold_emperors",
|
||||
"gold_tyche",
|
||||
"priests_advantage",
|
||||
]);
|
||||
});
|
||||
|
||||
it("wires the headstart badges to their tier's point lead", () => {
|
||||
for (const tier of TIERS) {
|
||||
const badge = BADGES.find(
|
||||
(candidate) => candidate.tier === tier && candidate.effect.kind === "headstart",
|
||||
);
|
||||
expect(badge?.effect).toEqual({ kind: "headstart", points: HEADSTART_POINTS[tier] });
|
||||
}
|
||||
});
|
||||
|
||||
it("starts from a rule set with every scoring badge off", () => {
|
||||
expect(Object.values(BASE_RULES).every((value) => value === false)).toBe(true);
|
||||
});
|
||||
|
||||
it("exposes editable formation values", () => {
|
||||
expect(DEFAULT_FORMATION_VALUES.cut).toBeGreaterThan(0);
|
||||
expect(DEFAULT_FORMATION_VALUES.gallows).toBeGreaterThan(0);
|
||||
expect(DEFAULT_FORMATION_VALUES.eye).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
380
kcd2_dice_solver/src/data/dice.ts
Normal file
380
kcd2_dice_solver/src/data/dice.ts
Normal file
@ -0,0 +1,380 @@
|
||||
/**
|
||||
* The 43 dice of Kingdom Come: Deliverance II, with their face distributions.
|
||||
*
|
||||
* Source (canonical): https://kingdom-come-deliverance.fandom.com/wiki/Dice/KCD2
|
||||
* Cross-check: https://inara.cz/kingdom-come-2/items-dice/
|
||||
*
|
||||
* The wiki publishes percentages rounded to one decimal place, so most rows do
|
||||
* not sum to exactly 100. `normaliseDie` divides through by the row total, which
|
||||
* both fixes the rounding and lets us store the raw wiki numbers verbatim — the
|
||||
* literals below can be diffed against the wiki without arithmetic.
|
||||
*
|
||||
* Where Inara disagrees with the wiki the Inara figures are recorded in a
|
||||
* comment rather than silently reconciled; the wiki is authoritative here.
|
||||
*/
|
||||
|
||||
/** A die face, 1-6. */
|
||||
export type Face = 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
/** Raw six-element weight vector, indexed by `face - 1`. */
|
||||
export type Weights = readonly [number, number, number, number, number, number];
|
||||
|
||||
export interface Die {
|
||||
/** Stable identifier used in saved inventories and worker messages. */
|
||||
readonly id: string;
|
||||
/** Display name exactly as the wiki spells it. */
|
||||
readonly name: string;
|
||||
/** Flavour text from the wiki, shown as a tooltip. */
|
||||
readonly description: string;
|
||||
/**
|
||||
* Probability of each face, already normalised to sum to 1.
|
||||
* For faces listed as wildcards this is still the probability of *landing* on
|
||||
* that face; the scoring engine then treats the result as a substitute.
|
||||
*/
|
||||
readonly weights: Weights;
|
||||
/**
|
||||
* Faces that act as a wildcard ("substitute") and may count as any value.
|
||||
* Empty for all but two dice.
|
||||
*/
|
||||
readonly wildcardFaces: readonly Face[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Divide a raw weight vector by its total so the probabilities sum to exactly 1.
|
||||
*
|
||||
* @param raw - Wiki percentages (or any non-negative weights) for faces 1-6.
|
||||
* @returns The same vector scaled to sum to 1.
|
||||
*/
|
||||
export function normalise(raw: Weights): Weights {
|
||||
const total = raw[0] + raw[1] + raw[2] + raw[3] + raw[4] + raw[5];
|
||||
return [
|
||||
raw[0] / total,
|
||||
raw[1] / total,
|
||||
raw[2] / total,
|
||||
raw[3] / total,
|
||||
raw[4] / total,
|
||||
raw[5] / total,
|
||||
];
|
||||
}
|
||||
|
||||
interface RawDie {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly weights: Weights;
|
||||
readonly wildcardFaces?: readonly Face[];
|
||||
}
|
||||
|
||||
const RAW_DICE: readonly RawDie[] = [
|
||||
{
|
||||
id: "aranka",
|
||||
name: "Aranka's die",
|
||||
description:
|
||||
"Aranka gave me this die to make it easier for me to play against her husband.",
|
||||
weights: [28.6, 4.8, 28.6, 4.8, 28.6, 4.8],
|
||||
},
|
||||
{
|
||||
id: "balatro",
|
||||
name: "Balatro's die",
|
||||
description:
|
||||
"A die crafted by the balatro Jimbo, marked with his grinning face. When it lands, you get to choose how it's counted!",
|
||||
// The wiki lists no percentages for this die. Every face is a wildcard, so
|
||||
// the face distribution is irrelevant to scoring: whichever face lands, the
|
||||
// player chooses its value. Modelled as uniform with all six faces wild.
|
||||
weights: [1, 1, 1, 1, 1, 1],
|
||||
wildcardFaces: [1, 2, 3, 4, 5, 6],
|
||||
},
|
||||
{
|
||||
id: "cautious_cheater",
|
||||
name: "Cautious cheater's die",
|
||||
description:
|
||||
"A die modified by an expert. It is precisely loaded, but also inconspicuous.",
|
||||
weights: [23.8, 14.3, 9.5, 14.3, 23.8, 14.3],
|
||||
},
|
||||
{
|
||||
id: "ci",
|
||||
name: "Ci die",
|
||||
description:
|
||||
"The second in the line of the demonic dice, she likes to get lost, but when she's with her sisters she's very strong.",
|
||||
// Inara: 14.3 / 14.3 / 14.3 / 14.3 / 14.3 / 28.6
|
||||
weights: [13, 13, 13, 13, 13, 34.8],
|
||||
},
|
||||
{
|
||||
id: "devils_head",
|
||||
name: "Devil's head die",
|
||||
description:
|
||||
"A die that feels hot to the touch. In place of a one it has a devil's head, which is not something folk like to gaze upon…",
|
||||
// Face 1 is the devil's head, which the wiki scoring table marks "Subst" —
|
||||
// it substitutes for any value.
|
||||
weights: [16.7, 16.7, 16.7, 16.7, 16.7, 16.7],
|
||||
wildcardFaces: [1],
|
||||
},
|
||||
{
|
||||
id: "misfortune",
|
||||
name: "Die of misfortune",
|
||||
description:
|
||||
"They say that when it rains, it pours. But if you play with this die, the only thing pouring will be your tears.",
|
||||
weights: [4.5, 22.7, 22.7, 22.7, 22.7, 4.5],
|
||||
},
|
||||
{
|
||||
id: "even",
|
||||
name: "Even die",
|
||||
description: "A die loaded in favour of even numbers.",
|
||||
weights: [6.7, 26.7, 6.7, 26.7, 6.7, 26.7],
|
||||
},
|
||||
{
|
||||
id: "favourable",
|
||||
name: "Favourable die",
|
||||
description: "A playing die that brings luck more often than you'd expect.",
|
||||
weights: [33.3, 0, 5.6, 5.6, 33.3, 22.2],
|
||||
},
|
||||
{
|
||||
id: "fer",
|
||||
name: "Fer die",
|
||||
description: "The third and last in the line of demonic dice.",
|
||||
// Inara: 14.3 / 14.3 / 14.3 / 14.3 / 14.3 / 28.6
|
||||
weights: [13, 13, 13, 13, 13, 34.8],
|
||||
},
|
||||
{
|
||||
id: "greasy",
|
||||
name: "Greasy die",
|
||||
description:
|
||||
"A more reliable die than a normal one, but it cannot be relied on for everything.",
|
||||
weights: [17.6, 11.8, 17.6, 11.7, 17.6, 23.5],
|
||||
},
|
||||
{
|
||||
id: "grimy",
|
||||
name: "Grimy die",
|
||||
description:
|
||||
"One could say it will get you out of the frying pan into the fire. And sometimes it will let you stew in your own juice.",
|
||||
weights: [6.2, 31.2, 6.2, 6.2, 43.7, 6.2],
|
||||
},
|
||||
{
|
||||
id: "grozav",
|
||||
name: "Grozav's lucky die",
|
||||
description: "It is actually not all that lucky, but don't tell anyone!",
|
||||
weights: [6.7, 66.7, 6.7, 6.7, 6.7, 6.7],
|
||||
},
|
||||
{
|
||||
id: "heavenly_kingdom",
|
||||
name: "Heavenly Kingdom die",
|
||||
description:
|
||||
"A miraculous playing die, sent from the Heavenly Kingdom to the kingdom of men.",
|
||||
weights: [36.8, 10.5, 10.5, 10.5, 10.5, 21],
|
||||
},
|
||||
{
|
||||
id: "holy_trinity",
|
||||
name: "Holy Trinity die",
|
||||
description:
|
||||
"A consecrated die commemorating the Holy Trinity, especially by rolling threes.",
|
||||
// Inara: 21.1 / 26.3 / 36.8 / 5.3 / 5.3 / 5.3
|
||||
weights: [18.2, 22.7, 45.4, 4.5, 4.5, 4.5],
|
||||
},
|
||||
{
|
||||
id: "hugo",
|
||||
name: "Hugo's die",
|
||||
description: "A die of the most loyal regular at the Hole. It bears his likeness.",
|
||||
weights: [16.7, 16.7, 16.7, 16.7, 16.7, 16.7],
|
||||
},
|
||||
{
|
||||
id: "kings",
|
||||
name: "King's die",
|
||||
description:
|
||||
"An anointed head has no need of a loaded die, since he is always right and can never lose.",
|
||||
weights: [12.5, 18.7, 21.9, 25, 12.5, 9.4],
|
||||
},
|
||||
{
|
||||
id: "lousy_gambler",
|
||||
name: "Lousy gambler's die",
|
||||
description: "A shoddy loaded die. It's quite noticeably unbalanced.",
|
||||
weights: [10, 15, 10, 15, 35, 15],
|
||||
},
|
||||
{
|
||||
id: "lu",
|
||||
name: "Lu die",
|
||||
description: "The first of the line of demonic dice.",
|
||||
// Inara: 14.3 / 14.3 / 14.3 / 14.3 / 14.3 / 28.6
|
||||
weights: [13, 13, 13, 13, 13, 34.8],
|
||||
},
|
||||
{
|
||||
id: "lucky",
|
||||
name: "Lucky die",
|
||||
description:
|
||||
"When fortune smiles on you, smile back. Otherwise you'll look suspicious.",
|
||||
weights: [27.3, 4.5, 9.1, 13.6, 18.2, 27.3],
|
||||
},
|
||||
{
|
||||
id: "mathematician",
|
||||
name: "Mathematician's die",
|
||||
description:
|
||||
"A die loaded based on the work of a forgotten mathematician. It may be better suited to solving equations than playing dice.",
|
||||
weights: [16.7, 20.8, 25, 29.2, 4.2, 4.2],
|
||||
},
|
||||
{
|
||||
id: "molar",
|
||||
name: "Molar die",
|
||||
description:
|
||||
"A die made out of a molar tooth. It's probably better not to know who it came from.",
|
||||
weights: [16.7, 16.7, 16.7, 16.7, 16.7, 16.7],
|
||||
},
|
||||
{
|
||||
id: "monk",
|
||||
name: "Monk's die",
|
||||
description:
|
||||
"No one knows for sure whether it was blessed by a holy man or just spat on by a drunken monk. Either way, the die rolls... sort of.",
|
||||
weights: [40, 40, 5, 5, 5, 5],
|
||||
},
|
||||
{
|
||||
id: "mother_of_pearl",
|
||||
name: "Mother-of-pearl die",
|
||||
description:
|
||||
"A rare pink mother-of-pearl die. Said to bring luck in games, but misfortune in love.",
|
||||
weights: [25, 8.3, 8.3, 8.3, 25, 25],
|
||||
},
|
||||
{
|
||||
id: "odd",
|
||||
name: "Odd die",
|
||||
description: "A die loaded to favour odd numbers.",
|
||||
weights: [26.7, 6.7, 26.7, 6.7, 26.7, 6.7],
|
||||
},
|
||||
{
|
||||
id: "ordinary",
|
||||
name: "Ordinary die",
|
||||
description: "An ordinary playing die.",
|
||||
weights: [16.7, 16.7, 16.7, 16.7, 16.7, 16.7],
|
||||
},
|
||||
{
|
||||
id: "painted",
|
||||
name: "Painted die",
|
||||
description:
|
||||
"One of the dice coloured using modern techniques that hide the attempt to load it.",
|
||||
// Inara: 20 / 6.7 / 6.7 / 6.7 / 40 / 20
|
||||
weights: [18.7, 6.2, 6.2, 6.2, 43.7, 18.7],
|
||||
},
|
||||
{
|
||||
id: "painters_b",
|
||||
name: "Painter's die B",
|
||||
description: "Painter Voyta's blue-painted die.",
|
||||
weights: [9.1, 27.2, 18.2, 18.2, 18.2, 9.1],
|
||||
},
|
||||
{
|
||||
id: "painters_g",
|
||||
name: "Painter's die G",
|
||||
description: "Painter Voyta's green-painted die.",
|
||||
weights: [9.1, 27.2, 18.2, 18.2, 18.2, 9.1],
|
||||
},
|
||||
{
|
||||
id: "painters_r",
|
||||
name: "Painter's die R",
|
||||
description: "Painter Voyta's red-painted die.",
|
||||
weights: [9.1, 27.2, 18.2, 18.2, 18.2, 9.1],
|
||||
},
|
||||
{
|
||||
id: "pie",
|
||||
name: "Pie die",
|
||||
description:
|
||||
"Doesn't look particularly tasty, but it's well balanced towards lower numbers.",
|
||||
weights: [46.2, 7.7, 23.1, 23.1, 0, 0],
|
||||
},
|
||||
{
|
||||
id: "premolar",
|
||||
name: "Premolar die",
|
||||
description: "A playing die made from a premolar tooth.",
|
||||
weights: [16.7, 16.7, 16.7, 16.7, 16.7, 16.7],
|
||||
},
|
||||
{
|
||||
id: "sad_greaser",
|
||||
name: "Sad Greaser's die",
|
||||
description: "A blue die mirroring the sadness of its original owner.",
|
||||
weights: [26.1, 26.1, 4.3, 4.3, 26.1, 13],
|
||||
},
|
||||
{
|
||||
id: "saint_antiochus",
|
||||
name: "Saint Antiochus' die",
|
||||
description:
|
||||
"The Saint Antiochus' die always rolls a 3. Not 2. And especially not 4.",
|
||||
weights: [20, 6.7, 40, 6.7, 6.7, 20],
|
||||
},
|
||||
{
|
||||
id: "shrinking",
|
||||
name: "Shrinking die",
|
||||
description:
|
||||
"A very lightly loaded die. One can barely differentiate it from an ordinary die.",
|
||||
weights: [22.2, 11.1, 11.1, 11.1, 11.1, 33.3],
|
||||
},
|
||||
{
|
||||
id: "st_stephens",
|
||||
name: "St. Stephen's die",
|
||||
description:
|
||||
"A die blessed by St. Stephen, guaranteeing favourable numbers in the game and protection from loose stones.",
|
||||
weights: [16.7, 16.7, 16.7, 16.7, 16.7, 16.7],
|
||||
},
|
||||
{
|
||||
id: "strip",
|
||||
name: "Strip die",
|
||||
description: "Legend has it this die will help you undress many a wench.",
|
||||
weights: [25, 12.5, 12.5, 12.5, 18.8, 18.8],
|
||||
},
|
||||
{
|
||||
id: "tengri",
|
||||
name: "Tengri's die",
|
||||
description:
|
||||
"This one appeared to me after I won on all of Tengri's tracks. A little token of my tremendous success, perhaps?",
|
||||
weights: [28.5, 14.3, 14.3, 14.3, 14.3, 14.3],
|
||||
},
|
||||
{
|
||||
id: "trinity",
|
||||
name: "Trinity die",
|
||||
description: "For some reason, this die usually rolls a three. Why?",
|
||||
// Inara: 18.2 / 9.1 / 36.4 / 9.1 / 18.2 / 9.1
|
||||
weights: [12.5, 6.2, 56.2, 6.2, 12.5, 6.2],
|
||||
},
|
||||
{
|
||||
id: "unbalanced",
|
||||
name: "Unbalanced die",
|
||||
description:
|
||||
"A playing die someone tried to load to his advantage, but didn't do a very good job.",
|
||||
weights: [25, 33.3, 8.3, 8.3, 16.7, 8.3],
|
||||
},
|
||||
{
|
||||
id: "unlucky",
|
||||
name: "Unlucky die",
|
||||
description:
|
||||
"Sometimes Lady Luck is on your side, sometimes she isn't. With this die, she most likely isn't.",
|
||||
weights: [9.1, 27.3, 18.2, 18.2, 18.2, 9.1],
|
||||
},
|
||||
{
|
||||
id: "wagoner",
|
||||
name: "Wagoner's die",
|
||||
description:
|
||||
"According to legend, this die belonged to the famous Roman charioteer Arnuldus, whose tactics consisted of tiring his opponents or lulling them to sleep.",
|
||||
weights: [5.6, 27.8, 33.3, 11.1, 11.1, 11.1],
|
||||
},
|
||||
{
|
||||
id: "weighted",
|
||||
name: "Weighted die",
|
||||
description:
|
||||
"A mysterious playing die found in a ruined house. Suspiciously, it tends to land on 1.",
|
||||
weights: [66.7, 6.7, 6.7, 6.7, 6.7, 6.7],
|
||||
},
|
||||
{
|
||||
id: "wisdom_tooth",
|
||||
name: "Wisdom tooth die",
|
||||
description: "A playing die made from a wisdom tooth.",
|
||||
weights: [16.7, 16.7, 16.7, 16.7, 16.7, 16.7],
|
||||
},
|
||||
];
|
||||
|
||||
/** All dice, sorted by display name, with weights normalised to sum to 1. */
|
||||
export const DICE: readonly Die[] = RAW_DICE.map((raw) => ({
|
||||
id: raw.id,
|
||||
name: raw.name,
|
||||
description: raw.description,
|
||||
weights: normalise(raw.weights),
|
||||
wildcardFaces: raw.wildcardFaces ?? [],
|
||||
})).sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
/** Lookup from die id to die, for resolving saved inventories. */
|
||||
export const DICE_BY_ID: ReadonlyMap<string, Die> = new Map(
|
||||
DICE.map((die) => [die.id, die]),
|
||||
);
|
||||
210
kcd2_dice_solver/src/hooks/useSolver.test.tsx
Normal file
210
kcd2_dice_solver/src/hooks/useSolver.test.tsx
Normal file
@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Tests for the worker-driving hook, especially the parts that only matter when
|
||||
* things go slightly wrong: a reply arriving for a question no longer being
|
||||
* asked, and tearing the worker down on unmount.
|
||||
*/
|
||||
|
||||
import { act, render, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SolveRequest, SolveResponse } from "../core/solve.ts";
|
||||
import type { WorkerReply, WorkerRequest } from "../worker/solver.worker.ts";
|
||||
import { createSolverWorker, useSolver } from "./useSolver.ts";
|
||||
import type { SolverPort } from "./useSolver.ts";
|
||||
|
||||
/** A controllable port: records requests, replies only when told to. */
|
||||
function makePort(): SolverPort & {
|
||||
sent: WorkerRequest[];
|
||||
reply: (reply: WorkerReply) => void;
|
||||
terminated: () => number;
|
||||
} {
|
||||
const listeners = new Set<(event: MessageEvent<WorkerReply>) => void>();
|
||||
const sent: WorkerRequest[] = [];
|
||||
let terminations = 0;
|
||||
return {
|
||||
sent,
|
||||
postMessage: (message) => sent.push(message),
|
||||
addEventListener: (_type, listener) => listeners.add(listener),
|
||||
removeEventListener: (_type, listener) => listeners.delete(listener),
|
||||
terminate: () => {
|
||||
terminations += 1;
|
||||
},
|
||||
reply: (reply) => {
|
||||
act(() => {
|
||||
for (const listener of listeners) {
|
||||
listener({ data: reply } as MessageEvent<WorkerReply>);
|
||||
}
|
||||
});
|
||||
},
|
||||
terminated: () => terminations,
|
||||
};
|
||||
}
|
||||
|
||||
const request: SolveRequest = { diceCounts: { ordinary: 6 }, badgeIds: [] };
|
||||
const response = { dice: [], inventorySize: 6 } as unknown as SolveResponse;
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("useSolver", () => {
|
||||
it("sends the request once the port exists", async () => {
|
||||
const port = makePort();
|
||||
renderHook(() => useSolver(request, () => port));
|
||||
await waitFor(() => {
|
||||
expect(port.sent).toHaveLength(1);
|
||||
});
|
||||
expect(port.sent[0].request).toEqual(request);
|
||||
});
|
||||
|
||||
it("reports a successful solve", async () => {
|
||||
const port = makePort();
|
||||
const { result } = renderHook(() => useSolver(request, () => port));
|
||||
await waitFor(() => {
|
||||
expect(port.sent).toHaveLength(1);
|
||||
});
|
||||
expect(result.current.solving).toBe(true);
|
||||
|
||||
port.reply({ id: port.sent[0].id, ok: true, response });
|
||||
expect(result.current.result).toBe(response);
|
||||
expect(result.current.solving).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("reports a failure", async () => {
|
||||
const port = makePort();
|
||||
const { result } = renderHook(() => useSolver(request, () => port));
|
||||
await waitFor(() => {
|
||||
expect(port.sent).toHaveLength(1);
|
||||
});
|
||||
|
||||
port.reply({ id: port.sent[0].id, ok: false, error: "boom" });
|
||||
expect(result.current.error).toBe("boom");
|
||||
expect(result.current.result).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores a reply to a question it is no longer asking", async () => {
|
||||
const port = makePort();
|
||||
const { result } = renderHook(() => useSolver(request, () => port));
|
||||
await waitFor(() => {
|
||||
expect(port.sent).toHaveLength(1);
|
||||
});
|
||||
|
||||
// A slow answer to an older inventory must not overwrite the current one.
|
||||
port.reply({ id: port.sent[0].id - 1, ok: true, response });
|
||||
expect(result.current.result).toBeNull();
|
||||
expect(result.current.solving).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the result when there is nothing to solve", async () => {
|
||||
const port = makePort();
|
||||
const initialProps: { current: SolveRequest | null } = { current: request };
|
||||
const { result, rerender } = renderHook(
|
||||
({ current }: { current: SolveRequest | null }) => useSolver(current, () => port),
|
||||
{ initialProps },
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(port.sent).toHaveLength(1);
|
||||
});
|
||||
port.reply({ id: port.sent[0].id, ok: true, response });
|
||||
expect(result.current.result).toBe(response);
|
||||
|
||||
rerender({ current: null });
|
||||
expect(result.current.result).toBeNull();
|
||||
expect(result.current.solving).toBe(false);
|
||||
// And nothing new was asked of the worker.
|
||||
expect(port.sent).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("hides a stale error while the next solve is running", async () => {
|
||||
const port = makePort();
|
||||
const initialProps: { current: SolveRequest | null } = { current: request };
|
||||
const { result, rerender } = renderHook(
|
||||
({ current }: { current: SolveRequest | null }) => useSolver(current, () => port),
|
||||
{ initialProps },
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(port.sent).toHaveLength(1);
|
||||
});
|
||||
port.reply({ id: port.sent[0].id, ok: false, error: "boom" });
|
||||
expect(result.current.error).toBe("boom");
|
||||
|
||||
// A new inventory is being solved: the old failure no longer describes it.
|
||||
rerender({ current: { diceCounts: { weighted: 6 }, badgeIds: [] } });
|
||||
expect(result.current.solving).toBe(true);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("terminates the worker on unmount", async () => {
|
||||
const port = makePort();
|
||||
const { unmount } = renderHook(() => useSolver(request, () => port));
|
||||
await waitFor(() => {
|
||||
expect(port.sent).toHaveLength(1);
|
||||
});
|
||||
unmount();
|
||||
expect(port.terminated()).toBe(1);
|
||||
});
|
||||
|
||||
it("copes with a port that cannot be terminated", async () => {
|
||||
const port = makePort();
|
||||
const withoutTerminate: SolverPort = {
|
||||
postMessage: port.postMessage.bind(port),
|
||||
addEventListener: port.addEventListener.bind(port),
|
||||
removeEventListener: port.removeEventListener.bind(port),
|
||||
};
|
||||
const { unmount } = renderHook(() => useSolver(request, () => withoutTerminate));
|
||||
await waitFor(() => {
|
||||
expect(port.sent).toHaveLength(1);
|
||||
});
|
||||
expect(() => {
|
||||
unmount();
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createSolverWorker", () => {
|
||||
it("builds a module worker pointing at the solver", () => {
|
||||
const constructed: { url: string; options: unknown }[] = [];
|
||||
class FakeWorker {
|
||||
addEventListener = vi.fn();
|
||||
removeEventListener = vi.fn();
|
||||
postMessage = vi.fn();
|
||||
constructor(url: URL, options: unknown) {
|
||||
constructed.push({ url: url.href, options });
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("Worker", FakeWorker);
|
||||
|
||||
createSolverWorker();
|
||||
expect(constructed).toHaveLength(1);
|
||||
expect(constructed[0].url).toMatch(/solver\.worker/);
|
||||
expect(constructed[0].options).toEqual({ type: "module" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSolver default port", () => {
|
||||
it("falls back to a real worker when no factory is supplied", () => {
|
||||
const created: unknown[] = [];
|
||||
class FakeWorker {
|
||||
addEventListener = vi.fn();
|
||||
removeEventListener = vi.fn();
|
||||
postMessage = vi.fn();
|
||||
terminate = vi.fn();
|
||||
constructor() {
|
||||
created.push(this);
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("Worker", FakeWorker);
|
||||
|
||||
/**
|
||||
* Minimal probe component exercising the hook's default argument.
|
||||
*
|
||||
* @returns Nothing rendered; the hook is the point.
|
||||
*/
|
||||
function Probe(): null {
|
||||
useSolver(request);
|
||||
return null;
|
||||
}
|
||||
render(<Probe />);
|
||||
expect(created).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
141
kcd2_dice_solver/src/hooks/useSolver.ts
Normal file
141
kcd2_dice_solver/src/hooks/useSolver.ts
Normal file
@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Drives the solver worker from React.
|
||||
*
|
||||
* Two things this has to get right:
|
||||
*
|
||||
* Stale replies. Requests are tagged with an incrementing id and a reply whose
|
||||
* id is not the current one is dropped, so a slow solve for an inventory you
|
||||
* have since changed can never overwrite the answer for the one on screen.
|
||||
*
|
||||
* No cascading renders. The effect does external work only — post a message —
|
||||
* and every piece of state is either written from the worker's own callback or
|
||||
* derived during render. Nothing calls setState synchronously in an effect
|
||||
* body.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { SolveRequest, SolveResponse } from "../core/solve.ts";
|
||||
import type { WorkerReply, WorkerRequest } from "../worker/solver.worker.ts";
|
||||
|
||||
export interface SolverState {
|
||||
/** The latest completed solve, or null before the first one lands. */
|
||||
readonly result: SolveResponse | null;
|
||||
/** Why the last request could not be solved, e.g. too few dice. */
|
||||
readonly error: string | null;
|
||||
/** True while a request is outstanding. */
|
||||
readonly solving: boolean;
|
||||
}
|
||||
|
||||
/** Anything that can accept solve requests and reply — the worker, or a fake. */
|
||||
export interface SolverPort {
|
||||
postMessage: (message: WorkerRequest) => void;
|
||||
addEventListener: (
|
||||
type: "message",
|
||||
listener: (event: MessageEvent<WorkerReply>) => void,
|
||||
) => void;
|
||||
removeEventListener: (
|
||||
type: "message",
|
||||
listener: (event: MessageEvent<WorkerReply>) => void,
|
||||
) => void;
|
||||
terminate?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the real worker. Injectable so tests can drive a fake port instead of
|
||||
* relying on jsdom's worker support.
|
||||
*
|
||||
* @returns A port backed by a fresh Web Worker.
|
||||
*/
|
||||
export function createSolverWorker(): SolverPort {
|
||||
return new Worker(new URL("../worker/solver.worker.ts", import.meta.url), {
|
||||
type: "module",
|
||||
});
|
||||
}
|
||||
|
||||
/** The last answer received, tagged with the request that produced it. */
|
||||
interface Answer {
|
||||
readonly request: SolveRequest;
|
||||
readonly result: SolveResponse | null;
|
||||
readonly error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Solve an inventory in the background, re-solving whenever it changes.
|
||||
*
|
||||
* @param request - The current solve request, or null when there is nothing to
|
||||
* solve yet (fewer than six dice owned).
|
||||
* @param createPort - Factory for the worker port; overridden in tests. Only the
|
||||
* value passed on the first render is used.
|
||||
* @returns The latest result, any error, and whether a solve is in flight.
|
||||
*/
|
||||
export function useSolver(
|
||||
request: SolveRequest | null,
|
||||
createPort: () => SolverPort = createSolverWorker,
|
||||
): SolverState {
|
||||
const [answer, setAnswer] = useState<Answer | null>(null);
|
||||
// The port lives in state, not a ref, so the request effect can depend on it
|
||||
// and re-run once it exists. On the very first render there is no port yet.
|
||||
const [port, setPort] = useState<SolverPort | null>(null);
|
||||
|
||||
const latestId = useRef(0);
|
||||
const latestRequest = useRef<SolveRequest | null>(null);
|
||||
// Only the first factory is ever used. Holding it in a ref means an inline
|
||||
// arrow at the call site cannot tear the worker down and rebuild it on every
|
||||
// render — building a worker is expensive, and any solve in flight would be
|
||||
// discarded with it.
|
||||
const factory = useRef(createPort);
|
||||
|
||||
useEffect(() => {
|
||||
const created = factory.current();
|
||||
setPort(created);
|
||||
|
||||
const onMessage = (event: MessageEvent<WorkerReply>): void => {
|
||||
const reply = event.data;
|
||||
const asked = latestRequest.current;
|
||||
// Drop answers to questions we are no longer asking.
|
||||
if (reply.id !== latestId.current || !asked) {
|
||||
return;
|
||||
}
|
||||
setAnswer(
|
||||
reply.ok
|
||||
? { request: asked, result: reply.response, error: null }
|
||||
: { request: asked, result: null, error: reply.error },
|
||||
);
|
||||
};
|
||||
|
||||
created.addEventListener("message", onMessage);
|
||||
return () => {
|
||||
created.removeEventListener("message", onMessage);
|
||||
created.terminate?.();
|
||||
setPort(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!port) {
|
||||
return;
|
||||
}
|
||||
// Bump the id even when there is nothing to ask, so a reply already on its
|
||||
// way for the previous inventory is discarded rather than applied.
|
||||
latestId.current += 1;
|
||||
latestRequest.current = request;
|
||||
if (request) {
|
||||
port.postMessage({ id: latestId.current, request });
|
||||
}
|
||||
}, [port, request]);
|
||||
|
||||
if (!request) {
|
||||
return { result: null, error: null, solving: false };
|
||||
}
|
||||
if (!answer) {
|
||||
return { result: null, error: null, solving: true };
|
||||
}
|
||||
const solving = answer.request !== request;
|
||||
return {
|
||||
// The previous answer stays on screen while a new one is computed, so the
|
||||
// panel dims rather than blanking out.
|
||||
result: answer.result,
|
||||
error: solving ? null : answer.error,
|
||||
solving,
|
||||
};
|
||||
}
|
||||
422
kcd2_dice_solver/src/index.css
Normal file
422
kcd2_dice_solver/src/index.css
Normal file
@ -0,0 +1,422 @@
|
||||
:root {
|
||||
--bg: #17150f;
|
||||
--panel: #201d16;
|
||||
--panel-2: #2a261d;
|
||||
--line: #3a3428;
|
||||
--text: #e8e0cf;
|
||||
--muted: #9d947f;
|
||||
--accent: #c8a24a;
|
||||
--good: #7fb069;
|
||||
--bad: #c96b5b;
|
||||
|
||||
color-scheme: dark;
|
||||
font-family: "Iosevka", "Cascadia Code", ui-monospace, "SF Mono", Menlo, monospace;
|
||||
font-size: 15px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0.25rem 0 1.25rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.columns {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
section.inventory,
|
||||
.result,
|
||||
.caveats {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
position: sticky;
|
||||
top: 1rem;
|
||||
}
|
||||
|
||||
/* --- toolbar ---------------------------------------------------------- */
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.search:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: var(--muted);
|
||||
min-width: 5rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.clear,
|
||||
.stepper-button {
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0.4rem 0.7rem;
|
||||
}
|
||||
|
||||
.clear:disabled,
|
||||
.stepper-button:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.clear:not(:disabled):hover,
|
||||
.stepper-button:not(:disabled):hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
margin: 0.6rem 0;
|
||||
}
|
||||
|
||||
/* --- dice list -------------------------------------------------------- */
|
||||
|
||||
.die-list,
|
||||
.badge-list,
|
||||
.loadout,
|
||||
.alternatives ul,
|
||||
.caveats ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.die-list {
|
||||
max-height: 46vh;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.die-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.die-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.die-row.owned {
|
||||
background: var(--panel-2);
|
||||
}
|
||||
|
||||
.die-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
padding: 0.35rem;
|
||||
border-radius: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.die-main:hover {
|
||||
background: rgba(200, 162, 74, 0.1);
|
||||
}
|
||||
|
||||
.die-name {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.die-name mark {
|
||||
background: none;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.die-faces {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: flex-end;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.die-face {
|
||||
width: 12px;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.die-face-bar {
|
||||
display: block;
|
||||
background: var(--accent);
|
||||
opacity: 0.75;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.die-face-label {
|
||||
position: absolute;
|
||||
bottom: -0.9rem;
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
font-size: 0.55rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.die-tag {
|
||||
font-size: 0.7rem;
|
||||
color: var(--bg);
|
||||
background: var(--accent);
|
||||
border-radius: 3px;
|
||||
padding: 0 0.3rem;
|
||||
}
|
||||
|
||||
/* --- stepper ---------------------------------------------------------- */
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.stepper-value {
|
||||
width: 3rem;
|
||||
padding: 0.35rem;
|
||||
text-align: center;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* --- badges ----------------------------------------------------------- */
|
||||
|
||||
.tier {
|
||||
margin: 0.75rem 0 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.tier-tin {
|
||||
color: #a8a196;
|
||||
}
|
||||
.tier-silver {
|
||||
color: #cfd4da;
|
||||
}
|
||||
.tier-gold {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.badge-list label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.15rem 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.badge-picker {
|
||||
columns: 2;
|
||||
column-gap: 1.5rem;
|
||||
}
|
||||
|
||||
.badge-picker section {
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
/* --- results ---------------------------------------------------------- */
|
||||
|
||||
.result h2,
|
||||
.caveats h3 {
|
||||
margin: 1rem 0 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.result h2:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.result.stale {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.loadout li {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
|
||||
.loadout-count {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
min-width: 2rem;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.stats div {
|
||||
background: var(--panel-2);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.6rem;
|
||||
}
|
||||
|
||||
.stats dt {
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.stats dd {
|
||||
margin: 0.15rem 0 0;
|
||||
font-size: 1.25rem;
|
||||
color: var(--good);
|
||||
}
|
||||
|
||||
.stats small {
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.provenance,
|
||||
.badge-reason,
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.alt-ev {
|
||||
color: var(--accent);
|
||||
display: inline-block;
|
||||
min-width: 4rem;
|
||||
}
|
||||
|
||||
.alternatives li,
|
||||
.badge-result details li {
|
||||
padding: 0.15rem 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
padding: 0.35rem 0;
|
||||
}
|
||||
|
||||
.badge-result {
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.badge-pick {
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
|
||||
.badge-value {
|
||||
color: var(--good);
|
||||
}
|
||||
|
||||
.caveats {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.caveats li {
|
||||
padding: 0.2rem 0 0.2rem 0.9rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.caveats li::before {
|
||||
content: "·";
|
||||
position: absolute;
|
||||
left: 0.2rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.caveats strong {
|
||||
color: var(--text);
|
||||
}
|
||||
58
kcd2_dice_solver/src/lib/fuzzy.test.ts
Normal file
58
kcd2_dice_solver/src/lib/fuzzy.test.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DICE } from "../data/dice.ts";
|
||||
import { fuzzyFilter, fuzzyMatch } from "./fuzzy.ts";
|
||||
|
||||
describe("fuzzyMatch", () => {
|
||||
it("matches an empty query against anything, scoring zero", () => {
|
||||
expect(fuzzyMatch("", "Weighted die")).toEqual({ score: 0, indices: [] });
|
||||
});
|
||||
|
||||
it("returns null when a character is missing", () => {
|
||||
expect(fuzzyMatch("zzz", "Weighted die")).toBeNull();
|
||||
});
|
||||
|
||||
it("matches a subsequence and reports where", () => {
|
||||
const match = fuzzyMatch("wei", "Weighted die");
|
||||
expect(match?.indices).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("ignores spaces in the query", () => {
|
||||
expect(fuzzyMatch("pain b", "Painter's die B")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rewards consecutive characters over scattered ones", () => {
|
||||
const consecutive = fuzzyMatch("luck", "Lucky die");
|
||||
const scattered = fuzzyMatch("luck", "Lousy gambler's cheek");
|
||||
expect(consecutive?.score).toBeGreaterThan(scattered?.score ?? Infinity);
|
||||
});
|
||||
|
||||
it("rewards matches at a word boundary", () => {
|
||||
// The 'd' of "die" is at a boundary in both, but "Odd die" is shorter and
|
||||
// starts its match at index 0.
|
||||
const boundary = fuzzyMatch("od", "Odd die");
|
||||
expect(boundary).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fuzzyFilter", () => {
|
||||
const byName = (die: { name: string }): string => die.name;
|
||||
|
||||
it("keeps the original order for an empty query", () => {
|
||||
const result = fuzzyFilter("", DICE, byName);
|
||||
expect(result.map((match) => match.item.id)).toEqual(DICE.map((die) => die.id));
|
||||
});
|
||||
|
||||
it("finds the Weighted die from a three-letter prefix", () => {
|
||||
const result = fuzzyFilter("wei", DICE, byName);
|
||||
expect(result[0]?.item.id).toBe("weighted");
|
||||
});
|
||||
|
||||
it("finds Painter's die B specifically", () => {
|
||||
const result = fuzzyFilter("painb", DICE, byName);
|
||||
expect(result[0]?.item.id).toBe("painters_b");
|
||||
});
|
||||
|
||||
it("returns nothing when no name matches", () => {
|
||||
expect(fuzzyFilter("qqqq", DICE, byName)).toEqual([]);
|
||||
});
|
||||
});
|
||||
148
kcd2_dice_solver/src/lib/fuzzy.ts
Normal file
148
kcd2_dice_solver/src/lib/fuzzy.ts
Normal file
@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Small fuzzy matcher for picking dice and badges by name.
|
||||
*
|
||||
* Written here rather than pulled in as a dependency: it is short, it needs to
|
||||
* be covered by the project's 100% bar anyway, and the ranking rules are
|
||||
* specific to this list ("wei" should find "Weighted die", "paintb" should find
|
||||
* "Painter's die B").
|
||||
*
|
||||
* It finds the *best* alignment rather than the leftmost one. A greedy
|
||||
* left-to-right scan is a few lines shorter, but it binds the "d" of a query
|
||||
* like "die" to the "d" that ends "Weighted", which both under-scores the right
|
||||
* answer and underlines the wrong letters.
|
||||
*/
|
||||
|
||||
export interface FuzzyMatch<T> {
|
||||
readonly item: T;
|
||||
readonly score: number;
|
||||
/** Indices in the haystack that the query matched, for highlighting. */
|
||||
readonly indices: readonly number[];
|
||||
}
|
||||
|
||||
/** A scored alignment of the query onto the haystack. */
|
||||
interface Alignment {
|
||||
readonly score: number;
|
||||
readonly indices: readonly number[];
|
||||
}
|
||||
|
||||
/** Points for matching a character at all. */
|
||||
const MATCH_BONUS = 1;
|
||||
/** Extra points when a character continues an unbroken run. */
|
||||
const ADJACENCY_BONUS = 4;
|
||||
/** Extra points when a character starts a word. */
|
||||
const BOUNDARY_BONUS = 3;
|
||||
/** Per-character penalty, so a short name outranks a long one that also matches. */
|
||||
const LENGTH_PENALTY = 0.01;
|
||||
|
||||
/**
|
||||
* Whether the character at `index` begins a word.
|
||||
*
|
||||
* @param target - The lower-cased haystack.
|
||||
* @param index - Position to test.
|
||||
* @returns True when the previous character is not alphanumeric.
|
||||
*/
|
||||
function isWordStart(target: string, index: number): boolean {
|
||||
if (index === 0) {
|
||||
return true;
|
||||
}
|
||||
return !/[a-z0-9]/.test(target[index - 1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a query against a string as a subsequence, taking the best alignment.
|
||||
*
|
||||
* Scoring rewards, in order: matching at all, matching consecutively, and
|
||||
* matching at a word boundary. A shorter haystack breaks ties, so an exact short
|
||||
* name beats a long name that merely contains the letters.
|
||||
*
|
||||
* @param query - What the user typed; case-insensitive, spaces are ignored.
|
||||
* @param haystack - The string being searched.
|
||||
* @returns The best alignment with its score and matched indices, or null.
|
||||
*/
|
||||
export function fuzzyMatch(query: string, haystack: string): Alignment | null {
|
||||
const needle = query.toLowerCase().replace(/\s+/g, "");
|
||||
const target = haystack.toLowerCase();
|
||||
if (needle.length === 0) {
|
||||
return { score: 0, indices: [] };
|
||||
}
|
||||
|
||||
// best[i][k] = the best alignment of needle[i..] given needle[i] sits at k.
|
||||
// Filled from the end of the needle backwards so each entry only depends on
|
||||
// entries already computed.
|
||||
const memo = new Map<number, Alignment | null>();
|
||||
|
||||
const solve = (i: number, at: number): Alignment | null => {
|
||||
const key = i * target.length + at;
|
||||
const cached = memo.get(key);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const own = MATCH_BONUS + (isWordStart(target, at) ? BOUNDARY_BONUS : 0);
|
||||
let result: Alignment | null = null;
|
||||
|
||||
if (i === needle.length - 1) {
|
||||
result = { score: own, indices: [at] };
|
||||
} else {
|
||||
for (let next = at + 1; next < target.length; next += 1) {
|
||||
if (target[next] !== needle[i + 1]) {
|
||||
continue;
|
||||
}
|
||||
const tail = solve(i + 1, next);
|
||||
if (!tail) {
|
||||
continue;
|
||||
}
|
||||
const adjacency = next === at + 1 ? ADJACENCY_BONUS : 0;
|
||||
const score = own + adjacency + tail.score;
|
||||
if (!result || score > result.score) {
|
||||
result = { score, indices: [at, ...tail.indices] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
memo.set(key, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
let best: Alignment | null = null;
|
||||
for (let start = 0; start < target.length; start += 1) {
|
||||
if (target[start] !== needle[0]) {
|
||||
continue;
|
||||
}
|
||||
const candidate = solve(0, start);
|
||||
if (candidate && (!best || candidate.score > best.score)) {
|
||||
best = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (!best) {
|
||||
return null;
|
||||
}
|
||||
return { score: best.score - target.length * LENGTH_PENALTY, indices: best.indices };
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter and rank a list by fuzzy name match.
|
||||
*
|
||||
* @param query - What the user typed. An empty query keeps the original order.
|
||||
* @param items - The items to search.
|
||||
* @param key - Extracts the searchable string from an item.
|
||||
* @returns Matching items, best first.
|
||||
*/
|
||||
export function fuzzyFilter<T>(
|
||||
query: string,
|
||||
items: readonly T[],
|
||||
key: (item: T) => string,
|
||||
): FuzzyMatch<T>[] {
|
||||
const matches: FuzzyMatch<T>[] = [];
|
||||
for (const item of items) {
|
||||
const match = fuzzyMatch(query, key(item));
|
||||
if (match) {
|
||||
matches.push({ item, score: match.score, indices: match.indices });
|
||||
}
|
||||
}
|
||||
if (query.trim().length === 0) {
|
||||
return matches;
|
||||
}
|
||||
return matches.sort((a, b) => b.score - a.score);
|
||||
}
|
||||
109
kcd2_dice_solver/src/lib/lib.test.ts
Normal file
109
kcd2_dice_solver/src/lib/lib.test.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fuzzyFilter, fuzzyMatch } from "./fuzzy.ts";
|
||||
import { mulberry32, randomInt } from "./rng.ts";
|
||||
|
||||
describe("fuzzyMatch", () => {
|
||||
it("matches a subsequence", () => {
|
||||
expect(fuzzyMatch("wei", "Weighted die")).not.toBeNull();
|
||||
expect(fuzzyMatch("wgt", "Weighted die")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects letters that are not present in order", () => {
|
||||
expect(fuzzyMatch("zzz", "Weighted die")).toBeNull();
|
||||
expect(fuzzyMatch("eiw", "Weighted die")).toBeNull();
|
||||
});
|
||||
|
||||
it("treats an empty query as a match with no highlights", () => {
|
||||
expect(fuzzyMatch("", "Weighted die")).toEqual({ score: 0, indices: [] });
|
||||
});
|
||||
|
||||
it("ignores case and spaces in the query", () => {
|
||||
const match = fuzzyMatch("W E I", "Weighted die");
|
||||
expect(match?.indices).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("reports where it matched, for highlighting", () => {
|
||||
// The best alignment, not the leftmost one: "die" underlines the word
|
||||
// "die" rather than binding its 'd' to the end of "Weighted".
|
||||
expect(fuzzyMatch("die", "Weighted die")?.indices).toEqual([9, 10, 11]);
|
||||
expect(fuzzyMatch("die", "Ordinary die")?.indices).toEqual([9, 10, 11]);
|
||||
});
|
||||
|
||||
it("prefers a whole-word run over an earlier scattered one", () => {
|
||||
// "pain" could scatter across "Painter's" or land on it contiguously.
|
||||
expect(fuzzyMatch("paint", "Painter's die B")?.indices).toEqual([0, 1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it("scores consecutive matches above scattered ones", () => {
|
||||
const consecutive = fuzzyMatch("wei", "Weighted die");
|
||||
const scattered = fuzzyMatch("wei", "Wagoner's engraved ivory");
|
||||
expect(consecutive?.score).toBeGreaterThan(scattered?.score ?? Infinity);
|
||||
});
|
||||
|
||||
it("rewards matching at a word boundary", () => {
|
||||
// The 'd' of "die" starts a word; the 'd' inside "Wisdom" does not.
|
||||
const boundary = fuzzyMatch("d", "a die");
|
||||
const inside = fuzzyMatch("d", "Wisdom");
|
||||
expect(boundary?.score).toBeGreaterThan(inside?.score ?? Infinity);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fuzzyFilter", () => {
|
||||
const names = [
|
||||
"Weighted die",
|
||||
"Wisdom tooth die",
|
||||
"Ordinary die",
|
||||
"Painter's die B",
|
||||
"Painter's die R",
|
||||
];
|
||||
|
||||
it("finds the die a short prefix is aiming at", () => {
|
||||
const results = fuzzyFilter("wei", names, (name) => name);
|
||||
expect(results[0].item).toBe("Weighted die");
|
||||
});
|
||||
|
||||
it("narrows to one die as the query gets specific", () => {
|
||||
const results = fuzzyFilter("paintb", names, (name) => name);
|
||||
expect(results[0].item).toBe("Painter's die B");
|
||||
});
|
||||
|
||||
it("drops non-matches", () => {
|
||||
expect(fuzzyFilter("zzzz", names, (name) => name)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps the original order for an empty query", () => {
|
||||
const results = fuzzyFilter(" ", names, (name) => name);
|
||||
expect(results.map((result) => result.item)).toEqual(names);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mulberry32", () => {
|
||||
it("is deterministic for a seed", () => {
|
||||
const a = mulberry32(42);
|
||||
const b = mulberry32(42);
|
||||
expect([a(), a(), a()]).toEqual([b(), b(), b()]);
|
||||
});
|
||||
|
||||
it("differs between seeds", () => {
|
||||
expect(mulberry32(1)()).not.toBe(mulberry32(2)());
|
||||
});
|
||||
|
||||
it("stays within [0, 1)", () => {
|
||||
const random = mulberry32(7);
|
||||
for (let i = 0; i < 1000; i += 1) {
|
||||
const value = random();
|
||||
expect(value).toBeGreaterThanOrEqual(0);
|
||||
expect(value).toBeLessThan(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("bounds randomInt below the given bound", () => {
|
||||
const random = mulberry32(9);
|
||||
for (let i = 0; i < 500; i += 1) {
|
||||
const value = randomInt(random, 5);
|
||||
expect(value).toBeGreaterThanOrEqual(0);
|
||||
expect(value).toBeLessThan(5);
|
||||
expect(Number.isInteger(value)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
41
kcd2_dice_solver/src/lib/rng.test.ts
Normal file
41
kcd2_dice_solver/src/lib/rng.test.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mulberry32, randomInt } from "./rng.ts";
|
||||
|
||||
describe("mulberry32", () => {
|
||||
it("is deterministic for a given seed", () => {
|
||||
const a = mulberry32(42);
|
||||
const b = mulberry32(42);
|
||||
const first = [a(), a(), a()];
|
||||
const second = [b(), b(), b()];
|
||||
expect(first).toEqual(second);
|
||||
});
|
||||
|
||||
it("produces different streams for different seeds", () => {
|
||||
expect(mulberry32(1)()).not.toBe(mulberry32(2)());
|
||||
});
|
||||
|
||||
it("stays within [0, 1)", () => {
|
||||
const random = mulberry32(7);
|
||||
for (let i = 0; i < 1000; i += 1) {
|
||||
const value = random();
|
||||
expect(value).toBeGreaterThanOrEqual(0);
|
||||
expect(value).toBeLessThan(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("randomInt", () => {
|
||||
it("stays within the requested bound", () => {
|
||||
const random = mulberry32(99);
|
||||
const seen = new Set<number>();
|
||||
for (let i = 0; i < 500; i += 1) {
|
||||
const value = randomInt(random, 5);
|
||||
expect(Number.isInteger(value)).toBe(true);
|
||||
expect(value).toBeGreaterThanOrEqual(0);
|
||||
expect(value).toBeLessThan(5);
|
||||
seen.add(value);
|
||||
}
|
||||
// With 500 draws over five buckets, every bucket should appear.
|
||||
expect(seen.size).toBe(5);
|
||||
});
|
||||
});
|
||||
38
kcd2_dice_solver/src/lib/rng.ts
Normal file
38
kcd2_dice_solver/src/lib/rng.ts
Normal file
@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Seeded pseudo-random number generator (mulberry32).
|
||||
*
|
||||
* `Math.random` is deliberately not used anywhere in this project: the Monte
|
||||
* Carlo turn simulator and the search's random restarts both need to be
|
||||
* reproducible so their tests can assert exact numbers instead of tolerances.
|
||||
*/
|
||||
|
||||
/** A function returning the next value in [0, 1). */
|
||||
export type Random = () => number;
|
||||
|
||||
/**
|
||||
* Create a deterministic random source.
|
||||
*
|
||||
* @param seed - Any 32-bit integer; the same seed always yields the same stream.
|
||||
* @returns A function producing successive values in [0, 1).
|
||||
*/
|
||||
export function mulberry32(seed: number): Random {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) >>> 0;
|
||||
let t = state;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a random integer in `[0, bound)`.
|
||||
*
|
||||
* @param random - Source of randomness.
|
||||
* @param bound - Exclusive upper bound, must be positive.
|
||||
* @returns An integer in the half-open range.
|
||||
*/
|
||||
export function randomInt(random: Random, bound: number): number {
|
||||
return Math.floor(random() * bound);
|
||||
}
|
||||
14
kcd2_dice_solver/src/main.tsx
Normal file
14
kcd2_dice_solver/src/main.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App.tsx";
|
||||
import "./index.css";
|
||||
|
||||
const container = document.getElementById("root");
|
||||
if (!container) {
|
||||
throw new Error("missing #root");
|
||||
}
|
||||
createRoot(container).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
1
kcd2_dice_solver/src/test/setup.ts
Normal file
1
kcd2_dice_solver/src/test/setup.ts
Normal file
@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
37
kcd2_dice_solver/src/worker/solver.worker.ts
Normal file
37
kcd2_dice_solver/src/worker/solver.worker.ts
Normal file
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Web Worker wrapper around the solver.
|
||||
*
|
||||
* A full solve runs tens of thousands of exact evaluations plus a Monte Carlo
|
||||
* run, which takes a couple of seconds on a large inventory. Off the main thread
|
||||
* the search box and the steppers stay responsive while it runs.
|
||||
*/
|
||||
|
||||
import { solve } from "../core/solve.ts";
|
||||
import type { SolveRequest, SolveResponse } from "../core/solve.ts";
|
||||
|
||||
/** A solve request tagged so stale replies can be discarded. */
|
||||
export interface WorkerRequest {
|
||||
readonly id: number;
|
||||
readonly request: SolveRequest;
|
||||
}
|
||||
|
||||
/** Either a completed solve or the reason it could not run. */
|
||||
export type WorkerReply =
|
||||
| { readonly id: number; readonly ok: true; readonly response: SolveResponse }
|
||||
| { readonly id: number; readonly ok: false; readonly error: string };
|
||||
|
||||
self.addEventListener("message", (event: MessageEvent<WorkerRequest>) => {
|
||||
const { id, request } = event.data;
|
||||
try {
|
||||
const response = solve(request);
|
||||
const reply: WorkerReply = { id, ok: true, response };
|
||||
self.postMessage(reply);
|
||||
} catch (error) {
|
||||
const reply: WorkerReply = {
|
||||
id,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
self.postMessage(reply);
|
||||
}
|
||||
});
|
||||
31
kcd2_dice_solver/tsconfig.json
Normal file
31
kcd2_dice_solver/tsconfig.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"module": "esnext",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable", "WebWorker"],
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client", "vitest/globals"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"exactOptionalPropertyTypes": true
|
||||
|
||||
/* Deliberately NOT enabling noUncheckedIndexedAccess: every array index in
|
||||
the solver is already bounds-derived, so the flag only forces `?? 0`
|
||||
fallbacks and `if (!x) continue` guards on branches that cannot execute.
|
||||
Those are untestable, and this project holds a 100% branch-coverage bar —
|
||||
the flag would buy nothing but coverage pragmas. */
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
28
kcd2_dice_solver/vite.config.ts
Normal file
28
kcd2_dice_solver/vite.config.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5174,
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: ["./src/test/setup.ts"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
include: ["src/**/*.{ts,tsx}"],
|
||||
// main.tsx is the DOM bootstrap and solver.worker.ts is the worker
|
||||
// entrypoint: both are pure wiring with no branches worth asserting, and
|
||||
// neither can run under jsdom without mocking away the thing under test.
|
||||
exclude: ["src/main.tsx", "src/worker/solver.worker.ts", "src/test/**"],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user