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:
Krzysztof Rudnicki 2026-07-21 18:17:13 +02:00
parent ea270db04f
commit 3babda92d2
48 changed files with 9580 additions and 0 deletions

View File

@ -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"
}

View File

@ -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
View File

@ -0,0 +1,4 @@
node_modules
dist
coverage
*.tsbuildinfo

View 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.

View 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,
},
);

View 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>

View 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"
}
}

File diff suppressed because it is too large Load Diff

65
kcd2_dice_solver/run.sh Executable file
View 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 "$@"

View 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();
});
});
});

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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");
});
});

View 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([]);
});
});

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

View 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;
}

View 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],
}));
}

View 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);
});
});

View 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;
}

View 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);
});
});

View 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;
}
}

View 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);
});
});

View 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;
}

View 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);
});
});

View 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,
};
}

View 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/,
);
});
});

View 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);
});
});

View 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),
};
}

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

View 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);
});
});

View 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]),
);

View 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);
});
});

View 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,
};
}

View 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);
}

View 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([]);
});
});

View 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);
}

View 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);
}
});
});

View 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);
});
});

View 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);
}

View 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>,
);

View File

@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";

View 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);
}
});

View 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"]
}

View 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,
},
},
},
});