fertig-classic-games/src/games/yatzi/YatziAI.js

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;
}