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:
112
apps/server/src/bots.test.ts
Normal file
112
apps/server/src/bots.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/** Computer players over the real HTTP/WS stack (docs/06-lobby-rooms.md §12). */
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { Client, api, boot, guest, sleep } from './test-utils';
|
||||
|
||||
type Booted = ReturnType<typeof boot>;
|
||||
const running: Booted[] = [];
|
||||
const clients: Client[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const c of clients.splice(0)) {
|
||||
try {
|
||||
c.close();
|
||||
} catch {}
|
||||
}
|
||||
for (const s of running.splice(0)) {
|
||||
try {
|
||||
await s.stop();
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
|
||||
const fastBots = { botDelay: () => 30 };
|
||||
|
||||
function start(dbPath?: string): Booted {
|
||||
const s = boot(dbPath, fastBots);
|
||||
running.push(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
async function client(s: Booted, nick: string, cookie?: { cookie: string; id: string }): Promise<Client> {
|
||||
const g = cookie ?? (await guest(s.base, nick));
|
||||
const c = await new Client(s.base, g.cookie, g.id).connect();
|
||||
clients.push(c);
|
||||
return c;
|
||||
}
|
||||
|
||||
async function practiceOmok(s: Booted, a: Client): Promise<string> {
|
||||
const r = await api(s.base, '/api/rooms', { method: 'POST', cookie: a.cookie, body: JSON.stringify({ gameId: 'omok', practice: true }) });
|
||||
expect(r.res.status).toBe(200);
|
||||
const code = r.body.code as string;
|
||||
a.send({ t: 'join', code, as: 'player' });
|
||||
await a.next('room');
|
||||
a.send({ t: 'config', options: { colorAssignment: 'hostBlack', ruleSet: 'free' } });
|
||||
await a.next('room', (m) => (m.room.options as any).colorAssignment === 'hostBlack');
|
||||
const st = a.next('state');
|
||||
a.send({ t: 'start' });
|
||||
await st;
|
||||
return code;
|
||||
}
|
||||
|
||||
describe('computer players', () => {
|
||||
test('practice room: host + computer seated, private, not listed; the computer answers moves', async () => {
|
||||
const s = start();
|
||||
const a = await client(s, '호랑이');
|
||||
const code = await practiceOmok(s, a);
|
||||
const room = a.lastOf('room')!.room;
|
||||
expect(room.visibility).toBe('private');
|
||||
expect(room.seats.map((x) => x?.user.kind)).toEqual(['guest', 'bot']);
|
||||
expect(room.seats[1]!.user.nickname).toBe('컴퓨터 1');
|
||||
const pub = await api(s.base, '/api/games/omok/rooms');
|
||||
expect(pub.body.rooms.find((r: { code: string }) => r.code === code)).toBeUndefined();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const answered = a.next('state', (m) => (m.view as any).moveCount === 2 * i + 2);
|
||||
const board = (a.lastOf('state')!.view as any).board as number[];
|
||||
const free = board.indexOf(0);
|
||||
a.act({ type: 'place', x: free % 15, y: Math.floor(free / 15) });
|
||||
await answered;
|
||||
}
|
||||
});
|
||||
|
||||
test('addBot / removeBot over the socket: host only, errors for others', async () => {
|
||||
const s = start();
|
||||
const a = await client(s, '호랑이');
|
||||
const b = await client(s, '고양이');
|
||||
const r = await api(s.base, '/api/rooms', { method: 'POST', cookie: a.cookie, body: JSON.stringify({ gameId: 'one-card', visibility: 'public' }) });
|
||||
const code = r.body.code as string;
|
||||
a.send({ t: 'join', code, as: 'player' });
|
||||
await a.next('room');
|
||||
b.send({ t: 'join', code, as: 'player' });
|
||||
await b.next('room');
|
||||
b.send({ t: 'addBot' });
|
||||
expect((await b.next('error')).message).toBe('방장만 컴퓨터를 넣을 수 있어요.');
|
||||
a.send({ t: 'addBot', seat: 3 });
|
||||
const withBot = await a.next('room', (m) => m.room.seats.some((x) => x?.user.kind === 'bot'));
|
||||
expect(withBot.room.visibility).toBe('private');
|
||||
const botId = withBot.room.seats[3]!.user.id;
|
||||
a.send({ t: 'removeBot', userId: botId });
|
||||
await a.next('room', (m) => !m.room.seats.some((x) => x?.user.kind === 'bot'));
|
||||
});
|
||||
|
||||
test('restart with the computer to move: after restore it plays on its own', async () => {
|
||||
const s1 = boot(undefined, fastBots);
|
||||
const a = await client(s1, '호랑이');
|
||||
const code = await practiceOmok(s1, a);
|
||||
// Stop the bot before it answers, then crash.
|
||||
for (const r of s1.rooms.all()) r.clearTimer();
|
||||
a.act({ type: 'place', x: 7, y: 7 });
|
||||
await a.next('state', (m) => (m.view as any).moveCount === 1);
|
||||
for (const r of s1.rooms.all()) r.clearTimer();
|
||||
await Promise.race([s1.server.stop(true), sleep(300)]);
|
||||
for (const r of s1.rooms.all()) r.clearTimer();
|
||||
s1.db.close();
|
||||
const s2 = start(s1.dbPath);
|
||||
await sleep(200); // the restored driver plays without anyone connected
|
||||
const room = s2.rooms.get(code)!;
|
||||
expect((room.game!.state as any).moves.length).toBe(2);
|
||||
const a2 = await client(s2, '', { cookie: a.cookie, id: a.id });
|
||||
a2.send({ t: 'join', code, as: 'player' });
|
||||
const st = await a2.next('state');
|
||||
expect((st.view as any).moveCount).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Hono, type Context } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import type { AnyGameDefinition } from '@bg/engine';
|
||||
import { nicknameError, normalizeNickname } from '@bg/shared';
|
||||
import { botProfile, nicknameError, normalizeNickname } from '@bg/shared';
|
||||
import type { Audit, Role, Roles } from '../admin/roles';
|
||||
import { GameSettingsSchema, SiteSettingsSchema, type SettingsStore } from '../admin/settings';
|
||||
import type { Sessions } from '../auth/sessions';
|
||||
@@ -100,7 +100,11 @@ export function createAdminApp(d: AdminDeps) {
|
||||
gameId: r.config.gameId,
|
||||
status: r.status,
|
||||
host: d.users.get(r.hostId)?.nickname ?? null,
|
||||
seats: r.seats.map((s) => (s ? { id: s.userId, nickname: d.users.get(s.userId)?.nickname ?? '?', connected: !!r.presence.get(s.userId)?.connected } : null)),
|
||||
seats: r.seats.map((s) => {
|
||||
if (!s) return null;
|
||||
const bot = botProfile(s.userId);
|
||||
return { id: s.userId, nickname: bot?.nickname ?? d.users.get(s.userId)?.nickname ?? '?', connected: !!bot || !!r.presence.get(s.userId)?.connected };
|
||||
}),
|
||||
spectators: r.spectators.size,
|
||||
connections: r.conns.size,
|
||||
visibility: r.config.visibility,
|
||||
|
||||
@@ -243,10 +243,10 @@ export function createHttpApp(d: HttpDeps) {
|
||||
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
|
||||
if (!createLimiter.allow(d.ipOf(c.req.raw))) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429);
|
||||
const body = z
|
||||
.object({ gameId: z.string().max(40), visibility: z.enum(['private', 'public']).optional() })
|
||||
.object({ gameId: z.string().max(40), visibility: z.enum(['private', 'public']).optional(), practice: z.boolean().optional() })
|
||||
.safeParse(await c.req.json().catch(() => null));
|
||||
if (!body.success || !d.settings.game(body.data.gameId)?.enabled) return c.json({ error: '없는 게임이에요.' }, 400);
|
||||
const r = d.rooms.create(u, body.data.gameId, body.data.visibility);
|
||||
const r = body.data.practice ? d.rooms.createPractice(u, body.data.gameId) : d.rooms.create(u, body.data.gameId, body.data.visibility);
|
||||
if (typeof r === 'string') return c.json({ error: r }, 429);
|
||||
return c.json({ code: r.code });
|
||||
});
|
||||
|
||||
286
apps/server/src/rooms/bots.room.test.ts
Normal file
286
apps/server/src/rooms/bots.room.test.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/** Computer players in rooms (docs/06-lobby-rooms.md §12): seating rules, practice rooms, driving, records, restore. */
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { GAMES } from '@bg/games';
|
||||
import { BOT_STRATEGIES } from '@bg/games/bots';
|
||||
import { BOT_GAMES, ClientMessage, botProfile, isBotId, type ServerMessage } from '@bg/shared';
|
||||
import { openDb } from '../db';
|
||||
import { defaultBotDelay } from './bots';
|
||||
import { RoomManager } from './manager';
|
||||
import { Room, type Conn, type RoomSnapshot } from './room';
|
||||
import { RoomStore } from './store';
|
||||
|
||||
const rooms: Room[] = [];
|
||||
afterEach(() => {
|
||||
for (const r of rooms.splice(0)) r.clearTimer();
|
||||
});
|
||||
|
||||
function harness(log?: (level: 'info' | 'warn' | 'error', msg: string) => void) {
|
||||
const db = openDb(':memory:');
|
||||
const store = new RoomStore(db);
|
||||
let now = 1_000_000;
|
||||
const clock = { now: () => now, advance: (ms: number) => (now += ms) };
|
||||
const users = { publicUser: (id: string) => botProfile(id) ?? { id, nickname: id, avatar: null, kind: 'guest' as const } };
|
||||
const manager = new RoomManager({ store, games: GAMES, users, now: clock.now, botDelay: () => 0, log });
|
||||
const conn = (userId: string) => {
|
||||
const msgs: ServerMessage[] = [];
|
||||
const c: Conn & { msgs: ServerMessage[] } = { userId, msgs, send: (m) => msgs.push(m), close: () => {} };
|
||||
return c;
|
||||
};
|
||||
const create = (host: string, gameId = 'omok', visibility: 'private' | 'public' = 'private') => {
|
||||
const r = manager.create(host, gameId, visibility);
|
||||
if (typeof r === 'string') throw new Error(r);
|
||||
rooms.push(r);
|
||||
return r;
|
||||
};
|
||||
return { db, store, clock, manager, conn, create, users };
|
||||
}
|
||||
|
||||
/** Plays every planned bot move (the delay is 0 in these tests) until the game waits on a human. */
|
||||
function drive(room: Room, max = 500): number {
|
||||
let n = 0;
|
||||
while (room.bots.dueAt !== null && n < max) {
|
||||
room.bots.fire();
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
const bots = (room: Room) => room.seatedIds().filter(isBotId);
|
||||
|
||||
describe('bot list', () => {
|
||||
test('BOT_GAMES matches the strategies and leaves out real-time / talk games', () => {
|
||||
expect([...BOT_GAMES].sort()).toEqual(Object.keys(BOT_STRATEGIES).sort());
|
||||
for (const id of BOT_GAMES) expect(GAMES[id]).toBeDefined();
|
||||
for (const id of ['dobble', 'halli-galli', 'catchmind', 'mafia']) expect(BOT_GAMES).not.toContain(id);
|
||||
});
|
||||
|
||||
test('thinking time: short for forced moves, longer for choices, longest when people act in the same window', () => {
|
||||
const r = () => 0.5;
|
||||
expect(defaultBotDelay(1, false, r)).toBe(550);
|
||||
expect(defaultBotDelay(5, false, r)).toBe(1100);
|
||||
expect(defaultBotDelay(5, true, r)).toBe(2600);
|
||||
});
|
||||
|
||||
test('protocol accepts addBot / removeBot', () => {
|
||||
expect(ClientMessage.safeParse({ t: 'addBot' }).success).toBe(true);
|
||||
expect(ClientMessage.safeParse({ t: 'addBot', seat: 1 }).success).toBe(true);
|
||||
expect(ClientMessage.safeParse({ t: 'addBot', seat: -1 }).success).toBe(false);
|
||||
expect(ClientMessage.safeParse({ t: 'removeBot', userId: 'bot:1:01J0000000000000000000000' }).success).toBe(true);
|
||||
expect(ClientMessage.safeParse({ t: 'removeBot' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addBot / removeBot', () => {
|
||||
test('host only, lobby only, empty seats only; the room turns private and drops out of the public list', () => {
|
||||
const { create, conn, manager } = harness();
|
||||
const room = create('A', 'omok', 'public');
|
||||
room.join(conn('A'), 'player');
|
||||
room.join(conn('S'), 'spectator');
|
||||
expect(manager.publicRooms('omok')).toHaveLength(1);
|
||||
expect(room.addBot('S')).toBe('방장만 컴퓨터를 넣을 수 있어요.');
|
||||
expect(room.addBot('A', 0)).toBe('이미 다른 사람이 앉은 자리예요.');
|
||||
expect(room.addBot('A')).toBeNull();
|
||||
const [bot] = bots(room);
|
||||
expect(bot).toMatch(/^bot:1:/);
|
||||
expect(room.addBot('A')).toBe('빈자리가 없어요.');
|
||||
expect(room.config.visibility).toBe('private');
|
||||
expect(manager.publicRooms('omok')).toHaveLength(0);
|
||||
expect(room.configure('A', { visibility: 'public' })).toBe('컴퓨터가 있는 방은 공개할 수 없어요.');
|
||||
expect(room.configure('A', { gameId: 'dobble' })).toContain('컴퓨터');
|
||||
const seat = room.roomView().seats[1]!;
|
||||
expect(seat).toMatchObject({ ready: true, connected: true, away: false });
|
||||
expect(seat.user).toMatchObject({ nickname: '컴퓨터 1', kind: 'bot' });
|
||||
expect(room.removeBot('S', bot!)).toBe('방장만 컴퓨터를 뺄 수 있어요.');
|
||||
expect(room.removeBot('A', 'B')).toBe('없는 컴퓨터예요.');
|
||||
expect(room.removeBot('A', bot!)).toBeNull();
|
||||
expect(bots(room)).toHaveLength(0);
|
||||
// Without computers the host may make it public again.
|
||||
expect(room.configure('A', { visibility: 'public' })).toBeNull();
|
||||
});
|
||||
|
||||
test('games without computer players refuse them', () => {
|
||||
const { create, conn } = harness();
|
||||
const room = create('A', 'dobble');
|
||||
room.join(conn('A'), 'player');
|
||||
expect(room.addBot('A')).toBe('이 게임은 컴퓨터와 할 수 없어요.');
|
||||
});
|
||||
|
||||
test('bots get the lowest free number; kick removes a bot; the host is never passed to a bot', () => {
|
||||
const { create, conn } = harness();
|
||||
const room = create('A', 'one-card');
|
||||
room.join(conn('A'), 'player');
|
||||
room.addBot('A');
|
||||
room.addBot('A');
|
||||
room.addBot('A');
|
||||
const [b1, b2] = bots(room);
|
||||
expect(room.kick('A', b2!)).toBeNull();
|
||||
room.addBot('A');
|
||||
expect(bots(room).map((b) => botProfile(b)!.nickname).sort()).toEqual(['컴퓨터 1', '컴퓨터 2', '컴퓨터 3']);
|
||||
expect(room.transferHost('A', b1!)).toBe('컴퓨터에게는 방장을 넘길 수 없어요.');
|
||||
// A spectator joins, then the host leaves: the spectator becomes host, not a computer.
|
||||
room.join(conn('S'), 'spectator');
|
||||
room.leave('A');
|
||||
expect(room.hostId).toBe('S');
|
||||
});
|
||||
|
||||
test('a room needs a seated person to start', () => {
|
||||
const { create, conn } = harness();
|
||||
const room = create('A', 'omok');
|
||||
room.join(conn('A'), 'spectator');
|
||||
room.addBot('A');
|
||||
room.addBot('A');
|
||||
expect(room.start('A')).toBe('사람이 한 명은 앉아야 해요.');
|
||||
});
|
||||
|
||||
test('bots cannot be added or removed during a game', () => {
|
||||
const { create, conn } = harness();
|
||||
const room = create('A', 'one-card');
|
||||
room.join(conn('A'), 'player');
|
||||
room.addBot('A');
|
||||
expect(room.start('A')).toBeNull();
|
||||
expect(room.addBot('A')).toBe('게임 중에는 바꿀 수 없어요.');
|
||||
expect(room.removeBot('A', bots(room)[0]!)).toBe('게임 중에는 바꿀 수 없어요.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPractice', () => {
|
||||
test('a private room with just enough computers to start; never offered by quick play', () => {
|
||||
const { manager, conn } = harness();
|
||||
const expected: Record<string, number> = { omok: 1, mahjong: 2, bang: 3, dalmuti: 3, quoridor: 1, yacht: 1 };
|
||||
for (const [gameId, n] of Object.entries(expected)) {
|
||||
const room = manager.createPractice(`host-${gameId}`, gameId);
|
||||
if (typeof room === 'string') throw new Error(room);
|
||||
rooms.push(room);
|
||||
expect(room.config.visibility).toBe('private');
|
||||
expect(bots(room)).toHaveLength(n);
|
||||
room.join(conn(`host-${gameId}`), 'player');
|
||||
expect(room.start(`host-${gameId}`)).toBeNull();
|
||||
expect(room.game!.players).toContain(`host-${gameId}`);
|
||||
}
|
||||
expect(manager.createPractice('X', 'mafia')).toBe('이 게임은 컴퓨터와 할 수 없어요.');
|
||||
// Quick play never lands in a practice room.
|
||||
const qp = manager.quickPlay('Q', 'omok');
|
||||
if (typeof qp === 'string') throw new Error(qp);
|
||||
rooms.push(qp);
|
||||
expect(qp.hasBots()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('playing with bots', () => {
|
||||
function omokVsBot() {
|
||||
const h = harness();
|
||||
const room = h.manager.createPractice('A', 'omok') as Room;
|
||||
rooms.push(room);
|
||||
const a = h.conn('A');
|
||||
room.join(a, 'player');
|
||||
room.configure('A', { options: { ...(room.config.options as object), colorAssignment: 'hostBlack' } });
|
||||
expect(room.start('A')).toBeNull();
|
||||
return { ...h, room, a };
|
||||
}
|
||||
|
||||
test('the bot answers each human move through the normal action path', () => {
|
||||
const { room, a, store } = omokVsBot();
|
||||
const moves = () => (room.game!.state as { moves: unknown[] }).moves.length;
|
||||
expect(room.bots.dueAt).toBeNull(); // black (human) to move
|
||||
room.act('A', 1, { type: 'place', x: 7, y: 7 });
|
||||
expect(room.bots.dueAt).not.toBeNull();
|
||||
expect(drive(room)).toBe(1);
|
||||
expect(moves()).toBe(2);
|
||||
expect(a.msgs.filter((m) => m.t === 'state').length).toBeGreaterThanOrEqual(3);
|
||||
// Logged like any action, with the bot as actor.
|
||||
const log = store.log(room.id);
|
||||
expect(log.some((e) => e.kind === 'act' && e.actorId !== null && isBotId(e.actorId))).toBe(true);
|
||||
});
|
||||
|
||||
test('a bot move planned before a state change re-reads the state when it fires', () => {
|
||||
const { room } = omokVsBot();
|
||||
room.act('A', 1, { type: 'place', x: 7, y: 7 });
|
||||
const due = room.bots.dueAt;
|
||||
room.scheduleTimer(); // e.g. a reconnect re-arms timers: the plan stays
|
||||
expect(room.bots.dueAt).toBe(due);
|
||||
drive(room);
|
||||
expect((room.game!.state as { moves: unknown[] }).moves).toHaveLength(2);
|
||||
expect(room.bots.dueAt).toBeNull();
|
||||
});
|
||||
|
||||
test('a practice game is kept but not counted; rematch only needs the people', () => {
|
||||
const { room, db } = omokVsBot();
|
||||
// Human resigns; the game is recorded as practice.
|
||||
room.act('A', 1, { type: 'resign' });
|
||||
expect(room.status).toBe('finished');
|
||||
const row = db.query<{ result_json: string }, []>('SELECT result_json FROM games').get()!;
|
||||
expect(JSON.parse(row.result_json).practice).toBe(true);
|
||||
expect(db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM user_stats').get()!.n).toBe(0);
|
||||
expect(db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM game_players').get()!.n).toBe(2);
|
||||
expect(room.rematch('A')).toBeNull();
|
||||
expect(room.status).toBe('playing');
|
||||
expect(room.gameNo).toBe(2);
|
||||
});
|
||||
|
||||
test('when every person leaves mid-game, the game ends uncounted', () => {
|
||||
const { room, db } = omokVsBot();
|
||||
room.act('A', 1, { type: 'place', x: 7, y: 7 });
|
||||
room.leave('A');
|
||||
expect(room.game!.finished).toBe(true);
|
||||
expect(room.bots.dueAt).toBeNull();
|
||||
expect(db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM user_stats').get()!.n).toBe(0);
|
||||
});
|
||||
|
||||
// The real driver + room timers for every bot game: the bots act on their own (reaction windows, simultaneous
|
||||
// choices, "ready" between hands) and the only person just lets the clock run out each time.
|
||||
test.each([...BOT_GAMES])('%s: a whole practice game with a person who only times out finishes', (gameId) => {
|
||||
const logs: string[] = [];
|
||||
const h = harness((level, msg) => level !== 'info' && logs.push(msg));
|
||||
const room = h.manager.createPractice('A', gameId) as Room;
|
||||
rooms.push(room);
|
||||
room.join(h.conn('A'), 'player');
|
||||
expect(room.start('A')).toBeNull();
|
||||
for (let i = 0; i < 5000 && !room.game!.finished; i++) {
|
||||
drive(room);
|
||||
if (room.game!.finished) break;
|
||||
const at = room.effectiveDeadline();
|
||||
expect(at).not.toBeNull();
|
||||
h.clock.advance(Math.max(0, at! - h.clock.now()));
|
||||
room.onTimer();
|
||||
}
|
||||
expect(room.game!.finished).toBe(true);
|
||||
expect(logs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('restore after restart', () => {
|
||||
test('bots stay seated, have no reconnect grace, and the driver re-plans their move', () => {
|
||||
const { manager, conn, store, clock, users } = harness();
|
||||
const room = manager.createPractice('A', 'omok') as Room;
|
||||
rooms.push(room);
|
||||
room.join(conn('A'), 'player');
|
||||
room.configure('A', { options: { ...(room.config.options as object), colorAssignment: 'hostBlack' } });
|
||||
room.start('A');
|
||||
room.act('A', 1, { type: 'place', x: 7, y: 7 });
|
||||
// Crash before the bot answers.
|
||||
room.clearTimer();
|
||||
const { seq, stateJson } = store.openRooms()[0]!;
|
||||
clock.advance(60_000);
|
||||
const restored = Room.restore(room.id, room.code, seq, JSON.parse(stateJson) as RoomSnapshot, {
|
||||
store,
|
||||
games: GAMES,
|
||||
users,
|
||||
now: clock.now,
|
||||
botDelay: () => 0,
|
||||
});
|
||||
rooms.push(restored);
|
||||
expect(restored.hasBots()).toBe(true);
|
||||
expect(restored.presence.has(bots(restored)[0]!)).toBe(false);
|
||||
expect(restored.roomView().seats[1]).toMatchObject({ connected: true, ready: true });
|
||||
expect(restored.bots.dueAt).not.toBeNull();
|
||||
drive(restored);
|
||||
expect((restored.game!.state as { moves: unknown[] }).moves).toHaveLength(2);
|
||||
|
||||
// The manager path too: restoreAll keeps bots out of the seat index (no "active room" for a bot).
|
||||
const m2 = new RoomManager({ store, games: GAMES, users, now: clock.now, botDelay: () => 0 });
|
||||
m2.restoreAll();
|
||||
for (const r of m2.all()) rooms.push(r);
|
||||
expect(m2.activeRoomFor('A')).toBe(room.code);
|
||||
expect(m2.activeRoomFor(bots(restored)[0]!)).toBeNull();
|
||||
});
|
||||
});
|
||||
122
apps/server/src/rooms/bots.ts
Normal file
122
apps/server/src/rooms/bots.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Computer players' driver (docs/06-lobby-rooms.md §12, docs/09-game-engine.md §8).
|
||||
* After every state change the room calls `schedule()`: if the game waits on a bot, one action is planned after a
|
||||
* short human-like pause and played through the room's normal action path (validated, logged as `act`).
|
||||
*/
|
||||
import type { AnyGameDefinition } from '@bg/engine';
|
||||
import { isBotId } from '@bg/shared';
|
||||
import { botCandidates, decideBotAction } from '@bg/games/bots';
|
||||
|
||||
export interface BotRoom {
|
||||
readonly status: string;
|
||||
readonly game: { state: unknown; players: string[]; finished: boolean } | null;
|
||||
readonly def: AnyGameDefinition;
|
||||
now(): number;
|
||||
/** Runs the action as `actor` through the room's normal path; returns an error message on rejection. */
|
||||
run(actor: string, action: unknown): string | null;
|
||||
log?: (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thinking time: a quick beat for forced moves, a little longer for real choices. When people may act in the same
|
||||
* window (e.g. 원카드 외치기, 마작 울기, simultaneous choices) the computer waits longer so it never beats a person
|
||||
* to a reaction just by being fast.
|
||||
*/
|
||||
export function defaultBotDelay(choices: number, withPeople = false, random: () => number = Math.random): number {
|
||||
const base = choices <= 1 ? 400 + Math.floor(random() * 300) : 700 + Math.floor(random() * 800);
|
||||
return withPeople ? base + 1500 : base;
|
||||
}
|
||||
|
||||
/** Bot move budget (ms); slower decisions are logged. */
|
||||
export const BOT_SLOW_MS = 50;
|
||||
|
||||
export class BotDriver {
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** When the planned action is due (room clock); null when nothing is planned. */
|
||||
dueAt: number | null = null;
|
||||
/** Bot the planned action is for. */
|
||||
private planned: string | null = null;
|
||||
|
||||
constructor(
|
||||
private room: BotRoom,
|
||||
private random: () => number = Math.random,
|
||||
private delay: (choices: number, withPeople: boolean) => number = (n, w) => defaultBotDelay(n, w, random),
|
||||
) {}
|
||||
|
||||
/** Plans the next bot action if the game is waiting on a bot. Keeps an already planned one (the bot is "thinking"). */
|
||||
schedule(): void {
|
||||
if (this.dueAt !== null) return;
|
||||
const next = this.next();
|
||||
if (!next) return;
|
||||
this.dueAt = this.room.now() + this.delay(next.choices, next.withPeople);
|
||||
this.planned = next.bot;
|
||||
this.arm();
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
this.dueAt = null;
|
||||
this.planned = null;
|
||||
}
|
||||
|
||||
private arm(): void {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => this.fire(), Math.max(0, (this.dueAt ?? 0) - this.room.now()));
|
||||
}
|
||||
|
||||
/** Bot waited on by the game that has something to do, in seat order. */
|
||||
private next(): { bot: string; choices: number; withPeople: boolean } | null {
|
||||
const g = this.room.game;
|
||||
if (!g || g.finished || this.room.status !== 'playing' || !g.players.some(isBotId)) return null;
|
||||
const def = this.room.def;
|
||||
const active = def.activePlayers(g.state);
|
||||
const waiting = new Set([...active, ...(def.timeoutPlayers?.(g.state) ?? [])]);
|
||||
const withPeople = active.some((p) => !isBotId(p));
|
||||
for (const p of g.players) {
|
||||
if (!isBotId(p) || !waiting.has(p)) continue;
|
||||
const choices = botCandidates(def, g.state, p).length;
|
||||
if (choices > 0) return { bot: p, choices, withPeople };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Plays the planned action when due. Public for tests (with a fake clock). */
|
||||
fire(): void {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
if (this.dueAt === null) return;
|
||||
if (this.room.now() < this.dueAt) return this.arm();
|
||||
const planned = this.planned;
|
||||
this.dueAt = null;
|
||||
this.planned = null;
|
||||
const next = this.next();
|
||||
const g = this.room.game;
|
||||
if (!next || !g) return;
|
||||
// Another bot's turn came up meanwhile: give it its own thinking time.
|
||||
if (next.bot !== planned) return this.schedule();
|
||||
const def = this.room.def;
|
||||
const t0 = performance.now();
|
||||
let action: unknown;
|
||||
try {
|
||||
action = decideBotAction(def, g.state, next.bot, { now: this.room.now(), random: this.random });
|
||||
} catch (err) {
|
||||
this.room.log?.('error', 'bot decision failed', { game: def.id, err: String(err) });
|
||||
action = null;
|
||||
}
|
||||
const ms = performance.now() - t0;
|
||||
if (ms > BOT_SLOW_MS) this.room.log?.('warn', 'slow bot move', { game: def.id, ms: Math.round(ms) });
|
||||
let err = action == null ? 'no action' : this.room.run(next.bot, action);
|
||||
if (err) {
|
||||
this.room.log?.('warn', 'bot action rejected', { game: def.id, action, err });
|
||||
// Last resort before the turn timer: the game's own timeout action.
|
||||
try {
|
||||
if (!this.room.game?.finished) err = this.room.run(next.bot, def.onTimeout(this.room.game!.state, next.bot));
|
||||
} catch {
|
||||
// keep err
|
||||
}
|
||||
if (err) return; // give up until the next state change; the room's deadline still applies
|
||||
}
|
||||
this.schedule();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Creates, finds, restores and garbage-collects rooms (docs/06 §11, docs/04 §3). */
|
||||
import { ulid } from 'ulid';
|
||||
import type { AnyGameDefinition } from '@bg/engine';
|
||||
import { isAllowedRoomCode, type PublicUser } from '@bg/shared';
|
||||
import { botsAllowed, isAllowedRoomCode, isBotId, type PublicUser } from '@bg/shared';
|
||||
import { Room, type RoomConfig, type RoomDeps, type RoomSnapshot } from './room';
|
||||
import type { RoomStore } from './store';
|
||||
import type { SettingsStore } from '../admin/settings';
|
||||
@@ -19,6 +19,7 @@ export interface ManagerDeps {
|
||||
log?: RoomDeps['log'];
|
||||
random?: () => number;
|
||||
settings?: SettingsStore;
|
||||
botDelay?: RoomDeps['botDelay'];
|
||||
}
|
||||
|
||||
export class RoomManager {
|
||||
@@ -40,7 +41,9 @@ export class RoomManager {
|
||||
now: this.now,
|
||||
log: this.deps.log,
|
||||
settings: this.deps.settings,
|
||||
botDelay: this.deps.botDelay,
|
||||
onSeatChange: (room, userId, seated) => {
|
||||
if (isBotId(userId)) return;
|
||||
const set = this.seatIndex.get(userId) ?? new Set<string>();
|
||||
if (seated) set.add(room.code);
|
||||
else set.delete(room.code);
|
||||
@@ -112,6 +115,23 @@ export class RoomManager {
|
||||
return room;
|
||||
}
|
||||
|
||||
/**
|
||||
* "컴퓨터랑 연습": a private room with just enough computer players to start right away (the host takes the
|
||||
* remaining seat on joining).
|
||||
*/
|
||||
createPractice(hostId: string, gameId: string): Room | string {
|
||||
if (!botsAllowed(gameId)) return '이 게임은 컴퓨터와 할 수 없어요.';
|
||||
const room = this.create(hostId, gameId, 'private');
|
||||
if (typeof room === 'string') return room;
|
||||
const def = this.deps.games[gameId]!;
|
||||
const { min, max } = room.playerRange();
|
||||
let bots = Math.max(1, min - 1);
|
||||
while (bots + 1 < max && def.playerCountError?.(room.config.options, bots + 1)) bots++;
|
||||
// Seat 0 stays free for the host.
|
||||
for (let i = 1; i <= Math.min(bots, room.config.maxPlayers - 1); i++) room.addBot(hostId, i);
|
||||
return room;
|
||||
}
|
||||
|
||||
/** Rooms where the user holds a seat in an unfinished game. */
|
||||
activeRoomFor(userId: string): string | null {
|
||||
for (const code of this.seatIndex.get(userId) ?? []) {
|
||||
@@ -132,7 +152,8 @@ export class RoomManager {
|
||||
|
||||
publicRooms(gameId: string | null): { code: string; gameId: string; host: string; seated: number; max: number; options: unknown }[] {
|
||||
return this.all()
|
||||
.filter((r) => (gameId === null || r.config.gameId === gameId) && r.config.visibility === 'public' && r.status === 'lobby')
|
||||
// Rooms with computer players are practice rooms: never listed, never filled by quick play.
|
||||
.filter((r) => (gameId === null || r.config.gameId === gameId) && r.config.visibility === 'public' && r.status === 'lobby' && !r.hasBots())
|
||||
.map((r) => ({
|
||||
code: r.code,
|
||||
gameId: r.config.gameId,
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
*/
|
||||
import { ulid } from 'ulid';
|
||||
import { SeededRng, createSeed, seedToHex, type AnyGameDefinition, type GameResult, type RngState } from '@bg/engine';
|
||||
import { maskProfanity, type PublicUser, type RoomView, type ServerMessage, type SeatView } from '@bg/shared';
|
||||
import { botsAllowed, isBotId, makeBotId, botNumber, maskProfanity, type PublicUser, type RoomView, type ServerMessage, type SeatView } from '@bg/shared';
|
||||
import { sha256Hex } from '../auth/sessions';
|
||||
import { BotDriver } from './bots';
|
||||
import type { LogEntry, RoomStore } from './store';
|
||||
import { applyLocks, type EffectiveGame, type SiteSettings } from '../admin/settings';
|
||||
|
||||
@@ -79,6 +80,8 @@ export interface RoomDeps {
|
||||
onSeatChange?: (room: Room, userId: string, seated: boolean) => void;
|
||||
/** Admin settings (docs/14-admin.md). Optional so unit tests can run without them. */
|
||||
settings?: { game(id: string): EffectiveGame | null; getSite(): SiteSettings };
|
||||
/** Computer players' thinking time (ms) for a decision with `choices` options; tests may pin it. */
|
||||
botDelay?: (choices: number, withPeople: boolean) => number;
|
||||
}
|
||||
|
||||
const CHAT_HISTORY = 30;
|
||||
@@ -113,6 +116,8 @@ export class Room {
|
||||
private lastChatAt = new Map<string, number>();
|
||||
private consecutiveErrors = 0;
|
||||
private readonly now: () => number;
|
||||
/** Plays the computer players' moves (docs/06 §12). */
|
||||
readonly bots: BotDriver;
|
||||
|
||||
constructor(
|
||||
readonly id: string,
|
||||
@@ -125,6 +130,28 @@ export class Room {
|
||||
this.seats = new Array(config.maxPlayers).fill(null);
|
||||
this.now = deps.now ?? Date.now;
|
||||
this.lastActiveAt = this.now();
|
||||
const room = this;
|
||||
this.bots = new BotDriver(
|
||||
{
|
||||
get status() {
|
||||
return room.status;
|
||||
},
|
||||
get game() {
|
||||
return room.game;
|
||||
},
|
||||
get def() {
|
||||
return room.def;
|
||||
},
|
||||
now: this.now,
|
||||
run: (bot, action) => {
|
||||
const parsed = this.def.actionSchema.safeParse(action);
|
||||
return parsed.success ? this.runAction(bot, parsed.data, undefined, false) : '잘못된 요청이에요.';
|
||||
},
|
||||
log: deps.log,
|
||||
},
|
||||
Math.random,
|
||||
deps.botDelay,
|
||||
);
|
||||
}
|
||||
|
||||
get def(): AnyGameDefinition {
|
||||
@@ -169,6 +196,10 @@ export class Room {
|
||||
return !!this.game && !this.game.finished && this.game.players.includes(userId);
|
||||
}
|
||||
|
||||
hasBots(): boolean {
|
||||
return this.seats.some((s) => !!s && isBotId(s.userId));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- snapshot
|
||||
|
||||
snapshot(): RoomSnapshot {
|
||||
@@ -203,7 +234,8 @@ export class Room {
|
||||
room.game = snap.game;
|
||||
const now = room.now();
|
||||
for (const s of room.seats) {
|
||||
if (s) room.presence.set(s.userId, { connected: false, since: now, joinedAt: snap.joinedAt[s.userId] ?? now });
|
||||
// Computer players have no connection (and so no reconnect grace period).
|
||||
if (s && !isBotId(s.userId)) room.presence.set(s.userId, { connected: false, since: now, joinedAt: snap.joinedAt[s.userId] ?? now });
|
||||
}
|
||||
if (room.game && !room.game.finished) {
|
||||
const def = room.def;
|
||||
@@ -261,6 +293,8 @@ export class Room {
|
||||
const pub = (id: string): PublicUser => this.deps.users.publicUser(id) ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
|
||||
const seats: (SeatView | null)[] = this.seats.map((s) => {
|
||||
if (!s) return null;
|
||||
// Computer players are always there and ready.
|
||||
if (isBotId(s.userId)) return { user: pub(s.userId), ready: true, connected: true, away: false };
|
||||
const p = this.presence.get(s.userId);
|
||||
const connected = !!p?.connected;
|
||||
const away = !connected && !!p && this.now() - p.since > this.config.graceSec * 1000;
|
||||
@@ -415,6 +449,11 @@ export class Room {
|
||||
this.presence.set(userId, { connected: false, since: 0, joinedAt: 0 });
|
||||
const action = this.def.onLeave?.(this.game!.state, userId);
|
||||
if (action) this.runAction(userId, action, undefined, true);
|
||||
// Only computer players left: nobody to practise with, so the game ends uncounted.
|
||||
const g = this.game;
|
||||
if (g && !g.finished && g.players.every((u) => isBotId(u) || this.leftDuringGame.has(u))) {
|
||||
this.finishGame({ ranking: [g.players], summary: '모두 나가서 연습 게임을 끝냈어요.', reason: 'abandoned' }, true);
|
||||
}
|
||||
} else {
|
||||
this.seats[seat] = null;
|
||||
this.rematchVotes.delete(userId);
|
||||
@@ -432,7 +471,7 @@ export class Room {
|
||||
const candidates = [...this.presence.entries()]
|
||||
.filter(([u, p]) => this.seatOf(u) >= 0 && p.connected)
|
||||
.sort((a, b) => a[1].joinedAt - b[1].joinedAt);
|
||||
const next = candidates[0]?.[0] ?? this.seatedIds()[0] ?? [...this.spectators][0];
|
||||
const next = candidates[0]?.[0] ?? this.seatedIds().find((u) => !isBotId(u)) ?? [...this.spectators][0];
|
||||
if (next) this.hostId = next;
|
||||
}
|
||||
|
||||
@@ -501,6 +540,8 @@ export class Room {
|
||||
next.options = eff?.defaultOptions ?? def.defaultOptions;
|
||||
next.maxPlayers = eff?.maxPlayers ?? def.maxPlayers;
|
||||
}
|
||||
if (this.hasBots() && !botsAllowed(next.gameId)) return '컴퓨터는 이 게임을 할 수 없어요. 컴퓨터를 먼저 빼 주세요.';
|
||||
if (this.hasBots() && patch.visibility === 'public') return '컴퓨터가 있는 방은 공개할 수 없어요.';
|
||||
const def = this.deps.games[next.gameId]!;
|
||||
if (patch.options !== undefined) {
|
||||
const parsed = def.optionsSchema.safeParse(patch.options);
|
||||
@@ -543,6 +584,7 @@ export class Room {
|
||||
kick(userId: string, target: string): string | null {
|
||||
if (userId !== this.hostId) return '방장만 내보낼 수 있어요.';
|
||||
if (target === userId) return '자기 자신은 내보낼 수 없어요.';
|
||||
if (isBotId(target)) return this.removeBot(userId, target);
|
||||
if (!this.isParticipant(target)) return '방에 없는 사람이에요.';
|
||||
const conn = this.conns.get(target);
|
||||
this.kicked.add(target);
|
||||
@@ -554,6 +596,7 @@ export class Room {
|
||||
|
||||
transferHost(userId: string, target: string): string | null {
|
||||
if (userId !== this.hostId) return '방장만 넘길 수 있어요.';
|
||||
if (isBotId(target)) return '컴퓨터에게는 방장을 넘길 수 없어요.';
|
||||
if (this.seatOf(target) < 0 && !this.spectators.has(target)) return '방에 없는 사람이에요.';
|
||||
this.hostId = target;
|
||||
this.persist({ actorId: userId, kind: 'host', data: { target } });
|
||||
@@ -561,6 +604,39 @@ export class Room {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Host seats a computer player on an empty seat (docs/06 §12). Rooms with computers are private practice rooms. */
|
||||
addBot(userId: string, seat?: number): string | null {
|
||||
if (userId !== this.hostId) return '방장만 컴퓨터를 넣을 수 있어요.';
|
||||
const err = this.requireLobby();
|
||||
if (err) return err;
|
||||
if (!botsAllowed(this.config.gameId)) return '이 게임은 컴퓨터와 할 수 없어요.';
|
||||
const target = seat ?? this.seats.findIndex((s) => s === null);
|
||||
if (target < 0 || target >= this.seats.length) return '빈자리가 없어요.';
|
||||
if (this.seats[target]) return '이미 다른 사람이 앉은 자리예요.';
|
||||
const used = new Set(this.seatedIds().map(botNumber));
|
||||
let n = 1;
|
||||
while (used.has(n)) n++;
|
||||
const id = makeBotId(n, ulid(this.now()));
|
||||
this.seats[target] = { userId: id, ready: true };
|
||||
if (this.config.visibility === 'public') this.config = { ...this.config, visibility: 'private' };
|
||||
this.persist({ actorId: userId, kind: 'addBot', data: { seat: target, bot: id } });
|
||||
this.broadcastRoom();
|
||||
return null;
|
||||
}
|
||||
|
||||
removeBot(userId: string, botId: string): string | null {
|
||||
if (userId !== this.hostId) return '방장만 컴퓨터를 뺄 수 있어요.';
|
||||
const err = this.requireLobby();
|
||||
if (err) return err;
|
||||
const seat = this.seatOf(botId);
|
||||
if (seat < 0 || !isBotId(botId)) return '없는 컴퓨터예요.';
|
||||
this.seats[seat] = null;
|
||||
this.rematchVotes.delete(botId);
|
||||
this.persist({ actorId: userId, kind: 'removeBot', data: { bot: botId } });
|
||||
this.broadcastRoom();
|
||||
return null;
|
||||
}
|
||||
|
||||
chat(userId: string, text: string): string | null {
|
||||
if (!this.config.chatEnabled) return '이 방은 채팅을 쓰지 않아요.';
|
||||
if (!this.isParticipant(userId)) return '방에 들어와야 채팅할 수 있어요.';
|
||||
@@ -605,6 +681,8 @@ export class Room {
|
||||
const range = this.playerRange();
|
||||
if (players.length < range.min) return `${range.min - players.length}명 더 필요해요.`;
|
||||
if (players.length > range.max) return `최대 ${range.max}명까지 할 수 있어요.`;
|
||||
if (this.hasBots() && !botsAllowed(this.config.gameId)) return '컴퓨터는 이 게임을 할 수 없어요.';
|
||||
if (players.every(isBotId)) return '사람이 한 명은 앉아야 해요.';
|
||||
return this.startGame(players);
|
||||
}
|
||||
|
||||
@@ -614,7 +692,7 @@ export class Room {
|
||||
this.rematchVotes.add(userId);
|
||||
const seated = this.seatedIds();
|
||||
const range = this.playerRange();
|
||||
if (seated.length >= range.min && seated.every((u) => this.rematchVotes.has(u))) {
|
||||
if (seated.length >= range.min && seated.every((u) => isBotId(u) || this.rematchVotes.has(u))) {
|
||||
return this.startGame(this.orderedPlayers());
|
||||
}
|
||||
this.persist(null);
|
||||
@@ -772,6 +850,7 @@ export class Room {
|
||||
endReason: voided ? 'void' : result.reason,
|
||||
result,
|
||||
players,
|
||||
practice: g.players.some(isBotId),
|
||||
});
|
||||
const allFirst = players.every((p) => p.rank === 1);
|
||||
if (!voided) {
|
||||
@@ -828,16 +907,21 @@ export class Room {
|
||||
}
|
||||
|
||||
scheduleTimer(): void {
|
||||
this.clearTimer();
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
// A computer move already planned stays planned (it re-reads the state when it fires).
|
||||
this.bots.schedule();
|
||||
const at = this.effectiveDeadline();
|
||||
if (at === null) return;
|
||||
const delay = Math.max(0, at - this.now());
|
||||
this.timer = setTimeout(() => this.onTimer(), Math.min(delay, 2 ** 31 - 1));
|
||||
}
|
||||
|
||||
/** Clears the turn timer and any planned computer move. */
|
||||
clearTimer(): void {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
this.bots.cancel();
|
||||
}
|
||||
|
||||
/** Fires due timeouts. Public for tests. */
|
||||
|
||||
@@ -100,12 +100,15 @@ export class RoomStore {
|
||||
endReason: string;
|
||||
result: unknown;
|
||||
players: { userId: string; seat: number; rank: number | null; score: number | null }[];
|
||||
/** Played with computer players: kept in history (result_json.practice) but not counted in user_stats. */
|
||||
practice?: boolean;
|
||||
}): void {
|
||||
this.db.transaction(() => {
|
||||
const result = g.practice ? { ...(g.result as object), practice: true } : g.result;
|
||||
this.db
|
||||
.query('UPDATE games SET seed = ?, ended_at = ?, end_reason = ?, result_json = ? WHERE id = ?')
|
||||
.run(g.seed, g.endedAt, g.endReason, JSON.stringify(g.result), g.id);
|
||||
const counted = g.endReason !== 'void';
|
||||
.run(g.seed, g.endedAt, g.endReason, JSON.stringify(result), g.id);
|
||||
const counted = g.endReason !== 'void' && !g.practice;
|
||||
const ranks = g.players.map((p) => p.rank).filter((r): r is number => r !== null);
|
||||
const firstCount = ranks.filter((r) => r === 1).length;
|
||||
for (const p of g.players) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { existsSync } from 'node:fs';
|
||||
import { join, normalize } from 'node:path';
|
||||
import type { Server } from 'bun';
|
||||
import { GAMES } from '@bg/games';
|
||||
import { botProfile } from '@bg/shared';
|
||||
import type { Config } from './config';
|
||||
import { openDb } from './db';
|
||||
import { SESSION_COOKIE, Sessions } from './auth/sessions';
|
||||
@@ -27,6 +28,8 @@ export interface StartOptions {
|
||||
log?: LogFn;
|
||||
staticDir?: string | null;
|
||||
discordExchange?: HttpDeps['discordExchange'];
|
||||
/** Computer players' thinking time (tests shorten it). */
|
||||
botDelay?: (choices: number, withPeople: boolean) => number;
|
||||
}
|
||||
|
||||
export function startServer(opts: StartOptions) {
|
||||
@@ -37,6 +40,8 @@ export function startServer(opts: StartOptions) {
|
||||
const sessions = new Sessions(db);
|
||||
const store = new RoomStore(db);
|
||||
const publicUser = (id: string) => {
|
||||
const bot = botProfile(id);
|
||||
if (bot) return bot;
|
||||
const u = users.get(id);
|
||||
return u ? toPublicUser(u) : null;
|
||||
};
|
||||
@@ -44,7 +49,7 @@ export function startServer(opts: StartOptions) {
|
||||
const roles = new Roles(db, config.superadminDiscordIds);
|
||||
const audit = new Audit(db);
|
||||
const startedAt = Date.now();
|
||||
const rooms = new RoomManager({ store, games: GAMES, users: { publicUser }, log, settings });
|
||||
const rooms = new RoomManager({ store, games: GAMES, users: { publicUser }, log, settings, botDelay: opts.botDelay });
|
||||
const restored = rooms.restoreAll();
|
||||
log('info', 'rooms restored', restored);
|
||||
const gateway = new Gateway({ rooms, me: publicUser, log });
|
||||
|
||||
@@ -173,6 +173,12 @@ export class Gateway {
|
||||
case 'host':
|
||||
e = room.transferHost(u, m.userId);
|
||||
break;
|
||||
case 'addBot':
|
||||
e = room.addBot(u, m.seat);
|
||||
break;
|
||||
case 'removeBot':
|
||||
e = room.removeBot(u, m.userId);
|
||||
break;
|
||||
case 'act':
|
||||
room.act(u, m.cs, m.a);
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user