Split kcd2 search.ts 417 -> 100 + two modules
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run

search.ts held three separable concerns. Extracted two:

  searchGroups.ts (109)     the vocabulary -- InventoryEntry, DiceGroup,
                            Selection -- plus groupInventory, countCandidates
                            and expand
  searchStrategies.ts (232) Leaderboard and both strategies: exhaustive
                            enumerateAll, and hillClimb with its greedy and
                            random seeds

search.ts (100) keeps findBestSet, which picks between the strategies.

Import sites were rewritten to name the owning module rather than leaving a
re-export barrel in search.ts, so two test files changed -- import paths only,
no assertion touched. DiceGroup and Selection had to become exported to cross
the module boundary.

Function bodies moved verbatim: 288 tests still pass and coverage totals are
identical at 863/375/181/810, not merely still 100%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
This commit is contained in:
Krzysztof Rudnicki 2026-08-17 09:26:42 +02:00
parent bb9b162afb
commit 1d833d7524
10 changed files with 436 additions and 331 deletions

View File

@ -0,0 +1,21 @@
{
"title": "Split kcd2_dice_solver src/core/search.ts under the 250-line cap",
"objective": "Bring kcd2_dice_solver/src/core/search.ts (417 lines) under the repo-wide 250-line file cap by extracting its shared vocabulary (SET_SIZE, InventoryEntry, DiceGroup, Selection, expand, groupInventory, countCandidates) and its two search strategies (Leaderboard, enumerateAll, hillClimb, greedySeed, randomSeed) into sibling modules, with every importer's import path rewritten to name the module that now owns each symbol rather than left pointing at a re-export barrel, and with behaviour proven unchanged by the project's own 288-test suite at 100% coverage.",
"acceptance_criteria": [
"search.ts and both new sibling modules are each at most 250 lines, and check_file_length.sh --all no longer lists src/core/search.ts.",
"npx vitest run still reports 24 files and 288 tests, all passing -- no test was added, deleted, or had an assertion weakened.",
"npx vitest run --coverage still reports 100% statements, 100% branches, 100% functions and 100% lines, with the same 863/375/181/810 totals, since no statement was added or removed.",
"npm run lint (tsc --noEmit && eslint src) still exits 0 with no output.",
"Every importer of a moved symbol imports it from the module that now defines it: no re-export barrel is left in search.ts. Import sites in App.tsx, badgeValue.ts, solve.ts, search.test.ts and badgeValue.test.ts are rewritten to the owning module.",
"Zero suppressions added: no eslint-disable, no @ts-ignore, no @ts-expect-error, and vite.config.ts is byte-identical (thresholds and exclude untouched)."
],
"out_of_scope": [
"Any behaviour change to the search itself -- the exhaustive/hillClimb cutoff, the steepest-ascent swap loop, the leaderboard dedup rule and the greedy/random seeds all keep their current semantics exactly.",
"Renaming any exported symbol; only the module a symbol lives in changes.",
"Modifying vite.config.ts, including adding any new file to the coverage exclude list.",
"Splitting scoring.ts, badgeValue.ts or App.tsx -- each is its own separate commit.",
"Wiring the file-length pre-commit hook -- it must land last, once check_file_length.sh --all exits 0.",
"The pre-existing single failure in test_security_hardening.sh, which belongs to block_compulsive_opening.sh and fails identically at HEAD."
],
"verifier": "npx vitest run (expect 24 files / 288 tests passed); npx vitest run --coverage (expect 100% on statements, branches, functions and lines, totals 863/375/181/810); npm run lint (expect exit 0, no output); bash ~/utils/scripts/check_file_length.sh --all (expect src/core/search.ts absent from the listing); git diff --stat vite.config.ts (expect empty); grep -rn 'eslint-disable\\|ts-ignore\\|ts-expect-error' src/ (expect no new hits); pre-commit run --files on every touched file. All four commands captured at HEAD as the baseline and re-run after the split."
}

View File

@ -0,0 +1,57 @@
{
"intent": "Bring kcd2_dice_solver/src/core/search.ts (417 lines) under the repo-wide 250-line cap with no change to solver behaviour, so the loadout search keeps returning identical recommendations.",
"scope": [
"kcd2_dice_solver/src/core/search.ts — reduced to findBestSet, EXHAUSTIVE_LIMIT and the result types",
"kcd2_dice_solver/src/core/searchGroups.ts — new: SET_SIZE, InventoryEntry, DiceGroup, Selection, groupInventory, countCandidates, expand",
"kcd2_dice_solver/src/core/searchStrategies.ts — new: SearchOptions, Leaderboard, enumerateAll, hillClimb, greedySeed, randomSeed",
"Import sites rewritten: App.tsx, core/solve.ts, core/badgeValue.ts, core/search.test.ts, core/badgeValue.test.ts",
"Non-goal: any behaviour change, any symbol rename, any edit to vite.config.ts"
],
"changes": [
"Extracted the search vocabulary (inventory entry, pooled dice group, per-group selection) and the pooling/counting/expansion helpers into searchGroups.ts; DiceGroup and Selection were module-private and are now exported so the strategies module can consume them.",
"Extracted the Leaderboard class and both search strategies (exhaustive enumerateAll, multi-start hillClimb with its greedySeed and randomSeed) into searchStrategies.ts, leaving findBestSet as the only logic in search.ts.",
"Rewrote all five import sites to name the module that now defines each symbol rather than leaving a re-export barrel in search.ts. This deviates from the brief's suggested re-export method at the user's explicit instruction ('rewrite 24 import sites properly'), so two test files were touched — the edits are import-path-only, and no assertion, test case or fixture was changed.",
"Copied the four strategy/grouping function bodies verbatim; no statement was added, removed or reordered, which is why the coverage totals are unchanged rather than merely still 100%."
],
"verification": [
{
"command": "npx vitest run",
"result": "pass",
"evidence": "Test Files 24 passed (24); Tests 288 passed (288) — identical to the pre-split baseline captured at HEAD."
},
{
"command": "npx vitest run --coverage",
"result": "pass",
"evidence": "Statements 100% (863/863), Branches 100% (375/375), Functions 100% (181/181), Lines 100% (810/810) — byte-identical totals to the baseline, confirming no statement moved out of test reach."
},
{
"command": "npm run lint (tsc --noEmit && eslint src)",
"result": "pass",
"evidence": "Exit 0, no output. Confirms the explicit .ts import extensions and export type usage on the widened DiceGroup/Selection surface are correct, and that eslint does not object to the newly exported types."
},
{
"command": "wc -l src/core/search.ts src/core/searchGroups.ts src/core/searchStrategies.ts",
"result": "pass",
"evidence": "search.ts 100, searchGroups.ts 109, searchStrategies.ts 232 — all three under the 250-line cap, down from a single 417-line file."
},
{
"command": "bash ~/utils/scripts/check_file_length.sh --all",
"result": "pass",
"evidence": "src/core/search.ts no longer appears in the over-cap listing; the remaining kcd2 entries (scoring.ts 361, badgeValue.ts 333, App.tsx 306) are the next commits in this series."
},
{
"command": "pre-commit run --files <changed files>",
"result": "pass",
"evidence": "All configured hooks pass on the eight touched files."
}
],
"risks": [
"search.ts's public surface widened: DiceGroup and Selection are now exported types that were previously module-private. They are structural types with no invariants attached, so external use cannot corrupt search state, but they are now part of the module's API.",
"Rewriting import sites rather than re-exporting means the two test files were modified. Verification stays honest because the edits are confined to import paths — the assertions that prove the search's behaviour are untouched and still 288 in number.",
"kcd2_dice_solver has no CI workflow and is not covered by pre-commit, so the vitest and lint runs above are the only gate; a regression here would not be caught by any hook or push."
],
"rollback": [
"git revert the commit — the change is confined to five edited files and two added files, with no data, config or schema migration.",
"After rollback, re-run npx vitest run --coverage and npm run lint; expect 288 tests passing at 100% coverage and lint exit 0, the same baseline this change preserved."
]
}

View File

@ -12,7 +12,7 @@ import { DiceList } from "./components/DiceList.tsx";
import { InventoryIo } from "./components/InventoryIo.tsx";
import { ResultPanel } from "./components/ResultPanel.tsx";
import type { CountUpdater } from "./components/QuantityStepper.tsx";
import { SET_SIZE } from "./core/search.ts";
import { SET_SIZE } from "./core/searchGroups.ts";
import type { SolveRequest } from "./core/solve.ts";
import { useSolver } from "./hooks/useSolver.ts";
import type { SolverPort } from "./hooks/useSolver.ts";

View File

@ -22,7 +22,7 @@ import {
} from "./badgeValue.ts";
import type { BadgeContext } from "./badgeValue.ts";
import { NO_CHARGES, DEFAULT_POLICY } from "./simulate.ts";
import type { InventoryEntry } from "./search.ts";
import type { InventoryEntry } from "./searchGroups.ts";
const die = (id: string): Die => {
const found = DICE_BY_ID.get(id);

View File

@ -29,7 +29,8 @@ 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 type { InventoryEntry } from "./searchGroups.ts";
import type { SearchOptions } from "./searchStrategies.ts";
import { DEFAULT_POLICY, NO_CHARGES, simulateTurns } from "./simulate.ts";
import type { BadgeCharges, TurnPolicy } from "./simulate.ts";

View File

@ -12,8 +12,9 @@ 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";
import { findBestSet } from "./search.ts";
import { SET_SIZE, countCandidates, groupInventory } from "./searchGroups.ts";
import type { InventoryEntry } from "./searchGroups.ts";
const die = (id: string): Die => {
const found = DICE_BY_ID.get(id);

View File

@ -6,27 +6,20 @@
* 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.
* Two strategies, picked automatically by problem size: exhaustive enumeration
* while the space is small enough to prove an answer, and a multi-start hill
* climb past that. Both live in `searchStrategies.ts`; the pooling of dice that
* score identically lives in `searchGroups.ts`.
*/
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;
import { SET_SIZE, countCandidates, expand, groupInventory } from "./searchGroups.ts";
import type { InventoryEntry, Selection } from "./searchGroups.ts";
import { Leaderboard, enumerateAll, hillClimb } from "./searchStrategies.ts";
import type { SearchOptions } from "./searchStrategies.ts";
/**
* Largest number of candidate sets we are willing to enumerate exhaustively.
@ -35,22 +28,6 @@ export const SET_SIZE = 6;
*/
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[];
@ -69,145 +46,6 @@ export interface SetCandidate {
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.
*
@ -260,158 +98,3 @@ export function findBestSet(
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,109 @@
/**
* The vocabulary the loadout search is expressed in: an inventory line, the
* pooled groups it collapses to, and a per-group selection.
*
* Dice whose *scoring* behaviour is identical are merged before any search
* runs. 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 pooling alone
* removes a large amount of duplicate work.
*/
import type { Die } from "../data/dice.ts";
import { categoryWeights } from "./distribution.ts";
/** How many dice go into a loadout. */
export const SET_SIZE = 6;
/** 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. */
export 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;
}
/** A candidate expressed as how many dice are taken from each group. */
export type Selection = number[];
/**
* 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];
}
/**
* 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.
*/
export 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;
}

View File

@ -0,0 +1,232 @@
/**
* The two ways the loadout search explores the space of size-6 selections, plus
* the leaderboard that both feed.
*
* enumerateAll 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
*
* Which one runs is decided by problem size in `findBestSet`; neither knows
* about that choice.
*/
import type { QuickEvaluation } from "./evaluate.ts";
import { SET_SIZE } from "./searchGroups.ts";
import type { DiceGroup, Selection } from "./searchGroups.ts";
import { mulberry32, randomInt } from "../lib/rng.ts";
/** 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;
}
/** Keeps the best few candidates seen, so the UI can show runners-up. */
export 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;
}
}
/**
* Enumerate every legal size-6 selection.
*
* @param groups - Pooled dice groups.
* @param visit - Called once per complete selection.
*/
export 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.
*/
export 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

@ -14,7 +14,8 @@ 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 type { InventoryEntry } from "./searchGroups.ts";
import type { SearchOptions } from "./searchStrategies.ts";
import { DEFAULT_POLICY, NO_CHARGES, simulateTurns } from "./simulate.ts";
import type { SimulationResult, TurnPolicy } from "./simulate.ts";