diff --git a/docs/superpowers/contracts/kcd2-balatro-single-joker-face-2026-07-25.json b/docs/superpowers/contracts/kcd2-balatro-single-joker-face-2026-07-25.json new file mode 100644 index 00000000..4fd8aeb2 --- /dev/null +++ b/docs/superpowers/contracts/kcd2-balatro-single-joker-face-2026-07-25.json @@ -0,0 +1,21 @@ +{ + "title": "Balatro's die has one joker face, not six", + "objective": "The KCD2 dice solver modelled Balatro's die as having all six faces wild, because the wiki's dice-effect row for that die is `{{Dice effect|d|d|d|d|d|d}}` — six unfilled placeholder cells rendering as \"d%\" — and a previous pass read those six `d`s as six wildcards. The die therefore resolved every throw in the player's favour (EV 8000, bust 0%) and the solver forced it into any loadout that could hold it. Correct the die to a single joker face, and while there, stop treating the game's two joker faces as one thing: a Devil's head 'matches any combination but never scores on its own' whereas Balatro's joker 'counts as if you threw 1' when picked alone. Success means the solver values Balatro's die like the fair die it is, and neither joker gets a scoring right the wiki denies it.", + "acceptance_criteria": [ + "Balatro's die carries exactly one wildcard face and no invented percentages: weights stay uniform with a comment stating that no source publishes its side weights, so the literal cannot be mistaken for a transcribed wiki figure.", + "categoryWeights routes Balatro's joker to a score-alone substitute slot and the Devil's head to a combination-only slot, with 1/6 on the joker and 1/6 on each of the five remaining printed faces.", + "A lone Devil's head scores 0 and is not a legal hold (scoreUsingAll returns -Infinity); a lone Balatro joker scores 100 and is a legal hold.", + "Both substitutes still fill in for any face of a straight, a triple, or an n-of-a-kind extension, matching every example the wiki gives.", + "The solver no longer prefers Balatro's die unconditionally: on an inventory holding both Balatro and Weighted dice it takes the Weighted set.", + "The test that asserted 'routes every Balatro face into the wildcard slot' is rewritten to assert the corrected distribution, not merely retitled — it is the test that should have caught this.", + "vitest run --coverage keeps 100% statement, branch, function and line coverage with no new suppressions, and tsc --noEmit, eslint src and pnpm build are clean.", + "README.md and the in-app caveat list state the remaining assumptions (uniform weights, joker-replaces-1, all-substitute combinations) instead of claiming the die always wins." + ], + "out_of_scope": [ + "Any other die's published percentages — no other row in src/data/dice.ts is touched.", + "Verifying in-game which pip the joker replaces; the 1 is assumed (matching the Devil's head die the game compares it to) and marked UNVERIFIED in the data.", + "UI restyling. DieRow keeps graphing the printed-face distribution, so both joker dice still show a bar for the face their joker replaces, exactly as the Devil's head always has.", + "The 12 pre-existing pytest failures in the untracked python_pkg/session_autopsy package, which this change (TypeScript and markdown only) neither causes nor fixes." + ], + "verifier": "node_modules/.bin/vitest run --coverage (222 tests, 100% branch coverage); node_modules/.bin/tsc --noEmit; node_modules/.bin/eslint src; pnpm build; pre-commit run --files ; plus a real solver run before and after the change (scratchpad demo bundled with esbuild) showing Balatro's EV 8000.0 -> 771.2, lone devil's head 100 -> 0, devil-alone hold legal -> -Infinity, and the solver switching from a forced Balatro pick to 6x Weighted." +} diff --git a/docs/superpowers/evidence/kcd2-balatro-single-joker-face-2026-07-25.json b/docs/superpowers/evidence/kcd2-balatro-single-joker-face-2026-07-25.json new file mode 100644 index 00000000..a5aa3539 --- /dev/null +++ b/docs/superpowers/evidence/kcd2-balatro-single-joker-face-2026-07-25.json @@ -0,0 +1,54 @@ +{ + "intent": "Model Balatro's die correctly in the KCD2 dice solver: it has ONE joker face, not six, so the solver must stop treating it as an unbeatable die that always resolves in the player's favour. Also honour the fact that the game's two joker faces score differently.", + "scope": [ + "kcd2_dice_solver/src/data/dice.ts - Balatro entry, Devil's head comment, new wildScoresAlone field", + "kcd2_dice_solver/src/core/{counts,scoring,distribution,simulate}.ts - two substitute slots instead of one", + "kcd2_dice_solver/README.md and src/App.tsx - the 'what the numbers are not' caveats", + "Non-goal: no change to any other die's published percentages, and no UI restyling" + ], + "changes": [ + "Root cause: the wiki's dice-effect row for Balatro's die is {{Dice effect|d|d|d|d|d|d}} - six UNFILLED placeholder cells that render as 'd%'. A previous pass read those six 'd's as six wildcard faces. Verified against the page wikitext via the fandom api.php, Template:Dice effect, and the item icon (one grinning face of six).", + "Balatro's die: wildcardFaces 1-6 -> [1], weights left as uniform [1,1,1,1,1,1] with a comment stating outright that no source publishes its side weights (wiki cells are placeholders, Inara omits the die), so uniform is an assumption rather than a transcribed figure.", + "Joker face mapping: assumed to replace the one, matching the Devil's head die the game compares it to and 'picking it alone will count as if you threw 1'. Marked UNVERIFIED in the data; the in-game check is whether the die shows a plain single pip. Confirmed as the intended assumption by the user.", + "The two joker faces are no longer modelled identically. Devil's head: 'matching any combination but never scoring on its own'. Balatro: 'picking it alone will count as if you threw 1'. counts.ts now has two substitute slots (WILD_ALONE, WILD_COMBO), CATEGORIES 7 -> 8, and Scorer.moves() spends a Devil's head only inside the combination it completes instead of resolving it to a plain face - so it can never be the lone 1 or 5 that makes a hold legal.", + "Scorer.scoreFaces(faces, wildcards) -> scoreFaces(faces, {alone, combo}) so no call site can be ambiguous about which kind of substitute it means.", + "A substitute-free hand keeps the original single-pass combination code path, so the change costs nothing on the overwhelming majority of scoring states." + ], + "verification": [ + { + "command": "node scratchpad/balatro_demo.ts (bundled with esbuild) - before vs after", + "result": "pass", + "evidence": "BEFORE: balatro wildFaces [1,2,3,4,5,6]; six-of-a-kind EV 8000.0, bust 0.00%; 'lone devil's head' scored 100; solver picked 6x balatro over 6x weighted (EV 8000 vs 2674). AFTER: balatro wildFaces [1]; EV 771.2, bust 3.09%; lone joker 100, lone devil's head 0; scoreUsingAll: joker alone 100, devil alone -Infinity (illegal hold), 1+devil -Infinity, 2,2+devil 200; simulated points/turn balatro 813.7 +/- 3.6 vs devils_head 808.3 +/- 3.7 vs ordinary 472.2; solver on {balatro:2, weighted:6, ordinary:6} now picks 6x weighted (EV 2674.6) instead of forcing balatro in." + }, + { + "command": "node_modules/.bin/vitest run --coverage", + "result": "pass", + "evidence": "14 files, 222 tests passed. Coverage 100% statements / 100% branches (307/307) / 100% functions / 100% lines - the repo's fail_under=100 bar held without any new ignores. evaluate.test.ts's 'routes every Balatro face into the wildcard slot' - the test that enshrined the bug - was rewritten to assert 1/6 in the score-alone slot and 1/6 on each of faces 2-6." + }, + { + "command": "node_modules/.bin/tsc --noEmit && node_modules/.bin/eslint src && pnpm build", + "result": "pass", + "evidence": "No type errors, no lint findings, vite build 43 modules -> dist in 599ms." + }, + { + "command": "perf comparison against HEAD in a throwaway git worktree (whole-collection inventory, 2 of every die)", + "result": "pass", + "evidence": "Search 191ms (before) -> 718ms (after) looked like a 3.7x regression, but with Balatro's die excluded from the inventory the two are 498ms (before) vs 461ms (after). The old speed was an artifact of the broken die's 8000-point EV pruning the branch-and-bound; the new code is marginally faster on identical work. No code regression." + }, + { + "command": "pre-commit run --files <13 changed files + this evidence file>", + "result": "pass", + "evidence": "Every applicable hook passed: binary-file block, AI evidence contract, workflow contract for multi-file code changes, append-only session logs, codespell, secret scans, line endings. Python/shell hooks skipped (no such files in the change)." + } + ], + "risks": [ + "Which pip the joker replaces is UNVERIFIED - no published source states it. If the die shows a plain single pip in-game, the joker replaces some other face and dice.ts must move; the assumption is deliberately the weaker of the two candidates so the error direction is conservative.", + "Its side weights are an assumption (uniform). If Warhorse loaded this die, its EV is wrong by however much the loading is worth.", + "A combination made entirely of substitutes (three Devil's heads as a triple) is allowed. The wiki's examples always pair a joker with natural dice and do not say whether an all-joker set counts.", + "Devil's head EV drops slightly because its joker can no longer be held as a lone 1. That is a deliberate correction, not a side effect, but it does change previously reported numbers for that die." + ], + "rollback": [ + "git revert the commit; the change is confined to kcd2_dice_solver/ and touches no shared tooling.", + "After rollback, re-run vitest (222 tests) and confirm the solver again reports Balatro's die at EV 8000 - i.e. that the bug is back - so it is obvious the revert restored the old behaviour rather than a third state." + ] +} diff --git a/kcd2_dice_solver/README.md b/kcd2_dice_solver/README.md index 77a30550..c808231c 100644 --- a/kcd2_dice_solver/README.md +++ b/kcd2_dice_solver/README.md @@ -64,9 +64,19 @@ every number here is reproducible. ## What the numbers are not -- **Balatro's die** publishes no face probabilities. Every face is a wildcard - ("you get to choose how it's counted"), so it is modelled as always resolving - in your favour — which means it always wins if you own it. +- **Balatro's die** publishes no face probabilities: the wiki's dice-effect row + for it is six unfilled `d` placeholders, and Inara does not list the die at + all. Exactly **one** of its faces is the joker (Jimbo's grinning face), so it + is modelled as a fair die whose 1 is replaced by that joker. Which pip the + joker replaces is unverified — the 1 is assumed, matching the Devil's head die + the game compares it to, and matching "picking it alone will count as if you + threw 1". +- **The two joker faces score differently**, and the scorer keeps them apart. A + Devil's head "matches any combination but never scores on its own", so it can + never be the lone 1 or 5 that lets you hold a die; Balatro's joker can. Both + are assumed able to fill in for any face of a combination, including one made + entirely of jokers — the wiki's examples only ever pair a joker with natural + dice, so that part is an assumption. - **The three "Advantage" formations** (Carpenter's Cut, Executioner's Gallows, Priest's Eye) and the **Headstart** point leads have no published values. The constants used are marked `UNVERIFIED` in `src/data/badges.ts`. Correct them diff --git a/kcd2_dice_solver/src/App.tsx b/kcd2_dice_solver/src/App.tsx index 8e34c19e..10e7c032 100644 --- a/kcd2_dice_solver/src/App.tsx +++ b/kcd2_dice_solver/src/App.tsx @@ -181,8 +181,10 @@ export function App({ createPort, storage }: AppProps = {}): JSX.Element { other, not a prediction of a scoreline.
  • - Balatro’s die is modelled as always counting in your favour, so it will - always be picked if you own it. + Balatro’s die has one joker face, not six, and the game + publishes no face probabilities for it. It is modelled as a + fair die whose 1 is replaced by the joker — the same shape as the Devil’s + head, but its joker also counts as a 1 when held alone.
  • The three “Advantage” formations and the Headstart leads have{" "} diff --git a/kcd2_dice_solver/src/components/components.test.tsx b/kcd2_dice_solver/src/components/components.test.tsx index db04f567..9e4ea0b2 100644 --- a/kcd2_dice_solver/src/components/components.test.tsx +++ b/kcd2_dice_solver/src/components/components.test.tsx @@ -37,6 +37,7 @@ const ordinary: Die = { description: "An ordinary playing die.", weights: [1 / 6, 1 / 6, 1 / 6, 1 / 6, 1 / 6, 1 / 6], wildcardFaces: [], + wildScoresAlone: false, }; describe("clampCount", () => { @@ -155,7 +156,12 @@ describe("DieRow", () => { }); it("tags the wildcard dice", () => { - const balatro: Die = { ...ordinary, id: "balatro", wildcardFaces: [1, 2, 3, 4, 5, 6] }; + const balatro: Die = { + ...ordinary, + id: "balatro", + wildcardFaces: [1], + wildScoresAlone: true, + }; render(); expect(screen.getByText("wild")).toBeInTheDocument(); }); diff --git a/kcd2_dice_solver/src/core/counts.ts b/kcd2_dice_solver/src/core/counts.ts index bca7c7e4..8ab580c9 100644 --- a/kcd2_dice_solver/src/core/counts.ts +++ b/kcd2_dice_solver/src/core/counts.ts @@ -1,21 +1,39 @@ /** * Packed representation of a roll. * - * A roll is only ever needed as a *multiset* of faces, so it is stored as seven - * counts — faces 1-6 plus the wildcard slot — and those seven counts are packed - * into one integer. That packing is the reason the solver is fast: adding a die - * to a category is a single integer addition, and the scorer's memo can be keyed - * on the packed value directly, so a warm run never touches an array at all. + * A roll is only ever needed as a *multiset* of faces, so it is stored as eight + * counts — faces 1-6 plus the two substitute slots — and those eight counts are + * packed into one integer. That packing is the reason the solver is fast: adding + * a die to a category is a single integer addition, and the scorer's memo can be + * keyed on the packed value directly, so a warm run never touches an array at + * all. + * + * There are two substitute slots because the game's two joker faces do not + * behave alike: Balatro's joker may be held on its own (it counts as a 1), + * whereas the Devil's head only ever counts as part of a combination. A single + * slot cannot express both, and a hand may legally contain dice of both kinds. */ /** Number of die faces. */ export const FACES = 6; -/** Length of a count vector: six faces plus the wildcard slot. */ -export const CATEGORIES = 7; +/** + * Index of the substitute slot for jokers that may also score on their own. + * + * Balatro's die: "picking it alone will count as if you threw 1". + */ +export const WILD_ALONE = 6; -/** Index of the wildcard ("substitute") slot in a count vector. */ -export const WILD = 6; +/** + * Index of the substitute slot for jokers that only count inside a combination. + * + * Devil's head die: "acts as a joker, matching any combination but never scoring + * on its own". + */ +export const WILD_COMBO = 7; + +/** Length of a count vector: six faces plus the two substitute slots. */ +export const CATEGORIES = 8; /** * Base used for packing. Base 8 holds up to 7 dice in any one category, which is @@ -24,8 +42,8 @@ export const WILD = 6; export const RADIX = 8; /** - * A roll expressed as counts per category: indices 0-5 are faces 1-6 and index - * 6 is the number of wildcard dice. + * A roll expressed as counts per category: indices 0-5 are faces 1-6, index 6 is + * the number of score-alone jokers and index 7 the combination-only ones. */ export type CountVector = readonly number[]; @@ -53,7 +71,7 @@ export function encode(counts: CountVector): number { * Unpack an integer key back into a count vector. * * @param key - Key produced by {@link encode} or by summing {@link SLOT_STEP}. - * @returns The seven-element count vector it stands for. + * @returns The eight-element count vector it stands for. */ export function decode(key: number): number[] { const counts = new Array(CATEGORIES).fill(0); diff --git a/kcd2_dice_solver/src/core/distribution.ts b/kcd2_dice_solver/src/core/distribution.ts index 8c7d3275..559df551 100644 --- a/kcd2_dice_solver/src/core/distribution.ts +++ b/kcd2_dice_solver/src/core/distribution.ts @@ -5,8 +5,8 @@ * evaluates tens of thousands of candidate sets. But scoring depends only on the * *multiset* of faces, and the dice are independent, so we convolve them into a * distribution over count vectors instead. After six dice there are at most - * C(12, 6) = 924 distinct count vectors — roughly fifty times less work, and, - * unlike sampling, exact. + * C(13, 7) = 1,716 distinct count vectors — well over an order of magnitude less + * work, and, unlike sampling, exact. * * The count vectors stay packed as integers throughout (see `counts.ts`), so a * convolution step is an integer addition and the scorer can look the result up @@ -14,7 +14,7 @@ */ import type { Die, Face } from "../data/dice.ts"; -import { CATEGORIES, SLOT_STEP, WILD, decode } from "./counts.ts"; +import { CATEGORIES, SLOT_STEP, WILD_ALONE, WILD_COMBO, decode } from "./counts.ts"; /** One reachable outcome: a count vector and the probability of reaching it. */ export interface Outcome { @@ -31,19 +31,21 @@ export interface PackedDistribution { } /** - * Collapse a die's face weights into the seven scoring categories. + * Collapse a die's face weights into the eight scoring categories. * - * Faces flagged as wildcards contribute their probability to the wildcard slot - * rather than to their printed face, because that is how they score. + * A joker face contributes its probability to one of the two substitute slots + * rather than to the face it replaces, because that is how it scores — and which + * of the two depends on whether the die's joker may be held on its own. * * @param die - The die to convert. - * @returns A seven-element probability vector, faces 1-6 then wildcard. + * @returns An eight-element probability vector: faces 1-6 then the substitutes. */ export function categoryWeights(die: Die): number[] { const weights = new Array(CATEGORIES).fill(0); + const wildSlot = die.wildScoresAlone ? WILD_ALONE : WILD_COMBO; for (let face = 0; face < 6; face += 1) { const isWild = die.wildcardFaces.includes((face + 1) as Face); - const slot = isWild ? WILD : face; + const slot = isWild ? wildSlot : face; weights[slot] += die.weights[face]; } return weights; diff --git a/kcd2_dice_solver/src/core/evaluate.test.ts b/kcd2_dice_solver/src/core/evaluate.test.ts index f0fe9f50..ffb1336f 100644 --- a/kcd2_dice_solver/src/core/evaluate.test.ts +++ b/kcd2_dice_solver/src/core/evaluate.test.ts @@ -2,8 +2,8 @@ * Cross-checks for the convolution. * * The whole speed story — packed integer keys, pooling identical dice, skipping - * zero-weight faces, routing wildcards into a seventh slot — is exactly the kind - * of optimisation that can produce subtly wrong probabilities while still + * zero-weight faces, routing joker faces into their own slots — is exactly the + * kind of optimisation that can produce subtly wrong probabilities while still * looking plausible. So the distribution is checked against a naive enumeration * of all 6^6 ordered outcomes, which shares none of that machinery. */ @@ -12,7 +12,7 @@ import { describe, expect, it } from "vitest"; import { BASE_RULES } from "../data/badges.ts"; import { DICE, DICE_BY_ID } from "../data/dice.ts"; import type { Die } from "../data/dice.ts"; -import { CATEGORIES, WILD } from "./counts.ts"; +import { CATEGORIES, WILD_ALONE, WILD_COMBO } from "./counts.ts"; import { categoryWeights, packedDistribution, rollDistribution } from "./distribution.ts"; import { evaluateQuick, evaluateSet, percentile, scoreDistribution } from "./evaluate.ts"; import { Scorer } from "./scoring.ts"; @@ -135,16 +135,26 @@ describe("distribution mechanics", () => { } }); - it("routes wildcard faces into the wildcard slot", () => { + it("routes the devil's head into the combination-only slot", () => { const weights = categoryWeights(die("devils_head")); // The devil's head replaces the one, so face 1 must be empty. expect(weights[0]).toBe(0); - expect(weights[WILD]).toBeCloseTo(1 / 6, 9); + expect(weights[WILD_COMBO]).toBeCloseTo(1 / 6, 9); + expect(weights[WILD_ALONE]).toBe(0); }); - it("routes every Balatro face into the wildcard slot", () => { + it("routes Balatro's single joker face into the score-alone slot", () => { const weights = categoryWeights(die("balatro")); - expect(weights[WILD]).toBeCloseTo(1, 12); + // One face of six is the joker, not all six: the wiki's dice-effect row for + // this die is six unfilled `d` placeholders, which an earlier version of the + // data read as six wildcards. + expect(weights[WILD_ALONE]).toBeCloseTo(1 / 6, 9); + expect(weights[WILD_COMBO]).toBe(0); + // The joker replaces the one, so the remaining five faces are 2-6. + expect(weights[0]).toBe(0); + for (const face of [1, 2, 3, 4, 5]) { + expect(weights[face]).toBeCloseTo(1 / 6, 9); + } }); it("decodes count vectors that sum to the number of dice", () => { diff --git a/kcd2_dice_solver/src/core/scoring.test.ts b/kcd2_dice_solver/src/core/scoring.test.ts index 7252f323..508cf679 100644 --- a/kcd2_dice_solver/src/core/scoring.test.ts +++ b/kcd2_dice_solver/src/core/scoring.test.ts @@ -7,12 +7,30 @@ import { describe, expect, it } from "vitest"; import { BASE_RULES, DEFAULT_FORMATION_VALUES } from "../data/badges.ts"; import type { ScoringRules } from "../data/badges.ts"; -import { Scorer, ofAKindValue, tripleBase } from "./scoring.ts"; +import { CATEGORIES, Scorer, WILD_ALONE, WILD_COMBO, ofAKindValue, tripleBase } from "./scoring.ts"; +import type { WildCounts } from "./scoring.ts"; const plain = (): Scorer => new Scorer(BASE_RULES); const withRules = (rules: Partial): Scorer => new Scorer({ ...BASE_RULES, ...rules }, DEFAULT_FORMATION_VALUES); +/** + * Build a count vector, for the calls that take one rather than a face list. + * + * @param faces - Natural face values in the hand. + * @param wilds - Substitutes of each kind in the hand. + * @returns The count vector those dice make. + */ +const countsOf = (faces: readonly number[], wilds: WildCounts = {}): number[] => { + const counts = new Array(CATEGORIES).fill(0); + for (const face of faces) { + counts[face - 1] += 1; + } + counts[WILD_ALONE] = wilds.alone ?? 0; + counts[WILD_COMBO] = wilds.combo ?? 0; + return counts; +}; + describe("wiki scoring table", () => { it("scores a single one and a single five", () => { expect(plain().scoreFaces([1, 2, 2, 3, 4, 4])).toBe(100); @@ -72,28 +90,76 @@ describe("wiki scoring table", () => { }); }); -describe("wildcards", () => { - it("lets a wildcard complete a straight", () => { - // 2,3,4,5,6 plus one wildcard becomes the full 1-6 straight. - expect(plain().scoreFaces([2, 3, 4, 5, 6], 1)).toBe(1500); +describe("substitutes", () => { + // Both joker faces substitute inside combinations, so these cases must come + // out the same whichever kind of substitute supplies the missing face. + for (const [kind, wilds] of [ + ["Balatro joker", { alone: 1 }], + ["devil's head", { combo: 1 }], + ] as const) { + it(`lets a ${kind} complete a straight`, () => { + // 2,3,4,5,6 plus one substitute becomes the full 1-6 straight. + expect(plain().scoreFaces([2, 3, 4, 5, 6], wilds)).toBe(1500); + }); + + it(`lets a ${kind} complete a triple`, () => { + // 6,6 + substitute-as-6 is 600, but 2,3,4,6 + substitute-as-5 is the 2-6 + // straight at 750 — the scorer must take the better of the two. + expect(plain().scoreFaces([6, 6, 2, 3, 4], wilds)).toBe(750); + // Without the 2-6 straight available, the triple is the best use. + expect(plain().scoreFaces([6, 6, 2, 2, 4], wilds)).toBe(600); + }); + + it(`extends an n-of-a-kind with a ${kind}`, () => { + // Three ones and a substitute are a quadruple, per the wiki's example. + expect(plain().scoreFaces([1, 1, 1, 2, 4], wilds)).toBe(2000); + }); + + it(`never makes a roll worse with a ${kind}`, () => { + expect(plain().scoreFaces([2, 2, 3, 3, 4], wilds)).toBeGreaterThan(0); + }); + } + + it("scores a lone Balatro joker as a one", () => { + // "Picking it alone will count as if you threw 1." + expect(plain().scoreFaces([], { alone: 1 })).toBe(100); + expect(plain().scoreFaces([2, 3], { alone: 1 })).toBe(100); }); - it("lets a wildcard complete a triple", () => { - // 6,6 + wildcard-as-6 is 600, but 2,3,4,6 + wildcard-as-5 is the 2-6 - // straight at 750 — the scorer must take the better of the two. - expect(plain().scoreFaces([6, 6, 2, 3, 4], 1)).toBe(750); - // Without the 2-6 straight available, the triple is the best use. - expect(plain().scoreFaces([6, 6, 2, 2, 4], 1)).toBe(600); + it("gives a lone devil's head nothing", () => { + // "Matching any combination but never scoring on its own." + expect(plain().scoreFaces([], { combo: 1 })).toBe(0); + expect(plain().scoreFaces([2, 3], { combo: 1 })).toBe(0); + // Nor may it ride along with a scoring die: a hold must be all scoring dice, + // and there is no combination for the devil's head to join here. + expect(plain().scoreUsingAll(countsOf([1], { combo: 1 }))).toBe(-Infinity); + expect(plain().scoreUsingAll(countsOf([], { alone: 1 }))).toBe(100); }); - it("resolves six wildcards to the best possible roll", () => { - // Balatro's die: every face is a wildcard, so six of them are six ones. - expect(plain().scoreFaces([], 6)).toBe(8000); + it("resolves six substitutes to the best possible roll", () => { + // Six of either kind can form two triples of ones, since a combination may + // be made entirely of substitutes. + expect(plain().scoreFaces([], { alone: 6 })).toBe(8000); + expect(plain().scoreFaces([], { combo: 6 })).toBe(8000); }); - it("never makes a roll worse", () => { + it("spends both kinds of substitute on one combination", () => { + // 2,2 + joker-as-2 + devil-as-2 is a four-of-a-kind, so both slots have to + // be reachable from the same combination. + expect(plain().scoreFaces([2, 2], { alone: 1, combo: 1 })).toBe(400); + expect(plain().scoreFaces([1, 1], { alone: 1, combo: 1 })).toBe(2000); + }); + + it("gives the joker every use the devil's head has, and one more", () => { const scorer = plain(); - expect(scorer.scoreFaces([2, 2, 3, 3, 4], 1)).toBeGreaterThan(0); + for (const faces of [[2, 3], [2, 2], [6, 6, 2, 3, 4], [1, 1, 1, 2, 4], [2, 3, 4, 5, 6]]) { + expect(scorer.scoreFaces(faces, { alone: 1 })).toBeGreaterThanOrEqual( + scorer.scoreFaces(faces, { combo: 1 }), + ); + } + // The one extra: scoring with no combination to join. + expect(scorer.scoreFaces([2, 3], { alone: 1 })).toBe(100); + expect(scorer.scoreFaces([2, 3], { combo: 1 })).toBe(0); }); }); @@ -136,24 +202,29 @@ describe("badge scoring rules", () => { describe("scoreUsingAll", () => { it("requires every die to be part of a combination", () => { const scorer = plain(); - expect(scorer.scoreUsingAll([1, 0, 0, 0, 0, 0, 0])).toBe(100); + expect(scorer.scoreUsingAll(countsOf([1]))).toBe(100); // A lone 2 cannot score, so no legal hold uses it. - expect(scorer.scoreUsingAll([0, 1, 0, 0, 0, 0, 0])).toBe(-Infinity); + expect(scorer.scoreUsingAll(countsOf([2]))).toBe(-Infinity); // A 1 plus a dead 2 is still illegal as a hold. - expect(scorer.scoreUsingAll([1, 1, 0, 0, 0, 0, 0])).toBe(-Infinity); + expect(scorer.scoreUsingAll(countsOf([1, 2]))).toBe(-Infinity); }); it("scores an empty hold as zero", () => { - expect(plain().scoreUsingAll([0, 0, 0, 0, 0, 0, 0])).toBe(0); + expect(plain().scoreUsingAll(countsOf([]))).toBe(0); }); - it("resolves wildcards in a hold", () => { - expect(plain().scoreUsingAll([0, 0, 0, 0, 0, 0, 1])).toBe(100); + it("resolves substitutes in a hold", () => { + // A Balatro joker is a legal hold on its own; a devil's head is not. + expect(plain().scoreUsingAll(countsOf([], { alone: 1 }))).toBe(100); + expect(plain().scoreUsingAll(countsOf([], { combo: 1 }))).toBe(-Infinity); + // Both may be held as part of a combination they complete. + expect(plain().scoreUsingAll(countsOf([2, 2], { combo: 1 }))).toBe(200); + expect(plain().scoreUsingAll(countsOf([2, 2], { alone: 1 }))).toBe(200); }); it("memoises repeated queries", () => { const scorer = plain(); - const first = scorer.scoreUsingAll([3, 0, 0, 0, 0, 0, 0]); - expect(scorer.scoreUsingAll([3, 0, 0, 0, 0, 0, 0])).toBe(first); + const first = scorer.scoreUsingAll(countsOf([1, 1, 1])); + expect(scorer.scoreUsingAll(countsOf([1, 1, 1]))).toBe(first); }); }); diff --git a/kcd2_dice_solver/src/core/scoring.ts b/kcd2_dice_solver/src/core/scoring.ts index 9996dbf3..5339b550 100644 --- a/kcd2_dice_solver/src/core/scoring.ts +++ b/kcd2_dice_solver/src/core/scoring.ts @@ -18,14 +18,31 @@ * * 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. */ import type { ScoringRules, FormationValues } from "../data/badges.ts"; import { DEFAULT_FORMATION_VALUES } from "../data/badges.ts"; -import { CATEGORIES, FACES, WILD, decode, encode } from "./counts.ts"; +import { CATEGORIES, FACES, WILD_ALONE, WILD_COMBO, decode, encode } from "./counts.ts"; import type { CountVector } from "./counts.ts"; -export { CATEGORIES, FACES, WILD } from "./counts.ts"; +export { CATEGORIES, FACES, WILD_ALONE, WILD_COMBO } from "./counts.ts"; export type { CountVector } from "./counts.ts"; /** @@ -49,6 +66,14 @@ 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. */ + readonly alone?: number; + /** Devil's heads, which only count inside a combination. */ + readonly combo?: number; +} + /** Everything `bestScore` needs beyond the roll itself. */ export interface ScoringConfig { readonly rules: ScoringRules; @@ -113,15 +138,16 @@ export class Scorer { * Score a plain list of face values, for tests and the simulator. * * @param faces - Face values rolled, each 1-6. - * @param wildcards - How many of the dice are wildcards. + * @param wilds - How many substitutes of each kind are in the roll. * @returns The best achievable score. */ - scoreFaces(faces: readonly number[], wildcards = 0): number { + scoreFaces(faces: readonly number[], wilds: WildCounts = {}): number { const counts = new Array(CATEGORIES).fill(0); for (const face of faces) { counts[face - 1] += 1; } - counts[WILD] = wildcards; + counts[WILD_ALONE] = wilds.alone ?? 0; + counts[WILD_COMBO] = wilds.combo ?? 0; return this.score(counts); } @@ -186,7 +212,7 @@ export class Scorer { /** * Every legal single step from a position: take one combination (or resolve - * one wildcard) and hand the leftovers back to the recursion. + * one Balatro joker) and hand the leftovers back to the recursion. * * Both scorers share this so the rules are stated exactly once. * @@ -194,15 +220,16 @@ export class Scorer { * @returns Each move's point value and the dice left afterwards. */ private moves(counts: CountVector): { value: number; rest: number[] }[] { - const wild = counts[WILD]; - if (wild > 0) { - // A wildcard is free to become any face, so try each in turn. Resolving - // one at a time is sufficient: further wildcards are handled by the - // recursive call, and every assignment is reachable that way. + 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] = wild - 1; + rest[WILD_ALONE] = alone - 1; rest[face] += 1; options.push({ value: 0, rest }); } @@ -211,16 +238,54 @@ export class Scorer { 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(); - for (const face of faces) { - if (rest[face - 1] === 0) { + 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; } - rest[face - 1] -= 1; - } - options.push({ value, rest }); + 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. @@ -239,19 +304,33 @@ export class Scorer { take([1, 3, 5], formationValues.eye); } - // N-of-a-kind, for every face and every usable size. + // 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; n += 1) { - const rest = counts.slice(); - rest[face] = available - n; - options.push({ value: this.combinationValue(face + 1, n), rest }); + 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 ones and fives score on their own. - take([1], 100); - take([5], 50); + // 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; } diff --git a/kcd2_dice_solver/src/core/simulate.test.ts b/kcd2_dice_solver/src/core/simulate.test.ts index 48c07cf2..7349a943 100644 --- a/kcd2_dice_solver/src/core/simulate.test.ts +++ b/kcd2_dice_solver/src/core/simulate.test.ts @@ -45,6 +45,7 @@ const alwaysRolls = (face: number): Die[] => description: "", weights: [1, 2, 3, 4, 5, 6].map((f) => (f === face ? 1 : 0)) as unknown as Die["weights"], wildcardFaces: [], + wildScoresAlone: false, }); const alwaysTwo = alwaysRolls(2); @@ -53,24 +54,24 @@ const alwaysThree = alwaysRolls(3); describe("subMultisets", () => { it("enumerates every sub-multiset including the empty one", () => { // Two categories holding one die each: 2 x 2 = 4 sub-multisets. - const counts = [1, 1, 0, 0, 0, 0, 0]; + const counts = [1, 1, 0, 0, 0, 0, 0, 0]; expect(subMultisets(counts)).toHaveLength(4); }); it("scales with the count in each category", () => { // Three of one face: 0, 1, 2 or 3 of them. - expect(subMultisets([3, 0, 0, 0, 0, 0, 0])).toHaveLength(4); + expect(subMultisets([3, 0, 0, 0, 0, 0, 0, 0])).toHaveLength(4); }); }); describe("chooseHold", () => { it("returns null on a bust", () => { - expect(chooseHold([0, 2, 2, 1, 0, 1, 0], scorer, DEFAULT_POLICY)).toBeNull(); + expect(chooseHold([0, 2, 2, 1, 0, 1, 0, 0], scorer, DEFAULT_POLICY)).toBeNull(); }); it("only ever holds dice that score", () => { // One 1 and five dead dice: the only legal hold is that single 1. - const hold = chooseHold([1, 2, 2, 1, 0, 0, 0], scorer, DEFAULT_POLICY); + const hold = chooseHold([1, 2, 2, 1, 0, 0, 0, 0], scorer, DEFAULT_POLICY); expect(hold).toEqual({ points: 100, used: 1 }); }); @@ -78,36 +79,47 @@ describe("chooseHold", () => { // A 1 (100) and a 5 (50) with four dead dice. Taking both scores 150 but // leaves four dice; taking just the 1 scores 100 and leaves five. With the // default 60-point-per-die bonus the policy keeps the extra die. - const hold = chooseHold([1, 2, 2, 0, 1, 0, 0], scorer, DEFAULT_POLICY); + const hold = chooseHold([1, 2, 2, 0, 1, 0, 0, 0], scorer, DEFAULT_POLICY); expect(hold?.used).toBe(1); expect(hold?.points).toBe(100); }); it("takes everything when dice in hand are worth nothing", () => { const greedy = { bankThreshold: 300, dieValueBonus: 0 }; - const hold = chooseHold([1, 2, 2, 0, 1, 0, 0], scorer, greedy); + const hold = chooseHold([1, 2, 2, 0, 1, 0, 0, 0], scorer, greedy); expect(hold?.used).toBe(2); expect(hold?.points).toBe(150); }); + + it("holds a lone Balatro joker but never a lone devil's head", () => { + // This is where the two joker faces come apart. A 2, a 3 and a 4 offer no + // combination for a substitute to join — no pair to extend, too few dice for + // a straight — so the joker's only use is as a lone 1, which the devil's head + // may not do. It is also why Balatro's die banks more points per turn than the + // Devil's head despite the two being worth the same on a single throw. + const joker = chooseHold([0, 1, 1, 1, 0, 0, 1, 0], scorer, DEFAULT_POLICY); + expect(joker).toEqual({ points: 100, used: 1 }); + expect(chooseHold([0, 1, 1, 1, 0, 0, 0, 1], scorer, DEFAULT_POLICY)).toBeNull(); + }); }); describe("applySetDie", () => { it("converts the die that gains the most", () => { // Two 1s and a dead 2: turning the 2 into a 1 completes the 1000 triple. - const counts = [2, 1, 0, 1, 0, 1, 0]; + const counts = [2, 1, 0, 1, 0, 1, 0, 0]; expect(applySetDie(counts, 1, scorer)).toBe(true); expect(counts[0]).toBe(3); }); it("declines to spend the charge when nothing improves", () => { // Six 1s are already worth 8000; converting any of them to a 3 is a loss. - const counts = [6, 0, 0, 0, 0, 0, 0]; + const counts = [6, 0, 0, 0, 0, 0, 0, 0]; expect(applySetDie(counts, 3, scorer)).toBe(false); expect(counts[0]).toBe(6); }); it("declines when there is no other die to convert", () => { - const counts = [0, 0, 3, 0, 0, 0, 0]; + const counts = [0, 0, 3, 0, 0, 0, 0, 0]; expect(applySetDie(counts, 3, scorer)).toBe(false); }); }); diff --git a/kcd2_dice_solver/src/core/simulate.ts b/kcd2_dice_solver/src/core/simulate.ts index 408afccd..a9bf950a 100644 --- a/kcd2_dice_solver/src/core/simulate.ts +++ b/kcd2_dice_solver/src/core/simulate.ts @@ -21,7 +21,7 @@ import type { Die } from "../data/dice.ts"; import { categoryWeights } from "./distribution.ts"; -import { CATEGORIES, WILD } from "./scoring.ts"; +import { CATEGORIES } from "./scoring.ts"; import type { Scorer } from "./scoring.ts"; import { mulberry32 } from "../lib/rng.ts"; import type { Random } from "../lib/rng.ts"; @@ -67,7 +67,7 @@ export const NO_CHARGES: BadgeCharges = { }; /** - * Sample one category (face index, or the wildcard slot) from a die. + * Sample one category (a face index, or one of the substitute slots) from a die. * * @param weights - Category weights summing to 1. * @param random - Seeded random source. @@ -84,7 +84,7 @@ function sampleCategory(weights: readonly number[], random: Random): number { return slot; } } - return WILD; + return CATEGORIES - 1; } /** diff --git a/kcd2_dice_solver/src/core/solve.test.ts b/kcd2_dice_solver/src/core/solve.test.ts index 513d99f1..c00591a6 100644 --- a/kcd2_dice_solver/src/core/solve.test.ts +++ b/kcd2_dice_solver/src/core/solve.test.ts @@ -60,9 +60,9 @@ describe("solve", () => { expect(result.inventorySize).toBe(9); }); - it("solves an inventory containing the wildcard dice", () => { - // Balatro's die is six wildcards and the Devil's head die is one; both take - // a different path through the scorer and the simulator's sampler. + it("solves an inventory containing both substitute dice", () => { + // Each of the two joker faces takes its own path through the scorer and the + // simulator's sampler, and this inventory exercises both at once. const result = solve({ diceCounts: { balatro: 3, devils_head: 3, ordinary: 6 }, badgeIds: [], @@ -70,7 +70,22 @@ describe("solve", () => { }); expect(result.dice).toHaveLength(6); expect(result.simulation.meanPerTurn).toBeGreaterThan(0); - // Six wildcards always resolve to six ones, so nothing can beat Balatro. + // Both substitute dice are uniform with the one replaced by a joker, so each + // is strictly better than an ordinary die and all six get taken. Balatro's is + // the better of the two — its joker can also be held on its own — but with + // only three of each that never has to be decided. + expect(result.dice.filter((die) => die.id === "ordinary")).toHaveLength(0); expect(result.dice.filter((die) => die.id === "balatro")).toHaveLength(3); + expect(result.dice.filter((die) => die.id === "devils_head")).toHaveLength(3); + }); + + it("ties the two substitute dice on a single throw", () => { + // Both are uniform with the one replaced by a joker, so on one throw of six + // they are worth exactly the same: with five other dice there is always some + // combination for a substitute to join, and the joker's extra "may be held + // alone" only pays off later in a turn, once dice have been set aside. + const balatro = solve({ diceCounts: { balatro: 6 }, badgeIds: [], simulationTurns: 200 }); + const devil = solve({ diceCounts: { devils_head: 6 }, badgeIds: [], simulationTurns: 200 }); + expect(balatro.evaluation.ev).toBeCloseTo(devil.evaluation.ev, 9); }); }); diff --git a/kcd2_dice_solver/src/data/data.test.ts b/kcd2_dice_solver/src/data/data.test.ts index 1e80af35..ad6b76e5 100644 --- a/kcd2_dice_solver/src/data/data.test.ts +++ b/kcd2_dice_solver/src/data/data.test.ts @@ -59,8 +59,17 @@ describe("dice data", () => { "balatro", "devils_head", ]); + // Both replace the one with a joker face, and each has exactly one such face + // — Balatro's die is not "all six faces wild". expect(DICE_BY_ID.get("devils_head")?.wildcardFaces).toEqual([1]); - expect(DICE_BY_ID.get("balatro")?.wildcardFaces).toHaveLength(6); + expect(DICE_BY_ID.get("balatro")?.wildcardFaces).toEqual([1]); + }); + + it("lets only Balatro's joker score on its own", () => { + expect(DICE_BY_ID.get("balatro")?.wildScoresAlone).toBe(true); + expect(DICE_BY_ID.get("devils_head")?.wildScoresAlone).toBe(false); + // Nothing else claims the flag, so no plain die can slip past the scorer. + expect(DICE.filter((die) => die.wildScoresAlone).map((die) => die.id)).toEqual(["balatro"]); }); it("keeps the dice with a zero-probability face", () => { diff --git a/kcd2_dice_solver/src/data/dice.ts b/kcd2_dice_solver/src/data/dice.ts index fd91d814..c93740a7 100644 --- a/kcd2_dice_solver/src/data/dice.ts +++ b/kcd2_dice_solver/src/data/dice.ts @@ -37,6 +37,14 @@ export interface Die { * Empty for all but two dice. */ readonly wildcardFaces: readonly Face[]; + /** + * Whether this die's joker also scores when held on its own. + * + * True for Balatro's die ("picking it alone will count as if you threw 1"), + * false for the Devil's head ("never scoring on its own"). Meaningless, and so + * false, when `wildcardFaces` is empty. + */ + readonly wildScoresAlone: boolean; } /** @@ -63,6 +71,7 @@ interface RawDie { readonly description: string; readonly weights: Weights; readonly wildcardFaces?: readonly Face[]; + readonly wildScoresAlone?: boolean; } const RAW_DICE: readonly RawDie[] = [ @@ -78,11 +87,25 @@ const RAW_DICE: readonly RawDie[] = [ name: "Balatro's die", description: "A die crafted by the balatro Jimbo, marked with his grinning face. When it lands, you get to choose how it's counted!", - // The wiki lists no percentages for this die. Every face is a wildcard, so - // the face distribution is irrelevant to scoring: whichever face lands, the - // player chooses its value. Modelled as uniform with all six faces wild. + // Only ONE face is the joker — Jimbo's grinning face, visible on the item + // icon. The wiki's dice-effect table for this die is `{{Dice effect|d|d|d|d| + // d|d}}`: six *unfilled placeholders* that render as "d%", not six wildcards. + // Reading them as wildcards is what made this die look unbeatable. + // + // No source publishes its side weights (the wiki cells are the placeholders + // above and Inara omits the die entirely), so uniform is an assumption, kept + // as [1,1,1,1,1,1] rather than a fake "16.7" so it cannot be mistaken for a + // transcribed figure. + // + // The joker is modelled as replacing the one, like the Devil's head it is + // explicitly compared to, which is also why holding it alone counts as a 1. + // UNVERIFIED: if the die turns out to show a plain single pip in-game, the + // joker replaces some other face and this should move. weights: [1, 1, 1, 1, 1, 1], - wildcardFaces: [1, 2, 3, 4, 5, 6], + wildcardFaces: [1], + // "When it lands, you get to choose how it's counted!" — and, unlike the + // Devil's head, "picking it alone will count as if you threw 1". + wildScoresAlone: true, }, { id: "cautious_cheater", @@ -105,7 +128,9 @@ const RAW_DICE: readonly RawDie[] = [ description: "A die that feels hot to the touch. In place of a one it has a devil's head, which is not something folk like to gaze upon…", // Face 1 is the devil's head, which the wiki scoring table marks "Subst" — - // it substitutes for any value. + // it substitutes for any value, but per the die's own page it "acts as a + // joker, matching any combination but never scoring on its own", so it + // cannot be the lone 1 or 5 that lets you hold a die. weights: [16.7, 16.7, 16.7, 16.7, 16.7, 16.7], wildcardFaces: [1], }, @@ -372,6 +397,7 @@ export const DICE: readonly Die[] = RAW_DICE.map((raw) => ({ description: raw.description, weights: normalise(raw.weights), wildcardFaces: raw.wildcardFaces ?? [], + wildScoresAlone: raw.wildScoresAlone ?? false, })).sort((a, b) => a.name.localeCompare(b.name)); /** Lookup from die id to die, for resolving saved inventories. */