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:
100
packages/games/src/gostop/bot.test.ts
Normal file
100
packages/games/src/gostop/bot.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
100
packages/games/src/gostop/bot.ts
Normal file
100
packages/games/src/gostop/bot.ts
Normal 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);
|
||||
};
|
||||
Reference in New Issue
Block a user