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:
53
packages/shared/src/bots.ts
Normal file
53
packages/shared/src/bots.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/** Computer players for solo practice (docs/06-lobby-rooms.md §12). Shared by server and web (no runtime deps). */
|
||||
import type { PublicUser } from './types';
|
||||
|
||||
/** Bot user ids look like `bot:<n>:<ulid>`; real user ids are plain ULIDs, so they can never collide. */
|
||||
export const BOT_PREFIX = 'bot:';
|
||||
|
||||
export const isBotId = (id: string): boolean => id.startsWith(BOT_PREFIX);
|
||||
|
||||
export const makeBotId = (n: number, unique: string): string => `${BOT_PREFIX}${n}:${unique}`;
|
||||
|
||||
/** Number shown in the bot's name ("컴퓨터 2"). */
|
||||
export function botNumber(id: string): number | null {
|
||||
if (!isBotId(id)) return null;
|
||||
const n = Number(id.slice(BOT_PREFIX.length).split(':')[0]);
|
||||
return Number.isInteger(n) && n > 0 ? n : null;
|
||||
}
|
||||
|
||||
/** Public profile of a bot id, or null for anything else. */
|
||||
export function botProfile(id: string): PublicUser | null {
|
||||
const n = botNumber(id);
|
||||
return n === null ? null : { id, nickname: `컴퓨터 ${n}`, avatar: null, kind: 'bot' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Games that offer computer players. Real-time and talk-based games (도블, 할리갈리, 그림 맞히기, 마피아)
|
||||
* don't: a bot there is either unbeatable or meaningless.
|
||||
*/
|
||||
export const BOT_GAMES: readonly string[] = [
|
||||
'omok',
|
||||
'othello',
|
||||
'chess',
|
||||
'janggi',
|
||||
'baduk',
|
||||
'seotda',
|
||||
'poker',
|
||||
'yacht',
|
||||
'yut',
|
||||
'one-card',
|
||||
'rummikub',
|
||||
'gostop',
|
||||
'bang',
|
||||
'davinci-code',
|
||||
'mahjong',
|
||||
'marble',
|
||||
'dalmuti',
|
||||
'splendor',
|
||||
'quoridor',
|
||||
'las-vegas',
|
||||
'cockroach-poker',
|
||||
'ninety-nine',
|
||||
];
|
||||
|
||||
export const botsAllowed = (gameId: string): boolean => BOT_GAMES.includes(gameId);
|
||||
@@ -3,3 +3,4 @@ export * from './types';
|
||||
export * from './nickname';
|
||||
export * from './room-code';
|
||||
export * from './catalog';
|
||||
export * from './bots';
|
||||
|
||||
@@ -27,6 +27,8 @@ export const ClientMessage = z.discriminatedUnion('t', [
|
||||
z.object({ t: z.literal('start') }),
|
||||
z.object({ t: z.literal('kick'), userId }),
|
||||
z.object({ t: z.literal('host'), userId }),
|
||||
z.object({ t: z.literal('addBot'), seat: z.number().int().min(0).max(31).optional() }),
|
||||
z.object({ t: z.literal('removeBot'), userId }),
|
||||
z.object({ t: z.literal('act'), cs: z.number().int().min(1), a: z.unknown() }),
|
||||
z.object({ t: z.literal('chat'), text: z.string().min(1).max(CHAT_MAX) }),
|
||||
z.object({ t: z.literal('emote'), id: z.string().max(20) }),
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface PublicUser {
|
||||
id: string;
|
||||
nickname: string;
|
||||
avatar: string | null;
|
||||
kind: 'guest' | 'member';
|
||||
kind: 'guest' | 'member' | 'bot';
|
||||
}
|
||||
|
||||
export interface SeatView {
|
||||
|
||||
Reference in New Issue
Block a user