diff --git a/docs/superpowers/contracts/kcd2-scoring-split-2026-08-17.json b/docs/superpowers/contracts/kcd2-scoring-split-2026-08-17.json new file mode 100644 index 00000000..fec11d60 --- /dev/null +++ b/docs/superpowers/contracts/kcd2-scoring-split-2026-08-17.json @@ -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." +} diff --git a/docs/superpowers/evidence/kcd2-scoring-split-2026-08-17.json b/docs/superpowers/evidence/kcd2-scoring-split-2026-08-17.json new file mode 100644 index 00000000..4576af57 --- /dev/null +++ b/docs/superpowers/evidence/kcd2-scoring-split-2026-08-17.json @@ -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 ", + "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." + ] +} diff --git a/kcd2_dice_solver/src/core/scoring.test.ts b/kcd2_dice_solver/src/core/scoring.test.ts index 508cf679..0b6da49a 100644 --- a/kcd2_dice_solver/src/core/scoring.test.ts +++ b/kcd2_dice_solver/src/core/scoring.test.ts @@ -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): Scorer => diff --git a/kcd2_dice_solver/src/core/scoring.ts b/kcd2_dice_solver/src/core/scoring.ts index 5339b550..a6957ffb 100644 --- a/kcd2_dice_solver/src/core/scoring.ts +++ b/kcd2_dice_solver/src/core/scoring.ts @@ -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; - } } diff --git a/kcd2_dice_solver/src/core/scoringMoves.ts b/kcd2_dice_solver/src/core/scoringMoves.ts new file mode 100644 index 00000000..4575dc01 --- /dev/null +++ b/kcd2_dice_solver/src/core/scoringMoves.ts @@ -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; +}