110 lines
4.0 KiB
JavaScript
110 lines
4.0 KiB
JavaScript
// 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);
|
||
}
|