// 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; }