Split kcd2 scoring.ts 361 -> 172 + move generator

The 114-line private moves() method was most of the file: it enumerates every
legal step from a position -- straights, badge formations, n-of-a-kind, lone
1s and 5s, and the two joker rules -- while Scorer just maximises over it and
caches the answers.

Extracted it to scoringMoves.ts (203) as a free function over ScoringConfig,
taking combinationValue with it (its only caller, and the home of the
Emperor/Tyche triple-only rule) plus the tripleBase/ofAKindValue helpers.
scoring.ts (172) keeps Scorer, its two caches and its two recursions.

moves() was private, so no consumer saw the seam; only scoring.test.ts moved,
for the two value helpers, import path only. Coverage totals are unchanged at
863/375/181/810 -- the generator's branches are all still exercised.

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:39:31 +02:00
parent 1d833d7524
commit d39ad67323
5 changed files with 292 additions and 199 deletions

View File

@ -0,0 +1,21 @@
{
"title": "Split kcd2_dice_solver src/core/scoring.ts under the 250-line cap",
"objective": "Bring kcd2_dice_solver/src/core/scoring.ts (361 lines) under the repo-wide 250-line file cap by extracting the move generator -- the enumeration of every legal single step from a position (straights, badge formations, n-of-a-kind, singles, and both joker rules) together with the n-of-a-kind value helpers -- into a sibling module, leaving Scorer with its caches and recursions, and proving Farkle scoring is unchanged with the project's own 288-test suite at 100% coverage.",
"acceptance_criteria": [
"scoring.ts and the new sibling module are each at most 250 lines, and check_file_length.sh --all no longer lists src/core/scoring.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 is 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, with no re-export barrel added to scoring.ts for the moved symbols. scoring.ts's pre-existing re-export of CATEGORIES/FACES/WILD_ALONE/WILD_COMBO/CountVector from counts.ts predates this change and stays as it is.",
"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 change to the scoring rules themselves -- the doubling rule, the two joker rules, the Emperor/Tyche triple-only multipliers, the straight and badge-formation values, and the 'only a natural 1 or 5 scores alone' rule all keep their current semantics exactly.",
"Changing Scorer's caching strategy or its public method set (score, scoreKey, scoreFaces, scoreUsingAll).",
"Removing or altering scoring.ts's existing re-exports from counts.ts, which other modules rely on.",
"Modifying vite.config.ts, including adding any new file to the coverage exclude list.",
"Splitting badgeValue.ts or App.tsx -- each is its own separate commit.",
"Wiring the file-length pre-commit hook -- it must land last."
],
"verifier": "npx vitest run (expect 24 files / 288 tests passed); npx vitest run --coverage (expect 100% on all four metrics, totals 863/375/181/810); npm run lint (expect exit 0, no output); bash ~/utils/scripts/check_file_length.sh --all (expect src/core/scoring.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."
}

View File

@ -0,0 +1,57 @@
{
"intent": "Bring kcd2_dice_solver/src/core/scoring.ts (361 lines) under the repo-wide 250-line cap with no change to Farkle scoring, so every roll still scores identically.",
"scope": [
"kcd2_dice_solver/src/core/scoring.ts — reduced to the Scorer class, its two caches and its two recursions",
"kcd2_dice_solver/src/core/scoringMoves.ts — new: ScoringConfig, ScoringMove, tripleBase, ofAKindValue, combinationValue, scoringMoves",
"Import site rewritten: core/scoring.test.ts (tripleBase, ofAKindValue)",
"Non-goal: any change to the scoring rules, to Scorer's caching, or to scoring.ts's pre-existing re-exports from counts.ts"
],
"changes": [
"Extracted the 114-line private moves() method into scoringMoves.ts as a free function taking the count vector and a ScoringConfig. It was private and called only from Scorer's two recursions, so the seam is invisible to every consumer.",
"Moved combinationValue() alongside it — it is the only caller of ofAKindValue and encodes the Emperor/Tyche triple-only rule the move generator applies — and moved the tripleBase/ofAKindValue value helpers with it, since they describe combination values rather than the scorer's caching.",
"Moved ScoringConfig to scoringMoves.ts, since it is exactly the generator's parameter object; Scorer imports the type and still builds it in its constructor.",
"Repointed scoring.test.ts's import of tripleBase and ofAKindValue to scoringMoves.ts — an import-path-only edit, with no assertion, test case or expected value changed.",
"Function bodies moved verbatim; the only edit inside them was threading `config` as a parameter where the method previously read this.config, so no statement was added or removed."
],
"verification": [
{
"command": "npx vitest run",
"result": "pass",
"evidence": "Test Files 24 passed (24); Tests 288 passed (288) — unchanged from the baseline, including the joker, straight, badge-formation and doubling-rule cases that exercise the moved generator."
},
{
"command": "npx vitest run --coverage",
"result": "pass",
"evidence": "Statements 100% (863/863), Branches 100% (375/375), Functions 100% (181/181), Lines 100% (810/810) — identical totals to the baseline, confirming no branch of the move generator fell out of test reach."
},
{
"command": "npm run lint (tsc --noEmit && eslint src)",
"result": "pass",
"evidence": "Exit 0, no output. Confirms ScoringConfig's move across the module boundary and the .ts import extensions typecheck."
},
{
"command": "wc -l src/core/scoring.ts src/core/scoringMoves.ts",
"result": "pass",
"evidence": "scoring.ts 172, scoringMoves.ts 203 — both under the 250-line cap, down from a single 361-line file."
},
{
"command": "bash ~/utils/scripts/check_file_length.sh --all",
"result": "pass",
"evidence": "src/core/scoring.ts no longer appears in the over-cap listing; badgeValue.ts (333) and App.tsx (306) remain and are the next commits."
},
{
"command": "pre-commit run --files <changed files>",
"result": "pass",
"evidence": "All configured hooks pass on the touched files."
}
],
"risks": [
"combinationValue changed from a private method reading this.config to a free function taking config explicitly. Behaviour is identical because the method only ever read config, but the Emperor/Tyche multipliers now depend on the caller passing the right config — scoringMoves is its only caller and threads its own parameter through.",
"ScoringConfig is now defined in scoringMoves.ts rather than scoring.ts. Nothing outside scoring.ts imported it, so no consumer is affected.",
"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 on this change."
],
"rollback": [
"git revert the commit — two edited files and one added file, 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."
]
}

View File

@ -7,8 +7,9 @@
import { describe, expect, it } from "vitest";
import { BASE_RULES, DEFAULT_FORMATION_VALUES } from "../data/badges.ts";
import type { ScoringRules } from "../data/badges.ts";
import { CATEGORIES, Scorer, WILD_ALONE, WILD_COMBO, ofAKindValue, tripleBase } from "./scoring.ts";
import { CATEGORIES, Scorer, WILD_ALONE, WILD_COMBO } from "./scoring.ts";
import type { WildCounts } from "./scoring.ts";
import { ofAKindValue, tripleBase } from "./scoringMoves.ts";
const plain = (): Scorer => new Scorer(BASE_RULES);
const withRules = (rules: Partial<ScoringRules>): Scorer =>

View File

@ -14,58 +14,24 @@
*
* 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.
* why it is expressed 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.
*
* Two dice carry a joker face, and the wiki gives them *different* rules:
*
* Devil's head "acts as a joker, matching any combination but never scoring
* on its own"
* Balatro "you can choose how it's counted; it can fit into a straight
* or a triple. Picking it alone will count as if you threw 1"
*
* So a Balatro joker is a free choice of face, while a Devil's head may only be
* spent inside a multi-die combination — it can never be the single 1 or 5 that
* lets you hold a die. Hence the two substitute slots in `counts.ts`.
*
* Both are allowed to fill in for *any* face of a combination, including cases
* where every die of the combination is a substitute (three Devil's heads make a
* triple). The wiki's examples all pair a joker with natural dice and it does not
* say whether an all-joker set counts, so this is an assumption; it is the same
* assumption the substitution rule makes everywhere else.
* over simply score nothing. That maximisation is what this file computes, over
* the legal steps enumerated by `scoringMoves.ts` — which is also where the two
* joker faces and their differing rules are handled.
*/
import type { ScoringRules, FormationValues } from "../data/badges.ts";
import { DEFAULT_FORMATION_VALUES } from "../data/badges.ts";
import { CATEGORIES, FACES, WILD_ALONE, WILD_COMBO, decode, encode } from "./counts.ts";
import { CATEGORIES, WILD_ALONE, WILD_COMBO, decode, encode } from "./counts.ts";
import type { CountVector } from "./counts.ts";
import { scoringMoves } from "./scoringMoves.ts";
import type { ScoringConfig } from "./scoringMoves.ts";
export { CATEGORIES, FACES, WILD_ALONE, WILD_COMBO } 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);
}
/** The two kinds of substitute die, for {@link Scorer.scoreFaces}. */
export interface WildCounts {
/** Balatro jokers, which may also be held on their own as a 1. */
@ -74,12 +40,6 @@ export interface WildCounts {
readonly combo?: number;
}
/** Everything `bestScore` needs beyond the roll itself. */
export interface ScoringConfig {
readonly rules: ScoringRules;
readonly formationValues: FormationValues;
}
/**
* A memoised scorer bound to one rule set.
*
@ -186,7 +146,7 @@ export class Scorer {
return 0;
}
let best = -Infinity;
for (const { value, rest } of this.moves(counts)) {
for (const { value, rest } of scoringMoves(counts, this.config)) {
const tail = this.scoreUsingAll(rest);
if (tail > -Infinity) {
best = Math.max(best, value + tail);
@ -204,158 +164,9 @@ export class Scorer {
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)) {
for (const { value, rest } of scoringMoves(counts, this.config)) {
best = Math.max(best, value + this.score(rest));
}
return best;
}
/**
* Every legal single step from a position: take one combination (or resolve
* one Balatro joker) 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 alone = counts[WILD_ALONE];
if (alone > 0) {
// A Balatro joker is a free choice of face — including a lone 1 — so it
// can simply be resolved into each face and scored as that face from then
// on. Resolving one at a time is sufficient: further jokers 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_ALONE] = alone - 1;
rest[face] += 1;
options.push({ value: 0, rest });
}
return options;
}
const options: { value: number; rest: number[] }[] = [];
const { rules, formationValues } = this.config;
// Devil's heads cannot be resolved the same way: once one became a plain
// face, nothing would stop it being held as a lone 1. They are instead spent
// inside the combination they complete, which is what `take` below does.
const substitutes = counts[WILD_COMBO];
/**
* Add every way of forming one combination out of distinct faces.
*
* Each required face is supplied either by a natural die or by a Devil's
* head, and each choice leaves a different remainder, so all of them are
* offered to the recursion.
*
* @param faces - The faces the combination needs, each at most once.
* @param value - What the combination scores.
*/
const take = (faces: readonly number[], value: number): void => {
const rest = counts.slice();
if (substitutes === 0) {
// The common case by far — most hands hold no Devil's head at all — and
// worth its own branch: it is one pass with no recursion and no second
// copy of the vector, which is what keeps a full search fast.
for (const face of faces) {
if (rest[face - 1] === 0) {
return;
}
rest[face - 1] -= 1;
}
options.push({ value, rest });
return;
}
const fill = (index: number): void => {
if (index === faces.length) {
options.push({ value, rest: rest.slice() });
return;
}
const slot = faces[index] - 1;
if (rest[slot] > 0) {
rest[slot] -= 1;
fill(index + 1);
rest[slot] += 1;
}
if (rest[WILD_COMBO] > 0) {
rest[WILD_COMBO] -= 1;
fill(index + 1);
rest[WILD_COMBO] += 1;
}
};
fill(0);
};
// 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. Counting substitutes
// by how many are spent rather than by which position they fill keeps this
// free of duplicate moves, which `take` cannot avoid for repeated faces.
for (let face = 0; face < FACES; face += 1) {
const available = counts[face];
for (let n = 3; n <= available + substitutes; n += 1) {
for (let spent = Math.max(0, n - available); spent <= Math.min(substitutes, n); spent += 1) {
const rest = counts.slice();
rest[face] = available - (n - spent);
rest[WILD_COMBO] = substitutes - spent;
options.push({ value: this.combinationValue(face + 1, n), rest });
}
}
}
// Singles: only a natural one or five scores on its own. A Devil's head
// deliberately has no move here — that is the whole of "never scoring on its
// own", and it is why a lone one cannot be held.
const takeSingle = (face: number, value: number): void => {
if (counts[face - 1] > 0) {
const rest = counts.slice();
rest[face - 1] -= 1;
options.push({ value, rest });
}
};
takeSingle(1, 100);
takeSingle(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,203 @@
/**
* The move generator behind Farkle scoring: every legal single step from a
* position, which `Scorer` maximises over.
*
* A step is either taking one combination — a straight, a badge formation, an
* n-of-a-kind, or a lone 1 or 5 — or resolving one Balatro joker into a face.
* Each move reports its point value and the dice left afterwards, and the two
* recursions in `scoring.ts` share this so the rules are stated exactly once.
*
* The two joker faces differ here, and that difference is the whole reason the
* generator is shaped this way:
*
* Balatro a free choice of face, so it is *resolved* into each face and
* scored as that face from then on — including as a lone 1
* Devil's head may only be spent inside a combination it completes, so it is
* never resolved; `take` consumes it in place
*/
import type { ScoringRules, FormationValues } from "../data/badges.ts";
import { FACES, WILD_ALONE, WILD_COMBO } from "./counts.ts";
import type { CountVector } from "./counts.ts";
/** Everything the move generator needs beyond the roll itself. */
export interface ScoringConfig {
readonly rules: ScoringRules;
readonly formationValues: FormationValues;
}
/** One legal step: what it scores, and the dice remaining after it. */
export interface ScoringMove {
readonly value: number;
readonly rest: number[];
}
/**
* 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);
}
/**
* 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.
* @param config - Active rules and formation values.
* @returns The point value, including the Emperor and Tyche multipliers.
*/
function combinationValue(face: number, n: number, config: ScoringConfig): 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 && config.rules.emperorTriple) {
return base * 3;
}
if (n === 3 && face === 6 && config.rules.tycheDouble) {
return base * 2;
}
return base;
}
/**
* Every legal single step from a position: take one combination (or resolve one
* Balatro joker) and hand the leftovers back to the recursion.
*
* @param counts - Count vector of the remaining dice.
* @param config - Active rules and formation values.
* @returns Each move's point value and the dice left afterwards.
*/
export function scoringMoves(counts: CountVector, config: ScoringConfig): ScoringMove[] {
const alone = counts[WILD_ALONE];
if (alone > 0) {
// A Balatro joker is a free choice of face — including a lone 1 — so it
// can simply be resolved into each face and scored as that face from then
// on. Resolving one at a time is sufficient: further jokers are handled by
// the recursive call, and every assignment is reachable that way.
const options: ScoringMove[] = [];
for (let face = 0; face < FACES; face += 1) {
const rest = counts.slice();
rest[WILD_ALONE] = alone - 1;
rest[face] += 1;
options.push({ value: 0, rest });
}
return options;
}
const options: ScoringMove[] = [];
const { rules, formationValues } = config;
// Devil's heads cannot be resolved the same way: once one became a plain
// face, nothing would stop it being held as a lone 1. They are instead spent
// inside the combination they complete, which is what `take` below does.
const substitutes = counts[WILD_COMBO];
/**
* Add every way of forming one combination out of distinct faces.
*
* Each required face is supplied either by a natural die or by a Devil's
* head, and each choice leaves a different remainder, so all of them are
* offered to the recursion.
*
* @param faces - The faces the combination needs, each at most once.
* @param value - What the combination scores.
*/
const take = (faces: readonly number[], value: number): void => {
const rest = counts.slice();
if (substitutes === 0) {
// The common case by far — most hands hold no Devil's head at all — and
// worth its own branch: it is one pass with no recursion and no second
// copy of the vector, which is what keeps a full search fast.
for (const face of faces) {
if (rest[face - 1] === 0) {
return;
}
rest[face - 1] -= 1;
}
options.push({ value, rest });
return;
}
const fill = (index: number): void => {
if (index === faces.length) {
options.push({ value, rest: rest.slice() });
return;
}
const slot = faces[index] - 1;
if (rest[slot] > 0) {
rest[slot] -= 1;
fill(index + 1);
rest[slot] += 1;
}
if (rest[WILD_COMBO] > 0) {
rest[WILD_COMBO] -= 1;
fill(index + 1);
rest[WILD_COMBO] += 1;
}
};
fill(0);
};
// 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. Counting substitutes
// by how many are spent rather than by which position they fill keeps this
// free of duplicate moves, which `take` cannot avoid for repeated faces.
for (let face = 0; face < FACES; face += 1) {
const available = counts[face];
for (let n = 3; n <= available + substitutes; n += 1) {
for (let spent = Math.max(0, n - available); spent <= Math.min(substitutes, n); spent += 1) {
const rest = counts.slice();
rest[face] = available - (n - spent);
rest[WILD_COMBO] = substitutes - spent;
options.push({ value: combinationValue(face + 1, n, config), rest });
}
}
}
// Singles: only a natural one or five scores on its own. A Devil's head
// deliberately has no move here — that is the whole of "never scoring on its
// own", and it is why a lone one cannot be held.
const takeSingle = (face: number, value: number): void => {
if (counts[face - 1] > 0) {
const rest = counts.slice();
rest[face - 1] -= 1;
options.push({ value, rest });
}
};
takeSingle(1, 100);
takeSingle(5, 50);
return options;
}