Files
joke-app/packages/games/src/mahjong/bot.ts
EJClaw a3f81ded37 M9: 컴퓨터 상대 — 게임 22종 컴퓨터 전략, 서버 구동(BotDriver), 연습 방
- packages/shared/src/bots.ts: 컴퓨터 id(bot:<n>:<ulid>)·프로필(컴퓨터 n)·BOT_GAMES
  (도블·할리갈리·그림 맞히기·마피아 제외)
- packages/games/src/bots: 전략 계약(자기 view + 합법 수만), decideBotAction(validate 재검사),
  게임별 <게임>/bot.ts 22개 + 행동 테스트, 전 게임 판 끝까지 돌리는 공통 테스트
- 서버: Room.addBot/removeBot(방장·대기실만, 방은 친구만), RoomManager.createPractice
  (POST /api/rooms {practice:true}), BotDriver(사람 같은 지연, 사람과 같은 창이면 더 천천히,
  재시작 후 다시 예약), 연습 게임은 전적 제외(result_json.practice), 사람이 모두 나가면 무효
- 프로토콜 addBot/removeBot, 문서 03·06 §12·08·09 §8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:32:28 +09:00

257 lines
9.8 KiB
TypeScript
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.
/**
* 마작 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<number>(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<string, Int8Array>();
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<MahjongView, MahjongAction> = ({ 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<MahjongAction, { type: 'discard' }> => 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<string, { sh: number; uke: number }>();
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<MahjongAction, { type: 'discard' }>): 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);
};