- 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>
257 lines
9.8 KiB
TypeScript
257 lines
9.8 KiB
TypeScript
/**
|
||
* 마작 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);
|
||
};
|