diff --git a/apps/server/src/bots.test.ts b/apps/server/src/bots.test.ts new file mode 100644 index 0000000..82f55ee --- /dev/null +++ b/apps/server/src/bots.test.ts @@ -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; +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 { + 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 { + 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); + }); +}); diff --git a/apps/server/src/http/admin.ts b/apps/server/src/http/admin.ts index 6f1fda9..0f05581 100644 --- a/apps/server/src/http/admin.ts +++ b/apps/server/src/http/admin.ts @@ -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, diff --git a/apps/server/src/http/app.ts b/apps/server/src/http/app.ts index 99fd473..15daaa6 100644 --- a/apps/server/src/http/app.ts +++ b/apps/server/src/http/app.ts @@ -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 }); }); diff --git a/apps/server/src/rooms/bots.room.test.ts b/apps/server/src/rooms/bots.room.test.ts new file mode 100644 index 0000000..b030252 --- /dev/null +++ b/apps/server/src/rooms/bots.room.test.ts @@ -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 = { 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(); + }); +}); diff --git a/apps/server/src/rooms/bots.ts b/apps/server/src/rooms/bots.ts new file mode 100644 index 0000000..9003a8e --- /dev/null +++ b/apps/server/src/rooms/bots.ts @@ -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) => 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 | 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(); + } +} diff --git a/apps/server/src/rooms/manager.ts b/apps/server/src/rooms/manager.ts index e5b21bd..0f0e102 100644 --- a/apps/server/src/rooms/manager.ts +++ b/apps/server/src/rooms/manager.ts @@ -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(); 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, diff --git a/apps/server/src/rooms/room.ts b/apps/server/src/rooms/room.ts index 9229c54..cecdd44 100644 --- a/apps/server/src/rooms/room.ts +++ b/apps/server/src/rooms/room.ts @@ -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(); 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. */ diff --git a/apps/server/src/rooms/store.ts b/apps/server/src/rooms/store.ts index b2532bd..67d3f63 100644 --- a/apps/server/src/rooms/store.ts +++ b/apps/server/src/rooms/store.ts @@ -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) { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f4c119f..cfc94cd 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -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 }); diff --git a/apps/server/src/ws/gateway.ts b/apps/server/src/ws/gateway.ts index 42ab45a..6faaccd 100644 --- a/apps/server/src/ws/gateway.ts +++ b/apps/server/src/ws/gateway.ts @@ -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; diff --git a/apps/web/src/components/ui.tsx b/apps/web/src/components/ui.tsx index 547227a..edc745c 100644 --- a/apps/web/src/components/ui.tsx +++ b/apps/web/src/components/ui.tsx @@ -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 - {user.avatar ? : [...user.nickname][0]} + {user.avatar ? : isBotId(user.id) ? '🤖' : [...user.nickname][0]} ); } diff --git a/apps/web/src/net/api.ts b/apps/web/src/net/api.ts index 5660dee..9d59938 100644 --- a/apps/web/src/net/api.ts +++ b/apps/web/src/net/api.ts @@ -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('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('GET', `/api/rooms/${code}`), allPublicRooms: () => call<{ rooms: { code: string; gameId: string; host: string; seated: number; max: number }[] }>('GET', '/api/public-rooms'), diff --git a/apps/web/src/pages/GamePage.tsx b/apps/web/src/pages/GamePage.tsx index 91f5148..34fb6c5 100644 --- a/apps/web/src/pages/GamePage.tsx +++ b/apps/web/src/pages/GamePage.tsx @@ -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() { + {botsAllowed(g.id) && ( + + )} 📖 규칙 보기 diff --git a/apps/web/src/pages/Home.tsx b/apps/web/src/pages/Home.tsx index 4df8abb..214f038 100644 --- a/apps/web/src/pages/Home.tsx +++ b/apps/web/src/pages/Home.tsx @@ -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); const [needName, setNeedName] = useState 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'))} > + 방 만들기 @@ -81,6 +82,15 @@ export function Home() {

받은 숫자 6자리를 넣으면 바로 들어가요.

+ {practiceGames.length > 0 && ( +
+

혼자 연습하기

+

컴퓨터와 바로 한 판 해 봐요. 기록에는 남지 않아요.

+ +
+ )}
@@ -114,10 +124,10 @@ export function Home() { {picker && ( - setPicker(false)} label="게임 고르기"> -

어떤 게임을 할까요?

- {available.map((g) => ( - ))} diff --git a/apps/web/src/pages/RoomPage.tsx b/apps/web/src/pages/RoomPage.tsx index 1d2e823..0ca4b75 100644 --- a/apps/web/src/pages/RoomPage.tsx +++ b/apps/web/src/pages/RoomPage.tsx @@ -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 })
4 ? '1fr 1fr' : '1fr', gap: 10 }}> {room.seats.map((s, i) => - s ? ( + s && isBotId(s.user.id) ? ( +
+ +
+
{s.user.nickname}
+
+ 🤖 컴퓨터 +
+
+ {isHost && ( + + )} +
+ ) : s ? (
@@ -312,9 +332,16 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser }) {isHost && s.user.id !== me.id && }
) : ( - +
+ + {canAddBot && i === firstEmpty && ( + + )} +
), )}
@@ -340,6 +367,7 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser }) )}

{room.visibility === 'public' ? '공개 방 (누구나 들어올 수 있어요)' : '친구만 (코드·링크로만 들어와요)'}

+ {practice &&

🤖 연습 게임이에요. 기록에는 남지 않아요.

} {room.lockedOptions.length > 0 &&

🔒 일부 규칙은 관리자가 정해 두어서 바꿀 수 없어요.

} {isHost && wide && } @@ -479,7 +507,11 @@ function SettingsForm({ room, ui, inline, onDone }: { room: RoomView; ui: GameUI
방 공개 - {seg(visibility === 'public', (v) => setVisibility(v ? 'public' : 'private'), '누구나', '친구만')} + {room.seats.some((s) => s && isBotId(s.user.id)) ? ( +

컴퓨터가 있는 방은 친구만 들어와요.

+ ) : ( + seg(visibility === 'public', (v) => setVisibility(v ? 'public' : 'private'), '누구나', '친구만') + )}
관전 @@ -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 setHidden(true)} label="게임 결과">

{allFirst ? '무승부예요' : won ? '🎉 이겼어요!' : lost ? '아쉽게 졌어요' : '게임이 끝났어요'}

{result.summary}

+ {seatedAll.length > seated.length &&

🤖 연습 게임이라 기록에는 남지 않아요.

}
{result.ranking.map((group, i) => group.map((u) => ( diff --git a/docs/03-realtime-protocol.md b/docs/03-realtime-protocol.md index 5fe7691..39079ec 100644 --- a/docs/03-realtime-protocol.md +++ b/docs/03-realtime-protocol.md @@ -28,6 +28,8 @@ | `start` | — | 방장: 시작 | | `kick` | `userId` | 방장: 내보내기 | | `host` | `userId` | 방장 넘기기 | +| `addBot` | `seat?: number` | 방장: 빈자리에 컴퓨터 넣기(대기실에서만, 06 §12) | +| `removeBot` | `userId` | 방장: 컴퓨터 빼기(대기실에서만) | | `act` | `cs: number`, `a: Action` | 게임 행동. `cs`는 클라이언트 행동 번호(1부터 증가) | | `chat` | `text`(1~200자) | 채팅 | | `emote` | `id` | 빠른 반응(👍, 😂, "잘했어요!" 등 고정 목록) | diff --git a/docs/06-lobby-rooms.md b/docs/06-lobby-rooms.md index f6313b9..79e4e0d 100644 --- a/docs/06-lobby-rooms.md +++ b/docs/06-lobby-rooms.md @@ -95,3 +95,12 @@ interface Room { ``` - 한 사용자는 동시에 한 방에만 플레이어로 있을 수 있다(다른 방에 들어가면 이전 방 대기실 자리에서 빠짐. 이전 방이 게임 중이면 "진행 중인 게임이 있어요. 그래도 이동할까요?"). - 제한: 서버 전체 방 2,000개, 사용자당 동시에 만든 방 3개. + +## 12. 컴퓨터 상대 (혼자 연습) +- 들어가는 곳: 게임 상세와 홈의 [🤖 컴퓨터와 연습하기]. 친구만 방이 열리고, 바로 시작할 수 있을 만큼 컴퓨터가 앉아 있다(예: 오목 1명, 마작 2명, 뱅 3명). +- 대기실: 방장은 첫 빈자리의 [🤖 컴퓨터 넣기]로 넣고, 컴퓨터 자리의 [✕]로 뺀다(대기실에서만). 컴퓨터 이름은 "컴퓨터 1", "컴퓨터 2"…, 아바타는 🤖. 늘 준비 완료이고, 방장은 컴퓨터에게 넘어가지 않는다. 사람이 한 명은 앉아야 시작된다. +- 컴퓨터가 앉은 방은 연습 방이다: 공개로 바꿀 수 없고, 공개 방 목록·빠른 시작에 나오지 않는다. 결과는 남지만 전적(`user_stats`)에는 넣지 않는다. 화면에 "연습 게임이에요. 기록에는 남지 않아요." 표시. +- 컴퓨터가 할 수 있는 게임: 실시간·대화 게임(도블, 할리갈리, 그림 맞히기, 마피아)을 뺀 전부(`packages/shared/src/bots.ts`의 `BOT_GAMES`). +- 움직임: 서버의 `BotDriver`(`apps/server/src/rooms/bots.ts`)가 게임이 컴퓨터를 기다릴 때(차례, 반응 창, 동시 선택, 다음 판 준비) 0.4~1.5초 쉬었다가 한 수를 둔다. 사람과 같은 행동 경로(검사·기록)를 거치고, 서버가 다시 켜져도 이어서 둔다. +- 수 고르기: `packages/games/src/<게임>/bot.ts`. 자기 화면(`view(state, 나)`)과 둘 수 있는 수만 보고 고르므로 남의 패를 볼 수 없다. 강하지 않지만 엉뚱하지 않게(오목은 4를 막고, 오셀로는 귀를 잡고, 카드 게임은 패 세기에 맞게). +- 사람이 모두 나가면 게임은 무효로 끝난다. diff --git a/docs/08-data-model.md b/docs/08-data-model.md index 3593a7d..c6764ad 100644 --- a/docs/08-data-model.md +++ b/docs/08-data-model.md @@ -86,13 +86,13 @@ CREATE TABLE games ( started_at INTEGER NOT NULL, ended_at INTEGER, end_reason TEXT, -- 'normal' | 'resign' | 'abandoned' | 'void' - result_json TEXT + result_json TEXT -- 컴퓨터와 한 연습 게임은 "practice": true ); CREATE INDEX games_room ON games(room_id); CREATE TABLE game_players ( game_pk TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, - user_id TEXT NOT NULL, + user_id TEXT NOT NULL, -- 컴퓨터는 'bot:<번호>:'(users에 없음) seat INTEGER NOT NULL, rank INTEGER, -- 1 = 1등, 무승부는 같은 순위 score REAL, @@ -117,7 +117,7 @@ CREATE TABLE user_stats ( | 대기실 변경(자리, 옵션, 준비) | `room_state` update(+ `room_log`) | | 게임 시작 | `games` insert, `room_state`, `room_log` | | 게임 행동 / 시간 초과 | `room_log` insert + `room_state` update (한 트랜잭션) | -| 게임 끝 | `games` update(결과, 시드 공개), `game_players`, `user_stats` upsert (한 트랜잭션) | +| 게임 끝 | `games` update(결과, 시드 공개), `game_players`, `user_stats` upsert (한 트랜잭션). 무효·연습 게임은 `user_stats` 제외 | | 방 닫힘 | `rooms.status='closed'`, `room_state` 삭제 | ## 4. 정리 작업(매시간) diff --git a/docs/09-game-engine.md b/docs/09-game-engine.md index 076b125..3dc51bb 100644 --- a/docs/09-game-engine.md +++ b/docs/09-game-engine.md @@ -129,3 +129,8 @@ export interface GameUI { - **API**: `GET /api/me/games?before=&limit=`(내가 끝까지 한 게임, 최신순), `GET /api/replays/:gamePk`. 다시 만든 결과는 잠깐(10분, 32개) 캐시하고, 한 번에 하나씩 조금씩 끊어 만들어 방 처리를 막지 않는다. - **화면**: 게임의 `Board`/`Side`를 그대로 쓰되 읽기 전용(`act`는 아무것도 안 함, 판 안의 누르기·키 입력은 막음). 처음 / 이전 / 재생·멈춤 / 다음 / 끝 버튼, 슬라이더, "n번째 / 전체 n번", 장면 설명(누가 움직였는지, 자동 진행). 키보드 ←/→/Home/End. 들어가는 곳: 결과 화면 [처음부터 다시 보기](`RoomView.lastGamePk`), 내 정보 › 내 게임 기록 [다시 보기]. - **테스트**: 모든 게임을 실제 `Room`으로 끝까지(무작위 행동, 시간 초과, 나가기) 둔 뒤 다시 만든 상태·결과가 같은지, 모든 장면이 그 플레이어가 받은 view와 같은지(`replay.test.ts`), 권한(`replay-api.test.ts`), 브라우저(`e2e/replay.e2e.ts`). + +## 9. 컴퓨터 상대 (`packages/games/src/bots/`, 06 §12) +- 게임마다 `<게임>/bot.ts`의 전략 하나: `(입력: { me, view, legal, random }) => 행동`. `view`는 `view(state, me)`, `legal`은 `legalActions(state, me)`. 게임 상태는 넘기지 않는다(비공개 정보 차단). +- `decideBotAction`이 고른 수를 `validate`로 다시 검사하고, 틀리면 다른 합법 수로 바꾼다. 서버는 그래도 실패하면 게임의 `onTimeout` 행동을 쓴다. +- 테스트(`bots/bots.test.ts`): 게임마다 "사람 1명은 시간 초과만, 나머지는 컴퓨터"와 "컴퓨터끼리" 판을 끝까지 돌려, 모든 수가 합법이고 컴퓨터 때문에 멈추지 않으며 한 수가 충분히 빠른지 본다. diff --git a/e2e/bots.e2e.ts b/e2e/bots.e2e.ts new file mode 100644 index 0000000..66c0415 --- /dev/null +++ b/e2e/bots.e2e.ts @@ -0,0 +1,170 @@ +/** + * E2E (docs/06 §12): practice against computer players from the UI. + * 1) PC: 오목 상세 → [컴퓨터와 연습하기] → 대기실(컴퓨터 1) → 시작 → 컴퓨터가 여러 번 받아 둔다. + * 2) 휴대폰: 원카드 연습 → [컴퓨터 넣기]로 2명, 하나 더 넣었다 빼기 → 시작 → 사람·컴퓨터가 여러 턴. + * Run: E2E_PORT=4534 bun e2e/bots.e2e.ts (needs apps/web/dist built) + */ +import { chromium, type Page } from 'playwright-core'; +import { mkdirSync, mkdtempSync } from 'node:fs'; +import { join } from 'node:path'; +import { loadConfig } from '../apps/server/src/config'; +import { startServer } from '../apps/server/src/server'; + +const PORT = Number(process.env.E2E_PORT ?? 4534); +const ORIGIN = `http://localhost:${PORT}`; +const SHOTS = process.env.E2E_SHOTS ?? join(import.meta.dir, '../test-results'); +mkdirSync(SHOTS, { recursive: true }); + +const dbDir = mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'bg-e2e-')); +const app = startServer({ + config: loadConfig({ PORT: String(PORT), DB_PATH: join(dbDir, 'app.db'), PUBLIC_ORIGIN: ORIGIN }), + log: () => {}, + staticDir: join(import.meta.dir, '../apps/web/dist'), +}); + +const browser = await chromium.launch({ executablePath: process.env.CHROME ?? '/usr/bin/google-chrome', headless: true }); +let failed = false; +const step = async (name: string, fn: () => Promise) => { + const t0 = performance.now(); + try { + await fn(); + console.log(`✓ ${name} (${Math.round(performance.now() - t0)}ms)`); + } catch (e) { + failed = true; + console.log(`✗ ${name}: ${(e as Error).message.split('\n')[0]}`); + throw e; + } +}; + +const status = (p: Page) => p.locator('.turn-status').innerText().catch(() => ''); +const resultShown = async (p: Page) => (await p.getByRole('dialog', { name: '게임 결과' }).count()) > 0; + +async function startPractice(p: Page, gameId: string, nick: string) { + await p.goto(`${ORIGIN}/g/${gameId}`); + await p.getByRole('button', { name: '🤖 컴퓨터와 연습하기' }).click(); + await p.getByLabel('내 이름(닉네임)').fill(nick); + await p.getByRole('button', { name: '이 이름으로 시작하기' }).click(); + await p.waitForURL(/\/r\/\d{6}$/); + await p.getByLabel('자리').getByText('컴퓨터 1').waitFor(); + await p.getByText('연습 게임이에요. 기록에는 남지 않아요.').first().waitFor(); +} + +// ---------------------------------------------------------------- omok (PC) +const PAD = 0.9; // board viewBox padding (coordinate labels) +async function stones(p: Page): Promise> { + const pts = await p.locator('svg[role="grid"] circle[r="0.46"]').evaluateAll((els) => + els.filter((e) => e.closest('g')?.getAttribute('opacity') !== '0.55').map((e) => [Number(e.getAttribute('cx')), Number(e.getAttribute('cy'))]), + ); + return new Set(pts.map(([x, y]) => y! * 15 + x!)); +} +async function clickCell(p: Page, x: number, y: number) { + const box = (await p.locator('svg[role="grid"]').boundingBox())!; + const span = 14 + 2 * PAD; + const px = box.x + ((x + PAD) / span) * box.width; + const py = box.y + ((y + PAD) / span) * box.height; + await p.mouse.move(px, py); + await p.mouse.click(px, py); +} + +try { + const pc = await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'ko-KR' }); + const phone = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true, locale: 'ko-KR' }); + const a = await pc.newPage(); + const b = await phone.newPage(); + const errors: string[] = []; + for (const p of [a, b]) p.on('pageerror', (e) => errors.push(e.message)); + + await step('오목: 상세 → 컴퓨터와 연습하기 → 대기실', async () => { + await startPractice(a, 'omok', '호랑이'); + await a.getByText('🤖 컴퓨터').first().waitFor(); + // The host is seated first, the computer next; no public toggle in a practice room. + await a.getByText('컴퓨터가 있는 방은 친구만 들어와요.').waitFor(); + await a.screenshot({ path: join(SHOTS, 'bots-1-lobby-pc.png') }); + }); + + await step('오목: 시작 → 컴퓨터가 5번 받아 둔다', async () => { + await a.getByRole('button', { name: '▶ 게임 시작' }).click(); + await a.locator('svg[role="grid"]').waitFor(); + let answers = 0; + // Cells around the centre, nearest first. + const order = Array.from({ length: 225 }, (_, i) => i).sort((i, j) => Math.hypot((i % 15) - 7, Math.floor(i / 15) - 7) - Math.hypot((j % 15) - 7, Math.floor(j / 15) - 7)); + for (let turn = 0; turn < 8 && answers < 5; turn++) { + await a.waitForFunction(() => /내 차례예요|게임이 끝|이겼|졌/.test(document.querySelector('.turn-status')?.textContent ?? ''), null, { timeout: 8000 }); + if (await resultShown(a)) break; + const before = await stones(a); + const cell = order.find((i) => !before.has(i) && (i % 15) % 2 === turn % 2)!; + await clickCell(a, cell % 15, Math.floor(cell / 15)); + // Our stone, then the computer's answer (it thinks ~1 s). + await a.waitForFunction((n) => document.querySelectorAll('svg[role="grid"] g:not([opacity="0.55"]) > circle[r="0.46"]').length >= n + 2 || !!document.querySelector('[role="dialog"]'), before.size, { timeout: 8000 }); + if (await resultShown(a)) break; + answers++; + if (answers === 2) await a.screenshot({ path: join(SHOTS, 'bots-2-omok-pc.png') }); + } + if (answers < 3) throw new Error(`the computer answered only ${answers} times`); + console.log(` omok: computer answered ${answers} times`); + }); + + // ---------------------------------------------------------------- one-card (phone) + await step('원카드: 연습 방 → 컴퓨터 넣기·빼기 → 3명', async () => { + await startPractice(b, 'one-card', '고양이'); + const seats = b.getByLabel('자리'); + await seats.getByRole('button', { name: '🤖 컴퓨터 넣기' }).click(); + await seats.getByText('컴퓨터 2').waitFor(); + await seats.getByRole('button', { name: '🤖 컴퓨터 넣기' }).click(); + await seats.getByText('컴퓨터 3').waitFor(); + await seats.getByRole('button', { name: '컴퓨터 3 빼기' }).click(); + await seats.getByText('컴퓨터 3').waitFor({ state: 'detached' }); + await b.screenshot({ path: join(SHOTS, 'bots-3-lobby-phone.png'), fullPage: true }); + }); + + await step('원카드: 시작 → 내 차례 4번, 컴퓨터도 둔다', async () => { + await b.getByRole('button', { name: '▶ 게임 시작' }).click(); + await b.locator('.turn-status').waitFor(); + let mine = 0; + let botTurns = 0; + let last = ''; + for (let t = 0; t < 400 && mine < 4; t++) { + if (await resultShown(b)) break; + const one = b.locator('.oc-onebtn'); + if ((await one.count()) && (await one.isEnabled().catch(() => false))) await one.click({ timeout: 1000 }).catch(() => {}); + const s = await status(b); + if (s !== last && /컴퓨터 \d님 차례예요/.test(s)) botTurns++; + last = s; + if (!s.startsWith('내 차례예요')) { + const next = b.getByRole('button', { name: /다음 판/ }); + if (await next.count()) await next.first().click().catch(() => {}); + await b.waitForTimeout(150); + continue; + } + const accept = b.getByRole('button', { name: /^받아들이기/ }); + const card = b.locator('button.oc-card[data-legal="true"]').first(); + if (await accept.count()) await accept.click(); + else if (await card.count()) { + await card.click(); + await card.click(); + const pick = b.locator('.oc-pick button').first(); + if (await pick.waitFor({ timeout: 400 }).then(() => true, () => false)) await pick.click(); + } else { + const pass = b.getByRole('button', { name: '넘기기' }); + if (await pass.count()) await pass.click(); + else await b.getByRole('button', { name: /장 먹기$/ }).first().click(); + } + await b.waitForFunction((x) => document.querySelector('.turn-status')?.textContent?.trim() !== x, s.trim(), { timeout: 8000 }); + mine++; + if (mine === 2) await b.screenshot({ path: join(SHOTS, 'bots-4-one-card-phone.png') }); + } + if (mine < 4 && !(await resultShown(b))) throw new Error(`only ${mine} of my turns were played`); + if (botTurns < 2) throw new Error(`computers took only ${botTurns} turns`); + console.log(` one-card: my turns ${mine}, computer turns seen ${botTurns}`); + }); + + if (errors.length) throw new Error(`page errors: ${errors.join(' | ')}`); +} catch (e) { + failed = true; + console.error(e); +} finally { + await browser.close(); + await app.stop(); +} +console.log(failed ? 'E2E FAILED' : 'E2E PASSED'); +process.exit(failed ? 1 : 0); diff --git a/packages/games/src/baduk/bot.test.ts b/packages/games/src/baduk/bot.test.ts new file mode 100644 index 0000000..6f961ee --- /dev/null +++ b/packages/games/src/baduk/bot.test.ts @@ -0,0 +1,69 @@ +/** 바둑 computer player: captures, saves stones in atari, passes when only territory is left, guards the score marks. */ +import { describe, expect, test } from 'bun:test'; +import { SeededRng } from '@bg/engine'; +import { decideBotAction } from '../bots/index'; +import { baduk, type BadukAction, type BadukOptions, type BadukState } from './index'; +import { hashBoard } from './board'; + +const COLS = 'ABCDEFGHJ'; +const at = (label: string) => ({ x: COLS.indexOf(label[0]!), y: 9 - Number(label.slice(1)) }); +const rng = () => SeededRng.fromSeed([1, 2, 3, 4]); + +/** 9×9, A = black (the bot here), B = white. Stones are set directly. */ +function make(stones: { black?: string[]; white?: string[] } = {}, turn: 'black' | 'white' = 'black', opts: Partial = {}): BadukState { + const options = baduk.optionsSchema.parse({ colorAssignment: 'hostBlack', boardSize: 9, ...opts }); + const s = baduk.setup({ players: ['A', 'B'], options, rng: rng(), now: 0 }); + for (const [color, v] of [['black', 1], ['white', 2]] as const) for (const l of stones[color] ?? []) s.board[at(l).y * 9 + at(l).x] = v; + s.turn = turn; + s.hash = hashBoard(s.board); + s.positionHistory = [s.hash]; + s.situationCounts = { [s.hash + (turn === 'black' ? 'b' : 'w')]: 1 }; + return s; +} +const decide = (s: BadukState, who = 'A', seed = 1) => { + const r = SeededRng.fromSeed([seed, 3, 3, 3]); + return decideBotAction(baduk, s, who, { now: 1000, random: () => r.next() }) as BadukAction; +}; +const act = (s: BadukState, who: string, a: BadukAction) => { + const v = baduk.validate(s, who, a); + if (!v.ok) throw new Error(v.reason); + return baduk.apply(s, who, a, { rng: rng(), now: 1000 }).state; +}; + +/** Black owns columns A–D, white F–J; the E/F boundary is closed. */ +const SPLIT = { + black: ['D1', 'D2', 'D3', 'D4', 'D5', 'D6', 'D7', 'D8', 'D9', 'E1', 'E2', 'E3', 'E4', 'E5', 'E6', 'E7', 'E8', 'E9'], + white: ['F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9'], +}; + +describe('baduk bot', () => { + test('captures a stone in atari', () => { + const s = make({ black: ['D5', 'F5', 'E6', 'C3'], white: ['E5', 'G7'] }); + expect(decide(s)).toEqual({ type: 'place', ...at('E4') }); + }); + + test('saves its own stone in atari instead of playing elsewhere', () => { + const s = make({ black: ['E5', 'C7'], white: ['D5', 'F5', 'E6'] }); + expect(decide(s)).toEqual({ type: 'place', ...at('E4') }); + }); + + test('passes when only settled territory is left, so the game can end', () => { + for (let seed = 1; seed <= 3; seed++) expect(decide(make(SPLIT), 'A', seed)).toEqual({ type: 'pass' }); + }); + + test('dead-stone phase: restores a group marked dead against it, then agrees', () => { + let s = make(SPLIT); + s = act(s, 'A', { type: 'pass' }); + s = act(s, 'B', { type: 'pass' }); + expect(s.phase).toBe('scoring'); + expect(decide(s)).toEqual({ type: 'agreeScore' }); + // The opponent marks the bot's wall dead. + s = act(s, 'B', { type: 'toggleDead', ...at('D5') }); + expect(s.scoringMarks!.dead.length).toBeGreaterThan(0); + const fix = decide(s); + expect(fix.type).toBe('toggleDead'); + s = act(s, 'A', fix); + expect(s.scoringMarks!.dead).toEqual([]); + expect(decide(s)).toEqual({ type: 'agreeScore' }); + }); +}); diff --git a/packages/games/src/baduk/bot.ts b/packages/games/src/baduk/bot.ts new file mode 100644 index 0000000..64df2f2 --- /dev/null +++ b/packages/games/src/baduk/bot.ts @@ -0,0 +1,137 @@ +/** + * 바둑 computer player: capture > save own stones in atari > put the opponent in atari > play near stones in + * contested areas, never self-atari, and pass once only settled territory is left. In the dead-stone phase it + * agrees, after restoring the automatic suggestion if the marks were changed against it. + */ +import type { BotStrategy } from '../bots/types'; +import { answerRequests, best, pick, playable, typeOf } from '../bots/policy'; +import type { BadukView } from './index'; +import { emptyRegionAt, mapChains, markGroup, neighbours, regionsOf, suggestMarks, type Cell, type Stone } from './board'; + +type Place = { type: 'place'; x: number; y: number }; +type Act = Place | { type: 'pass' | 'agreeScore' } | { type: 'toggleDead' | 'toggleSeki'; x: number; y: number }; + +/** Dead-stone phase: undo a change that disagrees with the automatic suggestion (dead groups, seki), else agree. */ +function scoringAction(view: BadukView): Act { + const { size, board } = view; + const sug = suggestMarks(board, size); + const want = new Set(sug.dead); + const have = new Set(view.scoring!.dead.map((p) => p.y * size + p.x)); + const at = (i: number) => ({ x: i % size, y: Math.floor(i / size) }); + // Toggle a group only if that strictly reduces the disagreement (so a bot never toggles back and forth). + const differs = (i: number) => want.has(i) !== have.has(i); + for (let i = 0; i < board.length; i++) { + if (!board[i] || !differs(i)) continue; + const group = markGroup(board, size, i, have); + if (group.filter(differs).length * 2 > group.length) return { type: 'toggleDead', ...at(i) }; + } + // Seki regions: one toggle per region that is marked but not suggested, or suggested but not marked. + const marked = view.scoring!.seki.map((p) => p.y * size + p.x); + for (const p of marked) { + if (!emptyRegionAt(board, size, p, have).some((q) => sug.seki.includes(q))) return { type: 'toggleSeki', ...at(p) }; + } + const markedSet = new Set(marked); + for (const q of sug.seki) if (!markedSet.has(q)) return { type: 'toggleSeki', ...at(q) }; + return { type: 'agreeScore' }; +} + +export const badukBot: BotStrategy = ({ view, legal, random }) => { + const req = answerRequests(legal); + if (req) return req; + if (view.phase === 'scoring') return legal.some((a) => a.type === 'agreeScore') ? scoringAction(view) : pick(playable(legal), random); + const places = legal.filter((a) => typeOf(a) === 'place') as Place[]; + const pass = legal.find((a) => a.type === 'pass'); + if (!view.myColor || !pass) return pick(playable(legal), random); + const { size, board } = view; + const me: Stone = view.myColor === 'black' ? 1 : 2; + const opp: Stone = me === 1 ? 2 : 1; + const nb = neighbours(size); + const { id, chains } = mapChains(board, size); + // Distance from each colour's stones through empty points (Infinity where it can't reach). + const dist = (c: Stone): number[] => { + const d = new Array(board.length).fill(Infinity); + const queue: number[] = []; + for (let i = 0; i < board.length; i++) { + if (board[i] !== c) continue; + d[i] = 0; + queue.push(i); + } + for (let h = 0; h < queue.length; h++) { + const p = queue[h]!; + for (let k = 1; k <= nb[p * 5]!; k++) { + const q = nb[p * 5 + k]!; + if (board[q] !== 0 || d[q] !== Infinity) continue; + d[q] = d[p]! + 1; + queue.push(q); + } + } + return d; + }; + const dMe = dist(me); + const dOpp = dist(opp); + // Settled points (ours or theirs): clearly closer to one colour, or in a region only one colour reaches + // (unless that region is half the board or more, e.g. right after the first stone). + const regions = regionsOf(board, size, (i) => board[i] === 0); + const settledMax = Math.floor((size * size) / 2); + const settled = (i: number) => { + const a = dMe[i]!; + const b = dOpp[i]!; + if ((a === Infinity) !== (b === Infinity)) return regions.list[regions.id[i]!]!.points.length <= settledMax; + return Math.min(a, b) <= 3 && Math.abs(a - b) >= 2; + }; + const line = (v: number) => Math.min(v, size - 1 - v); // 0 = first line + + const score = ({ x, y }: Place): number => { + const i = y * size + x; + let s = 0; + let captured = 0; + const libs = new Set(); + const seen = new Set(); + let ownSize = 1; + let rescue = false; + for (let k = 1; k <= nb[i * 5]!; k++) { + const q = nb[i * 5 + k]!; + const v = board[q] as Cell; + if (v === 0) { + libs.add(q); + continue; + } + const c = id[q]!; + if (seen.has(c)) continue; + seen.add(c); + const ch = chains[c]!; + if (v === opp) { + if (ch.libs.length === 1) captured += ch.stones.length; + else if (ch.libs.length === 2) s += 2 * ch.stones.length; // atari + } else { + ownSize += ch.stones.length; + for (const l of ch.libs) libs.add(l); + if (ch.libs.length === 1) { + rescue = true; + s += 8 * ch.stones.length; + } + } + } + libs.delete(i); + const after = libs.size + (captured ? 1 : 0); + if (captured) s += 10 * captured; + else if (after <= 1) return -10 * ownSize; // self-atari + else if (after === 2 && ownSize > 1) s -= 2; // crawling with few liberties + if (!captured && !rescue && settled(i)) return -3; // nothing to gain inside settled territory + // Shape: stay off the first line, like the 3rd/4th lines, keep in touch with the stones. + // (A first-line point touching stones is a boundary to close, not a bad opening move.) + const near = Math.min(dMe[i]!, dOpp[i]!); + const l = Math.min(line(x), line(y)); + s += l === 0 ? (near === 1 ? 0 : -1.5) : l === 1 ? 0 : l <= 3 ? 1 : 0.5; + if (near <= 2) s += 0.5; + else if (near > 3 && view.moveCount > 6) s -= 0.5; + return s + 1 + random() * 0.5; + }; + + // Very long game (endless capture fights): wind down so it always ends. + if (view.moveCount > 2 * board.length) return pass; + const scores = new Map(places.map((p) => [p, score(p)])); + const top = places.length ? best(places, (p) => scores.get(p)!, random) : null; + // Nothing useful left (only settled areas, dame on the edge or self-atari): pass so the game can end. + return top && scores.get(top)! > 1 ? top : pass; +}; diff --git a/packages/games/src/bang/bot.test.ts b/packages/games/src/bang/bot.test.ts new file mode 100644 index 0000000..62c3f28 --- /dev/null +++ b/packages/games/src/bang/bot.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from 'bun:test'; +import { SeededRng, seedN } from '@bg/engine'; +import { decideBotAction } from '../bots/index'; +import { botPlayout } from '../bots/playout'; +import { CARDS, CHARACTER_LIFE, bang, faceOf, type BangOptions, type BangState, type CardKind, type CharacterId, type Role } from './index'; + +interface PSpec { + id: string; + role: Role; + ch: CharacterId; + life?: number; + hand?: string[]; + inPlay?: string[]; +} + +/** Card picker for one rigged game: `c('bang')` = any unused 뱅!. */ +function pool() { + const used = new Set(); + return (kind: CardKind): string => { + const card = CARDS.find((x) => x.kind === kind && !used.has(x.id))!; + used.add(card.id); + return card.id; + }; +} + +/** Players in seat order; the first one is in its play phase. */ +function rig(specs: PSpec[], options: Partial = {}): BangState { + const players = specs.map((p, i) => { + const maxLife = CHARACTER_LIFE[p.ch] + (p.role === 'sheriff' ? 1 : 0); + return { id: p.id, seat: i, role: p.role, character: p.ch, characterOffer: null, maxLife, life: p.life ?? maxLife, alive: true, hand: p.hand ?? [], inPlay: p.inPlay ?? [], timeoutsInRow: 0, away: false }; + }); + const placed = new Set(players.flatMap((p) => [...p.hand, ...p.inPlay])); + return { + options: bang.optionsSchema.parse(options), + players, + deck: CARDS.map((c) => c.id).filter((id) => !placed.has(id)), + discard: [], + turn: { player: specs[0]!.id, phase: 'play', bangsPlayed: 0, deadline: 60_000, pausedAt: null, dynamiteChecked: true, jailChecked: true }, + reactions: [], + lastCheck: null, + winner: null, + deathOrder: [], + round: 1, + seq: 0, + log: [], + final: null, + }; +} +const decide = (s: BangState, p: string, seed = 1) => { + const r = SeededRng.fromSeed(seedN(seed)); + return decideBotAction(bang, s, p, { now: 1000, random: () => r.next() }) as Record; +}; + +describe('bang bot', () => { + test('an outlaw shoots the sheriff; a deputy never does and shoots the sheriff’s attacker', () => { + const c = pool(); + const bangA = c('bang'); + const s = rig([ + { id: 'O', role: 'outlaw', ch: 'bart', hand: [bangA] }, + { id: 'S', role: 'sheriff', ch: 'bart' }, + { id: 'D', role: 'deputy', ch: 'bart', hand: [c('bang')] }, + { id: 'R', role: 'renegade', ch: 'bart' }, + ]); + // O sits next to S and R (4 seats: distance 1 to both). + for (let seed = 1; seed <= 4; seed++) expect(decide(s, 'O', seed)).toEqual({ type: 'play', cardId: bangA, target: 'S' }); + // D's turn: the public log shows O shooting the sheriff, so O is the target (never the sheriff). + s.turn.player = 'D'; + s.log.push({ seq: 1, t: 'play', p: 'O', card: faceOf(bangA), target: 'S' }); + s.players.find((p) => p.id === 'D')!.inPlay = [c('winchester')]; + for (let seed = 1; seed <= 4; seed++) expect(decide(s, 'D', seed)).toMatchObject({ type: 'play', target: 'O' }); + }); + + test('answers a 뱅! with 빗나감!, takes the hit without one, drinks beer when dying', () => { + const c = pool(); + const missed = c('missed'); + const s = rig([ + { id: 'A', role: 'sheriff', ch: 'bart' }, + { id: 'B', role: 'outlaw', ch: 'bart', hand: [c('bang'), missed] }, + { id: 'C', role: 'outlaw', ch: 'bart', hand: [c('bang')] }, + { id: 'D', role: 'renegade', ch: 'bart', life: 0, hand: [c('beer'), c('bang')] }, + ]); + const shot = (target: string) => ({ kind: 'bang' as const, target, source: 'A', via: 'bang' as const, needed: 1, barrelTried: false, jourTried: false, deadline: 9_000 }); + s.reactions = [shot('B')]; + expect(decide(s, 'B')).toEqual({ type: 'respond', cardIds: [missed] }); + s.reactions = [shot('C')]; + expect(decide(s, 'C')).toEqual({ type: 'takeHit' }); + s.reactions = [{ kind: 'dying', player: 'D', killer: 'A', lost: 1, source: 'A', deadline: 9_000 }]; + expect(decide(s, 'D')).toMatchObject({ type: 'respond' }); + }); + + test('on its turn: drinks beer when hurt, equips a longer gun, then ends the turn', () => { + const c = pool(); + const beer = c('beer'); + const gun = c('remington'); + let s = rig([ + { id: 'A', role: 'deputy', ch: 'bart', life: 2, hand: [beer, gun, c('missed')] }, + { id: 'S', role: 'sheriff', ch: 'bart' }, + { id: 'C', role: 'outlaw', ch: 'bart' }, + { id: 'D', role: 'outlaw', ch: 'bart' }, + ]); + expect(decide(s, 'A')).toEqual({ type: 'play', cardId: beer }); + s = bang.apply(s, 'A', { type: 'play', cardId: beer }, { rng: SeededRng.fromSeed(seedN(1)), now: 1000 }).state; + expect(decide(s, 'A')).toEqual({ type: 'play', cardId: gun }); + s = bang.apply(s, 'A', { type: 'play', cardId: gun }, { rng: SeededRng.fromSeed(seedN(1)), now: 1000 }).state; + expect(decide(s, 'A')).toEqual({ type: 'endPlay' }); + }); + + test('option modes: draft, manual barrels, no auto-defence, house rules, 7 players finish without bot timeouts', () => { + const modes: Partial[] = [ + { characterDraft: 'choose2', autoBarrel: false }, + { autoDefend: false, beerAtFullHealth: true, selfTargetCatBalou: true, drawBeforeDeath: true }, + { deputyCount7: true }, + ]; + for (const [i, m] of modes.entries()) { + for (const n of [5, 7]) { + const r = botPlayout(bang as never, { + players: Array.from({ length: n }, (_, k) => (k === 0 && i === 0 ? 'human' : `bot:${k}`)), + options: bang.optionsSchema.parse(m), + seed: seedN(70 + i * 3 + n), + isBot: (p) => p.startsWith('bot:'), + }); + expect(r.finished).toBe(true); + expect(r.botTimeouts).toBe(0); + } + } + }); +}); diff --git a/packages/games/src/bang/bot.ts b/packages/games/src/bang/bot.ts new file mode 100644 index 0000000..e4d0956 --- /dev/null +++ b/packages/games/src/bang/bot.ts @@ -0,0 +1,167 @@ +/** + * 뱅! computer player. Knows only its own role and hand plus public information: the sheriff, dead + * players' roles, equipment, life and the recent public log (who attacked whom). From that it rates how + * hostile each player is to its side and only attacks players rated hostile: + * - sheriff / deputy: players who attacked the sheriff (or defenders of attackers less); + * - outlaw: the sheriff first, then whoever attacked the sheriff's attackers; + * - renegade: the outlaws' side first, the sheriff only when the two of them are left. + * Always answers reaction windows (missed / bang / beer), drinks beer when hurt, equips better gear. + */ +import type { PlayerId } from '@bg/engine'; +import type { BotStrategy } from '../bots/types'; +import { best, randomPolicy } from '../bots/policy'; +import { WEAPON_RANGE, kindOf, type BangAction, type BangView, type CardKind, type CharacterId } from './index'; + +/** Rough worth of a card kept in hand. */ +const KEEP: Record = { + missed: 7, beer: 7, bang: 6, wellsFargo: 8, stagecoach: 7, panic: 6, catBalou: 5, gatling: 5, barrel: 6, mustang: 5, + scope: 4, indians: 4, duel: 4, saloon: 4, jail: 3, generalStore: 3, remington: 3, carabine: 3, winchester: 3, + schofield: 2, volcanic: 2, dynamite: 1, +}; +const CHAR_PREF: CharacterId[] = ['willy', 'slab', 'jourdonnais', 'paul', 'rose', 'sid', 'lucky', 'calamity', 'bart', 'blackJack', 'kit', 'jesse', 'suzy', 'elGringo', 'pedro', 'vulture']; +const ATTACKS = new Set(['bang', 'duel', 'panic', 'catBalou', 'jail']); +const GEAR: CardKind[] = ['barrel', 'mustang', 'scope', 'volcanic', 'schofield', 'remington', 'carabine', 'winchester']; + +type Play = Extract; + +export const bangBot: BotStrategy = (input) => { + const { me, view: v, legal, random } = input; + const my = v.my; + const self = v.players.find((p) => p.id === me); + if (!my || !self) return randomPolicy(input); + const role = my.role; + const alive = v.players.filter((p) => p.alive); + const sheriff = v.players.find((p) => p.role === 'sheriff')!; + const first = (t: string) => legal.find((a) => a.type === t); + const keep = (id: string) => KEEP[kindOf(id)] + (kindOf(id) === 'beer' && self.life <= 2 ? 3 : 0); + const worst = (n: number) => [...my.hand].sort((a, b) => keep(a.id) - keep(b.id)).slice(0, n).map((c) => c.id); + + // Public suspicion: attacks on the sheriff (+1) and on those attackers (−0.5) in the visible log. + const attacked: [PlayerId, PlayerId][] = []; + for (const e of v.log) { + if (e.t !== 'play' && e.t !== 'equip') continue; + const kind = e.t === 'play' ? (e.as ?? e.card.kind) : e.card.kind; + if (e.target && ATTACKS.has(kind)) attacked.push([e.p, e.target]); + } + const outlawish = new Map(); + for (const [a, t] of attacked) if (t === sheriff.id) outlawish.set(a, (outlawish.get(a) ?? 0) + 1); + for (const [a, t] of attacked) if ((outlawish.get(t) ?? 0) > 0) outlawish.set(a, (outlawish.get(a) ?? 0) - 0.5); + /** > 0: worth attacking. */ + const hostility = (id: PlayerId): number => { + if (id === me) return -9; + const p = v.players.find((x) => x.id === id)!; + const o = outlawish.get(id) ?? 0; + if (role === 'sheriff') return 0.3 + o; + if (role === 'deputy') return p.role === 'sheriff' ? -9 : 0.2 + o; + if (role === 'outlaw') return p.role === 'sheriff' ? 2 : -0.2 - o; + // Renegade: hit the outlaws' side, keep the sheriff for a final showdown. + if (p.role === 'sheriff') return alive.length <= 2 ? 2 : -1; + return 0.3 + o * 0.5; + }; + + const prompt = my.prompt; + switch (prompt?.kind) { + case 'choose2': + return { type: 'pickCharacter', character: best(prompt.options, (c) => -CHAR_PREF.indexOf(c), random) }; + case 'kitKeep': { + const opts = legal.filter((a) => a.type === 'kitKeep') as Extract[]; + return best(opts, (a) => keep(a.keep[0]) + keep(a.keep[1]), random); + } + case 'store': + return { type: 'storePick', cardId: best(prompt.revealed, (c) => keep(c.id), random).id }; + case 'bang': { + if (prompt.canBarrel && first('useBarrel')) return { type: 'useBarrel' }; + if (prompt.options.length >= prompt.needed) { + // Calamity Janet: spend 빗나감! before 뱅!. + const cards = [...prompt.options].sort((a, b) => Number(kindOf(a) !== 'missed') - Number(kindOf(b) !== 'missed')); + return { type: 'respond', cardIds: cards.slice(0, prompt.needed) }; + } + return { type: 'takeHit' }; + } + case 'indians': + case 'duel': + return prompt.options.length ? { type: 'respond', cardIds: [prompt.options[0]!] } : { type: 'takeHit' }; + case 'dying': { + if (prompt.beerAllowed && prompt.beers.length >= prompt.needed) return { type: 'respond', cardIds: prompt.beers.slice(0, prompt.needed) }; + if (first('sidHeal')) return { type: 'sidHeal', cardIds: worst(2) as [string, string] }; + return { type: 'giveUp' }; + } + case 'draw': { + // Jesse Jones robs the most hostile player with cards; Pedro takes a useful discard. + const jesse = legal.filter((a) => a.type === 'draw' && a.jesseFrom) as Extract[]; + if (jesse.length) { + const j = best(jesse, (a) => hostility(a.jesseFrom!), random); + if (hostility(j.jesseFrom!) > 0) return j; + } + if (prompt.pedro && ['missed', 'beer', 'bang'].includes(prompt.pedro.kind) && first('draw')) { + const pd = legal.find((a) => a.type === 'draw' && a.pedroDiscard); + if (pd) return pd; + } + return { type: 'draw' }; + } + case 'discard': { + if (prompt.count >= 2 && first('sidHeal') && self.life < self.maxLife) return { type: 'sidHeal', cardIds: worst(2) as [string, string] }; + return { type: 'discard', cardIds: worst(prompt.count) }; + } + } + + if (!my.plays.length) return randomPolicy(input); + // Play phase: score every playable card; end the turn when nothing is worth it. + const hurt = self.life < self.maxLife; + const bangsInHand = my.hand.filter((c) => c.kind === 'bang').length; + const myWeapon = (self.inPlay.find((c) => WEAPON_RANGE[c.kind] !== undefined)?.kind ?? null) as CardKind | null; + const others = alive.filter((p) => p.id !== me); + const spread = others.reduce((a, p) => a + Math.sign(hostility(p.id)), 0); + const score = (a: BangAction): number => { + if (a.type === 'endPlay') return 0; + if (a.type === 'sidHeal') return self.life <= 2 && my.hand.length >= 4 ? 30 : -1; + if (a.type !== 'play') return -1; + const kind = (a as Play).as ?? kindOf(a.cardId); + const h = a.target ? hostility(a.target) : 0; + const tgt = a.target ? v.players.find((p) => p.id === a.target) : null; + switch (kind) { + case 'stagecoach': + case 'wellsFargo': + return 100; + case 'beer': + return hurt && alive.length > 2 ? 80 : -1; + case 'saloon': + return hurt ? 60 : -1; + case 'barrel': + case 'mustang': + case 'scope': + return 70; + case 'volcanic': + return bangsInHand >= 2 && myWeapon !== 'volcanic' ? 66 : -1; + case 'schofield': + case 'remington': + case 'carabine': + case 'winchester': + return WEAPON_RANGE[kind]! > my.range || (myWeapon === 'volcanic' && bangsInHand < 2) ? 65 + WEAPON_RANGE[kind]! : -1; + case 'dynamite': + return self.life >= 4 ? 5 : -1; + case 'jail': + return h > 0 ? 50 + h * 10 : -1; + case 'panic': + case 'catBalou': { + if (h <= 0) return -1; + const tc = (a as Play).targetCard!; + const gear = tc === 'hand' ? 0 : GEAR.includes(kindOf(tc)) ? 8 : -20; + return (kind === 'panic' ? 45 : 40) + h * 10 + gear; + } + case 'bang': + return h > 0 ? 40 + h * 10 + (tgt && tgt.life <= 1 ? 10 : 0) - ((a as Play).as ? 5 : 0) : -1; + case 'duel': + return h > 0 && bangsInHand >= 1 ? 30 + h * 10 : -1; + case 'gatling': + case 'indians': + return spread > 0 ? 35 : -1; + case 'generalStore': + return 20; + default: + return -1; + } + }; + const choice = best(legal, score, random); + return score(choice) >= 0 ? choice : (first('endPlay') ?? randomPolicy(input)); +}; diff --git a/packages/games/src/bots/bots.test.ts b/packages/games/src/bots/bots.test.ts new file mode 100644 index 0000000..d0fdfe4 --- /dev/null +++ b/packages/games/src/bots/bots.test.ts @@ -0,0 +1,50 @@ +/** Every bot-enabled game: bots play only accepted actions, react in every window and never stall a game. */ +import { describe, expect, test } from 'bun:test'; +import { seedN, type AnyGameDefinition } from '@bg/engine'; +import { GAMES } from '../index'; +import { BOT_STRATEGIES, decideBotAction } from './index'; +import { botPlayout } from './playout'; + +const isBot = (p: string) => p.startsWith('bot:'); +const seats = (n: number, humans: number) => Array.from({ length: n }, (_, i) => (i < humans ? `human${i}` : `bot:${i}:x`)); + +function counts(def: AnyGameDefinition): number[] { + const range = def.playersFor?.(def.defaultOptions) ?? { min: def.minPlayers, max: def.maxPlayers }; + const out = new Set([Math.max(2, range.min), Math.min(range.max, 4)]); + return [...out].filter((n) => !def.playerCountError?.(def.defaultOptions, n)); +} + +describe.each(Object.keys(BOT_STRATEGIES))('%s', (id) => { + const def = GAMES[id]!; + test('has a game definition with legalActions', () => { + expect(def).toBeDefined(); + expect(typeof def.legalActions).toBe('function'); + }); + for (const n of counts(def)) { + test(`${n} players: one human who only times out, the rest bots — the game finishes`, () => { + for (let s = 1; s <= 3; s++) { + const r = botPlayout(def, { players: seats(n, 1), seed: seedN(s), isBot, maxSteps: 20_000 }); + expect(r.finished).toBe(true); + expect(r.botTimeouts).toBe(0); + expect(r.maxBotMs).toBeLessThan(250); + } + }); + } + test('bots only: a whole game finishes without timeouts', () => { + const n = counts(def).at(-1)!; + for (let s = 1; s <= 2; s++) { + const r = botPlayout(def, { players: seats(n, 0), seed: seedN(100 + s), isBot, maxSteps: 30_000 }); + expect(r.finished).toBe(true); + expect(r.botTimeouts).toBe(0); + } + }); +}); + +test('decideBotAction returns null when the bot has nothing to do', () => { + const def = GAMES.omok!; + const s = def.setup({ players: ['a', 'bot:1:x'], options: def.defaultOptions, rng: { next: () => 0.5, int: () => 0 } as never, now: 0 }); + const waiting = def.activePlayers(s)[0]!; + const other = waiting === 'a' ? 'bot:1:x' : 'a'; + expect(decideBotAction(def, s, other, { now: 0 })).toBeNull(); + expect(decideBotAction(def, s, waiting, { now: 0 })).not.toBeNull(); +}); diff --git a/packages/games/src/bots/index.ts b/packages/games/src/bots/index.ts new file mode 100644 index 0000000..b86b314 --- /dev/null +++ b/packages/games/src/bots/index.ts @@ -0,0 +1,94 @@ +/** + * Computer players (docs/09-game-engine.md §9). `decideBotAction` is the only place that touches the game state: + * it hands the strategy the bot's own view and its legal actions — nothing else — so a bot can't peek at hidden + * information (other hands, deck order). + */ +import type { AnyGameDefinition, PlayerId } from '@bg/engine'; +import type { BotStrategy } from './types'; +import { pick, playable, randomPolicy } from './policy'; +import { omokBot } from '../omok/bot'; +import { othelloBot } from '../othello/bot'; +import { chessBot } from '../chess/bot'; +import { janggiBot } from '../janggi/bot'; +import { badukBot } from '../baduk/bot'; +import { seotdaBot } from '../seotda/bot'; +import { pokerBot } from '../poker/bot'; +import { yachtBot } from '../yacht/bot'; +import { yutBot } from '../yut/bot'; +import { oneCardBot } from '../one-card/bot'; +import { rummikubBot } from '../rummikub/bot'; +import { gostopBot } from '../gostop/bot'; +import { bangBot } from '../bang/bot'; +import { davinciBot } from '../davinci-code/bot'; +import { mahjongBot } from '../mahjong/bot'; +import { marbleBot } from '../marble/bot'; +import { dalmutiBot } from '../dalmuti/bot'; +import { splendorBot } from '../splendor/bot'; +import { quoridorBot } from '../quoridor/bot'; +import { lasVegasBot } from '../las-vegas/bot'; +import { cockroachPokerBot } from '../cockroach-poker/bot'; +import { ninetyNineBot } from '../ninety-nine/bot'; + +export type { BotInput, BotStrategy } from './types'; + +/** Strategy per bot-enabled game (must match `BOT_GAMES` in @bg/shared). */ +export const BOT_STRATEGIES: Record = { + omok: omokBot, + othello: othelloBot, + chess: chessBot, + janggi: janggiBot, + baduk: badukBot, + seotda: seotdaBot, + poker: pokerBot, + yacht: yachtBot, + yut: yutBot, + 'one-card': oneCardBot, + rummikub: rummikubBot, + gostop: gostopBot, + bang: bangBot, + 'davinci-code': davinciBot, + mahjong: mahjongBot, + marble: marbleBot, + dalmuti: dalmutiBot, + splendor: splendorBot, + quoridor: quoridorBot, + 'las-vegas': lasVegasBot, + 'cockroach-poker': cockroachPokerBot, + 'ninety-nine': ninetyNineBot, +}; + +/** The bot's legal actions as the game lists them (validated lazily: only the chosen one, see below). */ +export function botCandidates(def: AnyGameDefinition, state: unknown, me: PlayerId): unknown[] { + return def.legalActions ? def.legalActions(state, me) : []; +} + +/** + * Picks the bot's next action, or null when it has nothing to do. The strategy's choice is checked with `validate` + * (with the server clock); a rejected choice is dropped and the strategy asked again, so every returned action is + * one the server accepts. Validating only the chosen action keeps a move fast (chess validates ~0.3ms per move). + */ +export function decideBotAction( + def: AnyGameDefinition, + state: unknown, + me: PlayerId, + ctx: { now: number; random?: () => number }, +): unknown | null { + let legal = botCandidates(def, state, me); + if (!legal.length) return null; + const random = ctx.random ?? Math.random; + const valid = (a: unknown) => def.validate(state, me, a, { now: ctx.now }).ok; + const view = def.view(state, me); + const strategy = BOT_STRATEGIES[def.id] ?? randomPolicy; + for (let attempt = 0; attempt < 3 && legal.length; attempt++) { + let action: unknown; + try { + action = strategy({ me, view: structuredClone(view), legal, random }); + } catch { + break; + } + if (action !== undefined && valid(action)) return action; + legal = legal.filter((a) => a !== action); + } + const rest = legal.filter(valid); + return rest.length ? pick(playable(rest), random) : null; +} diff --git a/packages/games/src/bots/playout.ts b/packages/games/src/bots/playout.ts new file mode 100644 index 0000000..d2d7fc8 --- /dev/null +++ b/packages/games/src/bots/playout.ts @@ -0,0 +1,72 @@ +/** Test helper: bots play a whole game the way the room would run it, next to "humans" who only time out. */ +import { SeededRng, type AnyGameDefinition, type PlayerId, type RngState } from '@bg/engine'; +import { botCandidates, decideBotAction } from './index'; + +export interface BotPlayoutReport { + steps: number; + finished: boolean; + botMoves: number; + maxBotMs: number; + totalBotMs: number; + /** Timeouts of human seats (expected: they never act otherwise). */ + humanTimeouts: number; + /** Timeouts of bots the game was waiting on (a bot stuck until its turn clock ran out: should stay 0). */ + botTimeouts: number; + /** Timer-driven steps nobody had to act in (e.g. a minimum claim window or a result pause). */ + pauses: number; +} + +const waitingOf = (def: AnyGameDefinition, s: unknown): PlayerId[] => [...new Set([...def.activePlayers(s), ...(def.timeoutPlayers?.(s) ?? [])])]; + +/** + * Bots act as soon as the game waits on them (like `BotDriver`, before any turn clock); when only humans are left, + * the clock runs to the deadline and the game's own timeout action is played for them. + */ +export function botPlayout( + def: AnyGameDefinition, + opts: { players: PlayerId[]; options?: unknown; seed: RngState; maxSteps?: number; isBot: (p: PlayerId) => boolean }, +): BotPlayoutReport { + const rng = SeededRng.fromSeed(opts.seed); + const chooser = SeededRng.fromSeed([opts.seed[1], opts.seed[0], opts.seed[3], opts.seed[2] ^ 0x5bd1e995]); + const random = () => chooser.next(); + let now = 1_700_000_000_000; + let state = def.setup({ players: opts.players, options: opts.options ?? def.defaultOptions, rng, now, gameNo: 1 }); + const rep: BotPlayoutReport = { steps: 0, finished: false, botMoves: 0, maxBotMs: 0, totalBotMs: 0, humanTimeouts: 0, botTimeouts: 0, pauses: 0 }; + const maxSteps = opts.maxSteps ?? 20_000; + const apply = (actor: PlayerId, action: unknown, what: string) => { + const v = def.validate(state, actor, action, { now }); + if (!v.ok) throw new Error(`${def.id}: ${what} action rejected: ${v.reason} ${JSON.stringify(action)}`); + state = def.apply(state, actor, action, { rng, now }).state; + }; + for (; rep.steps < maxSteps; rep.steps++) { + if (def.result(state)) { + rep.finished = true; + return rep; + } + now += 500; + const waiting = waitingOf(def, state); + const bot = waiting.find((p) => opts.isBot(p) && botCandidates(def, state, p).length > 0); + if (bot) { + const t0 = performance.now(); + const action = decideBotAction(def, state, bot, { now, random }); + const ms = performance.now() - t0; + if (action === null) throw new Error(`${def.id}: bot ${bot} found no valid action`); + rep.botMoves++; + rep.totalBotMs += ms; + rep.maxBotMs = Math.max(rep.maxBotMs, ms); + apply(bot, action, 'bot'); + continue; + } + // Nobody else moves: let the clock run to the deadline like the room timer would. + const deadline = def.deadline?.(state) ?? null; + const timed = def.timeoutPlayers ? def.timeoutPlayers(state) : def.activePlayers(state); + const who = timed.find((p) => !opts.isBot(p)) ?? waiting.find((p) => !opts.isBot(p)) ?? timed[0]; + if (!who) throw new Error(`${def.id}: stalled (nobody can act, no timeout)`); + if (deadline !== null) now = Math.max(now, deadline); + if (!opts.isBot(who)) rep.humanTimeouts++; + else if (def.activePlayers(state).includes(who)) rep.botTimeouts++; + else rep.pauses++; + apply(who, def.onTimeout(state, who), 'timeout'); + } + return rep; +} diff --git a/packages/games/src/bots/policy.ts b/packages/games/src/bots/policy.ts new file mode 100644 index 0000000..f61c152 --- /dev/null +++ b/packages/games/src/bots/policy.ts @@ -0,0 +1,58 @@ +/** Default "random but sensible" bot policy and small helpers shared by per-game strategies. */ +import type { BotInput } from './types'; + +type Act = { type?: string; [k: string]: unknown }; + +/** Actions a bot never starts on its own: requests to the human, resigning, chatting, editing tricks. */ +const META = new Set(['requestUndo', 'offerDraw', 'claimDraw', 'resign', 'concede', 'leave', 'undoDraft', 'report', 'chat', 'reaction', 'emote', 'adjustTime']); + +export const pick = (xs: readonly T[], random: () => number): T => xs[Math.floor(random() * xs.length)]!; + +export const typeOf = (a: unknown): string => String((a as Act | null)?.type ?? ''); + +/** Legal actions minus meta ones (kept if nothing else is left). */ +export function playable(legal: A[]): A[] { + const out = legal.filter((a) => !META.has(typeOf(a))); + return out.length ? out : legal; +} + +/** Highest-scoring action; ties broken at random. */ +export function best(xs: readonly A[], score: (a: A) => number, random: () => number): A { + let top: A[] = []; + let topScore = -Infinity; + for (const a of xs) { + const s = score(a); + if (s > topScore) { + topScore = s; + top = [a]; + } else if (s === topScore) top.push(a); + } + return pick(top.length ? top : xs, random); +} + +/** + * Answers to the human's requests: undo is accepted (it's practice), draw offers are declined. + * Returns null when no request is pending. + */ +export function answerRequests(legal: A[]): A | null { + for (const a of legal) { + const t = typeOf(a); + if (t === 'respondUndo') return { ...(a as object), accept: true } as A; + if (t === 'respondDraw') return { ...(a as object), accept: false } as A; + } + return null; +} + +/** Random legal action, preferring to answer requests and skipping meta actions. */ +export function randomPolicy({ legal, random }: BotInput): A { + return answerRequests(legal) ?? pick(playable(legal), random); +} + +/** Random action, but "pass"-like actions only when nothing else is possible. */ +export function preferPlay(input: BotInput, passTypes: string[] = ['pass', 'draw']): A { + const req = answerRequests(input.legal); + if (req) return req; + const all = playable(input.legal); + const plays = all.filter((a) => !passTypes.includes(typeOf(a))); + return pick(plays.length ? plays : all, input.random); +} diff --git a/packages/games/src/bots/types.ts b/packages/games/src/bots/types.ts new file mode 100644 index 0000000..388e8f1 --- /dev/null +++ b/packages/games/src/bots/types.ts @@ -0,0 +1,22 @@ +/** Computer-player strategy contract (docs/09-game-engine.md §9). */ +import type { PlayerId } from '@bg/engine'; + +/** + * Everything a bot may know when it decides: exactly what its own screen shows (`view` for itself) + * and the actions the server would accept from it right now. Never the game state (other hands, deck order). + */ +export interface BotInput { + me: PlayerId; + /** Deep copy of `def.view(state, me)`; free to mutate. */ + view: V; + /** Legal actions (from `legalActions`, each accepted by `validate`). Never empty. */ + legal: A[]; + /** Uniform [0, 1). */ + random: () => number; +} + +/** + * Returns the action to play. Usually one of `legal`; it may also build another action + * (e.g. accept an undo request) — the caller validates it and falls back to the default policy. + */ +export type BotStrategy = (input: BotInput) => A; diff --git a/packages/games/src/chess/bot.test.ts b/packages/games/src/chess/bot.test.ts new file mode 100644 index 0000000..8bfede7 --- /dev/null +++ b/packages/games/src/chess/bot.test.ts @@ -0,0 +1,58 @@ +/** 체스 computer player: mates, wins material without hanging it, promotes to a queen. */ +import { describe, expect, test } from 'bun:test'; +import { SeededRng } from '@bg/engine'; +import { decideBotAction } from '../bots/index'; +import { chess, type ChessAction, type ChessState } from './index'; +import { fullFen, keyOf, load } from './rules'; + +/** White = 'A' (the bot in these tests), black = 'B'; both clocks running. */ +function make(fen: string): ChessState { + const options = chess.optionsSchema.parse({ colorAssignment: 'hostWhite' }); + const s = chess.setup({ players: ['A', 'B'], options, rng: SeededRng.fromSeed([1, 2, 3, 4]), now: 0 }); + s.startFen = fullFen(load(fen)); + s.fen = s.startFen; + s.repetition = { [keyOf(load(fen))]: 1 }; + s.clock.started = { white: true, black: true }; + return s; +} +const decide = (s: ChessState, seed = 1) => { + const r = SeededRng.fromSeed([seed, 7, 7, 7]); + return decideBotAction(chess, s, 'A', { now: 1000, random: () => r.next() }) as ChessAction; +}; +const uci = (a: ChessAction) => (a.type === 'move' ? `${a.from}${a.to}${a.promotion ?? ''}` : a.type); + +describe('chess bot', () => { + test('plays mate in one', () => { + for (let seed = 1; seed <= 5; seed++) expect(uci(decide(make('6k1/5ppp/8/8/8/8/8/R5K1 w - - 0 1'), seed))).toBe('a1a8'); + }); + + test('takes a hanging queen', () => { + expect(uci(decide(make('4k3/8/8/3q4/8/8/8/3RK3 w - - 0 1')))).toBe('d1d5'); + }); + + test('does not grab a defended pawn with the queen, and moves an attacked queen away', () => { + // Qxd5 would lose the queen to cxd5. + for (let seed = 1; seed <= 5; seed++) expect(uci(decide(make('4k3/8/2p5/3p4/8/8/8/3QK3 w - - 0 1'), seed))).not.toBe('d1d5'); + // The queen on d5 is attacked by the c6 pawn: it must leave, to a square nothing black attacks. + for (let seed = 1; seed <= 5; seed++) { + const fen = '4k3/8/2p5/3Q4/8/8/8/4K3 w - - 0 1'; + const a = decide(make(fen), seed) as Extract; + expect(a.from).toBe('d5'); + const c = load(fen); + c.move({ from: a.from, to: a.to }); + expect(c.isAttacked(a.to as 'a1', 'b')).toBe(false); + } + }); + + test('promotes to a queen', () => { + expect(uci(decide(make('8/P7/8/8/8/8/k7/4K3 w - - 0 1')))).toBe('a7a8q'); + }); + + test("accepts the opponent's undo request", () => { + let s = make('4k3/8/8/8/8/8/4P3/4K3 w - - 0 1'); + s = chess.apply(s, 'A', { type: 'move', from: 'e2', to: 'e4' }, { rng: SeededRng.fromSeed([1, 1, 1, 1]), now: 500 }).state; + s = chess.apply(s, 'B', { type: 'move', from: 'e8', to: 'd7' }, { rng: SeededRng.fromSeed([1, 1, 1, 1]), now: 600 }).state; + s = chess.apply(s, 'B', { type: 'requestUndo' }, { rng: SeededRng.fromSeed([1, 1, 1, 1]), now: 700 }).state; + expect(decide(s)).toEqual({ type: 'respondUndo', accept: true }); + }); +}); diff --git a/packages/games/src/chess/bot.ts b/packages/games/src/chess/bot.ts new file mode 100644 index 0000000..14219c8 --- /dev/null +++ b/packages/games/src/chess/bot.ts @@ -0,0 +1,74 @@ +/** + * 체스 computer player (1-ply with a cheap look at the reply): mate > win material without hanging it > + * checks > development / centre / pawn pushes. Promotions are scored by the new piece, so it queens + * (and under-promotes only when queening would stalemate). + */ +import type { PieceSymbol, Square } from 'chess.js'; +import type { BotStrategy } from '../bots/types'; +import { answerRequests, best, pick, playable, typeOf } from '../bots/policy'; +import type { ChessView } from './index'; +import { PIECE_VALUE, insufficientMaterial, load, pieces } from './rules'; + +type Move = { type: 'move'; from: Square; to: Square; promotion?: 'q' | 'r' | 'b' | 'n' }; +const CENTER = new Set(['d4', 'e4', 'd5', 'e5']); +const NEAR_CENTER = new Set(['c3', 'd3', 'e3', 'f3', 'c4', 'f4', 'c5', 'f5', 'c6', 'd6', 'e6', 'f6']); +const SAN_PIECE: Record = { N: 'n', B: 'b', R: 'r', Q: 'q', K: 'k' }; + +export const chessBot: BotStrategy = ({ view, legal, random }) => { + const req = answerRequests(legal); + if (req) return req; + const moves = legal.filter((a) => typeOf(a) === 'move') as Move[]; + if (!moves.length) return pick(playable(legal), random); + const c = load(view.fen); + const us = c.turn(); + const them = us === 'w' ? 'b' : 'w'; + let balance = 0; + for (const p of pieces(c)) balance += (p.color === us ? 1 : -1) * PIECE_VALUE[p.type]; + const opening = view.moves.length < 20; + const theirPieces = pieces(c).filter((p) => p.color === them && p.type !== 'k').length; + + /** Worst material loss the opponent can inflict right away (mate counts as everything). */ + const threat = (): number => { + let worst = 0; + for (const san of c.moves()) { + if (san.endsWith('#')) return 1000; + if (!san.includes('x')) continue; + const to = san.match(/[a-h][1-8]/g)!.at(-1)! as Square; + const victim = c.get(to)?.type ?? 'p'; // empty target = en passant + const attacker = SAN_PIECE[san[0]!] ?? 'p'; + // A defended victim only costs the difference (they lose the attacker to our recapture). + const defended = c.attackers(to, us).length > 0; + const loss = defended ? Math.max(0, PIECE_VALUE[victim] - PIECE_VALUE[attacker]) : PIECE_VALUE[victim]; + worst = Math.max(worst, loss); + } + return worst; + }; + + const score = (m: Move): number => { + const piece = c.get(m.from)!; + const done = c.move({ from: m.from, to: m.to, promotion: m.promotion }); + try { + if (c.isCheckmate()) return 1e6; + const gain = (done.captured ? PIECE_VALUE[done.captured] : 0) + (done.promotion ? PIECE_VALUE[done.promotion] - 1 : 0); + // A draw is bad when ahead, welcome when clearly behind. + if (c.isStalemate() || insufficientMaterial(c)) return (balance + gain > 0 ? -5000 : balance + gain < -2 ? 5000 : 0) + random(); + let s = (gain - threat()) * 100; + if (c.inCheck()) s += 25; + // Squeeze a lone king (helps finish won endgames): fewer replies is better. + if (theirPieces === 0) s -= c.moves().length * 3; + if (done.isKingsideCastle() || done.isQueensideCastle()) s += 40; + else if (piece.type === 'k' && opening) s -= 30; + if (CENTER.has(m.to)) s += piece.type === 'p' ? 12 : 6; + else if (NEAR_CENTER.has(m.to)) s += 4; + const backRank = us === 'w' ? '1' : '8'; + if (opening && (piece.type === 'n' || piece.type === 'b') && m.from[1] === backRank) s += 15; + if (opening && piece.type === 'q') s -= 10; + // Late in the game, push pawns towards promotion. + if (piece.type === 'p' && !opening) s += (us === 'w' ? Number(m.to[1]) : 9 - Number(m.to[1])) * 2; + return s + random() * 5; + } finally { + c.undo(); + } + }; + return best(moves, score, random); +}; diff --git a/packages/games/src/cockroach-poker/bot.test.ts b/packages/games/src/cockroach-poker/bot.test.ts new file mode 100644 index 0000000..839baae --- /dev/null +++ b/packages/games/src/cockroach-poker/bot.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from 'bun:test'; +import { SeededRng, seedN } from '@bg/engine'; +import { decideBotAction } from '../bots/index'; +import { botPlayout } from '../bots/playout'; +import { KINDS, cockroachPoker as game, kindOf, type CockroachAction, type CockroachOptions, type CockroachState, type Kind } from './index'; + +/** Hands / fronts as kinds (ids assigned in order); `starter` offers. Same shape as the game's own tests. */ +function rigged(hands: Record, fronts: Record = {}, starter?: string): CockroachState { + const players = Object.keys(hands); + const s = game.setup({ players, options: game.defaultOptions, rng: SeededRng.fromSeed(seedN(1)), now: 0 }); + const next = Object.fromEntries(KINDS.map((k) => [k, 0])) as Record; + const take = (k: Kind) => `${k}-${next[k]++}`; + for (const p of players) { + s.hands[p] = hands[p]!.map(take); + s.fronts[p] = (fronts[p] ?? []).map(take); + } + s.removed = []; + s.round = { no: 1, starter: starter ?? players[0]!, card: null, holder: null, lastPasser: null, claim: null, seen: [], peeked: false, claims: [] }; + return s; +} + +const act = (s: CockroachState, p: string, a: CockroachAction) => game.apply(s, p, a, { rng: SeededRng.fromSeed([7, 7, 7, 7]), now: 1 }).state; +const decide = (s: CockroachState, p: string) => decideBotAction(game, s, p, { now: 0, random: () => 0.3 }) as Record; + +describe('cockroach-poker bot', () => { + test('offers a kind to the player who already shows the most of it', () => { + const s = rigged({ A: ['rat', 'rat', 'fly'], B: ['bat'], C: ['toad'] }, { B: ['fly'], C: ['rat', 'rat'] }); + const a = decide(s, 'A'); + expect(a.type).toBe('offer'); + expect(kindOf(a.cardId as string)).toBe('rat'); + expect(a.to).toBe('C'); + }); + + test('calls "false" when every copy of the claimed kind is accounted for', () => { + // 3 bats in my hand + 5 face-up: the passed card cannot be a bat. + let s = rigged({ A: ['fly', 'toad'], B: ['bat', 'bat', 'bat'], C: ['rat'] }, { A: ['bat', 'bat'], C: ['bat', 'bat', 'bat'] }); + s = act(s, 'A', { type: 'offer', cardId: s.hands.A![0]!, to: 'B', claim: 'bat' }); + expect(decide(s, 'B')).toEqual({ type: 'call', call: 'false' }); + }); + + test('peeks and passes on when a wrong "false" would be deadly', () => { + let s = rigged({ A: ['fly', 'toad'], B: ['rat'], C: ['rat'], D: ['bat'] }, { B: ['spider', 'spider', 'spider'], C: ['fly', 'fly'] }); + s = act(s, 'A', { type: 'offer', cardId: s.hands.A![0]!, to: 'B', claim: 'spider' }); + expect(decide(s, 'B')).toEqual({ type: 'peek' }); + s = act(s, 'B', { type: 'peek' }); + // It saw a fly: C already shows two of them. + expect(decide(s, 'B')).toMatchObject({ type: 'pass', to: 'C' }); + }); + + const modes: [string, number, Partial][] = [ + ['2 players with the pass-back house rule', 2, { twoPlayerPass: true }], + ['6 players, 3 of a kind, 8-kinds rule', 6, { loseCount: 3, eightKindsRule: true }], + ]; + for (const [name, n, opts] of modes) { + test(`bots finish a game: ${name}`, () => { + const options = game.optionsSchema.parse(opts); + const players = ['h', ...Array.from({ length: n - 1 }, (_, i) => `bot:${i + 1}`)]; + for (let seed = 1; seed <= 3; seed++) { + const r = botPlayout(game as never, { players, options, seed: seedN(seed), isBot: (p) => p.startsWith('bot:') }); + expect(r.finished).toBe(true); + expect(r.botTimeouts).toBe(0); + } + }); + } +}); diff --git a/packages/games/src/cockroach-poker/bot.ts b/packages/games/src/cockroach-poker/bot.ts new file mode 100644 index 0000000..86f6be6 --- /dev/null +++ b/packages/games/src/cockroach-poker/bot.ts @@ -0,0 +1,69 @@ +/** + * 바퀴벌레 포커 computer player. Offering: a kind I hold many of, to the player who already has the most of that + * kind face-up (and that I am not close to losing with myself), telling the truth about half the time. Receiving: + * estimates how likely the claim is from the copies still unaccounted for (8 per kind minus my hand and every + * face-up card); unsure or the claim would be deadly → peeks and passes on when allowed, otherwise calls. + * Uses only the view: own hand, public fronts, the claim, and the card itself once the bot has seen it. + */ +import type { BotStrategy } from '../bots/types'; +import { best, pick, playable } from '../bots/policy'; +import { KINDS, PER_KIND, type CockroachAction, type CockroachView, type Kind } from './index'; + +type Offer = Extract; +type Pass = Extract; + +/** A claim for a card of `kind`: the truth about half the time, else a random other kind. */ +function claimFor(kind: Kind, random: () => number): Kind { + if (random() < 0.5) return kind; + const others = KINDS.filter((k) => k !== kind); + return pick(others, random); +} + +export const cockroachPokerBot: BotStrategy = ({ me, view, legal, random }) => { + const front = (p: string, k: Kind) => view.fronts[p]?.counts[k] ?? 0; + const myCount = (k: Kind) => view.myHand?.find((g) => g.kind === k)?.ids.length ?? 0; + const lose = view.loseCount; + + // Hand a card to someone: kind × target, then pick a claim among the legal ones. + const offers = legal.filter((a): a is Offer => a.type === 'offer'); + if (offers.length) { + const kindOfId = new Map((view.myHand ?? []).flatMap((g) => g.ids.map((id) => [id, g.kind] as const))); + const score = (a: Offer) => { + const k = kindOfId.get(a.cardId)!; + return front(a.to, k) * 2 - front(me, k) * (front(me, k) >= lose - 1 ? 10 : 2) + myCount(k) * 0.5 + random(); + }; + const top = best(offers, score, random); + const claim = claimFor(kindOfId.get(top.cardId)!, random); + return offers.find((a) => a.cardId === top.cardId && a.to === top.to && a.claim === claim) ?? top; + } + + // Peeked: pass it on to whoever is most at risk with that kind. + const passes = legal.filter((a): a is Pass => a.type === 'pass'); + if (passes.length) { + const kind = view.card && view.card.known ? view.card.kind : null; + if (!kind) return pick(passes, random); + const to = best([...new Set(passes.map((a) => a.to))], (p) => front(p, kind) + random() * 0.5, random); + const claim = claimFor(kind, random); + return passes.find((a) => a.to === to && a.claim === claim) ?? passes.find((a) => a.to === to)!; + } + + const calls = legal.filter((a) => a.type === 'call'); + const claim = view.round.claim; + if (!calls.length || !claim) return pick(playable(legal), random); + const call = (c: 'true' | 'false'): CockroachAction => ({ type: 'call', call: c }); + // Already know the card (it came back to me): answer right. + if (view.card?.known) return call(view.card.kind === claim ? 'true' : 'false'); + + // Copies of the claimed kind not in my hand and not face-up anywhere. + const faceUp = (k: Kind) => view.order.reduce((x, p) => x + front(p, k), 0); + const left = PER_KIND - myCount(claim) - faceUp(claim); + if (left <= 0) return call('false'); + const unknown = KINDS.length * PER_KIND - (view.myHand ?? []).reduce((x, g) => x + g.ids.length, 0) - view.order.reduce((x, p) => x + (view.fronts[p]?.total ?? 0), 0); + const pTrue = Math.min(0.9, Math.max(0.1, (0.5 * left) / Math.max(1, unknown / KINDS.length))); + // Calling "false" on a true claim gives me the claimed kind: deadly when I already have lose-1 of it. + const deadly = front(me, claim) >= lose - 1; + const canPeek = legal.some((a) => a.type === 'peek'); + if (canPeek && (deadly || (Math.abs(pTrue - 0.5) < 0.15 && random() < 0.5))) return { type: 'peek' }; + if (deadly) return call('true'); + return call(pTrue + (random() - 0.5) * 0.3 > 0.5 ? 'true' : 'false'); +}; diff --git a/packages/games/src/dalmuti/bot.test.ts b/packages/games/src/dalmuti/bot.test.ts new file mode 100644 index 0000000..d5ceda5 --- /dev/null +++ b/packages/games/src/dalmuti/bot.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from 'bun:test'; +import { SeededRng, seedN } from '@bg/engine'; +import { decideBotAction } from '../bots/index'; +import { botPlayout } from '../bots/playout'; +import { FULL_DECK, dalmuti, type DCard, type DalmutiOptions, type DalmutiState } from './index'; + +const BY_ID = new Map(FULL_DECK.map((c) => [c.id, c])); +const C = (n: number, k = 1) => `c${n}-${k}`; +const P = ['A', 'B', 'C', 'D', 'E']; + +function make(opts: Partial = {}): DalmutiState { + return dalmuti.setup({ players: P, options: dalmuti.optionsSchema.parse(opts), rng: SeededRng.fromSeed(seedN(1)), now: 0 }); +} + +/** Seats A..E in order, gives A `hand` (others a few cards) and starts a trick, optionally under B's set. */ +function rig(hand: string[], top?: string[]): DalmutiState { + const s = make(); + s.order = [...P]; + const cards = (ids: string[]) => ids.map((id) => BY_ID.get(id)! as DCard); + s.hands = { A: cards(hand), B: cards([C(12, 1)]), C: cards([C(12, 2)]), D: cards([C(12, 3)]), E: cards([C(12, 4)]) }; + s.phase = { kind: 'playing' }; + s.current = 'A'; + s.trick = { leader: top ? 'B' : 'A', top: null, passesSinceTop: [], lockedOut: [], pile: [] }; + if (top) { + const n = BY_ID.get(top[0]!)!.n; + s.trick.top = { cardIds: top, count: top.length, rank: n, by: 'B' }; + s.trick.pile = [s.trick.top]; + } + return s; +} + +const decide = (s: DalmutiState) => decideBotAction(dalmuti, s, 'A', { now: 0, random: () => 0.5 }) as { type: string; cardIds?: string[]; declareRevolution?: boolean }; + +describe('dalmuti bot', () => { + test('leads its weakest number as a whole set', () => { + const s = rig([C(3), C(7, 1), C(7, 2), C(11, 1), C(11, 2), C(11, 3)]); + expect(decide(s)).toEqual({ type: 'play', cardIds: [C(11, 1), C(11, 2), C(11, 3)] }); + }); + + test('follows with the weakest pair that beats the table, without jesters', () => { + const s = rig([C(4, 1), C(4, 2), C(6, 1), C(6, 2), C(9, 1), C(9, 2), 'j1'], [C(10, 1), C(10, 2)]); + expect(decide(s)).toEqual({ type: 'play', cardIds: [C(9, 1), C(9, 2)] }); + }); + + test('passes rather than spend a top card while the hand is big', () => { + const s = rig([C(2), C(3), C(8), C(9), C(10), C(11)], [C(5)]); + expect(decide(s)).toEqual({ type: 'pass' }); + }); + + test('gives back its two weakest numbers in the tax, never a jester', () => { + const s = rig([C(1), C(2), C(11), C(12), 'j1']); + s.phase = { kind: 'taxReturn', owed: { A: { to: 'E', count: 2, done: false } } }; + expect(decide(s).cardIds!.sort()).toEqual([C(11), C(12)].sort()); + }); + + test('declares a revolution only from the lower half', () => { + for (const [order, declare] of [[['B', 'C', 'D', 'E', 'A'], true], [['A', 'B', 'C', 'D', 'E'], false]] as const) { + const s = rig([C(5), 'j1', 'j2']); + s.order = [...order]; + s.phase = { kind: 'revolutionCheck', confirmed: Object.fromEntries(P.map((p) => [p, false])) }; + expect(!!decide(s).declareRevolution).toBe(declare); + } + }); + + const modes: [string, Partial][] = [ + ['pass locks out, 38 cards', { passLocksOut: true, removeHighCards: true }], + ['no revolution, taxable jesters, 5 rounds by last round', { revolution: false, jesterTaxable: true, rounds: 5, scoring: 'last-round' }], + ]; + for (const [name, opts] of modes) { + test(`bots finish a game: ${name}`, () => { + const options = dalmuti.optionsSchema.parse(opts); + for (let seed = 1; seed <= 2; seed++) { + const r = botPlayout(dalmuti as never, { players: ['h', 'bot:1', 'bot:2', 'bot:3', 'bot:4'], options, seed: seedN(seed), isBot: (p) => p.startsWith('bot:') }); + expect(r.finished).toBe(true); + expect(r.botTimeouts).toBe(0); + } + }); + } +}); diff --git a/packages/games/src/dalmuti/bot.ts b/packages/games/src/dalmuti/bot.ts new file mode 100644 index 0000000..a5be751 --- /dev/null +++ b/packages/games/src/dalmuti/bot.ts @@ -0,0 +1,58 @@ +/** + * 달무티 computer player (1 = strongest, 13 = jester/wild): leads its weakest number as a whole set, follows with + * the weakest set that still beats the table, keeps jesters and the top numbers for later (passing instead while + * the hand is big), gives its worst cards back in the tax and declares a revolution only from the lower half. + */ +import type { BotStrategy } from '../bots/types'; +import { best, pick, playable } from '../bots/policy'; +import { JESTER, type DalmutiAction, type DalmutiView, type DCard, type PlayAction } from './types'; + +type ReturnTax = Extract; + +export const dalmutiBot: BotStrategy = ({ me, view, legal, random }) => { + const hand = view.my?.hand ?? []; + const byId = new Map(hand.map((c) => [c.id, c])); + const cardsOf = (ids: string[]) => ids.map((id) => byId.get(id)).filter((c): c is DCard => !!c); + + // Revolution check: cancelling the tax only pays off for the lower half (and the last seat's great revolution). + const confirms = legal.filter((a) => a.type === 'confirmHand'); + if (confirms.length) { + const seat = view.seats.find((s) => s.id === me); + const lowerHalf = !!seat && seat.rank >= view.seats.length / 2; + return confirms.find((a) => a.type === 'confirmHand' && !!a.declareRevolution === lowerHalf) ?? confirms[0]!; + } + + // Tax: give back the weakest numbers (highest), never a jester. + const returns = legal.filter((a): a is ReturnTax => a.type === 'returnTax'); + if (returns.length) return best(returns, (a) => cardsOf(a.cardIds).reduce((x, c) => x + (c.n === JESTER ? -20 : c.n), 0), random); + + const plays = legal.filter((a): a is PlayAction => a.type === 'play'); + const canPass = legal.some((a) => a.type === 'pass'); + if (!plays.length) return pick(playable(legal), random); + + const count = (n: number) => hand.filter((c) => c.n === n).length; + const score = (a: PlayAction): number => { + const cards = cardsOf(a.cardIds); + if (cards.length === hand.length) return 1000; // goes out + const jesters = cards.filter((c) => c.n === JESTER).length; + const nums = cards.filter((c) => c.n !== JESTER); + const rank = nums.length ? nums[0]!.n : JESTER; + let v = rank * 2 - jesters * 6 + random() * 0.5; + if (nums.length) { + v -= (count(rank) - nums.length) * 1.5; // splitting a set leaves stragglers + if (!canPass) v += nums.length; // leading: shed as many as possible + } else v -= 10; // jesters alone: last resort + return v; + }; + const choice = best(plays, score, random); + if (canPass && score(choice) < 1000) { + // Following: hold back strong cards / jesters while the hand is still big, otherwise play. + const cards = cardsOf(choice.cardIds); + const rank = Math.min(...cards.map((c) => c.n)); + const usesJester = cards.some((c) => c.n === JESTER); + const big = hand.length > 5; + if (big && (rank <= 3 || usesJester)) return { type: 'pass' }; + if (random() < 0.1) return { type: 'pass' }; + } + return choice; +}; diff --git a/packages/games/src/davinci-code/bot.test.ts b/packages/games/src/davinci-code/bot.test.ts new file mode 100644 index 0000000..97ff7d5 --- /dev/null +++ b/packages/games/src/davinci-code/bot.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from 'bun:test'; +import { SeededRng, seedN } from '@bg/engine'; +import { decideBotAction } from '../bots/index'; +import { botPlayout } from '../bots/playout'; +import { randomPolicy } from '../bots/policy'; +import { allTileIds, davinci, tileOf, type DavinciOptions, type DavinciState } from './index'; + +/** A game in the guess phase: rows as written (`!` = revealed), A to move, pool = every other tile. */ +function rigged(rows: Record, opts: Partial = {}): DavinciState { + const players = Object.keys(rows); + const s = davinci.setup({ players, options: davinci.optionsSchema.parse(opts), rng: SeededRng.fromSeed(seedN(1)), now: 0 }); + const used = new Set(); + for (const p of players) { + s.rows[p] = rows[p]!.map((x) => { + const id = x.replace('!', ''); + used.add(id); + return { id, revealed: x.endsWith('!'), wrong: [] }; + }); + } + const rest = allTileIds(s.options.jokers).filter((id) => !used.has(id)); + s.pool = { black: rest.filter((id) => tileOf(id).color === 'black'), white: rest.filter((id) => tileOf(id).color === 'white') }; + s.jokerReserve = []; + s.phase = { kind: 'guess' }; + s.current = players[0]!; + return s; +} +const decide = (s: DavinciState, p: string, seed = 1) => { + const r = SeededRng.fromSeed(seedN(seed)); + return decideBotAction(davinci, s, p, { now: 1, random: () => r.next() }); +}; + +describe('davinci-code bot', () => { + test('guesses a tile pinned by the row order (w? < b? < b2 → w0, b1)', () => { + const s = rigged({ A: ['b5', 'w6', 'b8', 'w10'], B: ['w0', 'b1', 'b2!', 'w9'] }); + for (let seed = 1; seed <= 5; seed++) { + const a = decide(s, 'A', seed) as { type: string; target: string; index: number; value: number }; + expect(a.type).toBe('guess'); + expect(a.target).toBe('B'); + expect(['0:0', '1:1']).toContain(`${a.index}:${a.value}`); + } + }); + + test('keeps guessing after a hit only when the next tile is certain', () => { + const sure = rigged({ A: ['b5', 'w6', 'b8', 'w10'], B: ['w0', 'b1', 'b2!', 'w9'] }); + sure.phase = { kind: 'afterCorrect' }; + expect(decide(sure, 'A')).toEqual({ type: 'continue' }); + const vague = rigged({ A: ['b5', 'w6', 'b8', 'w10'], B: ['w1', 'b3', 'b7', 'w9'] }); + vague.phase = { kind: 'afterCorrect' }; + vague.pending = 'b11'; + expect(decide(vague, 'A')).toEqual({ type: 'stop' }); + }); + + test('splits the start tiles evenly; reveals its most exposed own tile', () => { + const s = davinci.setup({ players: ['A', 'B'], options: davinci.defaultOptions, rng: SeededRng.fromSeed(seedN(3)), now: 0 }); + expect(decide(s, 'A')).toEqual({ type: 'chooseStart', black: 2, white: 2 }); + // b3 sits between the revealed b2 and b4: everybody can already name it. + const r = rigged({ A: ['b0!', 'b1!', 'b2!', 'b3', 'b4!', 'w9', 'w10'], B: ['w0', 'w5'] }); + r.phase = { kind: 'revealOwn' }; + r.pool = { black: [], white: [] }; + expect(decide(r, 'A')).toEqual({ type: 'revealOwn', index: 3 }); + }); + + test('option modes: games with jokers / random colours / no penalty finish without bot timeouts', () => { + const modes: Partial[] = [{ jokers: true }, { chooseColors: false, emptyPoolPenalty: 'none' }, { jokers: true, tieOrder: 'white-left', startTiles: 5 }]; + for (const [i, m] of modes.entries()) { + for (const n of [2, 4]) { + const r = botPlayout(davinci as never, { + players: Array.from({ length: n }, (_, k) => `bot:${k}`), + options: davinci.optionsSchema.parse(m), + seed: seedN(10 + i * 7 + n), + isBot: () => true, + }); + expect(r.finished).toBe(true); + expect(r.botTimeouts).toBe(0); + } + } + }); + + test('beats a random player clearly', () => { + let wins = 0; + for (let g = 0; g < 20; g++) { + const rng = SeededRng.fromSeed(seedN(200 + g)); + const ch = SeededRng.fromSeed(seedN(900 + g)); + const random = () => ch.next(); + let s = davinci.setup({ players: ['bot', 'rnd'], options: davinci.defaultOptions, rng, now: 0 }); + for (let step = 0; step < 2000 && !davinci.result(s); step++) { + const p = davinci.activePlayers(s)[0]!; + const a = + p === 'bot' + ? decideBotAction(davinci, s, p, { now: 0, random }) + : randomPolicy({ me: p, view: davinci.view(s, p), legal: davinci.legalActions!(s, p), random }); + s = davinci.apply(s, p, a as never, { rng, now: 0 }).state; + } + if (davinci.result(s)?.ranking[0]?.[0] === 'bot') wins++; + } + expect(wins).toBeGreaterThanOrEqual(17); + }); +}); diff --git a/packages/games/src/davinci-code/bot.ts b/packages/games/src/davinci-code/bot.ts new file mode 100644 index 0000000..ebe1010 --- /dev/null +++ b/packages/games/src/davinci-code/bot.ts @@ -0,0 +1,106 @@ +/** + * 다빈치 코드 computer player: deduces candidate values for every hidden opponent tile from its own view + * (own tiles, revealed tiles, colours, wrong guesses and the sorted order of each row), guesses the most + * certain tile and keeps guessing after a hit only while it is confident. + */ +import type { PlayerId } from '@bg/engine'; +import type { BotStrategy } from '../bots/types'; +import { best, pick, randomPolicy } from '../bots/policy'; +import { candidatesFor, sortKey, type DavinciAction, type DavinciView, type TileValue } from './index'; + +type Cands = Record; + +/** Candidates per hidden tile of `players` (null for known tiles), refined by row order and uniqueness. */ +export function deduce(v: DavinciView, players: PlayerId[]): Cands { + const out: Cands = {}; + for (const p of players) out[p] = v.rows[p]!.map((t, i) => (t.n !== undefined ? null : candidatesFor(v.rows, v.pending, p, i, v.options))); + for (let round = 0; round < 3; round++) { + // Numbered tiles of a row are strictly increasing. A hidden tile that may be a joker breaks that chain, + // so only rows without such a tile are tightened (keeps the deduction sound). + for (const p of players) { + const row = v.rows[p]!; + const c = out[p]!; + if (c.some((x) => x?.includes('joker'))) continue; + const keys = row.map((t, i) => (t.n === 'joker' ? null : (c[i] ?? [t.n!]).map((n) => sortKey(t.color, n as number, v.options.tieOrder)))); + let prev = -Infinity; + for (let i = 0; i < row.length; i++) { + const k = keys[i]; + if (!k) continue; + keys[i] = k.filter((x) => x > prev); + prev = Math.min(...keys[i]!); + } + let next = Infinity; + for (let i = row.length - 1; i >= 0; i--) { + const k = keys[i]; + if (!k) continue; + keys[i] = k.filter((x) => x < next); + next = Math.max(...keys[i]!); + } + row.forEach((t, i) => { + if (c[i]) c[i] = c[i]!.filter((n) => keys[i]!.includes(sortKey(t.color, n as number, v.options.tieOrder))); + }); + } + // A tile pinned to one value can't be that value anywhere else. + const pinned = new Set(); + for (const p of players) out[p]!.forEach((c, i) => c?.length === 1 && pinned.add(`${v.rows[p]![i]!.color}${c[0]}`)); + for (const p of players) { + out[p]!.forEach((c, i) => { + if (c && c.length > 1) out[p]![i] = c.filter((n) => !pinned.has(`${v.rows[p]![i]!.color}${n}`)); + }); + } + } + return out; +} + +/** Jokers are rare: count only numbers unless a joker is the sole option. */ +const spread = (c: TileValue[]) => c.filter((n) => n !== 'joker').length || c.length; + +/** The most certain hidden tile of a living opponent (fewest candidates; ties: opponent closest to elimination). */ +function bestGuess(v: DavinciView, me: PlayerId, random: () => number): { target: PlayerId; index: number; cands: TileValue[] } | null { + const opps = v.seats.filter((p) => p !== me && v.alive[p]); + const all = deduce(v, opps); + const spots: { target: PlayerId; index: number; cands: TileValue[]; hidden: number }[] = []; + for (const p of opps) { + const hidden = v.rows[p]!.filter((t) => !t.revealed).length; + all[p]!.forEach((c, index) => c && c.length && !v.rows[p]![index]!.revealed && spots.push({ target: p, index, cands: c, hidden })); + } + if (!spots.length) return null; + return best(spots, (s) => -spread(s.cands) * 100 - s.hidden, random); +} + +export const davinciBot: BotStrategy = (input) => { + const { me, view: v, legal, random } = input; + switch (v.phase) { + case 'chooseStart': { + // Split the start tiles as evenly as allowed. + const opts = legal.filter((a): a is Extract => a.type === 'chooseStart'); + return best(opts, (a) => -Math.abs(a.black - a.white), random); + } + case 'guess': { + const g = bestGuess(v, me, random); + if (!g) break; + const nums = g.cands.filter((n) => n !== 'joker'); + return { type: 'guess', target: g.target, index: g.index, value: pick(nums.length ? nums : g.cands, random) }; + } + case 'afterCorrect': { + const g = bestGuess(v, me, random); + if (!g) return { type: 'stop' }; + // A miss costs the drawn tile (or one of mine); free when neither applies. + const free = !v.pending && v.options.emptyPoolPenalty === 'none'; + const n = spread(g.cands); + const go = free || n === 1 || (n === 2 && random() < 0.4); + return { type: go ? 'continue' : 'stop' }; + } + case 'revealOwn': { + // Give away the tile the others could already pin down best from public information. + const masked = structuredClone(v.rows); + for (const t of masked[me]!) if (!t.revealed) delete t.n; + const opts = legal.filter((a): a is Extract => a.type === 'revealOwn'); + return best(opts, (a) => -candidatesFor(masked, null, me, a.index, v.options).length, random); + } + default: + // draw (colour choice barely matters) and placeJoker (anywhere keeps the others guessing). + break; + } + return randomPolicy(input); +}; diff --git a/packages/games/src/gostop/bot.test.ts b/packages/games/src/gostop/bot.test.ts new file mode 100644 index 0000000..e1d7c5a --- /dev/null +++ b/packages/games/src/gostop/bot.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from 'bun:test'; +import { SeededRng, seedN, type PlayerId } from '@bg/engine'; +import { decideBotAction } from '../bots/index'; +import { botPlayout } from '../bots/playout'; +import { gostop, type FloorGroup, type GostopAction, type GostopOptions, type GostopState } from './index'; + +// Card ids: month m → (m-1)*4 + slot. 1월 0광 1홍단 2·3피, 2월 4열 5홍단 6·7피, 3월 8광 9홍단 10·11피, +// 5월 16열 17초단 18·19피, 6월 20열 21청단 22·23피, 7월 24열 25초단 26·27피, 8월 28광 29열 30·31피, 11월 40광 41쌍피 42·43피. + +/** A play-phase state for players A (bot under test), B, C… with exactly these cards. */ +function rigged(n: number, spec: { hands: number[][]; floor: number[]; deck: number[]; captured?: number[][] }, opts: Partial = {}): GostopState { + const players = ['A', 'B', 'C', 'D', 'E'].slice(0, n); + const s = gostop.setup({ players, options: gostop.optionsSchema.parse({ chongtong: 'off', ...opts }), rng: SeededRng.fromSeed(seedN(1)), now: 0 }); + s.dealer = 0; + const r = s.round!; + const zeros = () => Array.from({ length: n }, () => 0); + const lists = () => Array.from({ length: n }, (): number[] => []); + Object.assign(r, { + participants: Array.from({ length: Math.min(n, 3) }, (_, i) => i), + sellOrder: [], + sellCursor: 0, + sellDecisions: [], + sellers: [], + step: 'play', + turn: 0, + pending: null, + stopBy: null, + chongtongBy: null, + bombDummies: zeros(), + revealed: lists(), + shakenMonths: lists(), + goCount: zeros(), + lastGoScore: zeros(), + goOrder: [], + shakes: zeros(), + bombs: zeros(), + ppeokCount: zeros(), + ppeokTurns: lists(), + turnsTaken: zeros(), + instant: zeros(), + }); + r.hands = Array.from({ length: n }, (_, i) => [...(spec.hands[i] ?? [])]); + r.deck = [...spec.deck]; + r.captured = Array.from({ length: n }, (_, i) => [...(spec.captured?.[i] ?? [])]); + const groups: FloorGroup[] = []; + for (const c of spec.floor) { + const m = Math.floor(c / 4) + 1; + const g = groups.find((x) => x.month === m); + if (g) g.cards.push(c); + else groups.push({ month: m, cards: [c], ppeokBy: null, natural: false }); + } + r.floor = groups.sort((a, b) => a.month - b.month); + return s; +} + +const decide = (s: GostopState) => decideBotAction(gostop, s, 'A', { now: 0, random: () => 0.5 }) as GostopAction; + +describe('gostop bot', () => { + test('plays the card that captures a 광 rather than a plain 피', () => { + const s = rigged(2, { hands: [[9, 7, 42], [43]], floor: [8, 6, 20], deck: [24, 25, 26, 27, 16, 17] }); + expect(decide(s)).toEqual({ type: 'play', card: 9 }); + }); + + test('picks the 광 when two different floor cards of the month can be taken', () => { + const s = rigged(2, { hands: [[3], [43]], floor: [0, 2], deck: [24, 25, 26, 27] }); + expect(decide(s)).toEqual({ type: 'play', card: 3, target: 0 }); + }); + + test('with no match it throws away its cheapest card, not a 광', () => { + const s = rigged(2, { hands: [[8, 42, 16], [43]], floor: [20, 24], deck: [26, 27, 30, 31] }); + expect(decide(s)).toEqual({ type: 'play', card: 42 }); + }); + + test('go while the opponents have nothing, stop when one of them is close', () => { + const deck = [16, 17, 18, 19, 20, 21, 22, 23]; + const hands = [[24, 25, 26, 27], [30, 31, 42, 43], [40, 41, 36, 37]]; + const s = rigged(3, { hands, floor: [], deck, captured: [[0, 8, 28], [], []] }); + s.round!.step = 'goStop'; + expect(decide(s)).toEqual({ type: 'goStop', decision: 'go' }); + const t = rigged(3, { hands, floor: [], deck, captured: [[0, 8, 28], [1, 5, 9], []] }); + t.round!.step = 'goStop'; + expect(decide(t)).toEqual({ type: 'goStop', decision: 'stop' }); + }); + + test('bot-only games finish in every mode (맞고, 3인, 광팔기 4~5인, 국진 직접 선택, 총통, 보너스 3+1)', () => { + const isBot = (p: PlayerId) => p.startsWith('bot:'); + const cases: [number, Partial][] = [ + [2, { rounds: 5 }], + [3, { rounds: 5, gukjinMode: 'ask', bonusCards: '3+1' }], + [4, { rounds: 5, chongtong: 'win' }], + [5, { rounds: 5, gwangSellCards: 'gwangPlus', gukjinMode: 'ask' }], + ]; + for (const [i, [n, o]] of cases.entries()) { + const players = Array.from({ length: n }, (_, k) => `bot:${k}:x`); + const r = botPlayout(gostop, { players, options: gostop.optionsSchema.parse(o), seed: seedN(40 + i), isBot, maxSteps: 30_000 }); + expect(r.finished).toBe(true); + expect(r.botTimeouts).toBe(0); + } + }); +}); diff --git a/packages/games/src/gostop/bot.ts b/packages/games/src/gostop/bot.ts new file mode 100644 index 0000000..f167bbc --- /dev/null +++ b/packages/games/src/gostop/bot.ts @@ -0,0 +1,100 @@ +/** + * 고스톱 / 맞고 computer player. Plays the hand card whose capture is worth most (광 > 열끗·띠 > 쌍피 > 피), takes + * the better floor card when asked, always shakes / bombs when allowed, and throws away its cheapest card when + * nothing matches. Go only while the opponents are far from scoring and cards are left; 광팔기 with a weak hand. + */ +import type { BotStrategy } from '../bots/types'; +import { answerRequests, best, pick, playable } from '../bots/policy'; +import { cardInfo, type CardId } from './cards'; +import type { GostopAction, GostopView, HandCardView, PlayAction } from './types'; + +/** How much a captured card is worth to the bot. */ +function worth(id: CardId, nb: number): number { + const c = cardInfo(id, nb); + if (c.kind === 'gwang') return c.bi ? 4 : 6; + if (c.kind === 'yeol') return c.godori ? 4 : 3; + if (c.kind === 'tti') return c.dan ? 3 : 2.5; + return c.piValue * 1.1; +} + +/** Hand strength for 광팔기: 광, 열끗, 쌍피 and pairs of a month are what wins a round. */ +function handStrength(hand: HandCardView[]): number { + const months = new Map(); + let v = 0; + for (const h of hand) { + if (h.card.kind === 'gwang') v += 2; + else if (h.card.kind === 'yeol' || h.card.kind === 'ssangpi' || h.card.kind === 'bonus') v += 1; + months.set(h.card.month, (months.get(h.card.month) ?? 0) + 1); + } + for (const n of months.values()) if (n >= 2) v += n - 1; + return v; +} + +export const gostopBot: BotStrategy = ({ me, view, legal, random }) => { + const req = answerRequests(legal); + if (req) return req; + const nb = view.rules.nb; + const first = legal[0]!; + switch (first.type) { + case 'ready': + return first; + case 'chongtong': + return { type: 'chongtong', declare: true }; + case 'gukjin': { + const ask = view.gukjinAsk; + return { type: 'gukjin', as: ask && ask.piScore > ask.yeolScore ? 'pi' : 'yeol' }; + } + case 'sell': { + const sell = !!view.sell?.canSell && handStrength(view.myHand ?? []) <= 5; + return { type: 'sell', sell }; + } + case 'chooseFlip': { + const opts = legal.filter((a): a is Extract => a.type === 'chooseFlip'); + return best(opts, (a) => worth(a.target, nb), random); + } + case 'goStop': { + const g = view.goStop!; + const oppMax = Math.max(0, ...view.seats.filter((s) => s.participant && s.id !== me).map((s) => s.score.total)); + const cardsLeft = (view.myHand?.length ?? 0) + view.myDummies; + const perLoser = Math.max(0, ...g.stopNow.perLoser.map((x) => x.amount)); + const go = + cardsLeft >= 3 && + view.deckCount >= 6 && + g.goCount < 2 && + oppMax <= view.rules.winScore - 3 && + perLoser < view.rules.winScore * 3; // already a big win: take it + return { type: 'goStop', decision: go ? 'go' : 'stop' }; + } + case 'play': + break; + default: + return pick(playable(legal), random); + } + + // Play step: score every legal play by what it captures. + const hand = new Map((view.myHand ?? []).map((h) => [h.card.id, h])); + const sameMonthInHand = (m: number) => (view.myHand ?? []).filter((h) => h.card.month === m).length; + const plays = legal.filter((a): a is PlayAction => a.type === 'play'); + const score = (a: PlayAction): number => { + if (a.card === 'dummy') return 0.5; // free flip, keeps a real card + const h = hand.get(a.card); + if (!h) return -10; + if (h.card.kind === 'bonus') return 100; // free 피 and a new card + const mine = worth(a.card, nb); + let v: number; + if (a.bomb) { + v = 20 + (view.floor.find((g) => g.month === h.card.month)?.cards.reduce((x, c) => x + worth(c.id, nb), 0) ?? 0); + } else if (h.matches.length === 0) { + // Thrown away: lose the cheapest card; keep it if I hold the rest of the month (I can take it back). + v = -mine + (sameMonthInHand(h.card.month) >= 2 ? 0.5 : 0) + (a.shake ? 8 : 0); + } else if (h.matches.length === 3) { + v = 10 + mine + h.matches.reduce((x, c) => x + worth(c, nb), 0); // the whole pile plus a 피 from everyone + } else { + const target = a.target ?? h.matches[0]!; + v = 2 + mine + worth(target, nb); + } + if (h.hint?.includes('완성')) v += 6; + return v; + }; + return best(plays, score, random); +}; diff --git a/packages/games/src/janggi/bot.test.ts b/packages/games/src/janggi/bot.test.ts new file mode 100644 index 0000000..1b2532b --- /dev/null +++ b/packages/games/src/janggi/bot.test.ts @@ -0,0 +1,72 @@ +/** 장기 computer player: 외통, free material, no losing trades, and a 한수쉼 that wins on score. */ +import { describe, expect, test } from 'bun:test'; +import { SeededRng } from '@bg/engine'; +import { decideBotAction } from '../bots/index'; +import { janggi, type JanggiAction, type JanggiOptions, type JanggiState } from './index'; +import { idx, inCheck, positionKey, type Board, type PieceKind, type Side } from './rules'; + +type P = [Side, PieceKind, number, number]; +const rng = () => SeededRng.fromSeed([1, 2, 3, 4]); + +/** Game in play with a custom board; A = 초 (the bot here), B = 한. */ +function custom(pieces: P[], o: Partial = {}, turn: Side = 'cho'): JanggiState { + const s = janggi.setup({ players: ['A', 'B'], options: janggi.optionsSchema.parse({ sideAssignment: 'hostCho', ...o }), rng: rng(), now: 0 }); + const b: Board = new Array(90).fill(null); + for (const [side, kind, f, r] of pieces) b[idx(f, r)] = { side, kind }; + s.board = b; + s.startBoard = b.slice(); + s.phase = 'playing'; + s.turn = turn; + s.inCheck = inCheck(b, turn) ? turn : null; + s.positionCounts = { [positionKey(b, turn)]: 1 }; + return s; +} +const decide = (s: JanggiState, seed = 1) => { + const r = SeededRng.fromSeed([seed, 5, 5, 5]); + return decideBotAction(janggi, s, 'A', { now: 1000, random: () => r.next() }) as JanggiAction; +}; +const kings: P[] = [ + ['cho', 'king', 4, 1], + ['han', 'king', 3, 8], +]; + +describe('janggi bot', () => { + test('captures a hanging 차', () => { + const s = custom([...kings, ['cho', 'cha', 0, 0], ['han', 'cha', 0, 5]]); + for (let seed = 1; seed <= 3; seed++) expect(decide(s, seed)).toEqual({ type: 'move', from: { f: 0, r: 0 }, to: { f: 0, r: 5 } }); + }); + + test('does not trade its 차 for a defended 졸', () => { + // 한 병 on (0,5) is covered by the 한 차 on (0,9): taking it loses the 차. + const s = custom([...kings, ['cho', 'cha', 0, 0], ['han', 'jol', 0, 5], ['han', 'cha', 0, 9]]); + for (let seed = 1; seed <= 3; seed++) { + const a = decide(s, seed); + expect(a.type === 'move' && a.to.f === 0 && a.to.r === 5).toBe(false); + } + }); + + test('finds 외통 in one', () => { + // 한 궁 (4,9) is boxed in by its own 병; the 차 on (0,8) guards rank 8, the other 차 checks down file 4. + const s = custom([ + ['cho', 'king', 3, 1], + ['cho', 'cha', 0, 8], + ['cho', 'cha', 8, 5], + ['han', 'king', 4, 9], + ['han', 'jol', 3, 9], + ['han', 'jol', 5, 9], + ]); + const a = decide(s); + const after = janggi.apply(s, 'A', a, { rng: rng(), now: 1000 }).state; + expect(after.result?.reason).toBe('checkmate'); + expect(after.result?.winner).toBe('cho'); + }); + + test('answers a pass with a pass when that wins on score', () => { + const s = custom([...kings, ['cho', 'cha', 0, 0], ['cho', 'po', 1, 2], ['han', 'sa', 3, 9]]); + s.moves = [{ kind: 'pass', side: 'han' }]; + s.consecutivePasses = 1; + expect(decide(s)).toEqual({ type: 'pass' }); + const after = janggi.apply(s, 'A', { type: 'pass' }, { rng: rng(), now: 1000 }).state; + expect(after.result?.winner).toBe('cho'); + }); +}); diff --git a/packages/games/src/janggi/bot.ts b/packages/games/src/janggi/bot.ts new file mode 100644 index 0000000..9dcd7fe --- /dev/null +++ b/packages/games/src/janggi/bot.ts @@ -0,0 +1,138 @@ +/** + * 장기 computer player (1-ply with a cheap look at the reply): 외통 > win material without leaving pieces en + * prise > 장군 > activity. Knows 빅장 (judged by score), the 4th-repetition rule and when a 한수쉼 ends the game. + */ +import type { BotStrategy } from '../bots/types'; +import { answerRequests, best, pick, playable, typeOf } from '../bots/policy'; +import type { JanggiView } from './index'; +import { + HAN_BONUS, + PIECE_VALUE, + attacks, + findKing, + hasLegalMove, + idx, + inCheck, + kingsFacing, + makeMove, + material, + other, + positionKey, + pseudoMoves, + sqOf, + type Board, + type Side, + type Sq, +} from './rules'; + +type Move = { type: 'move'; from: Sq; to: Sq }; +type Pass = { type: 'pass' }; +/** After this many plies, bots stop dragging out a game a 한수쉼 can end (score judgement). */ +const LONG_GAME = 300; + +/** Positions seen so far (board + side to move), rebuilt backwards from the public move list. */ +function seenPositions(view: JanggiView): Map { + const seen = new Map(); + const b = view.board.slice() as Board; + let turn = view.turn; + const add = () => seen.set(positionKey(b, turn), (seen.get(positionKey(b, turn)) ?? 0) + 1); + add(); + for (let i = view.moves.length - 1; i >= 0; i--) { + const m = view.moves[i]!; + if (m.kind === 'move') { + b[idx(m.from.f, m.from.r)] = b[idx(m.to.f, m.to.r)]!; + b[idx(m.to.f, m.to.r)] = m.captured ? { side: other(m.side), kind: m.captured } : null; + } + turn = m.side; + add(); + } + return seen; +} + +/** Worst material the opponent can win at once on `b` (a defended victim only costs the difference; 외통 = 1000). */ +function threat(b: Board, me: Side): number { + const opp = other(me); + const king = sqOf(findKing(b, me)); + // Only moves touching the king's file/rank or its surroundings can give 장군 (lines, 멱, palace diagonals). + const nearKing = (i: number) => { + const q = sqOf(i); + return q.f === king.f || q.r === king.r || Math.max(Math.abs(q.f - king.f), Math.abs(q.r - king.r)) <= 3; + }; + let worst = 0; + for (let i = 0; i < b.length; i++) { + if (b[i]?.side !== opp) continue; + const fromNear = nearKing(i); + for (const m of pseudoMoves(b, i)) { + const victim = b[m.to]; + if (victim?.side === me && victim.kind === 'king') continue; + const after = makeMove(b, i, m.to); + if (inCheck(after, opp)) continue; // illegal for them + if ((fromNear || nearKing(m.to)) && inCheck(after, me) && !hasLegalMove(after, me)) return 1000; // 외통 next move + if (!victim || victim.side !== me) continue; + const v = PIECE_VALUE[victim.kind]; + const loss = attacks(after, me, m.to) ? Math.max(0, v - PIECE_VALUE[b[i]!.kind]) : v; + worst = Math.max(worst, loss); + } + } + return worst; +} + +export const janggiBot: BotStrategy = ({ view, legal, random }) => { + const req = answerRequests(legal); + if (req) return req; + const moves = legal.filter((a) => typeOf(a) === 'move') as Move[]; + const pass = legal.find((a) => typeOf(a) === 'pass') as Pass | undefined; + const me = view.mySide; + if ((!moves.length && !pass) || !me) return pick(playable(legal), random); // 상차림: any arrangement + const opp = other(me); + const board = view.board as Board; + const score = (b: Board) => material(b, me) + (me === 'han' ? HAN_BONUS : 0) - material(b, opp) - (opp === 'han' ? HAN_BONUS : 0); + /** Value of a game ended by score judgement right now (a draw when scoring is off). */ + const judged = (b: Board) => (!view.rules.scoring ? 0 : score(b) > 0 ? 1e5 : -1e5); + const seen = view.repeatWarning ? seenPositions(view) : null; + const long = view.moveCount >= LONG_GAME; + const capturedYet = view.moves.some((m) => m.kind === 'move' && m.captured); + const bikjangArmed = (b: Board) => + view.rules.bikjang === 'kjf' ? capturedYet : view.rules.bikjang === 'kja30' && material(b, 'cho') < 30 && material(b, 'han') < 30; + const lastOpp = view.moves.at(-1); + + const evaluate = (b: Board, gain: number): number => { + // The opponent leaving a 빅장 standing → score judgement. + if (view.bikjang && view.bikjang.calledBy === opp && kingsFacing(b)) return judged(b); + if (seen && (seen.get(positionKey(b, opp)) ?? 0) >= 3) return view.rules.repetition === 'lose' ? -1e6 : judged(b); + let s = (gain - threat(b, me)) * 100; + // Calling 빅장 hands the opponent the choice of a score judgement: only when ahead. + if (kingsFacing(b) && !kingsFacing(board) && bikjangArmed(b)) s += score(b) > 0 ? 30 : -300; + return s; + }; + + const scoreMove = (m: Move): number => { + const from = idx(m.from.f, m.from.r); + const to = idx(m.to.f, m.to.r); + const piece = board[from]!; + const b = makeMove(board, from, to); + const check = inCheck(b, opp); + if (check && !hasLegalMove(b, opp)) return 1e7; + let s = evaluate(b, board[to] ? PIECE_VALUE[board[to]!.kind] : 0); + if (Math.abs(s) >= 1e5) return s; + if (check) s += 25; + if (piece.kind === 'king') s -= 15; + // Activity: 졸 step forward, 차·포·마 leave the back ranks. + const ahead = (r: number) => (me === 'cho' ? r : 9 - r); + if (piece.kind === 'jol' && ahead(m.to.r) > ahead(m.from.r)) s += 4; + if ((piece.kind === 'cha' || piece.kind === 'po' || piece.kind === 'ma') && ahead(m.from.r) <= 2 && ahead(m.to.r) > 2) s += 6; + return s + random() * 5; + }; + + /** 한수쉼: only to dodge a threat, or when two passes in a row end the game in our favour. */ + const passScore = (): number => { + const oppPassed = lastOpp?.kind === 'pass' && lastOpp.side === opp; + const base = evaluate(board, 0) - 50; + if (oppPassed && view.rules.scoring && score(board) > 0) return judged(board); + // Long game: wind it down (the side ahead offers, the other answers) so bot games always finish. + if (long && (oppPassed || score(board) > 0)) return Math.max(base, 200); + return base; + }; + const options: (Move | Pass)[] = pass ? [...moves, pass] : moves; + return best(options, (a) => (typeOf(a) === 'pass' ? passScore() : scoreMove(a as Move)), random); +}; diff --git a/packages/games/src/las-vegas/bot.test.ts b/packages/games/src/las-vegas/bot.test.ts new file mode 100644 index 0000000..3d73c24 --- /dev/null +++ b/packages/games/src/las-vegas/bot.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from 'bun:test'; +import { SeededRng, seedN } from '@bg/engine'; +import { decideBotAction } from '../bots/index'; +import { botPlayout } from '../bots/playout'; +import { randomPolicy } from '../bots/policy'; +import { lasVegas, type LasVegasOptions, type LasVegasState } from './index'; + +const isBot = (p: string) => p.startsWith('bot'); + +/** 3 players, no neutral dice, 'bot' to place `own` with these bills on the casinos (index = value − 1). */ +function placing(own: number[], bills: number[][], opts: Partial = {}): LasVegasState { + const s = lasVegas.setup({ players: ['bot', 'B', 'C'], options: lasVegas.optionsSchema.parse({ neutralDice: 'off', ...opts }), rng: SeededRng.fromSeed(seedN(1)), now: 0 }); + Object.assign(s, { current: 0, phase: 'place', lastRoll: { own, neutral: [] } }); + s.casinos.forEach((c, i) => Object.assign(c, { notes: bills[i] ?? [], dice: {} })); + return s; +} +const decide = (s: LasVegasState) => decideBotAction(lasVegas, s, 'bot', { now: 1, random: () => 0.5 }); + +describe('las-vegas bot', () => { + test('rolls when it is time to roll', () => { + const s = placing([1], []); + Object.assign(s, { phase: 'roll', lastRoll: null }); + expect(decide(s)).toEqual({ type: 'ROLL' }); + }); + + test('takes the big bill with one die rather than spending three dice on a small one', () => { + const s = placing([1, 1, 1, 4, 2, 2, 3, 3], [[5], [5], [5], [9], [5], [5]]); + expect(decide(s)).toEqual({ type: 'PLACE', value: 4 }); + }); + + test('overtakes an opponent on the richest casino', () => { + const s = placing([6, 6, 6, 2, 3, 3, 5, 5], [[4], [4], [4], [4], [4], [9, 6]]); + s.casinos[5]!.dice = { B: 2 }; + expect(decide(s)).toEqual({ type: 'PLACE', value: 6 }); + }); + + test('beats random players clearly', () => { + let wins = 0; + const games = 20; + for (let g = 1; g <= games; g++) { + const rng = SeededRng.fromSeed(seedN(g)); + const chooser = SeededRng.fromSeed(seedN(g + 300)); + const random = () => chooser.next(); + const players = g % 2 ? ['bot', 'r1'] : ['r1', 'bot']; + let s = lasVegas.setup({ players, options: lasVegas.defaultOptions, rng, now: 0 }); + for (let i = 0; i < 2000 && !lasVegas.result(s); i++) { + const p = lasVegas.activePlayers(s)[0]!; + const a = p === 'bot' ? decideBotAction(lasVegas, s, p, { now: 1, random }) : randomPolicy({ me: p, view: null, legal: lasVegas.legalActions!(s, p), random }); + s = lasVegas.apply(s, p, a as never, { rng, now: 1 }).state; + } + if (lasVegas.result(s)!.ranking[0]!.includes('bot')) wins++; + } + expect(wins).toBeGreaterThanOrEqual(16); + }); + + for (const [opts, n] of [ + [{ neutralDice: 'on' as const, rounds: 3 as const }, 3], + [{ neutralDice: 'off' as const, hideMoney: false, rounds: 5 as const }, 2], + [{ neutralDice: 'auto' as const }, 5], + ] as const) { + test(`option mode ${JSON.stringify(opts)} with ${n} players finishes`, () => { + const options = lasVegas.optionsSchema.parse(opts); + const players = Array.from({ length: n }, (_, i) => (i === 0 ? 'human' : `bot:${i}`)); + for (let g = 1; g <= 3; g++) { + const r = botPlayout(lasVegas, { players, options, seed: seedN(g), isBot }); + expect(r.finished).toBe(true); + expect(r.botTimeouts).toBe(0); + expect(r.maxBotMs).toBeLessThan(100); + } + }); + } +}); diff --git a/packages/games/src/las-vegas/bot.ts b/packages/games/src/las-vegas/bot.ts new file mode 100644 index 0000000..996f235 --- /dev/null +++ b/packages/games/src/las-vegas/bot.ts @@ -0,0 +1,51 @@ +/** + * 라스베가스 computer player. Every rolled number is scored on its casino with the real payout rules: + * the bill I would hold there now vs. before (discounted when an opponent can still easily catch up), plus bills it + * knocks away from opponents (ties, neutral dice), minus the value of the dice it spends. + */ +import type { BotStrategy } from '../bots/types'; +import { answerRequests, best, pick, playable } from '../bots/policy'; +import { HOUSE, settleCasino, sum, type Owner } from './rules'; +import type { LasVegasAction, LasVegasView } from './index'; + +type Place = Extract; + +export const lasVegasBot: BotStrategy = ({ me, view, legal, random }) => { + const req = answerRequests(legal); + if (req) return req; + const places = legal.filter((a) => a.type === 'PLACE') as Place[]; + if (!places.length || !view.lastRoll) return pick(playable(legal), random); + + const order: Owner[] = [...view.seats, HOUSE]; + const others = view.seats.filter((p) => p !== me && !view.conceded.includes(p)); + const othersDice = Math.max(0, ...others.map((p) => view.hand[p]!.own)); + // A die is worth about the money still on the table shared by every die still to be placed. + const table = sum(view.casinos.flatMap((c) => c.notes)); + const diceInHands = view.seats.reduce((n, p) => n + view.hand[p]!.own + view.hand[p]!.neutral, 0); + const dieCost = (0.5 * table) / Math.max(1, diceInHands); + + /** Worth of one casino's dice layout to me: my bill (discounted while an opponent can still catch up) minus theirs. */ + const worth = (notes: number[], dice: Partial>): number => { + const { awards } = settleCasino(notes, dice, order); + const rival = Math.max(0, ...Object.entries(dice).filter(([o]) => o !== me).map(([, n]) => n ?? 0)); + const margin = (dice[me] ?? 0) - rival; + const safe = othersDice === 0 ? 1 : Math.max(0.2, Math.min(1, 0.55 + 0.15 * margin)); + let v = 0; + for (const a of awards) { + if (a.owner === me) v += a.note * safe; + else if (a.owner !== HOUSE) v -= a.note / Math.max(1, others.length); + } + return v; + }; + + const score = ({ value }: Place): number => { + const own = view.lastRoll!.own.filter((d) => d === value).length; + const neutral = view.lastRoll!.neutral.filter((d) => d === value).length; + const c = view.casinos[value - 1]!; + const dice = { ...c.dice }; + if (own) dice[me] = (dice[me] ?? 0) + own; + if (neutral) dice[HOUSE] = (dice[HOUSE] ?? 0) + neutral; + return worth(c.notes, dice) - worth(c.notes, c.dice) - dieCost * own; + }; + return best(places, (a) => score(a) + random() * 1e-3, random); +}; diff --git a/packages/games/src/mahjong/bot.test.ts b/packages/games/src/mahjong/bot.test.ts new file mode 100644 index 0000000..5d5fdd6 --- /dev/null +++ b/packages/games/src/mahjong/bot.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, test } from 'bun:test'; +import { SeededRng, seedN, type PlayerId } from '@bg/engine'; +import { decideBotAction } from '../bots/index'; +import { botPlayout } from '../bots/playout'; +import { __test, allTiles, kindFromCode, mahjong, tileCode, type HandState, type MahjongAction, type MahjongOptions, type MahjongState, type TileId } from './index'; + +const P4 = ['A', 'B', 'C', 'D']; + +/** Picks tile ids for a notation like "123m55z" (plain copies first, the red five last). */ +function take(notation: string, used: Set): TileId[] { + const out: TileId[] = []; + for (const m of notation.matchAll(/(\d+)([mpsz])/g)) { + for (const d of m[1]!) { + const kind = kindFromCode(`${d}${m[2]}`)!; + const id = [1, 2, 3, 0].map((c) => kind * 4 + c).find((x) => !used.has(x))!; + used.add(id); + out.push(id); + } + } + return out; +} + +/** 4-player hand in progress: seat `turn` has drawn `drawn`; '*' hands are inert single tiles. */ +function rig(o: { hands: string[]; drawn: string; turn?: number; discards?: { seat: number; tiles: string }[]; riichi?: number[] }): MahjongState { + const s = mahjong.setup({ players: P4, options: mahjong.optionsSchema.parse({}), rng: SeededRng.fromSeed(seedN(1)), now: 0 }); + s.players = [...P4]; // setup shuffles the seats; seat 0 = A = dealer here + const used = new Set(); + const hands = o.hands.map((h) => (h === '*' ? [] : take(h, used))); + const discards: HandState['discards'] = P4.map(() => []); + for (const d of o.discards ?? []) for (const t of take(d.tiles, used)) discards[d.seat]!.push({ tile: t, tsumogiri: false, riichi: false, calledBy: null }); + const turn = o.turn ?? 0; + const drawn = take(o.drawn, used)[0]!; + const doraInd = take('9m', used); + // Inert filler: 1·4·7 of each suit plus honors, skipping used copies. + const inert = [0, 3, 6, 9, 12, 15, 18, 21, 24, 27, 28, 29, 30, 31, 32, 33]; + o.hands.forEach((h, i) => { + if (h !== '*') return; + for (const k of inert) { + const id = [1, 2, 3, 0].map((c) => k * 4 + c).find((x) => !used.has(x)); + if (id !== undefined && hands[i]!.length < 13) (used.add(id), hands[i]!.push(id)); + } + }); + const rest = allTiles(false).filter((t) => !used.has(t)); + const empty = (f: () => T) => P4.map(f); + const h: HandState = { + wall: rest.slice(13), + rinshan: rest.slice(0, 4), + doraInd: [...doraInd, ...rest.slice(4, 8)], + uraInd: rest.slice(8, 13), + doraRevealed: 1, + pendingKanDora: 0, + hands, + drawn: empty(() => null), + melds: empty(() => []), + kita: empty(() => []), + discards, + riichi: empty(() => null), + sidewaysNext: empty(() => false), + tempFuriten: empty(() => false), + riichiFuriten: empty(() => false), + waits: empty(() => []), + turn, + stage: 'afterDraw', + interrupted: true, + kanCount: 0, + kanBySeat: empty(() => 0), + lastDrawRinshan: false, + suukanPending: false, + kuikae: [], + pao: empty(() => null), + claim: null, + turnStartedAt: 0, + }; + for (let i = 0; i < 4; i++) h.waits[i] = __test.computeWaits(h, i); + hands[turn]!.push(drawn); + h.drawn[turn] = drawn; + for (const seat of o.riichi ?? []) h.riichi[seat] = { discardIndex: 0, double: false, ippatsu: false }; + s.hand = h; + s.phase = 'playing'; + return s; +} + +const decide = (s: MahjongState, seat: number) => decideBotAction(mahjong, s, P4[seat]!, { now: 1000, random: () => 0.5 }) as MahjongAction; +const kindOfAction = (a: MahjongAction) => (a.type === 'discard' ? kindFromCode(a.tile.slice(0, 2)) : null); +const code = (kc: string) => kindFromCode(kc)!; +/** Seat 0 throws its drawn tile; the claim window opens. */ +function throwDrawn(s: MahjongState): MahjongState { + const t = s.hand!.drawn[0]!; + return mahjong.apply(s, 'A', { type: 'discard', tile: tileCode(t, s.red) }, { rng: SeededRng.fromSeed(seedN(2)), now: 1000 }).state; +} + +describe('mahjong bot', () => { + test('wins by tsumo when the drawn tile completes the hand', () => { + const s = rig({ hands: ['234m567p678s22p34s', '*', '*', '*'], drawn: '5s' }); + expect(decide(s, 0)).toEqual({ type: 'tsumo' }); + }); + + test('declares riichi with a closed tenpai hand, throwing the useless tile', () => { + const s = rig({ hands: ['234m567p678s22p3s5s', '*', '*', '*'], drawn: '9m' }); + const a = decide(s, 0); + expect(a).toMatchObject({ type: 'discard', riichi: true }); + expect(kindOfAction(a)).toBe(code('9m')); + }); + + test('throws an isolated non-yakuhai honor before a number tile', () => { + const s = rig({ hands: ['123m456p1279s55p8m', '*', '*', '*'], drawn: '3z' }); + const a = decide(s, 0); + expect(a.type).toBe('discard'); + expect(kindOfAction(a)).toBe(code('3z')); + }); + + test('pons a dragon, passes on a non-yakuhai honor pon', () => { + const dragon = throwDrawn(rig({ hands: ['*', '77z135m246p2389s1z', '*', '*'], drawn: '7z' })); + expect(decide(dragon, 1)).toMatchObject({ type: 'claim', call: 'pon' }); + const west = throwDrawn(rig({ hands: ['*', '33z135m246p2389s1z', '*', '*'], drawn: '3z' })); + expect(decide(west, 1)).toEqual({ type: 'pass' }); + }); + + test('far from tenpai against a riichi, it throws a tile the riichi player already discarded', () => { + const s = rig({ hands: ['45m19p19s1234z2s8s7p', '*', '*', '*'], drawn: '6z', discards: [{ seat: 1, tiles: '5m3p' }], riichi: [1] }); + const a = decide(s, 0); + expect(kindOfAction(a)).toBe(code('5m')); + }); + + test('bot-only games finish in several modes without stalling a claim window', () => { + const isBot = (p: PlayerId) => p.startsWith('bot:'); + const cases: [number, Partial][] = [ + [4, { length: 'tonpuu' }], + [3, { length: 'tonpuu', sanmaTsumo: 'northBisection' }], + [4, { length: 'tonpuu', akaDora: 0, kuitan: false, doubleRon: 'headBump', abortiveDraws: false }], + ]; + for (const [i, [n, o]] of cases.entries()) { + const players = Array.from({ length: n }, (_, k) => `bot:${k}:x`); + const r = botPlayout(mahjong, { players, options: mahjong.optionsSchema.parse(o), seed: seedN(60 + i), isBot, maxSteps: 30_000 }); + expect(r.finished).toBe(true); + expect(r.botTimeouts).toBe(0); + expect(r.maxBotMs).toBeLessThan(250); + } + }); +}); diff --git a/packages/games/src/mahjong/bot.ts b/packages/games/src/mahjong/bot.ts new file mode 100644 index 0000000..98dbe19 --- /dev/null +++ b/packages/games/src/mahjong/bot.ts @@ -0,0 +1,256 @@ +/** + * 마작 computer player (cautious beginner). Wins whenever it may (tsumo / ron), declares riichi as soon as a closed + * hand is tenpai, and otherwise discards to lower its shanten, keeping the discard with the most useful draws + * (ukeire); among equals honors / terminals go first and dora stay. Against a riichi with a far-off hand it throws + * tiles the riichi player already discarded. Calls only pon of 역패, or pon / chi that speeds up a hand that already + * has a yaku (역패 meld or 쿠이탕). Between hands it presses ready. Everything comes from its own view. + */ +import type { BotStrategy } from '../bots/types'; +import { answerRequests, best, pick, playable } from '../bots/policy'; +import { doraFromIndicator, isDragon, isYaochu, kindFromCode } from './tiles'; +import type { MahjongAction, MahjongView } from './types'; + +const kindOfCode = (code: string): number => kindFromCode(code.slice(0, 2))!; +const counts = (codes: readonly string[]): number[] => { + const c = new Array(34).fill(0); + for (const x of codes) c[kindOfCode(x)]!++; + return c; +}; + +/** + * Per suit (or the honors), the best number of partial shapes for each (sets, pair) split: `t[p * 5 + m]` = most + * taatsu with `m` sets and `p` pairs-as-head (−1 = impossible). Cached by the suit's counts, so shanten is cheap. + */ +const SUIT_CACHE = new Map(); +function suitTable(cs: number[], seq: boolean): Int8Array { + const key = (seq ? 's' : 'z') + cs.join(''); + let out = SUIT_CACHE.get(key); + if (out) return out; + const t = new Int8Array(10).fill(-1); + const c = [...cs]; + const n = c.length; + const go = (i: number, m: number, tt: number, p: number): void => { + while (i < n && c[i] === 0) i++; + if (i === n) { + const j = p * 5 + Math.min(m, 4); + if (tt > t[j]!) t[j] = Math.min(tt, 8); + return; + } + if (c[i]! >= 3) { + c[i]! -= 3; + go(i, m + 1, tt, p); + c[i]! += 3; + } + if (seq && i <= n - 3 && c[i + 1]! > 0 && c[i + 2]! > 0) { + c[i]!--, c[i + 1]!--, c[i + 2]!--; + go(i, m + 1, tt, p); + c[i]!++, c[i + 1]!++, c[i + 2]!++; + } + if (c[i]! >= 2) { + c[i]! -= 2; + if (!p) go(i, m, tt, 1); + go(i, m, tt + 1, p); + c[i]! += 2; + } + if (seq && i <= n - 2 && c[i + 1]! > 0) { + c[i]!--, c[i + 1]!--; + go(i, m, tt + 1, p); + c[i]!++, c[i + 1]!++; + } + if (seq && i <= n - 3 && c[i + 2]! > 0) { + c[i]!--, c[i + 2]!--; + go(i, m, tt + 1, p); + c[i]!++, c[i + 2]!++; + } + c[i]!--; + go(i + 1, m, tt, p); + c[i]!++; + }; + go(0, 0, 0, 0); + out = t; + SUIT_CACHE.set(key, out); + return out; +} + +/** Standard-form shanten (4 sets + pair, `melds` already called); -1 = complete. */ +function shantenStd(c: number[], melds: number): number { + // Combine the four tables: best taatsu for every (pair, sets) total. + let acc = new Int8Array(10).fill(-1); + acc[0] = 0; + for (const tab of [suitTable(c.slice(0, 9), true), suitTable(c.slice(9, 18), true), suitTable(c.slice(18, 27), true), suitTable(c.slice(27), false)]) { + const next = new Int8Array(10).fill(-1); + for (let a = 0; a < 10; a++) { + if (acc[a]! < 0) continue; + for (let b = 0; b < 10; b++) { + if (tab[b]! < 0) continue; + const p = Math.floor(a / 5) + Math.floor(b / 5); + if (p > 1) continue; + const j = p * 5 + Math.min((a % 5) + (b % 5), 4); + next[j] = Math.max(next[j]!, acc[a]! + tab[b]!); + } + } + acc = next; + } + let best = 8; + for (let j = 0; j < 10; j++) { + if (acc[j]! < 0) continue; + const sets = Math.min((j % 5) + melds, 4); + best = Math.min(best, 8 - 2 * sets - Math.min(acc[j]!, 4 - sets) - Math.floor(j / 5)); + } + return best; +} + +/** Shanten over standard form and 칠대자 (closed only). */ +function shanten(c: number[], melds: number): number { + let s = shantenStd(c, melds); + if (melds === 0) { + const pairs = c.filter((x) => x >= 2).length; + const kinds = c.filter((x) => x >= 1).length; + s = Math.min(s, 6 - pairs + Math.max(0, 7 - kinds)); + } + return s; +} + +/** Kinds worth drawing next to the hand (same honor, or a suit tile within 2 of one I hold). */ +function nearKinds(c: number[]): number[] { + const out: number[] = []; + for (let k = 0; k < 34; k++) { + if (k >= 27) { + if (c[k]! > 0) out.push(k); + } else { + const lo = k - (k % 9); + const from = Math.max(lo, k - 2); + const to = Math.min(lo + 8, k + 2); + if (c.slice(from, to + 1).some((x) => x > 0)) out.push(k); + } + } + return out; +} + +/** Shanten and number of unseen tiles that would lower it, for a 13-tile (or 13 − 3·melds) hand. */ +function evaluate(c: number[], melds: number, seen: number[]): { sh: number; uke: number } { + const sh = shanten(c, melds); + let uke = 0; + for (const k of nearKinds(c)) { + const left = 4 - seen[k]!; + if (left <= 0) continue; + c[k]!++; + if (shanten(c, melds) < sh) uke += left; + c[k]!--; + } + return { sh, uke }; +} + +export const mahjongBot: BotStrategy = ({ view, legal, random }) => { + const req = answerRequests(legal); + if (req) return req; + const has = (t: string) => legal.find((a) => a.type === t); + const ready = has('ready'); + if (ready) return ready; + const my = view.my; + if (!my) return pick(playable(legal), random); + const ron = legal.find((a) => a.type === 'claim' && a.call === 'ron'); + if (ron) return ron; + const tsumo = has('tsumo'); + if (tsumo) return tsumo; + const kita = has('kita'); + if (kita) return kita; + const kyuushu = has('kyuushu'); + if (kyuushu) return kyuushu; + + const me = view.seats[my.seat]!; + const melds = me.melds.length; + const sanma = view.mode === 'sanma'; + const handCodes = [...my.hand, ...(my.drawn ? [my.drawn] : [])]; + const c = counts(handCodes); + // Tiles I can see: my hand, every discard still on the river, every meld, kita and dora indicators. + const seen = [...c]; + for (const s of view.seats) { + for (const d of s.discards) if (!d.called) seen[kindOfCode(d.tile)]!++; + if (s.seat !== my.seat) for (const m of s.melds) for (const t of m.tiles) seen[kindOfCode(t)]!++; + for (const t of s.kita) seen[kindOfCode(t)]!++; + } + for (const m of me.melds) for (const t of m.tiles) seen[kindOfCode(t)]!++; + for (const t of view.doraIndicators) seen[kindOfCode(t)]!++; + const dora = new Set(view.doraIndicators.map((t) => doraFromIndicator(kindOfCode(t), sanma))); + const yakuhai = (k: number) => isDragon(k) || k === 27 + me.wind || k === 27 + view.roundWind; + + // ---- claim window (ron handled above): pon of 역패, or a call that speeds up a hand that already has a yaku. + if (my.claim) { + const pass = has('pass') ?? { type: 'pass' as const }; + if (my.riichi) return pass; + const k = kindOfCode(my.claim.tile); + const now = shanten(c, melds); + const openYakuhai = me.melds.some((m) => m.type !== 'chi' && yakuhai(kindOfCode(m.tiles[0]!))); + const simplesOnly = (ks: number[]) => ks.every((x) => !isYaochu(x)); + let bestCall: MahjongAction | null = null; + let bestSh = now; + for (const a of legal) { + if (a.type !== 'claim' || (a.call !== 'pon' && a.call !== 'chi')) continue; + const used = (a.tiles ?? []).map(kindOfCode); + const rest = [...c]; + for (const u of used) rest[u]!--; + // After the call one tile must go: the best shanten among those discards. + let after = 9; + for (let d = 0; d < 34; d++) { + if (!rest[d]) continue; + rest[d]!--; + after = Math.min(after, shanten(rest, melds + 1)); + rest[d]!++; + } + const meldKinds = me.melds.flatMap((m) => m.tiles.map(kindOfCode)); + const handYaochu = rest.reduce((n, x, i) => n + (isYaochu(i) ? x : 0), 0); + const tanyaoOk = view.options.kuitan && simplesOnly([k, ...used, ...meldKinds]) && handYaochu <= 1; + const isYakuhaiPon = a.call === 'pon' && yakuhai(k); + const ok = isYakuhaiPon ? after <= now : (openYakuhai || tanyaoOk) && after < now; + if (ok && (after < bestSh || (after === bestSh && !bestCall))) { + bestCall = a; + bestSh = after; + } + } + return bestCall ?? pass; + } + + // ---- my turn: closed kan when it doesn't hurt (not in riichi: keep it simple), then the discard. + const discards = legal.filter((a): a is Extract => a.type === 'discard'); + if (!discards.length) return pick(playable(legal), random); + const riichiFoes = view.seats.filter((s) => s.seat !== my.seat && s.riichi); + const genbutsu = riichiFoes.map((s) => new Set(s.discards.map((d) => kindOfCode(d.tile)))); + const scored = new Map(); + const evalDiscard = (code: string) => { + let e = scored.get(code.slice(0, 2)); + if (!e) { + const k = kindOfCode(code); + c[k]!--; + e = evaluate(c, melds, seen); + c[k]!++; + scored.set(code.slice(0, 2), e); + } + return e; + }; + if (!my.riichi) { + const minSh = Math.min(...discards.map((a) => evalDiscard(a.tile).sh)); + for (const a of legal) { + if (a.type !== 'ankan' && a.type !== 'kakan') continue; + const k = kindOfCode(a.tile); + const rest = [...c]; + rest[k] = a.type === 'ankan' ? 0 : rest[k]! - 1; + if (shanten(rest, melds + (a.type === 'ankan' ? 1 : 0)) <= minSh) return a; + } + } + const score = (a: Extract): number => { + const k = kindOfCode(a.tile); + const { sh, uke } = evalDiscard(a.tile); + let v = -sh * 1000 + uke * 10; + if (a.riichi) v += uke > 0 ? 500 : -5000; // riichi whenever tenpai on a live wait + if (k >= 27) v += yakuhai(k) && c[k]! >= 2 ? -3 : yakuhai(k) ? 2 : 4; + else if (isYaochu(k)) v += 1; + if (dora.has(k)) v -= 6; + if (a.tile.endsWith('r')) v -= 6; + // Defense: far from tenpai against a riichi → genbutsu first. + const safe = genbutsu.filter((g) => g.has(k)).length; + if (riichiFoes.length) v += sh >= 2 ? safe * 3000 : safe * 5; + return v; + }; + return best(discards, score, random); +}; diff --git a/packages/games/src/marble/bot.test.ts b/packages/games/src/marble/bot.test.ts new file mode 100644 index 0000000..75fa1e7 --- /dev/null +++ b/packages/games/src/marble/bot.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from 'bun:test'; +import { SeededRng, seedN } from '@bg/engine'; +import { decideBotAction } from '../bots/index'; +import { botPlayout } from '../bots/playout'; +import { randomPolicy } from '../bots/policy'; +import { marble, type Level, type MarbleOptions, type MarbleState } from './index'; + +const isBot = (p: string) => p.startsWith('bot'); + +/** 2-player game, 'bot' to move. */ +function make(opts: Partial = {}): MarbleState { + const s = marble.setup({ players: ['bot', 'H'], options: marble.optionsSchema.parse(opts), rng: SeededRng.fromSeed(seedN(1)), now: 0 }); + s.order = ['bot', 'H']; + s.current = 0; + return s; +} +const own = (s: MarbleState, pos: number, owner: string, level: Level = 0) => (s.owners[pos] = { owner, level }); +/** Bot standing on `pos` deciding whether to buy it. */ +function offered(pos: number, cash: number): MarbleState { + const s = make(); + Object.assign(s.players.bot!, { pos, cash }); + Object.assign(s, { phase: 'decide', decision: { pos, canBuy: true, canBuild: null, canTakeover: null, boughtThisVisit: false } }); + return s; +} +const decide = (s: MarbleState, who = 'bot') => decideBotAction(marble, s, who, { now: 1, random: () => 0.5 }); + +describe('marble bot', () => { + test('buys a city with cash to spare, passes when it would be left without a reserve against big tolls', () => { + expect(decide(offered(11, 300))).toEqual({ type: 'BUY' }); + const s = offered(11, 60); + own(s, 36, 'H', 3); // 뉴욕 hotel: toll 88 + expect(decide(s)).toEqual({ type: 'PASS' }); + }); + + test('covers a debt with the smallest sale that is enough', () => { + const s = make(); + own(s, 39, 'bot', 2); // 서울 빌딩: top sale 20, whole land 50 + own(s, 1, 'bot', 0); // 타이베이 land: 3 + own(s, 6, 'bot', 1); // 방콕 별장: top sale 2 + Object.assign(s.players.bot!, { cash: 0 }); + Object.assign(s, { phase: 'debt', debt: { debtor: 'bot', amount: 15, to: { kind: 'bank' }, reason: 'card', queue: [], then: { k: 'land' } } }); + expect(decide(s)).toEqual({ type: 'SELL', pos: 39, what: 'top' }); + }); + + test('accepts a generous trade and declines a bad one', () => { + const s = make({ trade: true }); + own(s, 39, 'bot', 0); + own(s, 1, 'H', 0); + Object.assign(s, { phase: 'tradeResponse', pendingTrade: { from: 'H', to: 'bot', give: { lands: [1], cash: 0 }, take: { lands: [39], cash: 0 } } }); + expect(decide(s)).toEqual({ type: 'TRADE_RESPOND', accept: false }); + s.pendingTrade = { from: 'H', to: 'bot', give: { lands: [], cash: 100 }, take: { lands: [1], cash: 0 } }; + own(s, 1, 'bot', 0); + expect(decide(s)).toEqual({ type: 'TRADE_RESPOND', accept: true }); + }); + + test('space travel avoids an enemy hotel and goes for something useful', () => { + const s = make(); + own(s, 39, 'H', 3); + Object.assign(s.players.bot!, { pos: 30, spacePending: true, cash: 300 }); + const a = decide(s) as { type: string; dest: number }; + expect(a.type).toBe('SPACE_TRAVEL'); + expect(a.dest).not.toBe(39); + expect([0, 10, 38]).not.toContain(a.dest); + }); + + test('beats a random player clearly in 1-on-1', () => { + let wins = 0; + const games = 16; + for (let g = 1; g <= games; g++) { + const rng = SeededRng.fromSeed(seedN(g)); + const chooser = SeededRng.fromSeed(seedN(g + 300)); + const random = () => chooser.next(); + let s = marble.setup({ players: ['bot', 'rnd'], options: marble.defaultOptions, rng, now: 0 }); + for (let i = 0; i < 5000 && !marble.result(s); i++) { + const p = marble.activePlayers(s)[0]!; + const a = p === 'bot' ? decideBotAction(marble, s, p, { now: 1, random }) : randomPolicy({ me: p, view: null, legal: marble.legalActions!(s, p), random }); + s = marble.apply(s, p, a as never, { rng, now: 1 }).state; + } + if (marble.result(s)?.ranking[0]!.includes('bot')) wins++; + } + expect(wins).toBeGreaterThanOrEqual(10); + }); + + for (const opts of [ + { trade: true, takeover: true, landmark: true, buildOnPurchase: true }, + { monopolyWin: true, islandFee: 20 as const, islandTurns: 1 as const }, + { maxRounds: 0 as const, startMoney: 200 as const, salary: 10 as const }, + ]) { + test(`option mode ${JSON.stringify(opts)}: games with a timing-out human finish`, () => { + const options = marble.optionsSchema.parse(opts); + for (let g = 1; g <= 3; g++) { + const r = botPlayout(marble, { players: ['human', 'bot:1', 'bot:2', 'bot:3'], options, seed: seedN(g), isBot, maxSteps: 30_000 }); + expect(r.finished).toBe(true); + expect(r.botTimeouts).toBe(0); + expect(r.maxBotMs).toBeLessThan(100); + } + }); + } +}); diff --git a/packages/games/src/marble/bot.ts b/packages/games/src/marble/bot.ts new file mode 100644 index 0000000..77f77f2 --- /dev/null +++ b/packages/games/src/marble/bot.ts @@ -0,0 +1,95 @@ +/** + * 주사위 땅따먹기 computer player: buys and builds while it keeps a cash reserve against the tolls ahead, sells the + * cheapest thing that covers a debt, escapes the island early in the game, flies to the best square on 우주여행, + * never proposes trades and only accepts clearly good ones. + */ +import type { BotStrategy } from '../bots/types'; +import { answerRequests, best, pick, playable, typeOf } from '../bots/policy'; +import { GROUPS, SPACES, investment, type Level } from './board'; +import type { MarbleAction, MarbleView } from './index'; + +type Act = Extract; + +export const marbleBot: BotStrategy = ({ me, view, legal, random }) => { + const req = answerRequests(legal); + if (req) return req; + const find = (t: T) => legal.find((a) => a.type === t) as Act | undefined; + const meP = view.players.find((p) => p.id === me)!; + const cash = meP.cash; + const owner = new Map(view.owned.map((o) => [o.pos, o])); + const theirs = view.owned.filter((o) => o.owner !== me); + + // Keep enough cash for a bad landing in the next lap; less as the round limit approaches + // (then cash and land count the same for the final ranking). + const maxToll = Math.max(0, ...theirs.map((o) => o.toll)); + let reserve = Math.min(150, Math.max(20, 0.6 * maxToll)); + if (view.maxRounds > 0) reserve *= Math.min(1, (view.maxRounds - view.round + 1) / 5); + /** Owning the rest of a colour group makes this square worth more (and can win with 독점). */ + const groupBonus = (pos: number) => { + const g = SPACES[pos]!.group; + if (!g) return 0; + const mine = GROUPS[g].filter((p) => owner.get(p)?.owner === me).length; + return mine * (view.options.monopolyWin ? 20 : 8); + }; + + // Trade offered to me: accept only when I get clearly more than I give (book value). + const respond = legal.filter((a) => a.type === 'TRADE_RESPOND') as Act<'TRADE_RESPOND'>[]; + if (respond.length && view.trade) { + const worth = (o: { lands: number[]; cash: number }) => o.cash + o.lands.reduce((s, p) => s + investment(p, (owner.get(p)?.level ?? 0) as Level), 0); + const good = worth(view.trade.give) >= 1.3 * worth(view.trade.take) + 5 && cash + view.trade.give.cash - view.trade.take.cash >= 0; + return respond.find((a) => a.accept === good) ?? respond[0]!; + } + + // Debt: the smallest single sale that covers it, else the biggest sale (top stages before whole lands). + const sells = legal.filter((a) => a.type === 'SELL') as Act<'SELL'>[]; + if (sells.length && view.my?.sell) { + const short = view.debt?.short ?? 0; + const amount = (a: Act<'SELL'>) => view.my!.sell!.find((o) => o.pos === a.pos && o.what === a.what)?.amount ?? 0; + const covers = sells.filter((a) => amount(a) >= short); + if (covers.length) return best(covers, (a) => -amount(a) - groupBonus(a.pos) + (a.what === 'top' ? 1 : 0), random); + return best(sells, (a) => amount(a) + (a.what === 'top' ? 100 : 0) - groupBonus(a.pos), random); + } + + // Landed on a square: buy / build / take over while the reserve stays. + const d = view.my?.decide; + if (d) { + const buy = find('BUY'); + if (buy && d.buy !== null && cash - d.buy >= reserve - groupBonus(d.pos)) return buy; + const build = find('BUILD'); + if (build && d.build && cash - d.build.cost >= reserve) return build; + const take = find('TAKEOVER'); + if (take && d.takeover !== null && cash - d.takeover >= 1.5 * reserve + 20) return take; + return find('PASS') ?? pick(playable(legal), random); + } + + // 우주여행: pick the destination worth most. + const space = legal.filter((a) => a.type === 'SPACE_TRAVEL') as Act<'SPACE_TRAVEL'>[]; + if (space.length && view.spacePreview) { + const prev = new Map(view.spacePreview.map((p) => [p.dest, p])); + const score = (dest: number): number => { + const p = prev.get(dest); + const sp = SPACES[dest]!; + let v = (p?.salary ? view.options.salary : 0) - (p?.toll ?? 0) * 1.5; + const o = owner.get(dest); + if (sp.price !== null && !o && cash - (p?.fare ?? 0) - sp.price >= reserve) v += 15 + sp.price / 2 + groupBonus(dest); + if (o?.owner === me && sp.kind === 'city' && o.level < 3) v += 8; + if (sp.kind === 'fundIn') v += view.fund; + if (sp.kind === 'island') v -= 30; + if (sp.kind === 'fundOut') v -= 15; + return v + random(); + }; + return best(space, (a) => score(a.dest), random); + } + + // Island: use an escape card, or pay the fee while the game is young and cash is plentiful. + if (view.my?.island) { + const card = find('USE_ESCAPE_CARD'); + if (card) return card; + const fee = find('PAY_ISLAND_FEE'); + const early = view.maxRounds === 0 || view.round <= view.maxRounds / 2; + if (fee && early && cash - (view.my.island.fee ?? 0) >= 2 * reserve) return fee; + } + // Never start trades: roll. + const rest = playable(legal).filter((a) => typeOf(a) !== 'PROPOSE_TRADE'); + return find('ROLL') ?? pick(rest.length ? rest : legal, random); +}; diff --git a/packages/games/src/ninety-nine/bot.test.ts b/packages/games/src/ninety-nine/bot.test.ts new file mode 100644 index 0000000..4e12227 --- /dev/null +++ b/packages/games/src/ninety-nine/bot.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from 'bun:test'; +import { SeededRng, seedN } from '@bg/engine'; +import { decideBotAction } from '../bots/index'; +import { botPlayout } from '../bots/playout'; +import { fullDeck, ninetyNine, type NNCard, type NNOptions, type NinetyNineState } from './index'; + +function make(opts: Partial = {}): NinetyNineState { + return ninetyNine.setup({ players: ['A', 'B', 'C'], options: ninetyNine.optionsSchema.parse(opts), rng: SeededRng.fromSeed(seedN(1)), now: 0 }); +} + +/** Gives A the cards with these face labels and sets the total; A to move. */ +function rig(s: NinetyNineState, labels: string[], total: number): NinetyNineState { + const pool = fullDeck(s); + const take = (label: string): NNCard => pool.splice(pool.findIndex((c) => c.face.label === label), 1)[0]!; + s.hands.A = labels.map(take); + s.hands.B = ['1', '2', '3'].map(take); + s.hands.C = ['1', '2', '3'].map(take); + s.total = total; + s.current = 'A'; + s.drawPile = pool; + s.discard = []; + return s; +} + +const decide = (s: NinetyNineState) => decideBotAction(ninetyNine, s, 'A', { now: 0, random: () => 0.5 }) as Record; +const labelOf = (s: NinetyNineState, a: Record) => s.hands.A!.find((c) => c.id === a.cardId)!.face.label; + +describe('ninety-nine bot', () => { + test('plays a big number while there is room and keeps the special cards', () => { + const s = rig(make(), ['2', '8', '조커', '0'], 30); + expect(labelOf(s, decide(s))).toBe('8'); + }); + + test('near 99 it brings the total down with ± instead of spending its 0 card', () => { + const s = rig(make(), ['8', '±10', '0'], 95); + const a = decide(s); + expect(labelOf(s, a)).toBe('±10'); + expect(a.sign).toBe('-'); + }); + + test('trump cards: holds with a 9 before spending its K', () => { + const s = rig(make({ mode: 'classic' }), ['K', '9', 'Q'], 92); + // Q (+10) does not fit; 9 keeps the total, K makes it 99: the cheaper 9 goes first. + expect(labelOf(s, decide(s))).toBe('9'); + }); + + const modes: [string, Partial][] = [ + ['trump cards with lives', { mode: 'classic', lives: 'auto' }], + ['dedicated cards, reset after a loss, 0-99 joker', { afterElimination: 'reset', jokerRange: '0-99', floorAtZero: 'clamp' }], + ['no refill, 5 cards, lives on the board', { refill: false, handSize: 5, lives: 2, onTheBoard: true }], + ]; + for (const [name, opts] of modes) { + test(`bots finish a game: ${name}`, () => { + const options = ninetyNine.optionsSchema.parse(opts); + for (let seed = 1; seed <= 2; seed++) { + const r = botPlayout(ninetyNine as never, { players: ['h', 'bot:1', 'bot:2', 'bot:3'], options, seed: seedN(seed), isBot: (p) => p.startsWith('bot:') }); + expect(r.finished).toBe(true); + expect(r.botTimeouts).toBe(0); + } + }); + } +}); diff --git a/packages/games/src/ninety-nine/bot.ts b/packages/games/src/ninety-nine/bot.ts new file mode 100644 index 0000000..3015f56 --- /dev/null +++ b/packages/games/src/ninety-nine/bot.ts @@ -0,0 +1,51 @@ +/** + * 구십구 computer player: plays plain number cards while they still fit (biggest first, they go dead as the total + * climbs) and saves the "safe" cards — 0/reverse, 9/hold, −10, ±9/±10, K, joker — for when the total is high. + * A forced safe card picks the cheapest one; ± goes down near 99, and K / the joker push the next player to 99 only + * when I still hold another safe card myself. + */ +import type { BotStrategy } from '../bots/types'; +import { best, pick, playable } from '../bots/policy'; +import type { CardKind, HandCardView, NinetyNineView, NNAction, PlayAction } from './types'; + +/** How much a card is worth keeping: it can be played on (almost) any total. */ +const KEEP: Record = { add: 0, ace: 0, reverse: 4, hold: 4, minus: 5, plusMinus: 5, to99: 6, joker: 8 }; +const isSafe = (c: HandCardView) => KEEP[c.kind] > 0; + +/** Total after `a` (null if it cannot be worked out from the view). */ +function totalAfter(a: PlayAction, c: HandCardView): number | null { + if (c.kind === 'joker') return a.jokerValue ?? null; + const ch = c.choices.find((x) => x.legal && x.sign === a.sign && x.aceValue === a.aceValue); + return ch ? ch.total : null; +} + +/** Smallest increase a card can make (plain numbers / ace), or null for safe cards. */ +function minRaise(c: HandCardView, total: number): number | null { + if (isSafe(c)) return null; + return Math.min(...c.choices.map((x) => x.total - total)); +} + +export const ninetyNineBot: BotStrategy = ({ view, legal, random }) => { + const hand = view.my?.hand ?? []; + const plays = legal.filter((a): a is PlayAction => a.type === 'play'); + if (!plays.length || !hand.length) return pick(playable(legal), random); + const byId = new Map(hand.map((c) => [c.id, c])); + + const score = (a: PlayAction): number => { + const c = byId.get(a.cardId); + if (!c) return -Infinity; + const after = totalAfter(a, c); + if (after === null) return -Infinity; + const rest = hand.filter((x) => x.id !== c.id); + const keepsSafe = rest.some(isSafe); + let v = -KEEP[c.kind] + random() * 0.2; + if (!isSafe(c)) v += (after - view.total) * 0.3; // dump big numbers while there is room + if (c.kind === 'plusMinus') v += after < view.total ? (view.total >= 80 ? 2 : -1) : view.total >= 80 ? -2 : 1; + if (c.kind === 'joker' || c.kind === 'to99') v += after >= 99 ? (keepsSafe ? 1.5 : -2) : after <= 70 ? 1 : 0; + // Will something in my hand still fit next turn (a safe card, or a number with a little room)? + const room = rest.some((x) => isSafe(x) || (minRaise(x, view.total) ?? 99) + after <= 95); + if (!room) v -= 6; + return v; + }; + return best(plays, score, random); +}; diff --git a/packages/games/src/omok/bot.test.ts b/packages/games/src/omok/bot.test.ts new file mode 100644 index 0000000..e7152a7 --- /dev/null +++ b/packages/games/src/omok/bot.test.ts @@ -0,0 +1,66 @@ +/** 오목 computer player: wins, blocks fours and open threes, knows overlines, never plays a forbidden point. */ +import { describe, expect, test } from 'bun:test'; +import { SeededRng } from '@bg/engine'; +import { decideBotAction } from '../bots/index'; +import { omok, type OmokAction, type OmokOptions, type OmokState } from './index'; +import { Board, forbiddenCells } from './rules'; + +const COLS = 'ABCDEFGHIJKLMNO'; +const at = (label: string) => ({ x: COLS.indexOf(label[0]!), y: 15 - Number(label.slice(1)) }); + +/** 15×15, A = black, B = white; `turn` is the bot to decide. Forbidden cells refreshed like the game does. */ +function make(stones: { black?: string[]; white?: string[] }, turn: 'black' | 'white', opts: Partial = {}): OmokState { + const options = omok.optionsSchema.parse({ colorAssignment: 'hostBlack', ...opts }); + const s = omok.setup({ players: ['A', 'B'], options, rng: SeededRng.fromSeed([1, 2, 3, 4]), now: 0 }); + for (const [color, v] of [['black', 1], ['white', 2]] as const) { + for (const l of stones[color] ?? []) { + const p = at(l); + s.board[p.y * 15 + p.x] = v; + s.moves.push({ color, pt: p, at: 0, auto: false }); + } + } + s.turn = turn; + s.forbidden = forbiddenCells(new Board(15, s.board), turn === 'black' ? 1 : 2, { ruleSet: options.ruleSet, freeWin: options.freeWin }); + return s; +} +const decide = (s: OmokState, seed = 1) => { + const r = SeededRng.fromSeed([seed, 9, 9, 9]); + return decideBotAction(omok, s, s.players[s.turn], { now: 1000, random: () => r.next() }) as OmokAction; +}; +const place = (l: string): OmokAction => ({ type: 'place', ...at(l) }); + +describe('omok bot', () => { + test('takes a winning five over blocking', () => { + const s = make({ black: ['D8', 'E8', 'F8', 'G8', 'C3'], white: ['H8', 'D10', 'E10', 'F10', 'G10'] }, 'black'); + expect(decide(s)).toEqual(place('C8')); + }); + + test('blocks a four', () => { + const s = make({ black: ['D8', 'E8', 'F8', 'G8'], white: ['C8', 'J3', 'K4'] }, 'white'); + expect(decide(s)).toEqual(place('H8')); + }); + + test('blocks an open three at one of its ends', () => { + for (let seed = 1; seed <= 4; seed++) { + const s = make({ black: ['F8', 'G8', 'H8', 'C3'], white: ['L12', 'M11'] }, 'white'); + expect([place('E8'), place('I8')]).toContainEqual(decide(s, seed)); + } + }); + + test('korean rule: an overline is no win, so it completes the exact five', () => { + // C8 makes exactly five (C–G); H8 would join six (D–I) which does not win under the korean rule. + const s = make({ black: ['D8', 'E8', 'F8', 'G8', 'I8'], white: ['A1', 'B1', 'O15', 'N15', 'O14'] }, 'black'); + for (let seed = 1; seed <= 4; seed++) expect(decide(s, seed)).toEqual(place('C8')); + }); + + test('renju: black never plays its forbidden double-three point', () => { + // H8 would make two open threes for black (G8-I8 row + H7-H9 column) — the strongest-looking point, but forbidden. + const s = make({ black: ['G8', 'I8', 'H7', 'H9'], white: ['A1', 'A3', 'A5', 'O15'] }, 'black', { ruleSet: 'renju' }); + expect(s.forbidden).toContain(at('H8').y * 15 + at('H8').x); + for (let seed = 1; seed <= 4; seed++) { + const a = decide(s, seed); + expect(a).not.toEqual(place('H8')); + expect(omok.validate(s, 'A', a).ok).toBe(true); + } + }); +}); diff --git a/packages/games/src/omok/bot.ts b/packages/games/src/omok/bot.ts new file mode 100644 index 0000000..118bbee --- /dev/null +++ b/packages/games/src/omok/bot.ts @@ -0,0 +1,74 @@ +/** 오목 computer player: win > block a five > open four > block open three > build lines near the stones. */ +import type { BotStrategy } from '../bots/types'; +import { answerRequests, best, pick, playable, typeOf } from '../bots/policy'; +import type { OmokView } from './index'; + +type Place = { type: 'place'; x: number; y: number }; +const DIRS = [ + [1, 0], + [0, 1], + [1, 1], + [1, -1], +] as const; + +/** + * Value of the lines a stone of `color` at (x, y) would make. `overlineWins`: does a run of 6+ win for that + * colour (korean / exact-five: no, it is neither a win nor a threat; renju white / free: yes). + */ +function lineValue(board: number[], size: number, x: number, y: number, color: number, overlineWins: boolean): number { + let total = 0; + for (const [dx, dy] of DIRS) { + let count = 1; + let open = 0; + for (const sign of [1, -1]) { + let cx = x + dx * sign; + let cy = y + dy * sign; + while (cx >= 0 && cy >= 0 && cx < size && cy < size && board[cy * size + cx] === color) { + count++; + cx += dx * sign; + cy += dy * sign; + } + if (cx >= 0 && cy >= 0 && cx < size && cy < size && board[cy * size + cx] === 0) open++; + } + if (count === 5 || (count > 5 && overlineWins)) total += 100_000; + else if (count > 5) continue; + else if (count === 4) total += open === 2 ? 10_000 : open === 1 ? 1_000 : 0; + else if (count === 3) total += open === 2 ? 1_000 : open === 1 ? 100 : 0; + else if (count === 2) total += open === 2 ? 100 : open === 1 ? 10 : 0; + else total += open; + } + return total; +} + +export const omokBot: BotStrategy = ({ view, legal, random }) => { + const req = answerRequests(legal); + if (req) return req; + const places = legal.filter((a) => typeOf(a) === 'place') as Place[]; + if (!places.length || !view.myColor) return pick(playable(legal), random); + const { size, board } = view; + const me = view.myColor === 'black' ? 1 : 2; + const opp = 3 - me; + const near = (p: Place) => { + for (let dy = -2; dy <= 2; dy++) + for (let dx = -2; dx <= 2; dx++) { + const x = p.x + dx; + const y = p.y + dy; + if (x >= 0 && y >= 0 && x < size && y < size && board[y * size + x] !== 0) return true; + } + return false; + }; + const close = places.filter(near); + // 6+ in a row wins only for renju white and in free play (the view doesn't say whether free play is + // "exact five"; assume the default "five or more"). + const overline = (c: number) => view.ruleSet === 'free' || (view.ruleSet === 'renju' && c === 2); + const center = (size - 1) / 2; + return best( + close.length ? close : places, + (p) => + lineValue(board, size, p.x, p.y, me, overline(me)) * 1.1 + + lineValue(board, size, p.x, p.y, opp, overline(opp)) - + (Math.abs(p.x - center) + Math.abs(p.y - center)) * 0.1 + + random() * 0.5, + random, + ); +}; diff --git a/packages/games/src/one-card/bot.test.ts b/packages/games/src/one-card/bot.test.ts new file mode 100644 index 0000000..f7e5ed2 --- /dev/null +++ b/packages/games/src/one-card/bot.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from 'bun:test'; +import { SeededRng, seedN } from '@bg/engine'; +import { decideBotAction } from '../bots/index'; +import { botPlayout } from '../bots/playout'; +import { classicDeck, oneCard, unoDeck, type Card, type OneCardOptions, type OneCardState } from './index'; + +const ALL: Card[] = [...classicDeck(2), ...unoDeck()]; +const card = (id: string): Card => ALL.find((x) => x.id === id)!; + +function make(opts: Partial = {}, players = ['A', 'B', 'C']): OneCardState { + return oneCard.setup({ players, options: oneCard.optionsSchema.parse(opts), rng: SeededRng.fromSeed(seedN(1)), now: 0 }); +} + +function rig(s: OneCardState, hands: Record, discard: string[], pending = 0): OneCardState { + for (const p of s.seats) s.hands[p] = (hands[p] ?? []).map(card); + s.discard = discard.map(card); + s.current = s.seats[0]!; + s.phase = { kind: 'turn' }; + s.pendingAttack = pending; + s.declaredSuit = null; + s.declaredColor = null; + s.oneCardWindow = null; + const used = new Set([...Object.values(hands).flat(), ...discard]); + s.drawPile = (s.mode === 'classic' ? classicDeck(s.decks) : unoDeck()).filter((c) => !used.has(c.id)); + return s; +} + +const decide = (s: OneCardState, p = 'A') => decideBotAction(oneCard, s, p, { now: 0, random: () => 0.5 }) as Record; + +describe('one-card bot', () => { + test('counters an attack instead of drawing', () => { + const s = rig(make(), { A: ['S2', 'H9', 'D5'], B: ['C4', 'C5'], C: ['D9', 'D10'] }, ['H5', 'H2'], 2); + expect(decide(s)).toEqual({ type: 'play', cardIds: ['S2'] }); + }); + + test('keeps the joker while a plain card fits', () => { + const s = rig(make(), { A: ['H3', 'H9', 'JB'], B: ['C4', 'C5', 'C6', 'C8'], C: ['D9', 'D10'] }, ['H5']); + expect(decide(s)).toEqual({ type: 'play', cardIds: ['H9'] }); + }); + + test('declares one-card, getting rid of a joker that may not be the last card', () => { + const s = rig(make(), { A: ['H9', 'JB'], B: ['C4', 'C5'], C: ['D9', 'D10'] }, ['H5']); + expect(decide(s)).toEqual({ type: 'play', cardIds: ['JB'], declareOneCard: true }); + }); + + test('a 7 changes to the suit it holds most of', () => { + const s = rig(make(), { A: ['H7', 'C3', 'C9', 'S4'], B: ['C4', 'C5'], C: ['D9', 'D10'] }, ['H5']); + // H7 is the only card that fits. + expect(decide(s)).toMatchObject({ type: 'play', cardIds: ['H7'], chooseSuit: 'C' }); + }); + + test("catches another player's missed one-card call", () => { + let s = rig(make(), { A: ['H9', 'C3'], B: ['C4', 'C5', 'S6'], C: ['D9', 'D10'] }, ['H5']); + s = oneCard.apply(s, 'A', { type: 'play', cardIds: ['H9'] }, { rng: SeededRng.fromSeed(seedN(2)), now: 1 }).state; + expect(s.oneCardWindow).toMatchObject({ player: 'A', declared: false }); + expect(decide(s, 'C')).toEqual({ type: 'declareOneCard' }); + }); + + test('colour cards: a wild picks the colour it holds most of', () => { + const s = rig(make({ mode: 'uno-style' }), { A: ['W1', 'B3a', 'B5a', 'G1a'], B: ['R1a', 'R2a'], C: ['Y1a', 'Y2a'] }, ['R9a']); + expect(decide(s)).toMatchObject({ type: 'play', cardIds: ['W1'], chooseColor: 'B' }); + }); + + const modes: [string, Partial][] = [ + ['classic multi-play, special last cards', { multiPlay: true, lastCardRule: 'special', kEffect: 'skip-two', afterJoker: 'any' }], + ['colour cards with stacking and +4 challenge', { mode: 'uno-style', unoStacking: true, unoWild4Rule: 'challenge' }], + ['colour cards to 200 points', { mode: 'uno-style', unoTargetScore: 200, handSize: 5 }], + ['classic until the last player, two decks', { endCondition: 'last-standing', deckCount: 2, playAfterDraw: 'on' }], + ]; + for (const [name, opts] of modes) { + test(`bots finish a game: ${name}`, () => { + const options = oneCard.optionsSchema.parse(opts); + for (let seed = 1; seed <= 2; seed++) { + const r = botPlayout(oneCard as never, { players: ['h', 'bot:1', 'bot:2', 'bot:3'], options, seed: seedN(seed), isBot: (p) => p.startsWith('bot:') }); + expect(r.finished).toBe(true); + expect(r.botTimeouts).toBe(0); + } + }); + } +}); diff --git a/packages/games/src/one-card/bot.ts b/packages/games/src/one-card/bot.ts new file mode 100644 index 0000000..bf2161c --- /dev/null +++ b/packages/games/src/one-card/bot.ts @@ -0,0 +1,105 @@ +/** + * 원카드 / 색깔 카드 computer player: always declare (and catch others' missed) one-card, counter attacks when it + * can, otherwise play a matching card while keeping jokers / 7s / wilds for later; attack when the next player is + * close to going out; a changed suit or colour is the one it holds most of. + */ +import type { BotStrategy } from '../bots/types'; +import { best, pick, playable, typeOf } from '../bots/policy'; +import { COLORS, SUITS, colorOf, isStd, type Card, type Color, type Suit } from './cards'; +import { attackOf, forbiddenAsLast } from './rules'; +import type { OneCardAction, OneCardView, PlayAction } from './types'; + +/** Seat that plays after me (skipping players that are out). */ +function nextSeat(view: OneCardView): OneCardView['seats'][number] | undefined { + const n = view.seats.length; + const i = view.seats.findIndex((s) => s.id === view.me); + for (let k = 1; k < n; k++) { + const s = view.seats[(((i + k * view.direction) % n) + n) % n]!; + if (s.status === 'playing') return s; + } + return undefined; +} + +/** Most common suit / colour among `cards` (random among ties or when there is none). */ +function majority(keys: K[], all: K[], random: () => number): K { + const count = new Map(); + for (const k of keys) count.set(k, (count.get(k) ?? 0) + 1); + return best(all, (k) => count.get(k) ?? 0, random); +} + +export const oneCardBot: BotStrategy = ({ view, legal, random }) => { + const types = new Set(legal.map(typeOf)); + if (types.has('ready')) return { type: 'ready' }; + // My own one-card call, or catching someone who forgot to call it. + if (types.has('declareOneCard')) return { type: 'declareOneCard' }; + + const hand = view.my?.hand ?? []; + const byId = new Map(hand.map((c) => [c.id, c])); + const plays = (legal.filter((a) => a.type === 'play') as PlayAction[]).filter((a) => a.declareOneCard || hand.length - a.cardIds.length !== 1); + const o = view.options; + const uno = view.mode === 'uno-style'; + + if (types.has('challengeWild4')) { + // Stack a +4 if allowed; otherwise challenge now and then (it is a guess: costs 2 more cards when wrong). + const stack = plays.find((a) => byId.get(a.cardIds[0]!)?.kind === 'wild4'); + if (stack) return withChoice(stack, hand, random); + return { type: random() < 0.25 ? 'challengeWild4' : 'acceptWild4' }; + } + if (!plays.length) { + if (types.has('pass')) return { type: 'pass' }; + if (types.has('draw')) return { type: 'draw' }; + return pick(playable(legal), random); + } + + const next = nextSeat(view); + const nextNearOut = !!next && next.cards <= 2; + const score = (a: PlayAction): number => { + const cards = a.cardIds.map((id) => byId.get(id)!); + const first = cards[0]!; + const rest = hand.filter((c) => !a.cardIds.includes(c.id)); + let v = cards.length * 2 + random() * 0.5; + if (view.pendingAttack > 0) return v + 10; // countering / blocking beats drawing a pile of cards + if (!uno) { + const atk = cards.reduce((x, c) => x + attackOf(o, c), 0); + if (first.kind === 'joker') v -= nextNearOut ? 0 : 4; + else if (atk > 0) v += nextNearOut ? 3 : -1; + if (isStd(first)) { + if (first.rank === 7) v -= 2; // the suit changer is a lifeline: keep it + if (first.rank === 13 && rest.length > 0) v += 1; // K: play again + if (first.rank === 11 || first.rank === 12) v += nextNearOut ? 2 : 0.5; + v += (first.rank >= 11 ? 10 : first.rank) / 20; // dump points first + } + // Don't leave myself only cards that may not finish the hand. + if (rest.length && rest.every((c) => forbiddenAsLast(o, c))) v -= 6; + } else { + if (first.kind === 'wild') v -= 3; + if (first.kind === 'wild4') { + v -= 4; + // Bluffing a +4 while holding the colour risks a challenge. + if (view.currentColor && rest.some((c) => colorOf(c) === view.currentColor)) v -= 3; + if (nextNearOut) v += 3; + } + if (first.kind === 'skip' || first.kind === 'reverse' || first.kind === 'draw2') v += nextNearOut ? 3 : 0.5; + if (first.kind === 'num') v += first.value / 20; + // Keep my colour going: prefer cards of the colour I hold most of. + const c = colorOf(first); + if (c) v += rest.filter((x) => colorOf(x) === c).length * 0.3; + } + return v; + }; + return withChoice(best(plays, score, random), hand, random); +}; + +/** Fixes the suit / colour of a 7 or a wild to the one the rest of the hand holds most of. */ +function withChoice(a: PlayAction, hand: Card[], random: () => number): PlayAction { + const rest = hand.filter((c) => !a.cardIds.includes(c.id)); + if (a.chooseSuit) { + const suits = rest.filter(isStd).map((c) => c.suit); + return { ...a, chooseSuit: majority(suits, SUITS, random) }; + } + if (a.chooseColor) { + const colors = rest.map(colorOf).filter((c): c is Color => !!c); + return { ...a, chooseColor: majority(colors, COLORS, random) }; + } + return a; +} diff --git a/packages/games/src/othello/bot.test.ts b/packages/games/src/othello/bot.test.ts new file mode 100644 index 0000000..0fba537 --- /dev/null +++ b/packages/games/src/othello/bot.test.ts @@ -0,0 +1,48 @@ +/** 오셀로 computer player: corners first, no X-squares next to an empty corner, no corner handed over. */ +import { describe, expect, test } from 'bun:test'; +import { SeededRng } from '@bg/engine'; +import { decideBotAction } from '../bots/index'; +import { othello, type OthelloAction, type OthelloState } from './index'; +import type { Cell } from './rules'; + +const at = (l: string) => ({ x: 'abcdefgh'.indexOf(l[0]!), y: Number(l.slice(1)) - 1 }); + +/** A = black (the bot here). 8 rows of '.', 'B', 'W' (row 1 first). */ +function fromRows(rows: string[]): OthelloState { + const options = othello.optionsSchema.parse({ colorAssignment: 'hostBlack' }); + const s = othello.setup({ players: ['A', 'B'], options, rng: SeededRng.fromSeed([1, 2, 3, 4]), now: 0 }); + s.board = rows.join('').split('').map((c) => (c === 'B' ? 1 : c === 'W' ? 2 : 0)) as Cell[]; + s.turn = 'black'; + return s; +} +const decide = (s: OthelloState, seed = 1) => { + const r = SeededRng.fromSeed([seed, 4, 4, 4]); + return decideBotAction(othello, s, 'A', { now: 1000, random: () => r.next() }) as OthelloAction; +}; +const legalLabels = (s: OthelloState) => othello.view(s, 'A').legalMoves!.map((m) => 'abcdefgh'[m.x]! + (m.y + 1)).sort(); + +describe('othello bot', () => { + test('takes a corner when available', () => { + // a1 (corner, flips b2) and f4 (flips e4 + more) are both legal. + const s = fromRows(['........', '.W......', '..B.....', '...WWB..', '...BW...', '........', '........', '........']); + expect(legalLabels(s)).toContain('a1'); + for (let seed = 1; seed <= 4; seed++) expect(decide(s, seed)).toEqual({ type: 'place', ...at('a1') }); + }); + + test('avoids the X-square next to an empty corner even when it flips more', () => { + // b2 flips two discs (c3, d4); g5 flips one. + const s = fromRows(['........', '........', '..W.....', '...W....', '....BW..', '........', '........', '........']); + const legal = legalLabels(s); + expect(legal).toContain('b2'); + for (let seed = 1; seed <= 4; seed++) expect(decide(s, seed)).not.toEqual({ type: 'place', ...at('b2') }); + }); + + test("accepts the opponent's undo request", () => { + let s = othello.setup({ players: ['A', 'B'], options: othello.optionsSchema.parse({ colorAssignment: 'hostBlack' }), rng: SeededRng.fromSeed([1, 2, 3, 4]), now: 0 }); + const step = (who: string, a: OthelloAction) => (s = othello.apply(s, who, a, { rng: SeededRng.fromSeed([1, 1, 1, 1]), now: 1000 }).state); + step('A', { type: 'place', ...at('d3') }); + step('B', { type: 'place', ...at('c3') }); + step('B', { type: 'requestUndo' }); + expect(decide(s)).toEqual({ type: 'respondUndo', accept: true }); + }); +}); diff --git a/packages/games/src/othello/bot.ts b/packages/games/src/othello/bot.ts new file mode 100644 index 0000000..97f4347 --- /dev/null +++ b/packages/games/src/othello/bot.ts @@ -0,0 +1,48 @@ +/** + * 오셀로 computer player: corners first, avoid the squares next to an empty corner, don't hand the opponent a + * corner, keep the opponent's choices few; flip counts only matter near the end. + */ +import type { BotStrategy } from '../bots/types'; +import { answerRequests, best, pick, playable, typeOf } from '../bots/policy'; +import type { OthelloView } from './index'; +import { CORNERS, legalMoves, type Cell, type Disc } from './rules'; + +type Place = { type: 'place'; x: number; y: number }; + +export const othelloBot: BotStrategy = ({ view, legal, random }) => { + const req = answerRequests(legal); + if (req) return req; + const places = legal.filter((a) => typeOf(a) === 'place') as Place[]; + if (!places.length || !view.myColor) return pick(playable(legal), random); + const n = view.size; + const last = n - 1; + const me: Disc = view.myColor === 'black' ? 1 : 2; + const opp: Disc = me === 1 ? 2 : 1; + const flipped = new Map((view.legalMoves ?? []).map((m) => [m.y * n + m.x, m.flipped])); + const endgame = view.discs.empty <= 12; + const cornerEmpty = (cx: number, cy: number) => view.board[cy * n + cx] === 0; + const score = ({ x, y }: Place): number => { + const i = y * n + x; + const flips = flipped.get(i) ?? []; + // Board after the move: what can the opponent answer? + const after = view.board.slice() as Cell[]; + after[i] = me; + for (const f of flips) after[f] = me; + const replies = legalMoves(after, opp); + let v = (endgame ? flips.length * 3 : flips.length * 0.3) - replies.length * 1.5 + random() * 0.5; + if (replies.some((m) => CORNERS.includes(m.i))) v -= 40; + if (replies.length === 0 && view.discs.empty > 1) v += 15; // opponent must pass + const edgeX = x === 0 || x === last; + const edgeY = y === 0 || y === last; + if (edgeX && edgeY) return v + 100; + // Nearest corner: X-square (diagonal) and C-squares (beside it) give it away while it is empty. + const cx = x < n / 2 ? 0 : last; + const cy = y < n / 2 ? 0 : last; + const dx = Math.abs(x - cx); + const dy = Math.abs(y - cy); + if (cornerEmpty(cx, cy) && dx <= 1 && dy <= 1) v -= dx === 1 && dy === 1 ? 50 : 20; + else if (edgeX || edgeY) v += 10; + return v; + }; + return best(places, score, random); +}; diff --git a/packages/games/src/poker/bot.test.ts b/packages/games/src/poker/bot.test.ts new file mode 100644 index 0000000..9ab680d --- /dev/null +++ b/packages/games/src/poker/bot.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from 'bun:test'; +import { SeededRng, seedN } from '@bg/engine'; +import { decideBotAction } from '../bots/index'; +import { botPlayout } from '../bots/playout'; +import { deck52, deckD20, type CardId } from './cards'; +import { poker, setupPoker, type PokerAction, type PokerOptions, type PokerState } from './index'; + +let clock = 1000; +const NAMES = ['A', 'B', 'C', 'D']; +const deckOf = (first: string, full: CardId[] = deck52()): CardId[] => { + const top = first.trim().split(/\s+/); + return [...top, ...full.filter((c) => !top.includes(c))]; +}; +const mk = (opts: Partial, n: number, deck: CardId[], dealerSeat = n - 1): PokerState => + setupPoker({ players: NAMES.slice(0, n), options: poker.optionsSchema.parse(opts), rng: SeededRng.fromSeed(seedN(1)), now: clock }, { dealerSeat, deck }); +const act = (s: PokerState, p: string, a: PokerAction) => { + const v = poker.validate(s, p, a); + if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`); + return poker.apply(s, p, a, { rng: SeededRng.fromSeed([7, 7, 7, 7]), now: (clock += 1000) }).state; +}; +const decide = (s: PokerState, p: string, r = 0.1) => decideBotAction(poker, s, p, { now: clock, random: () => r }) as PokerAction; +const toAct = (s: PokerState) => poker.view(s, null).toAct!; + +describe('poker bot', () => { + test("hold'em: raises pocket aces, folds 7-2 to a big raise", () => { + // order A(SB) B(BB) C; dealt A B C A B C → A 7♦2♣, C A♠A♥. + let s = mk({ mode: 'holdem', baseUnit: 10 }, 3, deckOf('7D 9S AS 2C 9H AH')); + expect(toAct(s)).toBe('C'); + const a = decide(s, 'C'); + expect(a.type).toBe('nl.raise'); + s = act(s, 'C', a); + expect(decide(s, 'A', 0.9).type).toBe('nl.fold'); + }); + + test('7포커 초이스: keeps the pair and opens the other card', () => { + // order A B C, four cards each round-robin → A: K♠ 2♣ 9♦ K♥. + const s = mk({ mode: 'sevenPoker' }, 3, deckOf('KS 3C 4D 2C 5S 6H 9D 7C 8S KH TC JD')); + const a = decide(s, 'A') as Extract; + expect(a.type).toBe('choose'); + expect(['KS', 'KH']).not.toContain(a.discardId); + expect(['KS', 'KH']).not.toContain(a.openId); + }); + + test('5카드 드로우: keeps the pair, swaps the rest', () => { + // order A B; dealt A B A B … → A: Q♠ Q♥ 3♣ 7♦ 9♠. + let s = mk({ mode: 'fiveDraw' }, 2, deckOf('QS 2D QH 4D 3C 6C 7D 8C 9S TH')); + while (s.hand!.phase === 'betting') s = act(s, toAct(s), { type: 'k.check' }); + expect(s.hand!.phase).toBe('draw'); + const a = decide(s, 'A') as Extract; + expect([...a.cardIds].sort()).toEqual(['3C', '7D', '9S']); + }); + + test('인디언: never sees its own forehead — same decision whatever it is; folds against a 10', () => { + // order A B C; foreheads A, B, C in that order. + const opts = { mode: 'indian' as const }; + const withTen = mk(opts, 3, deckOf('2S TS 3H', deckD20())); + const withTwo = mk(opts, 3, deckOf('TH TS 3H', deckD20())); + expect(poker.view(withTen, 'A').seats[0]!.forehead).toBeNull(); + const first = toAct(withTen); + expect(first).toBe('A'); + for (const r of [0.05, 0.3, 0.6, 0.95]) expect(decide(withTen, 'A', r)).toEqual(decide(withTwo, 'A', r)); + // A sees B's 10 and checks; B (sees a 2 and a 3) raises. + let s = act(withTen, 'A', { type: 'nl.check' }); + const b = decide(s, 'B'); + expect(b.type).toBe('nl.raise'); + s = act(s, 'B', b); + // C sees a 10 on B: folds. + expect(decide(s, 'C', 0.9).type).toBe('nl.fold'); + }); + + const MODES: [string, Partial][] = [ + ["hold'em (no-limit)", { mode: 'holdem' }], + ["hold'em (korean)", { mode: 'holdem', betting: 'korean' }], + ['omaha (pot-limit)', { mode: 'omaha' }], + ['7포커', { mode: 'sevenPoker' }], + ['7포커 (no-limit)', { mode: 'sevenPoker', betting: 'noLimit' }], + ['7포커 하이로우', { mode: 'sevenHiLo' }], + ['7포커 하이로우 (A-5, 8 qualifier)', { mode: 'sevenHiLo', hiLoLow: 'aceToFive', hiLoQualifier: 'eight' }], + ['바둑이', { mode: 'badugi' }], + ['5카드 드로우', { mode: 'fiveDraw' }], + ['5카드 드로우 (3장까지)', { mode: 'fiveDraw', drawLimit: 'three' }], + ['인디언 1장', { mode: 'indian' }], + ['인디언 2장', { mode: 'indian', indianCards: 2 }], + ['인디언 52장 (korean)', { mode: 'indian', indianDeck: 'd52', betting: 'korean' }], + ['최후 1인·리바이 제한', { mode: 'holdem', endCondition: 'lastStanding', rebuy: 'limited', rebuyLimit: 1 }], + ]; + test.each(MODES)('%s: 1 human + bots finish without bot timeouts', (_, o) => { + const options = poker.optionsSchema.parse(o); + for (const n of [3, 4]) { + const r = botPlayout(poker, { players: ['human', ...Array.from({ length: n - 1 }, (_, i) => `bot:${i}`)], options, seed: seedN(n), isBot: (p) => p.startsWith('bot:') }); + expect(r.finished).toBe(true); + expect(r.botTimeouts).toBe(0); + expect(r.maxBotMs).toBeLessThan(100); + } + }); +}); diff --git a/packages/games/src/poker/bot.ts b/packages/games/src/poker/bot.ts new file mode 100644 index 0000000..a676175 --- /dev/null +++ b/packages/games/src/poker/bot.ts @@ -0,0 +1,278 @@ +/** + * Poker computer player (all modes): a cautious beginner that bets by the strength of the cards it can see. + * Strength (0..1) comes from the game's own hand evaluators applied to my visible cards (+ board, + the open + * cards / forehead cards of the others). Strong hands raise, middling ones call fair prices, weak ones check or + * fold; an occasional small bluff; never all-in with trash. 초이스, draws and hi-lo declares use the same idea. + * In 인디언 포커 my own forehead card is hidden in my view, so the bot guesses it from the cards it can't see. + */ +import { suitOf } from '@bg/engine'; +import type { BotStrategy } from '../bots/types'; +import { pick, typeOf } from '../bots/policy'; +import { deck52, deckD20, hi, lo, type CardId, type HandValue } from './cards'; +import { evalBadugi } from './eval/badugi'; +import { evalIndian } from './eval/indian'; +import { INTL, bestIntl, bestOmaha } from './eval/international'; +import { KOR, bestKorean, evalKorean } from './eval/korean'; +import { bestLow } from './eval/low'; +import type { PokerAction, PokerView, SeatView } from './index'; + +const known = (cs: (CardId | null)[]): CardId[] => cs.filter((c): c is CardId => !!c); + +// ---------------------------------------------------------------- strength + +/** 국제 족보 category → rough strength. */ +function intlStrength(v: HandValue): number { + if (v.cat === INTL.high) return 0.08 + (v.key[1]! / 14) * 0.15; + if (v.cat === INTL.pair) return 0.3 + ((v.key[1]! - 2) / 12) * 0.2; + return [0, 0, 0, 0.62, 0.72, 0.8, 0.85, 0.92, 0.97, 1][v.cat]!; +} + +/** 한국식 족보 category → rough strength (7포커 hands run bigger, so the scale is a bit lower). */ +function korStrength(v: HandValue): number { + if (v.cat === KOR.high) return 0.05 + (v.key[1]! / 14) * 0.12; + if (v.cat === KOR.pair) return 0.25 + ((v.key[1]! - 2) / 12) * 0.17; + if (v.cat === KOR.twoPair) return 0.5 + ((v.key[1]! - 3) / 11) * 0.1; + return [0, 0, 0, 0, 0.68, 0.76, 0.78, 0.8, 0.84, 0.92, 0.97, 1, 1, 1][v.cat]!; +} + +/** Draw bonus: four cards of one suit while more cards are coming. */ +function flushDraw(cards: CardId[]): boolean { + const n: Record = {}; + for (const c of cards) n[suitOf(c)] = (n[suitOf(c)] ?? 0) + 1; + return Object.values(n).some((k) => k === 4); +} + +/** Low-hand strength for 하이로우: a made low by its top card, else how many small distinct cards I hold. */ +function lowStrength(cards: CardId[], view: PokerView): { s: number; made: boolean } { + const v = cards.length >= 5 ? bestLow(cards, view.options.hiLoLow, view.options.hiLoQualifier) : null; + if (v) return { s: Math.max(0.15, 0.85 - (-v.key[0]! - 5) * 0.08), made: true }; + if (cards.length >= 7) return { s: 0, made: false }; + const small = new Set(cards.map(lo).filter((r) => r <= 8)).size; + return { s: 0.08 * Math.min(small, 5), made: false }; +} + +/** 인디언: chance that my unseen forehead (plus my hand card) beats every forehead I can see. */ +function indianStrength(view: PokerView, me: SeatView): number { + const deck = view.options.indianDeck; + const others = view.seats.filter((x) => x.id !== me.id && x.inHand && !x.folded && x.forehead); + const mine = known(me.hole); + const seen = new Set([...view.indianUsed, ...mine, ...others.map((x) => x.forehead!)]); + const rest = (deck === 'd20' ? deckD20() : deck52()).filter((c) => !seen.has(c)); + if (!others.length || !rest.length) return 0.5; + let total = 0; + for (const f of rest) { + const my = evalIndian([f, ...mine], deck).key; + let p = 1; + for (const o of others) { + // Their hand card (2장 mode) is unknown too: average over what is left. + const pool = mine.length ? rest.filter((c) => c !== f) : [null]; + let w = 0; + for (const hc of pool) { + const their = evalIndian(hc ? [o.forehead!, hc] : [o.forehead!], deck).key; + const d = cmp(my, their); + w += d > 0 ? 1 : d === 0 ? 0.5 : 0; + } + p *= w / pool.length; + } + total += p; + } + return total / rest.length; +} + +function cmp(a: number[], b: number[]): number { + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const d = (a[i] ?? -1) - (b[i] ?? -1); + if (d) return d; + } + return 0; +} + +function strength(view: PokerView, me: SeatView): { s: number; low: { s: number; made: boolean } | null } { + const hole = known(me.hole); + const board = view.board; + switch (view.mode) { + case 'holdem': { + // Heads-up chances, minus a little for every extra opponent still in. + const crowd = 0.06 * Math.max(0, view.seats.filter((x) => x.id !== me.id && x.inHand && !x.folded).length - 1); + if (!board.length) { + const [a, b] = hole.map(hi).sort((x, y) => y - x) as [number, number]; + if (a === b) return { s: 0.5 + ((a - 2) / 12) * 0.35 - crowd, low: null }; + let s = 0.3 + ((a - 2) / 12) * 0.2 + ((b - 2) / 12) * 0.12; + if (suitOf(hole[0]!) === suitOf(hole[1]!)) s += 0.03; + if (a - b <= 2) s += 0.02; + return { s: s - crowd, low: null }; + } + const v = bestIntl([...hole, ...board]); + let s = intlStrength(v) + (v.cat <= INTL.pair ? 0.12 : 0); + // Only the board's hand (e.g. a paired board): my cards add nothing. + if (v.cat === bestIntl(board).cat && s < 0.8) s = Math.min(s, 0.25); + if (board.length < 5 && flushDraw([...hole, ...board])) s = Math.max(s, 0.45); + return { s: s - crowd, low: null }; + } + case 'omaha': { + if (!board.length) { + const ranks = hole.map(hi); + const pair = Math.max(0, ...ranks.filter((r, i) => ranks.indexOf(r) !== i)); + const suited = new Set(hole.map(suitOf)).size <= 3; + return { s: 0.22 + ranks.filter((r) => r >= 10).length * 0.05 + (pair ? 0.08 + pair / 100 : 0) + (suited ? 0.04 : 0), low: null }; + } + const v = bestOmaha(hole, board); + let s = v ? intlStrength(v) - 0.08 : 0.1; + if (board.length < 5 && hole.some((h) => flushDraw([h, ...board]) || hole.some((g) => g !== h && flushDraw([h, g, ...board])))) s = Math.max(s, 0.4); + return { s, low: null }; + } + case 'sevenPoker': + case 'sevenHiLo': { + const cards = [...hole, ...known(me.up)]; + const v = bestKorean(cards); + let s = korStrength(v); + if (cards.length < 7 && flushDraw(cards)) s = Math.max(s, 0.42); + // Someone's open cards already beat my whole hand: be careful. + const threat = view.seats.some((x) => x.id !== me.id && x.inHand && !x.folded && x.up.length && cmp(evalKorean(known(x.up).slice(0, 4)).key, v.key) > 0); + if (threat) s *= 0.7; + const low = view.mode === 'sevenHiLo' ? lowStrength(cards, view) : null; + return { s: low ? Math.max(s, low.s) : s, low }; + } + case 'badugi': { + const v = evalBadugi(hole); + const top = -(v.key[1] ?? -13); + const drawsLeft = Math.max(0, 3 - view.street); + if (v.cat === 4) return { s: 0.65 + ((13 - top) / 12) * 0.33, low: null }; + if (v.cat === 3) return { s: 0.25 + ((13 - top) / 12) * 0.15 + drawsLeft * 0.06, low: null }; + return { s: 0.08 + drawsLeft * 0.04, low: null }; + } + case 'fiveDraw': { + let s = intlStrength(bestIntl(hole)); + if (view.street === 0 && flushDraw(hole)) s = Math.max(s, 0.35); + return { s, low: null }; + } + case 'indian': + return { s: indianStrength(view, me), low: null }; + } +} + +// ---------------------------------------------------------------- non-betting choices + +/** 7포커 초이스: keep the three cards with the best start, open the least telling one. */ +function chooseCards(view: PokerView, choice: CardId[]): { discard: CardId; open: CardId } { + const startScore = (kept: CardId[]): number => { + const v = evalKorean(kept); + let sc = v.cat * 10 + Math.max(...kept.map(hi)) / 2; + if (new Set(kept.map(suitOf)).size === 1) sc += 6; + const r = kept.map(hi).sort((a, b) => a - b); + if (new Set(r).size === 3 && r[2]! - r[0]! <= 4) sc += 4; + if (view.mode === 'sevenHiLo') sc += new Set(kept.map(lo).filter((x) => x <= 6)).size * 4; + return sc; + }; + let discard = choice[0]!; + for (const d of choice) if (startScore(choice.filter((c) => c !== d)) > startScore(choice.filter((c) => c !== discard))) discard = d; + const kept = choice.filter((c) => c !== discard); + const paired = (c: CardId) => kept.some((k) => k !== c && hi(k) === hi(c)); + const asc = [...kept].sort((a, b) => hi(a) - hi(b)); + return { discard, open: asc.find((c) => !paired(c)) ?? asc[0]! }; +} + +/** Cards I'd like to throw away in 5카드 드로우 / 바둑이. */ +function wantedDiscards(view: PokerView, hole: CardId[]): CardId[] { + if (view.mode === 'badugi') { + const v = evalBadugi(hole); + if (v.cat === 4) return []; + // Keep the badugi part, but not its high cards. + const keep = v.best.filter((c) => lo(c) <= 9); + return hole.filter((c) => !keep.includes(c)); + } + const v = bestIntl(hole); + if (v.cat >= INTL.straight) return []; + const count = (c: CardId) => hole.filter((x) => hi(x) === hi(c)).length; + if (v.cat >= INTL.pair) return hole.filter((c) => count(c) < 2); + for (const s of new Set(hole.map(suitOf))) { + const same = hole.filter((c) => suitOf(c) === s); + if (same.length === 4) return hole.filter((c) => suitOf(c) !== s); + } + // Four to a straight (no pairs here): drop the card farthest from the others. + const asc = [...hole].sort((a, b) => hi(a) - hi(b)); + if (hi(asc[3]!) - hi(asc[0]!) <= 4) return [asc[4]!]; + if (hi(asc[4]!) - hi(asc[1]!) <= 4) return [asc[0]!]; + return asc.slice(0, 4); // keep the highest card only +} + +// ---------------------------------------------------------------- strategy + +type Bet = PokerView['my']['legal'][number]; + +export const pokerBot: BotStrategy = ({ me, view, legal, random }) => { + const first = legal[0]!; + const t = typeOf(first); + if (t === 'ready') return first; + if (t === 'rebuy') return { type: 'rebuy', accept: true }; + if (t === 'choose') { + const c = chooseCards(view, view.my.choice ?? []); + return legal.find((a) => a.type === 'choose' && a.discardId === c.discard && a.openId === c.open) ?? first; + } + const seat = view.seats.find((x) => x.id === me)!; + if (t === 'draw') { + const want = new Set(wantedDiscards(view, known(seat.hole))); + // The closest allowed draw: as many wanted discards as possible, never a card I want to keep. + let top = first; + let topScore = -Infinity; + for (const a of legal) { + if (a.type !== 'draw') continue; + const hit = a.cardIds.filter((c) => want.has(c)).length; + const sc = hit * 10 - (a.cardIds.length - hit) * 25; + if (sc > topScore) [top, topScore] = [a, sc]; + } + return top; + } + const { s, low } = strength(view, seat); + if (t === 'declare') { + const high = korStrength(bestKorean([...known(seat.hole), ...known(seat.up)])); + const side = low?.made && high >= 0.84 && low.s >= 0.75 ? 'swing' : low?.made && low.s > high ? 'low' : 'high'; + return { type: 'declare', side }; + } + + // Betting. + const bets = view.my.legal; + const by = (k: string) => bets.find((b) => b.type.endsWith(`.${k}`)); + const act = (b: Bet | undefined) => (b ? (legal.find((a) => a.type === b.type) ?? null) : null); + const check = by('check'); + const call = by('call'); + const fold = by('die') ?? by('fold'); + const pot = Math.max(view.pot, 1); + const raise = (big: boolean): PokerAction | null => { + if (view.betStyle === 'korean') { + const order = big ? ['half', 'full', 'quarter', 'ddadang', 'ping'] : ['ping', 'quarter', 'ddadang', 'half']; + for (const k of order) { + const b = by(k); + if (b && (!b.allIn || s >= 0.85)) return act(b); + } + return null; + } + const r = bets.find((b) => b.type === 'nl.raise'); + if (!r) return null; + const stackTo = seat.roundBet + seat.stack; + const to = Math.min(r.maxTo!, Math.max(r.minTo!, Math.round(view.currentBet + pot * (big ? 0.75 : 0.4)))); + if (to >= stackTo && s < 0.85) return null; + return { type: 'nl.raise', to }; + }; + + if (check) { + if (s >= 0.72) return raise(true) ?? act(check)!; + if ((s >= 0.5 && random() < 0.5) || random() < 0.07) return raise(false) ?? act(check)!; + return act(check)!; + } + if (s >= 0.75 && random() < 0.6) { + const r = raise(true); + if (r) return r; + } + if (call) { + const toCall = call.amount; + const price = toCall / (pot + toCall); + const worth = call.allIn ? s >= 0.55 : s >= Math.max(0.28, price + 0.12); + const cheapBluff = !call.allIn && toCall <= view.baseUnit && random() < 0.3; + if (worth || cheapBluff) return act(call)!; + } + // Only an all-in left to stay in: monsters only. + const allIn = bets.find((b) => b.type === 'nl.allIn'); + if (!call && allIn && s >= 0.75) return act(allIn)!; + return act(fold) ?? pick(legal, random); +}; diff --git a/packages/games/src/quoridor/bot.test.ts b/packages/games/src/quoridor/bot.test.ts new file mode 100644 index 0000000..2319b8c --- /dev/null +++ b/packages/games/src/quoridor/bot.test.ts @@ -0,0 +1,46 @@ +/** 벽 쌓기 길찾기 computer player: shortest path, winning step, walls a rival who is about to win. */ +import { describe, expect, test } from 'bun:test'; +import { SeededRng } from '@bg/engine'; +import { decideBotAction } from '../bots/index'; +import { quoridor, type Cell, type QuoridorAction, type QuoridorState, type Seat } from './index'; +import { emptyWallIndex, pathLength, setWall, type Wall } from './board'; + +/** 'e5' → { c: 4, r: 4 }. */ +const at = (l: string): Cell => ({ c: 'abcdefghi'.indexOf(l[0]!), r: Number(l.slice(1)) - 1 }); +/** 'h:d4' → horizontal wall at anchor d4. */ +const wall = (w: string): Wall => ({ o: w[0] as 'h' | 'v', ...at(w.slice(2)) }); + +/** 2 players: A = 남 (the bot here, to move, goal row 9), B = 북 (goal row 1). */ +function position(pos: Partial>, walls: string[] = []): QuoridorState { + const options = quoridor.optionsSchema.parse({ seatOrder: 'joinOrder' }); + const s = quoridor.setup({ players: ['A', 'B'], options, rng: SeededRng.fromSeed([1, 2, 3, 4]), now: 0 }); + for (const x of s.seats) if (pos[x.seat]) x.pos = at(pos[x.seat]!); + s.walls = walls.map(wall); + s.wallIndex = emptyWallIndex(); + for (const w of s.walls) setWall(s.wallIndex, w, true); + s.turnIndex = s.seats.findIndex((x) => x.seat === 'S'); + return s; +} +const decide = (s: QuoridorState, random = () => 0.5) => decideBotAction(quoridor, s, 'A', { now: 1000, random }) as QuoridorAction; + +describe('quoridor bot', () => { + test('walks the shortest way around a wall', () => { + // The wall above e2/f2 blocks the straight way; d2 → d3 is shortest (f2 is blocked above as well). + const s = position({ S: 'e2', N: 'e9' }, ['h:e2']); + expect(decide(s)).toEqual({ type: 'move', to: at('d2') }); + }); + + test('steps onto the goal row even when the rival is one step from winning', () => { + const s = position({ S: 'e8', N: 'a2' }); + expect(decide(s)).toEqual({ type: 'move', to: at('e9') }); + }); + + test('walls off a rival who is about to win', () => { + const s = position({ S: 'e5', N: 'e2' }); + const a = decide(s); + expect(a.type).toBe('placeWall'); + const after = quoridor.apply(s, 'A', a, { rng: SeededRng.fromSeed([1, 1, 1, 1]), now: 1000 }).state; + const north = after.seats.find((x) => x.seat === 'N')!; + expect(pathLength(after.wallIndex, north.pos!, 'N')).toBeGreaterThan(1); + }); +}); diff --git a/packages/games/src/quoridor/bot.ts b/packages/games/src/quoridor/bot.ts new file mode 100644 index 0000000..5a7c55d --- /dev/null +++ b/packages/games/src/quoridor/bot.ts @@ -0,0 +1,55 @@ +/** + * 벽 쌓기 길찾기 computer player: step onto the goal when possible, otherwise walk the shortest path; when the + * leading rival would get home first, put up the wall that delays that rival most compared to itself. + */ +import type { BotStrategy } from '../bots/types'; +import { answerRequests, best, pick, playable, typeOf } from '../bots/policy'; +import type { QuoridorView } from './index'; +import { emptyWallIndex, isGoal, pathLength, setWall } from './board'; + +type Move = { type: 'move'; to: { c: number; r: number } }; +type WallAct = { type: 'placeWall'; c: number; r: number; o: 'h' | 'v' }; + +export const quoridorBot: BotStrategy = ({ view, legal, random }) => { + const req = answerRequests(legal); + if (req) return req; + const moves = legal.filter((a) => typeOf(a) === 'move') as Move[]; + const walls = legal.filter((a) => typeOf(a) === 'placeWall') as WallAct[]; + const mine = view.seats.find((s) => s.seat === view.mySeat); + if ((!moves.length && !walls.length) || !mine?.pos) return pick(playable(legal), random); + const seat = mine.seat; + const win = moves.find((m) => isGoal(seat, m.to.c, m.to.r)); + if (win) return win; + const w = emptyWallIndex(); + for (const wall of view.walls) setWall(w, wall, true); + const myDist = pathLength(w, mine.pos, seat); + // The rival closest to its goal (pawns on the board only). + const leader = view.seats + .filter((s) => s.seat !== seat && s.status === 'playing' && s.pos) + .map((s) => ({ s, d: pathLength(w, s.pos!, s.seat) })) + .sort((a, b) => a.d - b.d)[0]; + // Behind (I move first, so a tie is mine) — or level and in the mood: look for a wall. + const behind = !!leader && leader.d < myDist; + if (walls.length && leader && (behind || (leader.d === myDist && random() < 0.3))) { + const gain = (wall: WallAct): number => { + setWall(w, wall, true); + const dRival = pathLength(w, leader.s.pos!, leader.s.seat); + const dMe = pathLength(w, mine.pos!, seat); + setWall(w, wall, false); + return dRival - leader.d - (dMe - myDist) * 1.5; + }; + const gains = new Map(walls.map((x) => [x, gain(x)])); + const top = best(walls, (x) => gains.get(x)! + random() * 0.3, random); + // Urgent (the rival gets home first): any net gain helps; otherwise only clearly good walls. + if (gains.get(top)! >= (behind ? 1 : 2) || (gains.get(top)! > 0 && !moves.length)) return top; + } + if (!moves.length) return pick(walls, random); + return best( + moves, + (m) => { + const d = pathLength(w, m.to, seat); + return d < 0 ? -1000 : -d; + }, + random, + ); +}; diff --git a/packages/games/src/rummikub/bot.test.ts b/packages/games/src/rummikub/bot.test.ts new file mode 100644 index 0000000..1de3f30 --- /dev/null +++ b/packages/games/src/rummikub/bot.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from 'bun:test'; +import { SeededRng, seedN, type PlayerId } from '@bg/engine'; +import { decideBotAction } from '../bots/index'; +import { botPlayout } from '../bots/playout'; +import { ALL_TILE_IDS, normalizeSet, rummikub, type RummikubAction, type RummikubOptions, type RummikubState } from './index'; + +const ME = 'bot:1:x'; +const OTHER = 'human0'; + +function rig(o: { rack: string[]; table?: string[][]; melded?: boolean; opts?: Partial }): RummikubState { + const options = rummikub.optionsSchema.parse(o.opts ?? {}); + const s = rummikub.setup({ players: [ME, OTHER], options, rng: SeededRng.fromSeed(seedN(1)), now: 0 }); + const table = o.table ?? []; + const otherRack = ALL_TILE_IDS.filter((id) => !o.rack.includes(id) && !table.flat().includes(id)).slice(-14); + const used = new Set([...o.rack, ...table.flat(), ...otherRack]); + s.racks = { [ME]: [...o.rack], [OTHER]: otherRack }; + s.table = table.map((ids, i) => { + const n = normalizeSet({ tileIds: ids })!; + return { id: `s${i + 1}`, tileIds: n.tileIds, kind: n.kind, jokerAs: n.jokerAs }; + }); + s.pool = ALL_TILE_IDS.filter((id) => !used.has(id)); + s.current = ME; + s.hasMelded = { [ME]: !!o.melded, [OTHER]: true }; + s.draft = { sets: s.table.map((t) => ({ tileIds: [...t.tileIds], as: t.kind })), history: [], edits: 0 }; + return s; +} + +/** Lets the bot finish its turn (draft, then commit); returns the actions it took. */ +function botTurn(s: RummikubState): { state: RummikubState; acts: RummikubAction[] } { + const acts: RummikubAction[] = []; + let now = 1000; + while (s.current === ME && s.phase === 'playing' && acts.length < 5) { + const a = decideBotAction(rummikub, s, ME, { now, random: () => 0.5 }) as RummikubAction; + acts.push(a); + s = rummikub.apply(s, ME, a, { rng: SeededRng.fromSeed(seedN(2)), now: (now += 1000) }).state; + } + return { state: s, acts }; +} + +describe('rummikub bot', () => { + test('makes the 30-point first meld from its rack and commits it in the same turn', () => { + const s = rig({ rack: ['R10a', 'R11a', 'R12a', 'K3a', 'B3a', 'O3a', 'B7a', 'K9b'] }); + const { state, acts } = botTurn(s); + expect(acts.map((a) => a.type)).toEqual(['setDraft', 'commit']); + expect(state.hasMelded[ME]).toBe(true); + expect(state.racks[ME]!.sort()).toEqual(['B7a', 'K9b']); + }); + + test('draws when the first meld would be under 30 points', () => { + const s = rig({ rack: ['R1a', 'R2a', 'R3a', 'K2a', 'B2a', 'O2a', 'B9a'] }); + const { state, acts } = botTurn(s); + expect(acts.map((a) => a.type)).toEqual(['draw']); + expect(state.racks[ME]).toHaveLength(8); + expect(state.table).toHaveLength(0); + }); + + test('after melding it lays tiles off on table sets (extending runs repeatedly)', () => { + const s = rig({ rack: ['B8a', 'B9a', 'O5a', 'K1a'], table: [['B5a', 'B6a', 'B7a'], ['R5a', 'K5a', 'B5b']], melded: true }); + const { state, acts } = botTurn(s); + expect(acts.map((a) => a.type)).toEqual(['setDraft', 'commit']); + expect(state.racks[ME]).toEqual(['K1a']); + expect(state.table.map((t) => t.tileIds.length).sort()).toEqual([4, 5]); + }); + + test('a half-built draft (left from a timeout race) is dropped by drawing, never committed', () => { + let s = rig({ rack: ['R1a', 'B9a', 'K4a'], melded: true }); + s = rummikub.apply(s, ME, { type: 'setDraft', sets: [{ tileIds: ['R1a'] }] }, { rng: SeededRng.fromSeed(seedN(2)), now: 500 }).state; + const { state, acts } = botTurn(s); + expect(acts.map((a) => a.type)).toEqual(['draw']); + expect(state.racks[ME]).toHaveLength(4); + }); + + test('bot-only games finish under several option sets', () => { + const isBot = (p: PlayerId) => p.startsWith('bot:'); + const optionSets: Partial[] = [{}, { initialMeldPoints: 0 }, { initialMeldPoints: 50, invalidCommitPenalty: 'penalty3' }, { gamesPerMatch: '2' }]; + for (const [i, o] of optionSets.entries()) { + const options = rummikub.optionsSchema.parse(o); + const r = botPlayout(rummikub, { players: ['bot:0:x', 'bot:1:x', 'bot:2:x'], options, seed: seedN(30 + i), isBot, maxSteps: 30_000 }); + expect(r.finished).toBe(true); + expect(r.botTimeouts).toBe(0); + } + }); +}); diff --git a/packages/games/src/rummikub/bot.ts b/packages/games/src/rummikub/bot.ts new file mode 100644 index 0000000..089944f --- /dev/null +++ b/packages/games/src/rummikub/bot.ts @@ -0,0 +1,62 @@ +/** + * 숫자 타일 computer player. A turn is two decisions: first one `setDraft` with the whole plan (rack-only sets, then + * single tiles laid off on table / new sets), then `commit` once the view says the draft is valid. If the draft is + * not valid (should not happen) it draws, which also throws the draft away — it never leaves a half-built draft. + * Table sets are never rearranged beyond adding tiles to their ends. + */ +import type { BotStrategy } from '../bots/types'; +import { answerRequests } from '../bots/policy'; +import { extendSet, isJoker, pickDisjoint, type JokerAs } from './rules'; +import type { RummikubAction, RummikubView } from './index'; + +type Plan = { tileIds: string[]; kind: 'run' | 'group'; jokerAs: Record }; + +/** Lays rack tiles off on the given sets in place (repeating, so 5-6-7 + 8 + 9 works). */ +function layOff(sets: Plan[], rack: string[], keepJokers: boolean): void { + const used = new Set(); + for (let changed = true; changed; ) { + changed = false; + for (const id of rack) { + if (used.has(id) || (keepJokers && isJoker(id))) continue; + for (let i = 0; i < sets.length; i++) { + const ext = extendSet(sets[i]!, id); + if (!ext) continue; + sets[i] = ext; + used.add(id); + changed = true; + break; + } + } + } +} + +export const rummikubBot: BotStrategy = ({ view, legal }) => { + const req = answerRequests(legal); + if (req) return req; + if (legal.some((a) => a.type === 'ready')) return { type: 'ready' }; + const my = view.my!; + const endTurn: RummikubAction = legal.find((a) => a.type === 'pass') ?? { type: 'draw' }; + // Second step of a turn: the plan is on the table — commit it, or give up cleanly (draw resets the draft). + if (my.changed) return my.check?.ok ? { type: 'commit', turn: view.turnNo } : endTurn; + + const rack = my.rack; + const table: Plan[] = view.table.map((t) => ({ tileIds: [...t.tileIds], kind: t.kind, jokerAs: t.jokerAs })); + const disjoint = pickDisjoint(rack); + const fresh = disjoint.sets.map((n): Plan => ({ tileIds: n.tileIds, kind: n.kind, jokerAs: n.jokerAs })); + const rest = rack.filter((id) => !fresh.some((s) => s.tileIds.includes(id))); + // A lone joker is only laid off near the end (it's the most flexible tile, but costs 30 if still on the rack). + const keepJokers = rack.length > 4; + let sets: Plan[]; + if (!my.melded) { + // First meld: every disjoint rack set at once, if together they reach the minimum. Table sets stay untouched. + if (!fresh.length || disjoint.points < view.options.initialMeldPoints) return endTurn; + layOff(fresh, rest, keepJokers); + sets = [...table, ...fresh]; + } else { + sets = [...table, ...fresh]; + layOff(sets, rest, keepJokers); + } + const before = new Set(view.table.flatMap((t) => t.tileIds)); + if (!sets.some((s) => s.tileIds.some((id) => !before.has(id)))) return endTurn; + return { type: 'setDraft', sets: sets.map((s) => ({ tileIds: s.tileIds, as: s.kind })), turn: view.turnNo }; +}; diff --git a/packages/games/src/seotda/bot.test.ts b/packages/games/src/seotda/bot.test.ts new file mode 100644 index 0000000..6e457be --- /dev/null +++ b/packages/games/src/seotda/bot.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from 'bun:test'; +import { SEOTDA_DECK, SeededRng, seedN } from '@bg/engine'; +import { decideBotAction } from '../bots/index'; +import { botPlayout } from '../bots/playout'; +import { seotda, type SeotdaAction, type SeotdaOptions, type SeotdaState } from './index'; + +/** Special card of a month (1·3·8 광, others 열끗) and the plain card. */ +const S = (m: number) => (m - 1) * 4; +const P = (m: number) => (m - 1) * 4 + 1; +let clock = 1000; + +function make(opts: Partial = {}, players = ['A', 'B', 'C'], dealer = 'A'): SeotdaState { + const options = seotda.optionsSchema.parse(opts); + for (let seed = 1; seed < 500; seed++) { + const s = seotda.setup({ players, options, rng: SeededRng.fromSeed(seedN(seed)), now: 0 }); + if (s.hand!.dealer === dealer) return s; + } + throw new Error('no seed with that dealer'); +} +function rig(s: SeotdaState, cards: Record, next: number[] = []): SeotdaState { + const h = s.hand!; + for (const [id, cs] of Object.entries(cards)) h.cards[id] = [...cs]; + const used = new Set([...Object.values(h.cards).flat(), ...next]); + h.deck = [...next, ...SEOTDA_DECK.filter((c) => !used.has(c))]; + return s; +} +const act = (s: SeotdaState, p: string, a: SeotdaAction) => seotda.apply(s, p, a, { rng: SeededRng.fromSeed([7, 7, 7, 7]), now: (clock += 1000) }).state; +const decide = (s: SeotdaState, p: string, r = 0.1) => decideBotAction(seotda, s, p, { now: clock, random: () => r }) as SeotdaAction; + +describe('seotda bot', () => { + test('raises a 38광땡', () => { + const s = rig(make({ dealMode: 'oneRound' }), { A: [S(3), S(8)], B: [P(5), P(6)], C: [P(2), P(7)] }); + expect(['half', 'quarter', 'full']).toContain(decide(s, 'A').type); + }); + + test('dies with 망통 facing a half bet, calls it with 장땡', () => { + let s = rig(make({ dealMode: 'oneRound' }), { A: [S(3), S(8)], B: [P(2), P(8)], C: [S(10), P(10)] }); + s = act(s, 'A', { type: 'half' }); + expect(decide(s, 'B', 0.9).type).toBe('die'); + s = act(s, 'B', { type: 'die' }); + expect(['call', 'ddadang', 'quarter', 'half']).toContain(decide(s, 'C', 0.9).type); + }); + + test('checks a weak hand when betting is free (never dies for nothing)', () => { + const s = rig(make({ dealMode: 'oneRound' }), { A: [P(2), P(3)], B: [P(5), P(6)], C: [P(2), P(7)] }); + expect(decide(s, 'A', 0.9).type).toBe('check'); + }); + + test('3장 섯다: keeps the best pair of its three cards', () => { + let s = make({ cardMode: 'three' }); + s = rig(s, { A: [P(4), P(9)], B: [P(5), P(7)], C: [P(2), P(3)] }, [P(6), S(2), S(5)]); + for (const p of ['A', 'B', 'C']) s = act(s, p, { type: 'check' }); + expect(s.hand!.phase).toBe('choose'); + const a = decide(s, 'A') as Extract; + expect([...a.cardIds].sort((x, y) => x - y)).toEqual([P(4), P(6)]); // 세륙 over 구사·5끗 + }); + + test.each([ + ['2장 섯다', {}], + ['3장 섯다', { cardMode: 'three' }], + ] as const)('%s: 1 human + 3 bots finish without bot timeouts', (_, options) => { + const opts = seotda.optionsSchema.parse(options); + for (let s = 1; s <= 2; s++) { + const r = botPlayout(seotda, { players: ['human', 'bot:1', 'bot:2', 'bot:3'], options: opts, seed: seedN(s), isBot: (p) => p.startsWith('bot:') }); + expect(r.finished).toBe(true); + expect(r.botTimeouts).toBe(0); + expect(r.maxBotMs).toBeLessThan(100); + } + }); +}); diff --git a/packages/games/src/seotda/bot.ts b/packages/games/src/seotda/bot.ts new file mode 100644 index 0000000..2e211bc --- /dev/null +++ b/packages/games/src/seotda/bot.ts @@ -0,0 +1,112 @@ +/** + * 섯다 computer player: a cautious beginner that bets by the strength of its own cards. + * Strong 족보 raise, middling ones call cheap bets, weak ones check or die; an occasional small bluff. + * Uses only its own view (own cards, pot, legal bet amounts) and the pure 족보 helpers. + */ +import type { BotStrategy } from '../bots/types'; +import { pick, typeOf } from '../bots/policy'; +import { evalSeotda, isGwang, monthOf, type HandRules } from './hands'; +import type { SeotdaAction, SeotdaView } from './index'; + +type Bet = SeotdaView['legal'][number]; + +const rulesOf = (o: SeotdaView['options']): HandRules => ({ + gwangTtaeng: o.gwangTtaeng, + ttaengjabi: o.ttaengjabi, + amhaeng: o.amhaeng, + gusa: o.gusa, + mungGusa: o.gusa ? o.mungGusa : 'off', +}); + +/** Rough winning chance (0..1) of a 2-card hand; specials count for what they can do. */ +export function pairStrength(a: number, b: number, rules: HandRules): number { + const e = evalSeotda(a, b, rules); + let s: number; + if (e.value >= 980) s = 0.96 + (e.value - 980) / 500; // 광땡 + else if (e.value >= 901) s = 0.8 + (e.value - 900) * 0.015; // 땡 + else if (e.value >= 801) s = 0.6 + (e.value - 801) * 0.025; // 알리 … 세륙 + else s = 0.12 + e.value * 0.045; // 망통 0.12 … 갑오 0.53 + if (e.special === 'ttaengjabi' || e.special === 'gusa') s = Math.max(s, 0.4); + if (e.special === 'mungGusa') s = Math.max(s, 0.5); + if (e.special === 'amhaeng') s = Math.max(s, 0.3); + return s; +} + +/** Strength from the cards I hold right now (1 card in 2장 섯다's first round, 2 of 3 before choosing). */ +function strength(cards: number[], rules: HandRules, threeMode: boolean): number { + if (cards.length === 1) { + const c = cards[0]!; + const m = monthOf(c); + // 광 and 10월 lead to the big hands; 1·4 to 알리/독사/세륙 and friends. + return isGwang(c) || m === 10 ? 0.5 : m === 1 || m === 4 ? 0.45 : 0.35; + } + let s = 0; + for (let i = 0; i < cards.length; i++) for (let j = i + 1; j < cards.length; j++) s = Math.max(s, pairStrength(cards[i]!, cards[j]!, rules)); + // 3장 섯다 first round: a third card is still coming. + return threeMode && cards.length === 2 ? Math.min(1, s + 0.06) : s; +} + +export const seotdaBot: BotStrategy = ({ me, view, legal, random }) => { + const rules = rulesOf(view.options); + const mine = view.seats.find((x) => x.id === me); + const cards = (mine?.cards ?? []).flatMap((c) => ('id' in c ? [c.id] : [])); + const first = legal[0]!; + + switch (typeOf(first)) { + case 'ready': + return first; + case 'rebuy': + // Keep playing: take the chips while rebuys remain. + return { type: 'rebuy', accept: true }; + case 'rejoin': { + // Pay into a 구사 rematch only when it is cheap compared to the pot. + const cheap = !!view.rejoin && view.rejoin.cost <= Math.min(view.pot / 3, (mine?.stack ?? 0) / 5); + return legal.find((a) => a.type === 'rejoin' && a.accept === cheap) ?? first; + } + case 'choose': { + // 3장 섯다: keep the pair with the best chance (specials such as 땡잡이 included). + const chooses = legal.filter((a): a is Extract => a.type === 'choose'); + let top = chooses[0]!; + for (const c of chooses) if (pairStrength(c.cardIds[0], c.cardIds[1], rules) > pairStrength(top.cardIds[0], top.cardIds[1], rules)) top = c; + return top; + } + } + + // Betting. + const s = strength(cards, rules, view.options.cardMode === 'three'); + const bets = view.legal; + const by = (t: string) => bets.find((b) => b.type === t); + const act = (b: Bet | undefined) => (b ? (legal.find((a) => a.type === b.type) ?? null) : null); + const stack = mine?.stack ?? 0; + const call = by('call'); + const toCall = call?.amount ?? 0; + const pot = Math.max(view.pot, 1); + // Raises that keep chips behind unless the hand is a monster. + const raises = bets.filter((b) => ['ping', 'ddadang', 'quarter', 'half', 'full'].includes(b.type) && (!b.allIn || s >= 0.85)); + const raise = (big: boolean) => { + const order = big ? ['half', 'full', 'ddadang', 'quarter', 'ping'] : ['ping', 'quarter', 'ddadang', 'half']; + for (const t of order) { + const b = raises.find((r) => r.type === t); + if (b) return act(b); + } + return null; + }; + + if (!call) { + // Free to check (or open the betting). + if (s >= 0.75) return raise(true) ?? act(by('check'))!; + if ((s >= 0.5 && random() < 0.5) || random() < 0.08) return raise(false) ?? act(by('check'))!; + return act(by('check')) ?? act(by('die'))!; + } + // Facing a bet: raise monsters, call when the hand is worth the price, otherwise die. + if (s >= 0.8 && random() < 0.7) { + const r = raise(true); + if (r) return r; + } + const price = toCall / (pot + toCall); + const allInCall = call.allIn || toCall >= stack; + const worth = allInCall ? s >= 0.6 : s >= Math.max(0.3, price + 0.12); + const cheapBluff = toCall <= view.baseUnit && random() < 0.3; + if (worth || cheapBluff) return act(call)!; + return act(by('die')) ?? pick(legal, random); +}; diff --git a/packages/games/src/splendor/bot.test.ts b/packages/games/src/splendor/bot.test.ts new file mode 100644 index 0000000..78f73ed --- /dev/null +++ b/packages/games/src/splendor/bot.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from 'bun:test'; +import { SeededRng, seedN } from '@bg/engine'; +import { decideBotAction } from '../bots/index'; +import { botPlayout } from '../bots/playout'; +import { randomPolicy } from '../bots/policy'; +import { emptyTokens, splendor, type SplendorOptions, type SplendorState, type Tokens } from './index'; + +const tk = (t: Partial): Tokens => ({ ...emptyTokens(), ...t }); + +/** Two players, A to move, a fixed board row 1 / 2 and the given nobles. */ +function mk(opts: Partial = {}): SplendorState { + const s = splendor.setup({ players: ['A', 'B'], options: splendor.optionsSchema.parse(opts), rng: SeededRng.fromSeed(seedN(1)), now: 0 }); + s.order = ['A', 'B']; + s.current = 0; + s.board[1] = ['1W01', '1U07', '1G08', '1K05']; + s.board[2] = ['2W06', '2U05', '2G01', '2K05']; + s.board[3] = ['3W02', '3U02', '3G02', '3K02']; + s.nobles = ['N01', 'N02', 'N03']; + return s; +} +const decide = (s: SplendorState, p = 'A', seed = 1) => { + const r = SeededRng.fromSeed(seedN(seed)); + return decideBotAction(splendor, s, p, { now: 1, random: () => r.next() }); +}; + +describe('splendor bot', () => { + test('buys the affordable card with the most points', () => { + const s = mk(); + // Affordable: 1U07 (3 K, 0 pts) and 2U05 (5 U, 2 pts); 1G08 (4 K, 1 pt) is not. + s.players.A!.tokens = tk({ K: 3, R: 4, U: 5 }); + s.supply = tk({ W: 4, U: 0, G: 4, R: 0, K: 1, gold: 5 }); + expect(decide(s)).toEqual({ type: 'BUY', from: { tier: 2, slot: 1 } }); + }); + + test('without a buy, takes the colours its target card is missing', () => { + const s = mk(); + s.board[1] = ['1W01', '1R07', '1K05', '1R05']; + // 1W01 costs U1 G1 R1 K1; holding R and K, this closest goal needs U and G. + s.players.A!.tokens = tk({ R: 1, K: 1 }); + s.supply = tk({ W: 4, U: 4, G: 4, R: 3, K: 3, gold: 5 }); + const a = decide(s) as { type: string; colors: string[] }; + expect(a.type).toBe('TAKE_DIFFERENT'); + expect(a.colors).toContain('U'); + expect(a.colors).toContain('G'); + }); + + test('gives back tokens it does not need for its target, never gold first', () => { + const s = mk(); + s.phase = 'discard'; + // Target 1W01 needs U G R K; 4 W are useless. + s.players.A!.tokens = tk({ W: 4, U: 1, G: 1, R: 1, K: 1, gold: 3 }); + const a = decide(s) as { type: string; tokens: Tokens }; + expect(a.type).toBe('DISCARD'); + expect(a.tokens).toEqual(tk({ W: 1 })); + }); + + test('option modes finish without bot timeouts (10 points, strict 3 colours, 4 players)', () => { + const modes: Partial[] = [{ targetPoints: 10 }, { allowFewerTokens: false, turnSeconds: null }, {}]; + for (const [i, m] of modes.entries()) { + for (const n of [2, 4]) { + const r = botPlayout(splendor as never, { + players: Array.from({ length: n }, (_, k) => `bot:${k}`), + options: splendor.optionsSchema.parse(m), + seed: seedN(30 + i * 5 + n), + isBot: () => true, + }); + expect(r.finished).toBe(true); + expect(r.botTimeouts).toBe(0); + } + } + }); + + test('beats a random player clearly, reaching the target', () => { + let wins = 0; + let target = 0; + for (let g = 0; g < 15; g++) { + const rng = SeededRng.fromSeed(seedN(300 + g)); + const ch = SeededRng.fromSeed(seedN(700 + g)); + const random = () => ch.next(); + let s = splendor.setup({ players: ['bot', 'rnd'], options: splendor.defaultOptions, rng, now: 0 }); + for (let step = 0; step < 3000 && !splendor.result(s); step++) { + const p = splendor.activePlayers(s)[0]!; + const a = + p === 'bot' + ? decideBotAction(splendor, s, p, { now: 0, random }) + : randomPolicy({ me: p, view: splendor.view(s, p), legal: splendor.legalActions!(s, p), random }); + s = splendor.apply(s, p, a as never, { rng, now: 0 }).state; + } + if (splendor.result(s)?.ranking[0]?.[0] === 'bot') wins++; + if (s.endReason === 'target') target++; + } + expect(wins).toBeGreaterThanOrEqual(13); + expect(target).toBeGreaterThanOrEqual(13); + }); +}); diff --git a/packages/games/src/splendor/bot.ts b/packages/games/src/splendor/bot.ts new file mode 100644 index 0000000..f412b3d --- /dev/null +++ b/packages/games/src/splendor/bot.ts @@ -0,0 +1,91 @@ +/** + * 보석 상인 computer player: buys the best affordable card (points first, then bonuses that nobles and + * wanted cards need); otherwise picks a target card (value per missing gem) and takes gems toward it, + * reserving it now and then. Uses only its own view (board, nobles, its tokens / bonuses / reservations). + */ +import type { BotStrategy } from '../bots/types'; +import { best, pick, randomPolicy } from '../bots/policy'; +import { COLORS, TOKEN_COLORS, emptyTokens, needFor, type BuyFrom, type Color, type DevCard, type SplendorAction, type SplendorView, type Tokens } from './index'; + +interface Spot { + card: DevCard; + from: BuyFrom; +} + +export const splendorBot: BotStrategy = (input) => { + const { me, view: v, legal, random } = input; + const my = v.players[me]; + const mv = v.me; + if (!my || !mv) return randomPolicy(input); + const tokens = my.tokens; + const bonuses = my.bonuses; + + // How much a card's bonus helps: open nobles still missing that colour (+ a little engine value early on). + const bonusUse = (c: Color) => v.nobles.reduce((a, n) => a + (n.req[c] > bonuses[c] ? 1 : 0), 0) + (my.points < 8 ? 0.5 : 0); + const value = (card: DevCard) => card.points * 2 + bonusUse(card.bonus) + 1.5; + /** Gems still missing for a card after bonuses, tokens and gold. */ + const short = (card: DevCard): Record => { + const need = needFor(card, bonuses); + const out = {} as Record; + for (const c of COLORS) out[c] = Math.max(0, need[c] - tokens[c]); + return out; + }; + const deficit = (card: DevCard) => Math.max(0, COLORS.reduce((a, c) => a + short(card)[c], 0) - tokens.gold); + + const spots: Spot[] = []; + for (const tier of [1, 2, 3] as const) v.board[tier].forEach((card, slot) => card && spots.push({ card, from: { tier, slot } })); + my.reserved.forEach((r, i) => !r.hidden && spots.push({ card: r, from: { reservedIndex: i } })); + const ranked = spots.filter((s) => deficit(s.card) > 0).sort((a, b) => value(b.card) / (deficit(b.card) + 1) ** 2 - value(a.card) / (deficit(a.card) + 1) ** 2); + const target = ranked[0]; + + if (v.phase === 'noble') return pick(legal, random); + + if (v.phase === 'discard') { + // Give back, one by one, the token least needed for the target (never gold while colours are left). + const keep = target ? needFor(target.card, bonuses) : null; + const left: Tokens = { ...tokens }; + const out = emptyTokens(); + for (let k = 0; k < mv.discardCount; k++) { + const held = TOKEN_COLORS.filter((c) => left[c] > 0); + const c = best(held, (c) => (c === 'gold' ? -100 : left[c] - (keep ? keep[c as Color] * 2 : 0)), random); + left[c]--; + out[c]++; + } + return { type: 'DISCARD', tokens: out }; + } + + // Buy: the best card I can afford. + const buys = Object.keys(mv.buy).map((key): Spot => { + const from: BuyFrom = key[0] === 'r' ? { reservedIndex: Number(key.slice(1)) } : { tier: Number(key[1]) as 1 | 2 | 3, slot: Number(key.slice(3)) }; + const card = 'reservedIndex' in from ? (my.reserved[from.reservedIndex] as DevCard) : v.board[from.tier][from.slot]!; + return { card, from }; + }); + if (buys.length) { + const b = best(buys, (s) => s.card.points * 10 + bonusUse(s.card.bonus) * 2 - mv.buy[key(s.from)]!.total * 0.3 + ('reservedIndex' in s.from ? 2 : 0), random); + return { type: 'BUY', from: b.from }; + } + + // Now and then reserve a strong target (also gets a gold), or when my hand of tokens is nearly full. + const t = target; + if (t && mv.canReserve && 'tier' in t.from && (tokenTotal(tokens) >= 8 || (t.card.points >= 3 && random() < 0.15))) { + return { type: 'RESERVE', from: t.from }; + } + + // Take gems toward the target, then toward the next-best cards. + const want = (c: Color) => ranked.slice(0, 3).reduce((a, s, i) => a + short(s.card)[c] * (i === 0 ? 10 : 3 - i), 0); + const shortT = t ? short(t.card) : null; + const same = mv.takeSame.filter((c) => shortT && shortT[c] >= 2); + const diff = mv.takeDifferent; + if (diff) { + const colors = [...diff.available].sort((a, b) => want(b) - want(a) + (random() - 0.5) * 0.1).slice(0, diff.count); + const useful = colors.filter((c) => shortT && shortT[c] > 0).length; + if (same.length && useful <= 1) return { type: 'TAKE_SAME', color: same[0]! }; + return { type: 'TAKE_DIFFERENT', colors }; + } + if (mv.takeSame.length) return { type: 'TAKE_SAME', color: best(mv.takeSame, want, random) }; + if (t && mv.canReserve && 'tier' in t.from) return { type: 'RESERVE', from: t.from }; + return randomPolicy(input); +}; + +const key = (from: BuyFrom) => ('reservedIndex' in from ? `r${from.reservedIndex}` : `b${from.tier}-${from.slot}`); +const tokenTotal = (t: Tokens) => TOKEN_COLORS.reduce((a, c) => a + t[c], 0); diff --git a/packages/games/src/yacht/bot.test.ts b/packages/games/src/yacht/bot.test.ts new file mode 100644 index 0000000..444752a --- /dev/null +++ b/packages/games/src/yacht/bot.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from 'bun:test'; +import { SeededRng, seedN } from '@bg/engine'; +import { decideBotAction } from '../bots/index'; +import { botPlayout } from '../bots/playout'; +import { yacht, type YachtOptions, type YachtState } from './index'; + +const random = () => 0.5; +const isBot = (p: string) => p.startsWith('bot'); + +/** Bot to move with forced dice after `rollsUsed` rolls. */ +function at(dice: number[], rollsUsed: number, opts: Partial = {}, scores: YachtState['sheets'][string]['scores'] = {}): YachtState { + const s = yacht.setup({ players: ['bot'], options: yacht.optionsSchema.parse(opts), rng: SeededRng.fromSeed(seedN(1)), now: 0 }); + s.dice = dice; + s.rollsUsed = rollsUsed; + s.sheets.bot!.scores = scores; + return s; +} +const decide = (s: YachtState) => decideBotAction(yacht, s, 'bot', { now: 1, random }); + +describe('yacht bot', () => { + test('rolls first, then keeps four sixes and rerolls the odd die', () => { + const s = yacht.setup({ players: ['bot'], options: yacht.defaultOptions, rng: SeededRng.fromSeed(seedN(1)), now: 0 }); + expect(decide(s)).toEqual({ type: 'roll' }); + expect(decide(at([6, 2, 6, 6, 6], 1))).toEqual({ type: 'hold', held: [true, false, true, true, true] }); + }); + + test('scores a yacht at once and a large straight in its box', () => { + expect(decide(at([4, 4, 4, 4, 4], 1))).toEqual({ type: 'score', category: 'yacht' }); + expect(decide(at([2, 3, 4, 5, 6], 3))).toEqual({ type: 'score', category: 'largeStraight' }); + }); + + test('a hopeless last roll sacrifices a weak box, not the yacht', () => { + // Only 'ones' and 'yacht' are open. + const scores = Object.fromEntries(['twos', 'threes', 'fours', 'fives', 'sixes', 'choice', 'fourKind', 'fullHouse', 'smallStraight', 'largeStraight'].map((c) => [c, 10])); + expect(decide(at([2, 3, 4, 6, 6], 3, {}, scores))).toEqual({ type: 'score', category: 'ones' }); + }); + + /** Solo game played by the bot only; returns its final total. */ + function solo(options: YachtOptions, seed: number): number { + const rng = SeededRng.fromSeed(seedN(seed)); + const chooser = SeededRng.fromSeed(seedN(seed + 1000)); + let s = yacht.setup({ players: ['bot'], options, rng, now: 0 }); + for (let i = 0; i < 200 && !yacht.result(s); i++) { + const a = decideBotAction(yacht, s, 'bot', { now: 1, random: () => chooser.next() })!; + s = yacht.apply(s, 'bot', a as never, { rng, now: 1 }).state; + } + return yacht.result(s)!.scores!.bot!; + } + + test('averages a solid score over solo games', () => { + const scores = Array.from({ length: 10 }, (_, g) => solo(yacht.defaultOptions, g + 1)); + // A random player averages well under 100; a careful beginner is comfortably above. + expect(scores.reduce((a, b) => a + b, 0) / scores.length).toBeGreaterThan(150); + }); + + for (const opts of [{ scoring: 'yahtzee' as const }, { scoring: 'yahtzee' as const, yahtzeeJoker: 'free' as const }, { fullHouseAllowsYacht: false }]) { + test(`option mode ${JSON.stringify(opts)}: games with a timing-out human finish`, () => { + const options = yacht.optionsSchema.parse(opts); + for (let g = 1; g <= 3; g++) { + const r = botPlayout(yacht, { players: ['human', 'bot:1', 'bot:2'], options, seed: seedN(g), isBot }); + expect(r.finished).toBe(true); + expect(r.botTimeouts).toBe(0); + expect(r.maxBotMs).toBeLessThan(100); + } + }); + } +}); diff --git a/packages/games/src/yacht/bot.ts b/packages/games/src/yacht/bot.ts new file mode 100644 index 0000000..af507f6 --- /dev/null +++ b/packages/games/src/yacht/bot.ts @@ -0,0 +1,114 @@ +/** + * 요트 다이스 computer player. Every final hand is valued as "points minus the par of the category it uses up" + * (so a 0 lands in a weak box, and a high box is not wasted on a poor hand); holds are chosen by the exact + * expected value of that over the remaining rerolls (dice multisets, cached per decision). + */ +import type { BotStrategy } from '../bots/types'; +import { answerRequests, best, pick, playable, typeOf } from '../bots/policy'; +import { UPPER_BONUS_THRESHOLD, allowedCategories, faceOf, isUpper, scoreFor, type Category, type Sheet } from './scoring'; +import type { YachtAction, YachtView } from './index'; + +/** Roughly what a box is worth on average: the cost of filling it now. */ +const PAR: Record<'yacht51' | 'yahtzee', Partial>> = { + yacht51: { choice: 22, fourKind: 12, fullHouse: 14, smallStraight: 10, largeStraight: 16, yacht: 14 }, + yahtzee: { choice: 22, threeKind: 17, fourKind: 10, fullHouse: 17, smallStraight: 20, largeStraight: 22, yacht: 14 }, +}; + +// A dice multiset is keyed by its face counts in base 6 (key = Σ count[f] · 6^(f-1)); keys of disjoint parts add up. +const POW = [0, 1, 6, 36, 216, 1296, 7776]; +const KEYS = 6 ** 6; +const keyOf = (dice: number[]) => dice.reduce((k, d) => k + POW[d]!, 0); +const diceOf = (key: number) => { + const out: number[] = []; + for (let f = 1; f <= 6; f++) for (let c = Math.floor(key / POW[f]!) % 6; c > 0; c--) out.push(f); + return out; +}; + +/** All outcomes of rolling k dice as [multiset key, probability]. */ +const OUTCOMES: [number, number][][] = []; +for (let k = 0; k <= 5; k++) { + const m = new Map(); + const rec = (left: number, key: number, p: number) => { + if (left === 0) return void m.set(key, (m.get(key) ?? 0) + p); + for (let f = 1; f <= 6; f++) rec(left - 1, key + POW[f]!, p / 6); + }; + rec(k, 0, 1); + OUTCOMES.push([...m]); +} + +export const yachtBot: BotStrategy = ({ me, view, legal, random }) => { + const req = answerRequests(legal); + if (req) return req; + const roll = legal.find((a) => a.type === 'roll'); + if (!view.dice || view.rollsUsed === 0) return roll ?? pick(playable(legal), random); + + const o = view.options; + const rules = { scoring: o.scoring, fullHouseAllowsYacht: o.fullHouseAllowsYacht, yahtzeeJoker: o.yahtzeeJoker }; + const mine = view.sheets[me]!; + const sheet: Sheet = { scores: mine.scores, yahtzeeBonus: mine.yahtzeeBonus }; + const par = PAR[o.scoring]; + const upperNeed = UPPER_BONUS_THRESHOLD - mine.upper; + + /** Value of writing `dice` into `c`. */ + const catValue = (dice: number[], c: Category): number => { + const out = scoreFor(dice, c, sheet, rules); + let v = out.points + out.yahtzeeBonus; + if (isUpper(c)) { + const f = faceOf(c); + v -= 3 * f; // three of a face keeps the 63 bonus on track + if (upperNeed > 0 && out.points >= 3 * f) v += 6; + } else v -= par[c] ?? 0; + return v; + }; + + // V: best box value of a final hand; EV1/EV2: expected value with 1/2 rerolls left after holding a multiset. + const V = new Float64Array(KEYS).fill(NaN); + const val = (key: number): number => { + if (Number.isNaN(V[key]!)) { + const dice = diceOf(key); + V[key] = Math.max(...allowedCategories(dice, sheet, rules).map((c) => catValue(dice, c))); + } + return V[key]!; + }; + const E1 = new Float64Array(KEYS).fill(NaN); + const ev1 = (held: number, n: number): number => { + if (Number.isNaN(E1[held]!)) E1[held] = OUTCOMES[5 - n]!.reduce((s, [k, p]) => s + p * val(held + k), 0); + return E1[held]!; + }; + /** Best expected value reachable from a full hand with one reroll left (holding everything = stop). */ + const B1 = new Map(); + const bestHold1 = (hand: number): number => { + if (!B1.has(hand)) { + const dice = diceOf(hand); + let top = -Infinity; + for (let m = 0; m < 32; m++) { + const kept = dice.filter((_, i) => (m >> i) & 1); + top = Math.max(top, ev1(keyOf(kept), kept.length)); + } + B1.set(hand, top); + } + return B1.get(hand)!; + }; + const ev2 = (held: number, n: number) => OUTCOMES[5 - n]!.reduce((s, [k, p]) => s + p * bestHold1(held + k), 0); + + const dice = view.dice; + if (view.rollsLeft > 0) { + const hold2 = new Map(); + const expect = (kept: number[]) => { + const key = keyOf(kept); + if (kept.length === 5) return val(key); + if (view.rollsLeft === 1) return ev1(key, kept.length); + if (!hold2.has(key)) hold2.set(key, ev2(key, kept.length)); + return hold2.get(key)!; + }; + const masks = Array.from({ length: 32 }, (_, m) => [0, 1, 2, 3, 4].map((i) => ((m >> i) & 1) === 1)); + const want = best(masks, (h) => expect(dice.filter((_, i) => h[i])) + random() * 1e-6, random); + if (!want.every(Boolean)) { + if (want.some((h, i) => h !== view.held[i])) return { type: 'hold', held: want }; + if (roll) return roll; + } + } + const scores = legal.filter((a) => a.type === 'score') as Extract[]; + if (!scores.length) return roll ?? pick(playable(legal), random); + return best(scores, (a) => catValue(dice, a.category), random); +}; diff --git a/packages/games/src/yut/bot.test.ts b/packages/games/src/yut/bot.test.ts new file mode 100644 index 0000000..34b9f0e --- /dev/null +++ b/packages/games/src/yut/bot.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from 'bun:test'; +import { SeededRng, seedN } from '@bg/engine'; +import { decideBotAction } from '../bots/index'; +import { botPlayout } from '../bots/playout'; +import { randomPolicy } from '../bots/policy'; +import { yut, type NodeId, type Throw, type YutOptions, type YutState } from './index'; + +const isBot = (p: string) => p.startsWith('bot'); + +/** Two-player game, side 0 ('bot') to move with these results. */ +function moving(pending: Throw[], opts: Partial = {}): YutState { + const s = yut.setup({ players: ['bot', 'H'], options: yut.optionsSchema.parse(opts), rng: SeededRng.fromSeed(seedN(1)), now: 0 }); + Object.assign(s, { turnSide: 0, current: 'bot', phase: 'move', throwsOwed: 0, pending }); + return s; +} +function put(s: YutState, side: number, piece: number, node: NodeId, trail: NodeId[] = [node]): void { + Object.assign(s.sides[side]!.pieces[piece]!, { status: 'board', node, trail, lapped: node === 0 }); +} +const decide = (s: YutState) => decideBotAction(yut, s, s.current, { now: 1, random: () => 0.5 }); + +describe('yut bot', () => { + test('throws when it is time to throw', () => { + const s = yut.setup({ players: ['bot', 'H'], options: yut.defaultOptions, rng: SeededRng.fromSeed(seedN(1)), now: 0 }); + Object.assign(s, { turnSide: 0, current: 'bot' }); + expect(decide(s)).toEqual({ type: 'THROW' }); + }); + + test('catches an enemy piece instead of just running ahead', () => { + const s = moving(['geol']); + put(s, 0, 0, 2); + put(s, 0, 1, 12); + put(s, 1, 0, 5); + expect(decide(s)).toEqual({ type: 'MOVE', throwIndex: 0, from: 2 }); + }); + + test('uses 모 on the piece standing on a corner to take the shortcut home', () => { + const s = moving(['mo']); + put(s, 0, 0, 10, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + put(s, 0, 1, 3, [1, 2, 3]); + // From 10 the diagonal leads 25 → 26 → 22 → 27 → 28: five steps that nearly finish. + expect(decide(s)).toEqual({ type: 'MOVE', throwIndex: 0, from: 10 }); + }); + + test('does not stop right in front of an enemy when an equal move is safe', () => { + const s = moving(['gae']); + for (const side of [0, 1]) for (const p of s.sides[side]!.pieces) Object.assign(p, { status: 'done', node: null, trail: [] }); + put(s, 0, 0, 7); + put(s, 0, 1, 14); + put(s, 1, 0, 15); + // 14 → 16 would stand one 도 ahead of the enemy on 15; 7 → 9 is out of its reach. + expect(decide(s)).toEqual({ type: 'MOVE', throwIndex: 0, from: 7 }); + }); + + test('beats a random player clearly in 1-on-1', () => { + let wins = 0; + const games = 20; + for (let g = 1; g <= games; g++) { + const rng = SeededRng.fromSeed(seedN(g)); + const chooser = SeededRng.fromSeed(seedN(g + 500)); + const random = () => chooser.next(); + const players = g % 2 ? ['bot', 'rnd'] : ['rnd', 'bot']; + let s = yut.setup({ players, options: yut.defaultOptions, rng, now: 0 }); + for (let i = 0; i < 5000 && !yut.result(s); i++) { + const p = s.current; + const a = p === 'bot' ? decideBotAction(yut, s, p, { now: 1, random }) : randomPolicy({ me: p, view: null, legal: yut.legalActions!(s, p), random }); + s = yut.apply(s, p, a as never, { rng, now: 1 }).state; + } + if (yut.result(s)!.ranking[0]!.includes('bot')) wins++; + } + expect(wins).toBeGreaterThanOrEqual(14); + }); + + for (const [opts, n] of [ + [{ mode: 'team' as const }, 4], + [{ mode: 'team' as const, pieces: 2 as const }, 6], + [{ finishRule: 'reach' as const, backdoFromDo: 'finish' as const, nakPercent: 10 as const, stickModel: 'fair' as const }, 3], + [{ ranking: 'all' as const, backdo: false }, 4], + ] as const) { + test(`option mode ${JSON.stringify(opts)} with ${n} players finishes`, () => { + const options = yut.optionsSchema.parse(opts); + const players = Array.from({ length: n }, (_, i) => (i === 1 ? 'human' : `bot:${i}`)); + for (let g = 1; g <= 3; g++) { + const r = botPlayout(yut, { players, options, seed: seedN(g), isBot }); + expect(r.finished).toBe(true); + expect(r.botTimeouts).toBe(0); + expect(r.maxBotMs).toBeLessThan(100); + } + }); + } +}); diff --git a/packages/games/src/yut/bot.ts b/packages/games/src/yut/bot.ts new file mode 100644 index 0000000..92a1d3f --- /dev/null +++ b/packages/games/src/yut/bot.ts @@ -0,0 +1,61 @@ +/** + * 윷놀이 computer player. Each legal move is scored by: catching enemies (worth their progress + the bonus throw), + * steps of progress (shortcuts and finishing come out of the distance table), stacking a little, and the chance an + * enemy lands on the piece with its next throw (estimated from the visible board and the throw odds). + */ +import type { BotStrategy } from '../bots/types'; +import { answerRequests, best, pick, playable } from '../bots/policy'; +import { FLAT_P, GOAL, THROW_STEPS, advance, defaultPrev, remainingDistance, throwProbabilities, type NodeId, type Throw } from './board'; +import type { YutAction, YutLegalMove, YutView } from './index'; + +const STEP = 3; // points per step of progress per piece +const THROWS: Throw[] = ['backdo', 'do', 'gae', 'geol', 'yut', 'mo']; + +export const yutBot: BotStrategy = ({ view, legal, random }) => { + const req = answerRequests(legal); + if (req) return req; + const moves = legal.filter((a) => a.type === 'MOVE') as Extract[]; + if (!moves.length || !view.legalMoves) return pick(playable(legal), random); + + const o = view.options; + const me = view.mySide; + const dist = (n: NodeId | 'home' | typeof GOAL) => (n === GOAL ? 0 : remainingDistance(n, o.finishRule)); + const home = dist('home'); + const odds = throwProbabilities(FLAT_P[o.stickModel], o.backdo); + const live = new Set(view.sides.filter((x) => x.id !== me && !x.out && x.finishedRank === null).map((x) => x.id)); + const enemyStacks = view.stacks.filter((st) => live.has(st.side)); + const enemyHome = view.sides.some((x) => live.has(x.id) && x.home > 0); + + /** Chance that some enemy can land on `node` with its next throw (bonus throws ignored). */ + const risk = (node: NodeId): number => { + let p = 0; + for (const t of THROWS) { + const hits = + (t !== 'backdo' && enemyHome && advance('home', THROW_STEPS[t], o.finishRule).to === node) || + enemyStacks.some((st) => (t === 'backdo' ? defaultPrev(st.node) === node : advance(st.node, THROW_STEPS[t], o.finishRule).to === node)); + if (hits) p += odds[t]; + } + return Math.min(1, p); + }; + const mineAt = (node: NodeId) => view.stacks.find((st) => st.side === me && st.node === node)?.count ?? 0; + /** What losing `n` own pieces standing on `node` would cost. */ + const exposure = (node: NodeId, n: number) => STEP * n * (home - dist(node)) + 10; + + const score = (m: YutLegalMove): number => { + let v = STEP * m.count * (dist(m.from) - dist(m.to)); + if (m.from !== 'home') v += risk(m.from) * exposure(m.from, m.count); // leaving danger + if (m.finishes) return v + 15 * m.count; + const to = m.to as NodeId; + if (m.captures) v += 30 + m.captures * (STEP * (home - dist(to)) + 10); + const group = m.count + (m.stacks ? mineAt(to) : 0); + if (m.stacks) v += 6; + // After a capture we throw again first, so the danger matters less. + v -= (m.captures ? 0.5 : 1) * risk(to) * exposure(to, group); + return v; + }; + const byKey = new Map(view.legalMoves.map((m) => [`${m.throwIndex}:${m.from}`, m])); + return best(moves, (a) => { + const m = byKey.get(`${a.throwIndex}:${a.from}`); + return m ? score(m) : -1e9; + }, random); +}; diff --git a/packages/shared/src/bots.ts b/packages/shared/src/bots.ts new file mode 100644 index 0000000..c69a71b --- /dev/null +++ b/packages/shared/src/bots.ts @@ -0,0 +1,53 @@ +/** Computer players for solo practice (docs/06-lobby-rooms.md §12). Shared by server and web (no runtime deps). */ +import type { PublicUser } from './types'; + +/** Bot user ids look like `bot::`; real user ids are plain ULIDs, so they can never collide. */ +export const BOT_PREFIX = 'bot:'; + +export const isBotId = (id: string): boolean => id.startsWith(BOT_PREFIX); + +export const makeBotId = (n: number, unique: string): string => `${BOT_PREFIX}${n}:${unique}`; + +/** Number shown in the bot's name ("컴퓨터 2"). */ +export function botNumber(id: string): number | null { + if (!isBotId(id)) return null; + const n = Number(id.slice(BOT_PREFIX.length).split(':')[0]); + return Number.isInteger(n) && n > 0 ? n : null; +} + +/** Public profile of a bot id, or null for anything else. */ +export function botProfile(id: string): PublicUser | null { + const n = botNumber(id); + return n === null ? null : { id, nickname: `컴퓨터 ${n}`, avatar: null, kind: 'bot' }; +} + +/** + * Games that offer computer players. Real-time and talk-based games (도블, 할리갈리, 그림 맞히기, 마피아) + * don't: a bot there is either unbeatable or meaningless. + */ +export const BOT_GAMES: readonly string[] = [ + 'omok', + 'othello', + 'chess', + 'janggi', + 'baduk', + 'seotda', + 'poker', + 'yacht', + 'yut', + 'one-card', + 'rummikub', + 'gostop', + 'bang', + 'davinci-code', + 'mahjong', + 'marble', + 'dalmuti', + 'splendor', + 'quoridor', + 'las-vegas', + 'cockroach-poker', + 'ninety-nine', +]; + +export const botsAllowed = (gameId: string): boolean => BOT_GAMES.includes(gameId); diff --git a/packages/shared/src/lite.ts b/packages/shared/src/lite.ts index 9285347..07abb76 100644 --- a/packages/shared/src/lite.ts +++ b/packages/shared/src/lite.ts @@ -4,3 +4,4 @@ export * from './nickname'; export * from './room-code'; export * from './catalog'; export * from './view-patch'; +export * from './bots'; diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index 53cd53d..d4e0eef 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -27,6 +27,8 @@ export const ClientMessage = z.discriminatedUnion('t', [ z.object({ t: z.literal('start') }), z.object({ t: z.literal('kick'), userId }), z.object({ t: z.literal('host'), userId }), + z.object({ t: z.literal('addBot'), seat: z.number().int().min(0).max(31).optional() }), + z.object({ t: z.literal('removeBot'), userId }), z.object({ t: z.literal('act'), cs: z.number().int().min(1), a: z.unknown() }), z.object({ t: z.literal('chat'), text: z.string().min(1).max(CHAT_MAX) }), z.object({ t: z.literal('emote'), id: z.string().max(20) }), diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 59bce07..a5ae747 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -19,7 +19,7 @@ export interface PublicUser { id: string; nickname: string; avatar: string | null; - kind: 'guest' | 'member'; + kind: 'guest' | 'member' | 'bot'; } export interface SeatView {