/** * 마작 computer player (cautious beginner). Wins whenever it may (tsumo / ron), declares riichi as soon as a closed * hand is tenpai, and otherwise discards to lower its shanten, keeping the discard with the most useful draws * (ukeire); among equals honors / terminals go first and dora stay. Against a riichi with a far-off hand it throws * tiles the riichi player already discarded. Calls only pon of 역패, or pon / chi that speeds up a hand that already * has a yaku (역패 meld or 쿠이탕). Between hands it presses ready. Everything comes from its own view. */ import type { BotStrategy } from '../bots/types'; import { answerRequests, best, pick, playable } from '../bots/policy'; import { doraFromIndicator, isDragon, isYaochu, kindFromCode } from './tiles'; import type { MahjongAction, MahjongView } from './types'; const kindOfCode = (code: string): number => kindFromCode(code.slice(0, 2))!; const counts = (codes: readonly string[]): number[] => { const c = new Array(34).fill(0); for (const x of codes) c[kindOfCode(x)]!++; return c; }; /** * Per suit (or the honors), the best number of partial shapes for each (sets, pair) split: `t[p * 5 + m]` = most * taatsu with `m` sets and `p` pairs-as-head (−1 = impossible). Cached by the suit's counts, so shanten is cheap. */ const SUIT_CACHE = new Map(); function suitTable(cs: number[], seq: boolean): Int8Array { const key = (seq ? 's' : 'z') + cs.join(''); let out = SUIT_CACHE.get(key); if (out) return out; const t = new Int8Array(10).fill(-1); const c = [...cs]; const n = c.length; const go = (i: number, m: number, tt: number, p: number): void => { while (i < n && c[i] === 0) i++; if (i === n) { const j = p * 5 + Math.min(m, 4); if (tt > t[j]!) t[j] = Math.min(tt, 8); return; } if (c[i]! >= 3) { c[i]! -= 3; go(i, m + 1, tt, p); c[i]! += 3; } if (seq && i <= n - 3 && c[i + 1]! > 0 && c[i + 2]! > 0) { c[i]!--, c[i + 1]!--, c[i + 2]!--; go(i, m + 1, tt, p); c[i]!++, c[i + 1]!++, c[i + 2]!++; } if (c[i]! >= 2) { c[i]! -= 2; if (!p) go(i, m, tt, 1); go(i, m, tt + 1, p); c[i]! += 2; } if (seq && i <= n - 2 && c[i + 1]! > 0) { c[i]!--, c[i + 1]!--; go(i, m, tt + 1, p); c[i]!++, c[i + 1]!++; } if (seq && i <= n - 3 && c[i + 2]! > 0) { c[i]!--, c[i + 2]!--; go(i, m, tt + 1, p); c[i]!++, c[i + 2]!++; } c[i]!--; go(i + 1, m, tt, p); c[i]!++; }; go(0, 0, 0, 0); out = t; SUIT_CACHE.set(key, out); return out; } /** Standard-form shanten (4 sets + pair, `melds` already called); -1 = complete. */ function shantenStd(c: number[], melds: number): number { // Combine the four tables: best taatsu for every (pair, sets) total. let acc = new Int8Array(10).fill(-1); acc[0] = 0; for (const tab of [suitTable(c.slice(0, 9), true), suitTable(c.slice(9, 18), true), suitTable(c.slice(18, 27), true), suitTable(c.slice(27), false)]) { const next = new Int8Array(10).fill(-1); for (let a = 0; a < 10; a++) { if (acc[a]! < 0) continue; for (let b = 0; b < 10; b++) { if (tab[b]! < 0) continue; const p = Math.floor(a / 5) + Math.floor(b / 5); if (p > 1) continue; const j = p * 5 + Math.min((a % 5) + (b % 5), 4); next[j] = Math.max(next[j]!, acc[a]! + tab[b]!); } } acc = next; } let best = 8; for (let j = 0; j < 10; j++) { if (acc[j]! < 0) continue; const sets = Math.min((j % 5) + melds, 4); best = Math.min(best, 8 - 2 * sets - Math.min(acc[j]!, 4 - sets) - Math.floor(j / 5)); } return best; } /** Shanten over standard form and 칠대자 (closed only). */ function shanten(c: number[], melds: number): number { let s = shantenStd(c, melds); if (melds === 0) { const pairs = c.filter((x) => x >= 2).length; const kinds = c.filter((x) => x >= 1).length; s = Math.min(s, 6 - pairs + Math.max(0, 7 - kinds)); } return s; } /** Kinds worth drawing next to the hand (same honor, or a suit tile within 2 of one I hold). */ function nearKinds(c: number[]): number[] { const out: number[] = []; for (let k = 0; k < 34; k++) { if (k >= 27) { if (c[k]! > 0) out.push(k); } else { const lo = k - (k % 9); const from = Math.max(lo, k - 2); const to = Math.min(lo + 8, k + 2); if (c.slice(from, to + 1).some((x) => x > 0)) out.push(k); } } return out; } /** Shanten and number of unseen tiles that would lower it, for a 13-tile (or 13 − 3·melds) hand. */ function evaluate(c: number[], melds: number, seen: number[]): { sh: number; uke: number } { const sh = shanten(c, melds); let uke = 0; for (const k of nearKinds(c)) { const left = 4 - seen[k]!; if (left <= 0) continue; c[k]!++; if (shanten(c, melds) < sh) uke += left; c[k]!--; } return { sh, uke }; } export const mahjongBot: BotStrategy = ({ view, legal, random }) => { const req = answerRequests(legal); if (req) return req; const has = (t: string) => legal.find((a) => a.type === t); const ready = has('ready'); if (ready) return ready; const my = view.my; if (!my) return pick(playable(legal), random); const ron = legal.find((a) => a.type === 'claim' && a.call === 'ron'); if (ron) return ron; const tsumo = has('tsumo'); if (tsumo) return tsumo; const kita = has('kita'); if (kita) return kita; const kyuushu = has('kyuushu'); if (kyuushu) return kyuushu; const me = view.seats[my.seat]!; const melds = me.melds.length; const sanma = view.mode === 'sanma'; const handCodes = [...my.hand, ...(my.drawn ? [my.drawn] : [])]; const c = counts(handCodes); // Tiles I can see: my hand, every discard still on the river, every meld, kita and dora indicators. const seen = [...c]; for (const s of view.seats) { for (const d of s.discards) if (!d.called) seen[kindOfCode(d.tile)]!++; if (s.seat !== my.seat) for (const m of s.melds) for (const t of m.tiles) seen[kindOfCode(t)]!++; for (const t of s.kita) seen[kindOfCode(t)]!++; } for (const m of me.melds) for (const t of m.tiles) seen[kindOfCode(t)]!++; for (const t of view.doraIndicators) seen[kindOfCode(t)]!++; const dora = new Set(view.doraIndicators.map((t) => doraFromIndicator(kindOfCode(t), sanma))); const yakuhai = (k: number) => isDragon(k) || k === 27 + me.wind || k === 27 + view.roundWind; // ---- claim window (ron handled above): pon of 역패, or a call that speeds up a hand that already has a yaku. if (my.claim) { const pass = has('pass') ?? { type: 'pass' as const }; if (my.riichi) return pass; const k = kindOfCode(my.claim.tile); const now = shanten(c, melds); const openYakuhai = me.melds.some((m) => m.type !== 'chi' && yakuhai(kindOfCode(m.tiles[0]!))); const simplesOnly = (ks: number[]) => ks.every((x) => !isYaochu(x)); let bestCall: MahjongAction | null = null; let bestSh = now; for (const a of legal) { if (a.type !== 'claim' || (a.call !== 'pon' && a.call !== 'chi')) continue; const used = (a.tiles ?? []).map(kindOfCode); const rest = [...c]; for (const u of used) rest[u]!--; // After the call one tile must go: the best shanten among those discards. let after = 9; for (let d = 0; d < 34; d++) { if (!rest[d]) continue; rest[d]!--; after = Math.min(after, shanten(rest, melds + 1)); rest[d]!++; } const meldKinds = me.melds.flatMap((m) => m.tiles.map(kindOfCode)); const handYaochu = rest.reduce((n, x, i) => n + (isYaochu(i) ? x : 0), 0); const tanyaoOk = view.options.kuitan && simplesOnly([k, ...used, ...meldKinds]) && handYaochu <= 1; const isYakuhaiPon = a.call === 'pon' && yakuhai(k); const ok = isYakuhaiPon ? after <= now : (openYakuhai || tanyaoOk) && after < now; if (ok && (after < bestSh || (after === bestSh && !bestCall))) { bestCall = a; bestSh = after; } } return bestCall ?? pass; } // ---- my turn: closed kan when it doesn't hurt (not in riichi: keep it simple), then the discard. const discards = legal.filter((a): a is Extract => a.type === 'discard'); if (!discards.length) return pick(playable(legal), random); const riichiFoes = view.seats.filter((s) => s.seat !== my.seat && s.riichi); const genbutsu = riichiFoes.map((s) => new Set(s.discards.map((d) => kindOfCode(d.tile)))); const scored = new Map(); const evalDiscard = (code: string) => { let e = scored.get(code.slice(0, 2)); if (!e) { const k = kindOfCode(code); c[k]!--; e = evaluate(c, melds, seen); c[k]!++; scored.set(code.slice(0, 2), e); } return e; }; if (!my.riichi) { const minSh = Math.min(...discards.map((a) => evalDiscard(a.tile).sh)); for (const a of legal) { if (a.type !== 'ankan' && a.type !== 'kakan') continue; const k = kindOfCode(a.tile); const rest = [...c]; rest[k] = a.type === 'ankan' ? 0 : rest[k]! - 1; if (shanten(rest, melds + (a.type === 'ankan' ? 1 : 0)) <= minSh) return a; } } const score = (a: Extract): number => { const k = kindOfCode(a.tile); const { sh, uke } = evalDiscard(a.tile); let v = -sh * 1000 + uke * 10; if (a.riichi) v += uke > 0 ? 500 : -5000; // riichi whenever tenpai on a live wait if (k >= 27) v += yakuhai(k) && c[k]! >= 2 ? -3 : yakuhai(k) ? 2 : 4; else if (isYaochu(k)) v += 1; if (dora.has(k)) v -= 6; if (a.tile.endsWith('r')) v -= 6; // Defense: far from tenpai against a riichi → genbutsu first. const safe = genbutsu.filter((g) => g.has(k)).length; if (riichiFoes.length) v += sh >= 2 ? safe * 3000 : safe * 5; return v; }; return best(discards, score, random); };