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,100 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, seedN, type PlayerId } from '@bg/engine';
import { decideBotAction } from '../bots/index';
import { botPlayout } from '../bots/playout';
import { gostop, type FloorGroup, type GostopAction, type GostopOptions, type GostopState } from './index';
// Card ids: month m → (m-1)*4 + slot. 1월 0광 1홍단 2·3피, 2월 4열 5홍단 6·7피, 3월 8광 9홍단 10·11피,
// 5월 16열 17초단 18·19피, 6월 20열 21청단 22·23피, 7월 24열 25초단 26·27피, 8월 28광 29열 30·31피, 11월 40광 41쌍피 42·43피.
/** A play-phase state for players A (bot under test), B, C… with exactly these cards. */
function rigged(n: number, spec: { hands: number[][]; floor: number[]; deck: number[]; captured?: number[][] }, opts: Partial<GostopOptions> = {}): GostopState {
const players = ['A', 'B', 'C', 'D', 'E'].slice(0, n);
const s = gostop.setup({ players, options: gostop.optionsSchema.parse({ chongtong: 'off', ...opts }), rng: SeededRng.fromSeed(seedN(1)), now: 0 });
s.dealer = 0;
const r = s.round!;
const zeros = () => Array.from({ length: n }, () => 0);
const lists = () => Array.from({ length: n }, (): number[] => []);
Object.assign(r, {
participants: Array.from({ length: Math.min(n, 3) }, (_, i) => i),
sellOrder: [],
sellCursor: 0,
sellDecisions: [],
sellers: [],
step: 'play',
turn: 0,
pending: null,
stopBy: null,
chongtongBy: null,
bombDummies: zeros(),
revealed: lists(),
shakenMonths: lists(),
goCount: zeros(),
lastGoScore: zeros(),
goOrder: [],
shakes: zeros(),
bombs: zeros(),
ppeokCount: zeros(),
ppeokTurns: lists(),
turnsTaken: zeros(),
instant: zeros(),
});
r.hands = Array.from({ length: n }, (_, i) => [...(spec.hands[i] ?? [])]);
r.deck = [...spec.deck];
r.captured = Array.from({ length: n }, (_, i) => [...(spec.captured?.[i] ?? [])]);
const groups: FloorGroup[] = [];
for (const c of spec.floor) {
const m = Math.floor(c / 4) + 1;
const g = groups.find((x) => x.month === m);
if (g) g.cards.push(c);
else groups.push({ month: m, cards: [c], ppeokBy: null, natural: false });
}
r.floor = groups.sort((a, b) => a.month - b.month);
return s;
}
const decide = (s: GostopState) => decideBotAction(gostop, s, 'A', { now: 0, random: () => 0.5 }) as GostopAction;
describe('gostop bot', () => {
test('plays the card that captures a 광 rather than a plain 피', () => {
const s = rigged(2, { hands: [[9, 7, 42], [43]], floor: [8, 6, 20], deck: [24, 25, 26, 27, 16, 17] });
expect(decide(s)).toEqual({ type: 'play', card: 9 });
});
test('picks the 광 when two different floor cards of the month can be taken', () => {
const s = rigged(2, { hands: [[3], [43]], floor: [0, 2], deck: [24, 25, 26, 27] });
expect(decide(s)).toEqual({ type: 'play', card: 3, target: 0 });
});
test('with no match it throws away its cheapest card, not a 광', () => {
const s = rigged(2, { hands: [[8, 42, 16], [43]], floor: [20, 24], deck: [26, 27, 30, 31] });
expect(decide(s)).toEqual({ type: 'play', card: 42 });
});
test('go while the opponents have nothing, stop when one of them is close', () => {
const deck = [16, 17, 18, 19, 20, 21, 22, 23];
const hands = [[24, 25, 26, 27], [30, 31, 42, 43], [40, 41, 36, 37]];
const s = rigged(3, { hands, floor: [], deck, captured: [[0, 8, 28], [], []] });
s.round!.step = 'goStop';
expect(decide(s)).toEqual({ type: 'goStop', decision: 'go' });
const t = rigged(3, { hands, floor: [], deck, captured: [[0, 8, 28], [1, 5, 9], []] });
t.round!.step = 'goStop';
expect(decide(t)).toEqual({ type: 'goStop', decision: 'stop' });
});
test('bot-only games finish in every mode (맞고, 3인, 광팔기 4~5인, 국진 직접 선택, 총통, 보너스 3+1)', () => {
const isBot = (p: PlayerId) => p.startsWith('bot:');
const cases: [number, Partial<GostopOptions>][] = [
[2, { rounds: 5 }],
[3, { rounds: 5, gukjinMode: 'ask', bonusCards: '3+1' }],
[4, { rounds: 5, chongtong: 'win' }],
[5, { rounds: 5, gwangSellCards: 'gwangPlus', gukjinMode: 'ask' }],
];
for (const [i, [n, o]] of cases.entries()) {
const players = Array.from({ length: n }, (_, k) => `bot:${k}:x`);
const r = botPlayout(gostop, { players, options: gostop.optionsSchema.parse(o), seed: seedN(40 + i), isBot, maxSteps: 30_000 });
expect(r.finished).toBe(true);
expect(r.botTimeouts).toBe(0);
}
});
});

View File

@@ -0,0 +1,100 @@
/**
* 고스톱 / 맞고 computer player. Plays the hand card whose capture is worth most (광 > 열끗·띠 > 쌍피 > 피), takes
* the better floor card when asked, always shakes / bombs when allowed, and throws away its cheapest card when
* nothing matches. Go only while the opponents are far from scoring and cards are left; 광팔기 with a weak hand.
*/
import type { BotStrategy } from '../bots/types';
import { answerRequests, best, pick, playable } from '../bots/policy';
import { cardInfo, type CardId } from './cards';
import type { GostopAction, GostopView, HandCardView, PlayAction } from './types';
/** How much a captured card is worth to the bot. */
function worth(id: CardId, nb: number): number {
const c = cardInfo(id, nb);
if (c.kind === 'gwang') return c.bi ? 4 : 6;
if (c.kind === 'yeol') return c.godori ? 4 : 3;
if (c.kind === 'tti') return c.dan ? 3 : 2.5;
return c.piValue * 1.1;
}
/** Hand strength for 광팔기: 광, 열끗, 쌍피 and pairs of a month are what wins a round. */
function handStrength(hand: HandCardView[]): number {
const months = new Map<number, number>();
let v = 0;
for (const h of hand) {
if (h.card.kind === 'gwang') v += 2;
else if (h.card.kind === 'yeol' || h.card.kind === 'ssangpi' || h.card.kind === 'bonus') v += 1;
months.set(h.card.month, (months.get(h.card.month) ?? 0) + 1);
}
for (const n of months.values()) if (n >= 2) v += n - 1;
return v;
}
export const gostopBot: BotStrategy<GostopView, GostopAction> = ({ me, view, legal, random }) => {
const req = answerRequests(legal);
if (req) return req;
const nb = view.rules.nb;
const first = legal[0]!;
switch (first.type) {
case 'ready':
return first;
case 'chongtong':
return { type: 'chongtong', declare: true };
case 'gukjin': {
const ask = view.gukjinAsk;
return { type: 'gukjin', as: ask && ask.piScore > ask.yeolScore ? 'pi' : 'yeol' };
}
case 'sell': {
const sell = !!view.sell?.canSell && handStrength(view.myHand ?? []) <= 5;
return { type: 'sell', sell };
}
case 'chooseFlip': {
const opts = legal.filter((a): a is Extract<GostopAction, { type: 'chooseFlip' }> => a.type === 'chooseFlip');
return best(opts, (a) => worth(a.target, nb), random);
}
case 'goStop': {
const g = view.goStop!;
const oppMax = Math.max(0, ...view.seats.filter((s) => s.participant && s.id !== me).map((s) => s.score.total));
const cardsLeft = (view.myHand?.length ?? 0) + view.myDummies;
const perLoser = Math.max(0, ...g.stopNow.perLoser.map((x) => x.amount));
const go =
cardsLeft >= 3 &&
view.deckCount >= 6 &&
g.goCount < 2 &&
oppMax <= view.rules.winScore - 3 &&
perLoser < view.rules.winScore * 3; // already a big win: take it
return { type: 'goStop', decision: go ? 'go' : 'stop' };
}
case 'play':
break;
default:
return pick(playable(legal), random);
}
// Play step: score every legal play by what it captures.
const hand = new Map((view.myHand ?? []).map((h) => [h.card.id, h]));
const sameMonthInHand = (m: number) => (view.myHand ?? []).filter((h) => h.card.month === m).length;
const plays = legal.filter((a): a is PlayAction => a.type === 'play');
const score = (a: PlayAction): number => {
if (a.card === 'dummy') return 0.5; // free flip, keeps a real card
const h = hand.get(a.card);
if (!h) return -10;
if (h.card.kind === 'bonus') return 100; // free 피 and a new card
const mine = worth(a.card, nb);
let v: number;
if (a.bomb) {
v = 20 + (view.floor.find((g) => g.month === h.card.month)?.cards.reduce((x, c) => x + worth(c.id, nb), 0) ?? 0);
} else if (h.matches.length === 0) {
// Thrown away: lose the cheapest card; keep it if I hold the rest of the month (I can take it back).
v = -mine + (sameMonthInHand(h.card.month) >= 2 ? 0.5 : 0) + (a.shake ? 8 : 0);
} else if (h.matches.length === 3) {
v = 10 + mine + h.matches.reduce((x, c) => x + worth(c, nb), 0); // the whole pile plus a 피 from everyone
} else {
const target = a.target ?? h.matches[0]!;
v = 2 + mine + worth(target, nb);
}
if (h.hint?.includes('완성')) v += 6;
return v;
};
return best(plays, score, random);
};