Merge branch 'm9/bots' into codex/owner/grp_discord_joke-app
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,
|
||||
|
||||
@@ -268,10 +268,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 §9).
|
||||
* 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;
|
||||
@@ -262,6 +294,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;
|
||||
@@ -417,6 +451,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);
|
||||
@@ -434,7 +473,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;
|
||||
}
|
||||
|
||||
@@ -503,6 +542,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);
|
||||
@@ -545,6 +586,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);
|
||||
@@ -556,6 +598,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 } });
|
||||
@@ -563,6 +606,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 '방에 들어와야 채팅할 수 있어요.';
|
||||
@@ -607,6 +683,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);
|
||||
}
|
||||
|
||||
@@ -616,7 +694,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);
|
||||
@@ -774,6 +852,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) {
|
||||
@@ -830,16 +909,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';
|
||||
@@ -28,6 +29,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) {
|
||||
@@ -38,6 +41,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;
|
||||
};
|
||||
@@ -45,7 +50,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;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Shared UI primitives (docs/07 §4). */
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
import { formatRoomCode } from '@bg/shared/lite';
|
||||
import { formatRoomCode, isBotId } from '@bg/shared/lite';
|
||||
import { useToasts, toast } from '../store/toast';
|
||||
import { useRoom } from '../net/socket';
|
||||
import { haptic } from '../lib/feedback';
|
||||
@@ -15,7 +15,7 @@ export function Avatar({ user, size = 40 }: { user: Pick<PublicUser, 'id' | 'nic
|
||||
const bg = AVATAR_COLORS[h % AVATAR_COLORS.length];
|
||||
return (
|
||||
<span className="avatar" style={{ width: size, height: size, background: bg, fontSize: size * 0.45 }} aria-hidden>
|
||||
{user.avatar ? <img src={user.avatar} alt="" loading="lazy" referrerPolicy="no-referrer" /> : [...user.nickname][0]}
|
||||
{user.avatar ? <img src={user.avatar} alt="" loading="lazy" referrerPolicy="no-referrer" /> : isBotId(user.id) ? '🤖' : [...user.nickname][0]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ export const api = {
|
||||
call<{ games: MyGameSummary[]; next: string | null }>('GET', `/api/me/games${before ? `?before=${encodeURIComponent(before)}` : ''}`),
|
||||
replay: (gamePk: string) => call<ReplayData>('GET', `/api/replays/${encodeURIComponent(gamePk)}`),
|
||||
createRoom: (gameId: string, visibility?: 'private' | 'public') => call<{ code: string }>('POST', '/api/rooms', { gameId, visibility }),
|
||||
/** 컴퓨터와 연습: a private room with enough computer players to start (docs/06 §12). */
|
||||
practice: (gameId: string) => call<{ code: string }>('POST', '/api/rooms', { gameId, practice: true }),
|
||||
quickPlay: (gameId: string) => call<{ code: string }>('POST', `/api/quick/${gameId}`, {}),
|
||||
room: (code: string) => call<RoomPreview>('GET', `/api/rooms/${code}`),
|
||||
allPublicRooms: () => call<{ rooms: { code: string; gameId: string; host: string; seated: number; max: number }[] }>('GET', '/api/public-rooms'),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** 게임 소개 + 방 만들기 / 빠른 시작 / 규칙 / 공개 방 목록 (docs/06 §2, §7, §10). */
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router';
|
||||
import { catalogById, formatRoomCode } from '@bg/shared/lite';
|
||||
import { botsAllowed, catalogById, formatRoomCode } from '@bg/shared/lite';
|
||||
import { Sheet, TopBar } from '../components/ui';
|
||||
import { NicknameForm } from '../components/NicknameForm';
|
||||
import { api } from '../net/api';
|
||||
@@ -64,6 +64,11 @@ export default function GamePage() {
|
||||
<button className="btn btn-secondary btn-block" disabled={busy} onClick={() => run(() => api.quickPlay(g.id))}>
|
||||
⚡ 아무나와 빠른 시작
|
||||
</button>
|
||||
{botsAllowed(g.id) && (
|
||||
<button className="btn btn-secondary btn-block" disabled={busy} onClick={() => run(() => api.practice(g.id))}>
|
||||
🤖 컴퓨터와 연습하기
|
||||
</button>
|
||||
)}
|
||||
<Link className="btn btn-ghost btn-block" to={`/rules/${g.id}`}>
|
||||
📖 규칙 보기
|
||||
</Link>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** 홈 (PC 기준, docs/07-ui-ux.md §3.3): 큰 행동 줄 + 게임 격자 + 열린 공개 방. */
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { CATALOG, formatRoomCode } from '@bg/shared/lite';
|
||||
import { CATALOG, botsAllowed, formatRoomCode } from '@bg/shared/lite';
|
||||
import { Sheet } from '../components/ui';
|
||||
import { NicknameForm } from '../components/NicknameForm';
|
||||
import { CodeInput } from '../components/CodeInput';
|
||||
@@ -17,12 +17,13 @@ export function Home() {
|
||||
const { me, activeRoom } = useSession();
|
||||
const catalog = useSession((s) => (s.catalog.length ? s.catalog : CATALOG));
|
||||
const nav = useNavigate();
|
||||
const [picker, setPicker] = useState(false);
|
||||
const [picker, setPicker] = useState<null | 'room' | 'practice'>(null);
|
||||
const [needName, setNeedName] = useState<null | (() => void)>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [rooms, setRooms] = useState<{ code: string; gameId: string; host: string; seated: number; max: number }[]>([]);
|
||||
const [codeKey, setCodeKey] = useState(0);
|
||||
const available = catalog.filter((g) => g.available);
|
||||
const practiceGames = available.filter((g) => botsAllowed(g.id));
|
||||
|
||||
useEffect(() => {
|
||||
const load = () => api.allPublicRooms().then((r) => setRooms(r.rooms), () => {});
|
||||
@@ -32,11 +33,11 @@ export function Home() {
|
||||
}, []);
|
||||
|
||||
const withName = (fn: () => void) => (me ? fn() : setNeedName(() => fn));
|
||||
const create = (gameId: string) =>
|
||||
const create = (gameId: string, practice = false) =>
|
||||
withName(async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const { code } = await api.createRoom(gameId);
|
||||
const { code } = await (practice ? api.practice(gameId) : api.createRoom(gameId));
|
||||
nav(`/r/${code}`);
|
||||
} catch (e) {
|
||||
toast((e as Error).message, 'error');
|
||||
@@ -71,7 +72,7 @@ export function Home() {
|
||||
className="btn btn-primary btn-block"
|
||||
style={{ minHeight: 72, fontSize: '1.3rem' }}
|
||||
disabled={busy}
|
||||
onClick={() => (available.length === 1 ? create(available[0]!.id) : setPicker(true))}
|
||||
onClick={() => (available.length === 1 ? create(available[0]!.id) : setPicker('room'))}
|
||||
>
|
||||
+ 방 만들기
|
||||
</button>
|
||||
@@ -81,6 +82,15 @@ export function Home() {
|
||||
<p className="muted">받은 숫자 6자리를 넣으면 바로 들어가요.</p>
|
||||
<CodeInput key={codeKey} onComplete={joinCode} autoFocus={false} />
|
||||
</div>
|
||||
{practiceGames.length > 0 && (
|
||||
<div className="card stack" style={{ padding: 24, justifyContent: 'center' }}>
|
||||
<h2>혼자 연습하기</h2>
|
||||
<p className="muted">컴퓨터와 바로 한 판 해 봐요. 기록에는 남지 않아요.</p>
|
||||
<button className="btn btn-secondary btn-block" style={{ minHeight: 60 }} disabled={busy} onClick={() => setPicker('practice')}>
|
||||
🤖 컴퓨터와 연습하기
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="two-col" style={{ gridTemplateColumns: undefined }}>
|
||||
@@ -114,10 +124,10 @@ export function Home() {
|
||||
<InstallCard />
|
||||
|
||||
{picker && (
|
||||
<Sheet onClose={() => setPicker(false)} label="게임 고르기">
|
||||
<h2>어떤 게임을 할까요?</h2>
|
||||
{available.map((g) => (
|
||||
<button key={g.id} className="btn btn-secondary btn-block" onClick={() => (setPicker(false), create(g.id))}>
|
||||
<Sheet onClose={() => setPicker(null)} label="게임 고르기">
|
||||
<h2>{picker === 'practice' ? '컴퓨터와 어떤 게임을 할까요?' : '어떤 게임을 할까요?'}</h2>
|
||||
{(picker === 'practice' ? practiceGames : available).map((g) => (
|
||||
<button key={g.id} className="btn btn-secondary btn-block" onClick={() => (setPicker(null), create(g.id, picker === 'practice'))}>
|
||||
{GAME_ICON[g.id]} {g.nameKo}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** 방: 대기실 ↔ 게임 ↔ 결과를 한 주소에서 전환 (docs/06 §5–§9). */
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router';
|
||||
import { formatRoomCode, type PublicUser, type RoomView } from '@bg/shared/lite';
|
||||
import { botsAllowed, formatRoomCode, isBotId, type PublicUser, type RoomView } from '@bg/shared/lite';
|
||||
import { Avatar, CodeLabel, ConfirmSheet, CopyButton, ShareButton, Sheet, TopBar } from '../components/ui';
|
||||
import { NicknameForm } from '../components/NicknameForm';
|
||||
import { GameNotice, gameName } from '../components/GameNotice';
|
||||
@@ -269,6 +269,11 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
|
||||
const shareText = `${host}님이 ${gName} 방에 초대했어요! 코드 ${formatRoomCode(room.code)}`;
|
||||
const notReady = room.seats.filter((s) => s && !s.ready && s.user.id !== room.hostId).length;
|
||||
const [askStart, setAskStart] = useState(false);
|
||||
// Computer players (docs/06 §12): the host fills empty seats; a room with them is a practice room.
|
||||
const canAddBot = isHost && botsAllowed(room.gameId);
|
||||
const practice = room.seats.some((s) => s && isBotId(s.user.id));
|
||||
// Offered on the first empty seat only, so big rooms don't show a column of the same button.
|
||||
const firstEmpty = room.seats.findIndex((s) => s === null);
|
||||
|
||||
const wide = useWide();
|
||||
const invite = (
|
||||
@@ -294,7 +299,22 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
|
||||
</h2>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: wide && room.seats.length > 4 ? '1fr 1fr' : '1fr', gap: 10 }}>
|
||||
{room.seats.map((s, i) =>
|
||||
s ? (
|
||||
s && isBotId(s.user.id) ? (
|
||||
<div key={i} className="card row" style={{ gap: 12, flexWrap: 'nowrap' }}>
|
||||
<Avatar user={s.user} size={44} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 700 }}>{s.user.nickname}</div>
|
||||
<div className="row small" style={{ gap: 6 }}>
|
||||
<span className="badge">🤖 컴퓨터</span>
|
||||
</div>
|
||||
</div>
|
||||
{isHost && (
|
||||
<button className="btn btn-ghost" onClick={() => socket.send({ t: 'removeBot', userId: s.user.id })} aria-label={`${s.user.nickname} 빼기`}>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : s ? (
|
||||
<div key={i} className="card row" style={{ gap: 12, flexWrap: 'nowrap' }}>
|
||||
<Avatar user={s.user} size={44} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
@@ -312,9 +332,16 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
|
||||
{isHost && s.user.id !== me.id && <HostMenu room={room} target={s.user} />}
|
||||
</div>
|
||||
) : (
|
||||
<button key={i} className="card row" style={{ border: '2px dashed var(--border)', boxShadow: 'none', cursor: 'pointer', minHeight: 76, justifyContent: 'center', color: 'var(--text-sub)' }} onClick={() => (mySeat < 0 ? socket.send({ t: 'seat', seat: i }) : setQr(true))}>
|
||||
{mySeat < 0 ? '+ 여기 앉기' : '+ 빈자리 · 친구 초대하기'}
|
||||
</button>
|
||||
<div key={i} className="row" style={{ gap: 8, flexWrap: 'nowrap' }}>
|
||||
<button className="card row" style={{ flex: 1, border: '2px dashed var(--border)', boxShadow: 'none', cursor: 'pointer', minHeight: 76, justifyContent: 'center', color: 'var(--text-sub)' }} onClick={() => (mySeat < 0 ? socket.send({ t: 'seat', seat: i }) : setQr(true))}>
|
||||
{mySeat < 0 ? '+ 여기 앉기' : '+ 빈자리 · 친구 초대하기'}
|
||||
</button>
|
||||
{canAddBot && i === firstEmpty && (
|
||||
<button className="btn btn-secondary" style={{ minHeight: 76 }} onClick={() => socket.send({ t: 'addBot', seat: i })}>
|
||||
🤖 컴퓨터 넣기
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
@@ -340,6 +367,7 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
|
||||
)}
|
||||
</div>
|
||||
<p className="muted small">{room.visibility === 'public' ? '공개 방 (누구나 들어올 수 있어요)' : '친구만 (코드·링크로만 들어와요)'}</p>
|
||||
{practice && <p className="small">🤖 연습 게임이에요. 기록에는 남지 않아요.</p>}
|
||||
{room.lockedOptions.length > 0 && <p className="muted small">🔒 일부 규칙은 관리자가 정해 두어서 바꿀 수 없어요.</p>}
|
||||
<GameNotice gameId={room.gameId} />
|
||||
{isHost && wide && <SettingsForm room={room} ui={ui} inline />}
|
||||
@@ -479,7 +507,11 @@ function SettingsForm({ room, ui, inline, onDone }: { room: RoomView; ui: GameUI
|
||||
<div style={{ display: 'grid', gridTemplateColumns: inline ? 'repeat(3, minmax(0, 1fr))' : '1fr', gap: 12 }}>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>방 공개</strong>
|
||||
{seg(visibility === 'public', (v) => setVisibility(v ? 'public' : 'private'), '누구나', '친구만')}
|
||||
{room.seats.some((s) => s && isBotId(s.user.id)) ? (
|
||||
<p className="muted small">컴퓨터가 있는 방은 친구만 들어와요.</p>
|
||||
) : (
|
||||
seg(visibility === 'public', (v) => setVisibility(v ? 'public' : 'private'), '누구나', '친구만')
|
||||
)}
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>관전</strong>
|
||||
@@ -571,8 +603,10 @@ function ResultPanel({ room, me, players, onLeave }: { room: RoomView; me: Publi
|
||||
const result = useRoom((s) => s.result)!;
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const played = useRef(false);
|
||||
const seated = room.seats.filter(Boolean).map((s) => s!.user.id);
|
||||
const amSeated = seated.includes(me.id);
|
||||
const seatedAll = room.seats.filter(Boolean).map((s) => s!.user.id);
|
||||
const amSeated = seatedAll.includes(me.id);
|
||||
// Computer players always agree to a rematch.
|
||||
const seated = seatedAll.filter((u) => !isBotId(u));
|
||||
const voted = room.rematchVotes.includes(me.id);
|
||||
// Solo games (e.g. 요트 다이스) have one ranked player: neither a draw nor a win.
|
||||
const solo = result.ranking.flat().length === 1;
|
||||
@@ -600,6 +634,7 @@ function ResultPanel({ room, me, players, onLeave }: { room: RoomView; me: Publi
|
||||
<Sheet onClose={() => setHidden(true)} label="게임 결과">
|
||||
<h1 style={{ textAlign: 'center' }}>{allFirst ? '무승부예요' : won ? '🎉 이겼어요!' : lost ? '아쉽게 졌어요' : '게임이 끝났어요'}</h1>
|
||||
<p style={{ textAlign: 'center' }}>{result.summary}</p>
|
||||
{seatedAll.length > seated.length && <p className="muted small" style={{ textAlign: 'center' }}>🤖 연습 게임이라 기록에는 남지 않아요.</p>}
|
||||
<div className="stack">
|
||||
{result.ranking.map((group, i) =>
|
||||
group.map((u) => (
|
||||
|
||||
Reference in New Issue
Block a user