160 lines
5.3 KiB
JavaScript
160 lines
5.3 KiB
JavaScript
// Heuristic Yatzi AI. Stateless functions consumed by YatziGame.
|
|
//
|
|
// `chooseDiceToHold(dice, scorecard, rollsRemaining)` → 5-bool hold mask
|
|
// `chooseCategory(dice, scorecard)` → category slug
|
|
// `shouldKeepRolling(holdMask, rollsRemaining)` → boolean
|
|
|
|
import {
|
|
UPPER, legalCategories, scoreForCommit, isYahtzee,
|
|
} from './YatziLogic.js';
|
|
|
|
export function chooseDiceToHold(dice, scorecard, rollsRemaining) {
|
|
const open = (c) => scorecard[c] === null;
|
|
const counts = {};
|
|
for (const d of dice) counts[d] = (counts[d] || 0) + 1;
|
|
|
|
// Yahtzee in hand — always hold all (Joker rules let us score it somewhere)
|
|
if (isYahtzee(dice)) return [true, true, true, true, true];
|
|
|
|
const face4 = faceWithCount(counts, 4);
|
|
const face3 = faceWithCount(counts, 3);
|
|
const pairs = Object.entries(counts).filter(([, c]) => c >= 2).map(([f]) => Number(f));
|
|
|
|
// 4-of-a-kind: hold the four; reroll the spare hunting Yahtzee
|
|
if (face4 !== null && (open('yahtzee') || open('fourOfKind') || open('threeOfKind'))) {
|
|
return dice.map((d) => d === face4);
|
|
}
|
|
|
|
// Full house in hand
|
|
if (face3 !== null && pairs.some((p) => p !== face3) && open('fullHouse')) {
|
|
return [true, true, true, true, true];
|
|
}
|
|
|
|
// Large straight in hand (5 in a row)
|
|
if (hasLargeStraight(dice) && open('largeStraight')) {
|
|
return [true, true, true, true, true];
|
|
}
|
|
|
|
// Small straight in hand (4 in a row) — hold those 4, reroll the dupe
|
|
const smallStraightHold = holdForSmallStraight(dice);
|
|
if (smallStraightHold && (open('smallStraight') || open('largeStraight'))) {
|
|
return smallStraightHold;
|
|
}
|
|
|
|
// 3-of-a-kind in hand
|
|
if (face3 !== null) {
|
|
if (open('threeOfKind') || open('fourOfKind') || open('yahtzee') || open(UPPER[face3 - 1])) {
|
|
return dice.map((d) => d === face3);
|
|
}
|
|
}
|
|
|
|
// 4-in-a-row distinct values — chase a straight if slots open
|
|
const run = longestConsecutive(dice);
|
|
if (run.length >= 4 && (open('largeStraight') || open('smallStraight'))) {
|
|
return maskMatchingValues(dice, run);
|
|
}
|
|
|
|
// 3-in-a-row + open straight goals — keep the run
|
|
if (run.length === 3 && rollsRemaining >= 1 && (open('largeStraight') || open('smallStraight'))) {
|
|
return maskMatchingValues(dice, run);
|
|
}
|
|
|
|
// Highest pair toward upper / 3-of-a-kind
|
|
if (pairs.length > 0) {
|
|
const bestPair = Math.max(...pairs);
|
|
if (open(UPPER[bestPair - 1]) || open('threeOfKind') || open('fourOfKind') || open('yahtzee')) {
|
|
return dice.map((d) => d === bestPair);
|
|
}
|
|
}
|
|
|
|
// Nothing matched: keep individual high pips whose upper slot is open
|
|
const held = [false, false, false, false, false];
|
|
for (let i = 0; i < 5; i++) {
|
|
const f = dice[i];
|
|
if (f >= 4 && open(UPPER[f - 1])) held[i] = true;
|
|
}
|
|
return held;
|
|
}
|
|
|
|
// AI decides to stop early if every die is held (further rolls do nothing).
|
|
export function shouldKeepRolling(holdMask, rollsRemaining) {
|
|
if (rollsRemaining <= 0) return false;
|
|
return holdMask.some((h) => !h);
|
|
}
|
|
|
|
// Pick the category to commit to. Best score wins; ties resolved by
|
|
// sacrificing the cheapest slot first.
|
|
export function chooseCategory(dice, scorecard) {
|
|
const legal = legalCategories(dice, scorecard);
|
|
if (legal.length === 0) return null;
|
|
const scored = legal.map((c) => ({ c, v: scoreForCommit(dice, c, scorecard) ?? 0 }));
|
|
scored.sort((a, b) => {
|
|
if (b.v !== a.v) return b.v - a.v;
|
|
return sacrificeRank(a.c) - sacrificeRank(b.c);
|
|
});
|
|
return scored[0].c;
|
|
}
|
|
|
|
// Lower rank = cheaper to zero out when no slot scores positive
|
|
const SACRIFICE_ORDER = [
|
|
'ones', 'twos', 'threes',
|
|
'fourOfKind', 'threeOfKind',
|
|
'fours', 'fives',
|
|
'fullHouse', 'smallStraight',
|
|
'sixes',
|
|
'largeStraight', 'chance', 'yahtzee',
|
|
];
|
|
function sacrificeRank(category) {
|
|
const i = SACRIFICE_ORDER.indexOf(category);
|
|
return i === -1 ? 99 : i;
|
|
}
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
function faceWithCount(counts, n) {
|
|
for (const [f, c] of Object.entries(counts)) {
|
|
if (c >= n) return Number(f);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function hasLargeStraight(dice) {
|
|
const s = new Set(dice);
|
|
return [[1, 2, 3, 4, 5], [2, 3, 4, 5, 6]].some((seq) => seq.every((n) => s.has(n)));
|
|
}
|
|
|
|
function holdForSmallStraight(dice) {
|
|
const s = new Set(dice);
|
|
for (const seq of [[1, 2, 3, 4], [2, 3, 4, 5], [3, 4, 5, 6]]) {
|
|
if (seq.every((n) => s.has(n))) {
|
|
return maskMatchingValues(dice, seq);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Build a hold mask that picks one die per target value (in order).
|
|
function maskMatchingValues(dice, targets) {
|
|
const need = {};
|
|
for (const t of targets) need[t] = (need[t] ?? 0) + 1;
|
|
const mask = [false, false, false, false, false];
|
|
for (let i = 0; i < 5; i++) {
|
|
if (need[dice[i]] > 0) { mask[i] = true; need[dice[i]] -= 1; }
|
|
}
|
|
return mask;
|
|
}
|
|
|
|
// Longest run of consecutive distinct values in dice.
|
|
function longestConsecutive(dice) {
|
|
const unique = [...new Set(dice)].sort((a, b) => a - b);
|
|
if (unique.length === 0) return [];
|
|
let best = [unique[0]];
|
|
let cur = [unique[0]];
|
|
for (let i = 1; i < unique.length; i++) {
|
|
if (unique[i] === unique[i - 1] + 1) cur.push(unique[i]);
|
|
else cur = [unique[i]];
|
|
if (cur.length > best.length) best = [...cur];
|
|
}
|
|
return best;
|
|
}
|