fertig-classic-games/src/games/mastermind/MastermindAI.js

110 lines
4.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Mastermind AI — the code-cracker. Same 1..5 skill model as ChessAI:
// • strategy — how the next guess is chosen
// • consistency — chance the guess actually respects the feedback so far
// (otherwise it "blunders" a fully random code, wasting a turn)
// • delay — "thinking" pause (ms range) before guessing, for pacing
//
// The AI tracks the *consistent set*: every code whose feedback against each of
// its past guesses matches what it actually got back. Higher skill uses that set
// ever more rigorously, culminating in Knuth's minimax at skill 5.
import { allCodes, scoreGuess } from './MastermindLogic.js';
const SKILL_PROFILES = {
1: { strategy: 'random', consistency: 0.15, delay: [900, 1500] }, // mostly ignores feedback
2: { strategy: 'consistent', consistency: 0.55, delay: [800, 1300] },
3: { strategy: 'consistent', consistency: 0.85, delay: [700, 1100] },
4: { strategy: 'consistent', consistency: 1.00, delay: [550, 950] }, // always a valid guess
5: { strategy: 'minimax', consistency: 1.00, delay: [450, 850] }, // Knuth-optimal
};
// Above this code-space size, full Knuth minimax (candidates × set) is too slow
// for a snappy turn, so we restrict the candidate pool to the consistent set.
const MINIMAX_FULL_LIMIT = 4000;
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);
}
function randomFrom(list) {
return list[Math.floor(Math.random() * list.length)];
}
// A strong, palette-agnostic opener (two pairs) when there's room for it.
function opener(config) {
const code = [];
for (let i = 0; i < config.pegs; i++) {
code.push(Math.min(config.colors - 1, Math.floor(i / 2)));
}
return code;
}
// Codes still consistent with every past AI guess + its feedback.
function consistentSet(config, history) {
const all = allCodes(config);
if (history.length === 0) return all;
return all.filter((candidate) =>
history.every((h) => {
const fb = scoreGuess(h.guess, candidate);
return fb.exact === h.exact && fb.partial === h.partial;
}));
}
// Knuth minimax: pick the guess whose worst-case feedback bucket leaves the
// fewest candidates remaining. Tie-break toward guesses that are themselves
// still-possible solutions.
function minimaxGuess(config, set) {
if (set.length <= 2) return set[0];
const candidates = set.length > MINIMAX_FULL_LIMIT ? set : allCodes(config);
const setKeys = new Set(set.map((c) => c.join(',')));
let best = null;
let bestWorst = Infinity;
let bestIsSolution = false;
for (const guess of candidates) {
const buckets = new Map();
for (const code of set) {
const fb = scoreGuess(guess, code);
const key = fb.exact * 100 + fb.partial;
buckets.set(key, (buckets.get(key) ?? 0) + 1);
}
let worst = 0;
for (const v of buckets.values()) if (v > worst) worst = v;
const isSolution = setKeys.has(guess.join(','));
if (worst < bestWorst || (worst === bestWorst && isSolution && !bestIsSolution)) {
bestWorst = worst;
best = guess;
bestIsSolution = isSolution;
}
}
return best ?? set[0];
}
// Choose the AI's next guess at the player's code.
// state.aiGuesses — the AI's own feedback history (vs the player's code)
export function chooseGuess(state, skill) {
const prof = profileFor(skill);
const cfg = state.config;
const history = state.aiGuesses;
// Opening move.
if (history.length === 0) {
return prof.strategy === 'minimax' ? opener(cfg) : randomFrom(allCodes(cfg));
}
// Low-skill blunder: ignore feedback and fire a random code.
if (prof.strategy === 'random' || Math.random() > prof.consistency) {
return randomFrom(allCodes(cfg));
}
const set = consistentSet(cfg, history);
if (set.length === 0) return randomFrom(allCodes(cfg)); // shouldn't happen
if (prof.strategy === 'minimax') return minimaxGuess(cfg, set);
return randomFrom(set);
}