Merge branch 'm9/bots' into codex/owner/grp_discord_joke-app

This commit is contained in:
EJClaw
2026-10-06 13:35:33 +09:00
73 changed files with 5355 additions and 38 deletions

View 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);
});
});

View File

@@ -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,

View File

@@ -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 });
});

View 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();
});
});

View 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();
}
}

View File

@@ -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,

View File

@@ -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. */

View File

@@ -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) {

View File

@@ -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 });

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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'),

View File

@@ -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>

View File

@@ -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>
))}

View File

@@ -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) => (