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>
This commit is contained in:
EJClaw
2026-10-06 13:32:28 +09:00
parent c27cb0a11a
commit a3f81ded37
67 changed files with 5113 additions and 18 deletions

View File

@@ -0,0 +1,256 @@
/**
* 마작 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);
};