testsAndMisc/kcd2_dice_solver/src/core/distribution.ts
Krzysztof kuhy Rudnicki 4aae92ae09 kcd2_dice_solver: Balatro's die has one joker face, not six
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, which made the die resolve every throw in
the player's favour: EV 8000, bust 0%, and the solver forced it into every
loadout it could. Only one face is the joker (Jimbo's grinning face, visible on
the item icon), and no source publishes the die's side weights at all.

The two joker faces also do not score alike, and the scorer was treating them as
one thing. Per the wiki, a Devil's head "matches any combination but never
scores on its own", while Balatro's joker counts as a 1 when picked alone. So
there are now two substitute slots: a Devil's head is spent inside the
combination it completes rather than resolved to a plain face, which is what
stops it being held as a lone 1. Hands with no substitute keep the original
single-pass code path.

Balatro's die: EV 8000 -> 771.2, bust 0% -> 3.09%; a lone devil's head scores
100 -> 0 and is no longer a legal hold. The two dice now tie exactly on a single
throw of six and differ only mid-turn (813.7 vs 808.3 points per turn), which is
where "may be held alone" actually pays.

Which pip the joker replaces is unpublished; the 1 is assumed, matching the
Devil's head die the game compares it to, and marked UNVERIFIED in the data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 16:14:04 +02:00

115 lines
4.1 KiB
TypeScript

/**
* Exact outcome distribution for a set of loaded dice.
*
* Naively a six-dice set has 6^6 = 46,656 ordered outcomes, and the search
* 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(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
* without ever building an array.
*/
import type { Die, Face } from "../data/dice.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 {
readonly counts: readonly number[];
readonly probability: number;
}
/** The same distribution with count vectors left packed. */
export interface PackedDistribution {
/** Packed count vectors, one per reachable outcome. */
readonly keys: readonly number[];
/** Probability of each, index-aligned with `keys`. Sums to 1. */
readonly probabilities: readonly number[];
}
/**
* Collapse a die's face weights into the eight scoring categories.
*
* 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 An eight-element probability vector: faces 1-6 then the substitutes.
*/
export function categoryWeights(die: Die): number[] {
const weights = new Array<number>(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 ? wildSlot : face;
weights[slot] += die.weights[face];
}
return weights;
}
/**
* Drop the zero-probability categories from a die's weights.
*
* Several dice have a 0% face and the Pie die has two. Skipping them keeps the
* convolution from branching into states that can never happen.
*
* @param weights - Category weights of one die.
* @returns Key step and probability for each category the die can actually roll.
*/
function transitions(weights: readonly number[]): { step: number; weight: number }[] {
const result: { step: number; weight: number }[] = [];
for (let slot = 0; slot < CATEGORIES; slot += 1) {
const weight = weights[slot];
if (weight > 0) {
result.push({ step: SLOT_STEP[slot], weight });
}
}
return result;
}
/**
* Convolve a set of dice into the exact distribution over packed count vectors.
*
* @param dice - The dice being rolled together, in any order.
* @returns Packed keys with their probabilities, which sum to 1.
*/
export function packedDistribution(dice: readonly Die[]): PackedDistribution {
let keys: number[] = [0];
let probabilities: number[] = [1];
for (const die of dice) {
const steps = transitions(categoryWeights(die));
const merged = new Map<number, number>();
for (let i = 0; i < keys.length; i += 1) {
const key = keys[i];
const probability = probabilities[i];
for (const { step, weight } of steps) {
const nextKey = key + step;
merged.set(nextKey, (merged.get(nextKey) ?? 0) + probability * weight);
}
}
keys = [...merged.keys()];
probabilities = [...merged.values()];
}
return { keys, probabilities };
}
/**
* Convolve a set of dice into the exact distribution over count vectors.
*
* @param dice - The dice being rolled together, in any order.
* @returns Every reachable outcome with its probability. Probabilities sum to 1.
*/
export function rollDistribution(dice: readonly Die[]): Outcome[] {
const { keys, probabilities } = packedDistribution(dice);
return keys.map((key, index) => ({
counts: decode(key),
probability: probabilities[index],
}));
}