fertig-classic-games/public/src/games/farkel/FarkelAI.js

56 lines
2.3 KiB
JavaScript

// Farkel — heuristic opponent. No Phaser, no timers, no state mutation. After a
// roll the AI always takes the greedy best-scoring set (FarkelLogic.bestScoring);
// the only real decision is reroll vs. bank, made with an expected-value model
// over the standard Farkle odds, shaded by a 1-5 skill profile for human-like
// pacing and the occasional greedy blunder.
import { WIN_TARGET, ON_BOARD_MIN } from './FarkelData.js';
// Probability a roll of N dice scores nothing (standard Farkle figures).
const FARKLE_PROB = { 1: 0.667, 2: 0.444, 3: 0.278, 4: 0.157, 5: 0.077, 6: 0.023 };
// Rough expected points added by scoring dice in a roll of N.
const AVG_GAIN = { 1: 25, 2: 50, 3: 75, 4: 113, 5: 150, 6: 200 };
const SKILL_PROFILES = {
1: { greed: 1.6, noise: 0.5, delay: [700, 1200] },
2: { greed: 1.3, noise: 0.35, delay: [650, 1100] },
3: { greed: 1.1, noise: 0.2, delay: [600, 1000] },
4: { greed: 1.0, noise: 0.1, delay: [520, 900] },
5: { greed: 1.0, noise: 0.0, delay: [440, 820] },
};
function profileFor(skill) {
return SKILL_PROFILES[Math.max(1, Math.min(5, skill | 0))] ?? SKILL_PROFILES[3];
}
export function nextThinkDelay(skill) {
const [lo, hi] = profileFor(skill).delay;
return lo + Math.random() * (hi - lo);
}
// Decide whether to reroll the remaining dice (true) or bank (false). Called
// after the AI has already set aside its scoring dice for the current roll.
export function decideReroll(state, skill = 3) {
const prof = profileFor(skill);
const t = state.turn;
const me = state.players[state.current];
// Below the on-board minimum, banking would score nothing — so the kept total
// is worthless until it reaches 500. Always push on (there's nothing to lose).
if (!me.onBoard && t.kept < ON_BOARD_MIN) return true;
// If banking now wins the game, take it.
if (me.score + t.kept >= WIN_TARGET) return false;
const prob = FARKLE_PROB[t.available] ?? 0.5;
const gain = (AVG_GAIN[t.available] ?? 50) * prof.greed;
let ev = (1 - prob) * gain - prob * t.kept;
ev += (Math.random() * 2 - 1) * prof.noise * 100;
// If an opponent is on the brink, gamble harder to keep pace.
let oppMax = 0;
state.players.forEach((p) => { if (p.seat !== me.seat && p.score > oppMax) oppMax = p.score; });
if (oppMax >= WIN_TARGET * 0.8 && me.score + t.kept < oppMax) ev += 150;
return ev > 0;
}