fertig-classic-games/public/src/games/mahjong/MahjongAI.js

216 lines
7.6 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.

// Mahjong — heuristic opponent. No Phaser, no timers, no state mutation.
// Discards minimise shanten with an ukeire (useful-draw count) tiebreak at
// skill 3+; skills 1-2 use a cheap tile-isolation heuristic instead and claim
// every pung/chow they are offered. Higher skills subtract tiles they can see
// from ukeire counts and lean toward safe discards once an opponent shows a
// big exposed hand. Wins are always taken.
import { isSuited, isHonor, rankOf } from './MahjongData.js';
import { counts34, shanten, claimOptionsFor } from './MahjongLogic.js';
const SKILL_PROFILES = {
1: { noise: 3.0, claimAlways: true, seenAware: false, defense: 0.0, delay: [750, 1300] },
2: { noise: 2.0, claimAlways: true, seenAware: false, defense: 0.0, delay: [700, 1200] },
3: { noise: 1.0, claimAlways: false, seenAware: false, defense: 0.3, delay: [620, 1050] },
4: { noise: 0.4, claimAlways: false, seenAware: true, defense: 0.7, delay: [540, 950] },
5: { noise: 0.0, claimAlways: false, seenAware: true, defense: 1.0, delay: [460, 850] },
};
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);
}
// Tiles visible to everyone: all rivers, exposed melds and bonus rows. (We
// also count concealed kongs — a minor peek that keeps the bookkeeping flat.)
function seenCounts(state) {
const seen = new Array(34).fill(0);
for (const river of state.discards) for (const k of river) seen[k]++;
for (const p of state.players) {
for (const m of p.melds) for (const k of m.kinds) seen[k]++;
}
return seen;
}
// Cheap usefulness of holding `kind` — for the skill 1-2 discard heuristic.
function usefulness(counts, kind) {
let u = (counts[kind] - 1) * 3;
if (isHonor(kind)) return u + (counts[kind] >= 2 ? 3 : 0);
const r = rankOf(kind);
if (r >= 2 && counts[kind - 1] > 0) u += 2;
if (r <= 8 && counts[kind + 1] > 0) u += 2;
if (r >= 3 && counts[kind - 2] > 0) u += 1;
if (r <= 7 && counts[kind + 2] > 0) u += 1;
if (r === 1 || r === 9) u -= 1;
return u;
}
// Draws that could improve a hand: pairing an existing tile or extending a
// suited shape. A brand-new isolated kind never lowers standard shanten.
function improverKinds(counts) {
const out = new Set();
for (let k = 0; k < 34; k++) {
if (counts[k] === 0) continue;
out.add(k);
if (!isSuited(k)) continue;
const r = rankOf(k);
for (const d of [-2, -1, 1, 2]) {
const t = k + d;
if (r + d >= 1 && r + d <= 9) out.add(t);
}
}
return [...out];
}
// Pick a discard for the player on turn (hand holds 14 3·melds tiles).
export function chooseDiscard(state, seat, skill = 3) {
const prof = profileFor(skill);
const p = state.players[seat];
const counts = counts34(p.hand);
const kinds = [];
for (let k = 0; k < 34; k++) if (counts[k] > 0) kinds.push(k);
if (prof.claimAlways) { // skills 1-2: discard the least useful tile
let best = kinds[0], bestScore = Infinity;
for (const k of kinds) {
const s = usefulness(counts, k) + Math.random() * prof.noise * 2;
if (s < bestScore) { bestScore = s; best = k; }
}
return best;
}
const m = p.melds.length;
let minSh = Infinity;
const perKind = new Map();
for (const k of kinds) {
counts[k]--;
const s = shanten(counts, m);
counts[k]++;
perKind.set(k, s);
if (s < minSh) minSh = s;
}
const candidates = kinds.filter((k) => perKind.get(k) === minSh);
const seen = prof.seenAware ? seenCounts(state) : null;
const threat = prof.defense > 0 && minSh >= 2
&& state.players.some((q) => q.seat !== seat && q.melds.length >= 3);
let best = candidates[0], bestScore = -Infinity;
for (const k of candidates) {
counts[k]--;
let ukeire = 0;
for (const t of improverKinds(counts)) {
counts[t]++;
if (shanten(counts, m) < minSh) {
let left = 4 - counts[t] + 1;
if (seen) left -= seen[t];
if (left > 0) ukeire += left;
}
counts[t]--;
}
counts[k]++;
let score = ukeire + Math.random() * prof.noise * 2;
// someone looks close to winning — favour tiles already seen on the table
if (threat && seen && seen[k] >= 1) score += 4 * prof.defense;
if (score > bestScore) { bestScore = score; best = k; }
}
return best;
}
// React to another player's discard. `options` comes from claimOptionsFor.
// Returns a claim ({ type, tiles? }) or null to pass.
export function chooseClaim(state, seat, options, skill = 3) {
if (!options) return null;
if (options.win) return { type: 'win' };
const prof = profileFor(skill);
const p = state.players[seat];
const kind = state.lastDiscard.kind;
const m = p.melds.length;
const counts = counts34(p.hand);
if (prof.claimAlways) {
if (options.kong) return { type: 'kong' };
if (options.pung) return { type: 'pung' };
if (options.chows.length) return { type: 'chow', tiles: options.chows[0] };
return null;
}
const s0 = shanten(counts, m);
if (options.kong) { // free replacement draw — accept when not a step back
counts[kind] -= 3;
const s = shanten(counts, m + 1);
counts[kind] += 3;
if (s <= s0) return { type: 'kong' };
}
if (options.pung) {
counts[kind] -= 2;
const s = shanten(counts, m + 1);
counts[kind] += 2;
if (s < s0) return { type: 'pung' };
}
for (const tiles of options.chows) {
counts[tiles[0]]--; counts[tiles[1]]--;
const s = shanten(counts, m + 1);
counts[tiles[0]]++; counts[tiles[1]]++;
if (s < s0) return { type: 'chow', tiles };
}
return null;
}
// Decide on a self-drawn win or a kong declaration while awaiting discard.
// `actions` comes from selfActions. Returns { type: 'win' },
// { type: 'kong', spec } or null to just discard.
export function chooseSelfAction(state, seat, actions, skill = 3) {
if (actions.canWin) return { type: 'win' };
const prof = profileFor(skill);
if (!actions.concealedKongs.length && !actions.addedKongs.length) return null;
if (prof.claimAlways) {
if (actions.concealedKongs.length) return { type: 'kong', spec: { type: 'concealed', kind: actions.concealedKongs[0] } };
return { type: 'kong', spec: { type: 'added', kind: actions.addedKongs[0] } };
}
const p = state.players[seat];
const m = p.melds.length;
const counts = counts34(p.hand);
let bestDiscardSh = Infinity;
for (let k = 0; k < 34; k++) {
if (counts[k] === 0) continue;
counts[k]--;
const s = shanten(counts, m);
counts[k]++;
if (s < bestDiscardSh) bestDiscardSh = s;
}
for (const k of actions.concealedKongs) {
counts[k] -= 4;
const s = shanten(counts, m + 1);
counts[k] += 4;
if (s <= bestDiscardSh) return { type: 'kong', spec: { type: 'concealed', kind: k } };
}
for (const k of actions.addedKongs) {
counts[k]--;
const s = shanten(counts, m); // pung→kong: still one meld, one tile fewer
counts[k]++;
if (s <= bestDiscardSh) return { type: 'kong', spec: { type: 'added', kind: k } };
}
return null;
}
// Collect claim intents from every AI seat for the current discard. The
// scene merges these with the human's choice before calling resolveClaims.
export function collectAIClaims(state, humanSeat = 0) {
const intents = [];
for (let seat = 0; seat < 4; seat++) {
if (seat === humanSeat) continue;
const p = state.players[seat];
if (!p.isAI) continue;
const options = claimOptionsFor(state, seat);
if (!options) continue;
const claim = chooseClaim(state, seat, options, p.skill);
if (claim) intents.push({ seat, claim });
}
return intents;
}