mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 20:27:02 +01:00
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:
parent
1d833d7524
commit
d39ad67323
@ -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."
|
||||
}
|
||||
57
docs/superpowers/evidence/kcd2-scoring-split-2026-08-17.json
Normal file
57
docs/superpowers/evidence/kcd2-scoring-split-2026-08-17.json
Normal 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."
|
||||
]
|
||||
}
|
||||
@ -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 =>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
203
kcd2_dice_solver/src/core/scoringMoves.ts
Normal file
203
kcd2_dice_solver/src/core/scoringMoves.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user