testsAndMisc/kcd2_dice_solver/src/core/solve.ts
Krzysztof kuhy Rudnicki 1d833d7524
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
Split kcd2 search.ts 417 -> 100 + two modules
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
2026-08-17 09:26:42 +02:00

118 lines
4.1 KiB
TypeScript

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