216 lines
7.6 KiB
JavaScript
216 lines
7.6 KiB
JavaScript
// 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;
|
||
}
|