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

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

View File

@@ -0,0 +1,112 @@
/** Computer players over the real HTTP/WS stack (docs/06-lobby-rooms.md §12). */
import { afterEach, describe, expect, test } from 'bun:test';
import { Client, api, boot, guest, sleep } from './test-utils';
type Booted = ReturnType<typeof boot>;
const running: Booted[] = [];
const clients: Client[] = [];
afterEach(async () => {
for (const c of clients.splice(0)) {
try {
c.close();
} catch {}
}
for (const s of running.splice(0)) {
try {
await s.stop();
} catch {}
}
});
const fastBots = { botDelay: () => 30 };
function start(dbPath?: string): Booted {
const s = boot(dbPath, fastBots);
running.push(s);
return s;
}
async function client(s: Booted, nick: string, cookie?: { cookie: string; id: string }): Promise<Client> {
const g = cookie ?? (await guest(s.base, nick));
const c = await new Client(s.base, g.cookie, g.id).connect();
clients.push(c);
return c;
}
async function practiceOmok(s: Booted, a: Client): Promise<string> {
const r = await api(s.base, '/api/rooms', { method: 'POST', cookie: a.cookie, body: JSON.stringify({ gameId: 'omok', practice: true }) });
expect(r.res.status).toBe(200);
const code = r.body.code as string;
a.send({ t: 'join', code, as: 'player' });
await a.next('room');
a.send({ t: 'config', options: { colorAssignment: 'hostBlack', ruleSet: 'free' } });
await a.next('room', (m) => (m.room.options as any).colorAssignment === 'hostBlack');
const st = a.next('state');
a.send({ t: 'start' });
await st;
return code;
}
describe('computer players', () => {
test('practice room: host + computer seated, private, not listed; the computer answers moves', async () => {
const s = start();
const a = await client(s, '호랑이');
const code = await practiceOmok(s, a);
const room = a.lastOf('room')!.room;
expect(room.visibility).toBe('private');
expect(room.seats.map((x) => x?.user.kind)).toEqual(['guest', 'bot']);
expect(room.seats[1]!.user.nickname).toBe('컴퓨터 1');
const pub = await api(s.base, '/api/games/omok/rooms');
expect(pub.body.rooms.find((r: { code: string }) => r.code === code)).toBeUndefined();
for (let i = 0; i < 3; i++) {
const answered = a.next('state', (m) => (m.view as any).moveCount === 2 * i + 2);
const board = (a.lastOf('state')!.view as any).board as number[];
const free = board.indexOf(0);
a.act({ type: 'place', x: free % 15, y: Math.floor(free / 15) });
await answered;
}
});
test('addBot / removeBot over the socket: host only, errors for others', async () => {
const s = start();
const a = await client(s, '호랑이');
const b = await client(s, '고양이');
const r = await api(s.base, '/api/rooms', { method: 'POST', cookie: a.cookie, body: JSON.stringify({ gameId: 'one-card', visibility: 'public' }) });
const code = r.body.code as string;
a.send({ t: 'join', code, as: 'player' });
await a.next('room');
b.send({ t: 'join', code, as: 'player' });
await b.next('room');
b.send({ t: 'addBot' });
expect((await b.next('error')).message).toBe('방장만 컴퓨터를 넣을 수 있어요.');
a.send({ t: 'addBot', seat: 3 });
const withBot = await a.next('room', (m) => m.room.seats.some((x) => x?.user.kind === 'bot'));
expect(withBot.room.visibility).toBe('private');
const botId = withBot.room.seats[3]!.user.id;
a.send({ t: 'removeBot', userId: botId });
await a.next('room', (m) => !m.room.seats.some((x) => x?.user.kind === 'bot'));
});
test('restart with the computer to move: after restore it plays on its own', async () => {
const s1 = boot(undefined, fastBots);
const a = await client(s1, '호랑이');
const code = await practiceOmok(s1, a);
// Stop the bot before it answers, then crash.
for (const r of s1.rooms.all()) r.clearTimer();
a.act({ type: 'place', x: 7, y: 7 });
await a.next('state', (m) => (m.view as any).moveCount === 1);
for (const r of s1.rooms.all()) r.clearTimer();
await Promise.race([s1.server.stop(true), sleep(300)]);
for (const r of s1.rooms.all()) r.clearTimer();
s1.db.close();
const s2 = start(s1.dbPath);
await sleep(200); // the restored driver plays without anyone connected
const room = s2.rooms.get(code)!;
expect((room.game!.state as any).moves.length).toBe(2);
const a2 = await client(s2, '', { cookie: a.cookie, id: a.id });
a2.send({ t: 'join', code, as: 'player' });
const st = await a2.next('state');
expect((st.view as any).moveCount).toBe(2);
});
});

View File

@@ -2,7 +2,7 @@
import { Hono, type Context } from 'hono'; import { Hono, type Context } from 'hono';
import { z } from 'zod'; import { z } from 'zod';
import type { AnyGameDefinition } from '@bg/engine'; 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 type { Audit, Role, Roles } from '../admin/roles';
import { GameSettingsSchema, SiteSettingsSchema, type SettingsStore } from '../admin/settings'; import { GameSettingsSchema, SiteSettingsSchema, type SettingsStore } from '../admin/settings';
import type { Sessions } from '../auth/sessions'; import type { Sessions } from '../auth/sessions';
@@ -100,7 +100,11 @@ export function createAdminApp(d: AdminDeps) {
gameId: r.config.gameId, gameId: r.config.gameId,
status: r.status, status: r.status,
host: d.users.get(r.hostId)?.nickname ?? null, 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, spectators: r.spectators.size,
connections: r.conns.size, connections: r.conns.size,
visibility: r.config.visibility, visibility: r.config.visibility,

View File

@@ -268,10 +268,10 @@ export function createHttpApp(d: HttpDeps) {
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401); if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
if (!createLimiter.allow(d.ipOf(c.req.raw))) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429); if (!createLimiter.allow(d.ipOf(c.req.raw))) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429);
const body = z 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)); .safeParse(await c.req.json().catch(() => null));
if (!body.success || !d.settings.game(body.data.gameId)?.enabled) return c.json({ error: '없는 게임이에요.' }, 400); 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); if (typeof r === 'string') return c.json({ error: r }, 429);
return c.json({ code: r.code }); return c.json({ code: r.code });
}); });

View File

@@ -0,0 +1,286 @@
/** Computer players in rooms (docs/06-lobby-rooms.md §12): seating rules, practice rooms, driving, records, restore. */
import { afterEach, describe, expect, test } from 'bun:test';
import { GAMES } from '@bg/games';
import { BOT_STRATEGIES } from '@bg/games/bots';
import { BOT_GAMES, ClientMessage, botProfile, isBotId, type ServerMessage } from '@bg/shared';
import { openDb } from '../db';
import { defaultBotDelay } from './bots';
import { RoomManager } from './manager';
import { Room, type Conn, type RoomSnapshot } from './room';
import { RoomStore } from './store';
const rooms: Room[] = [];
afterEach(() => {
for (const r of rooms.splice(0)) r.clearTimer();
});
function harness(log?: (level: 'info' | 'warn' | 'error', msg: string) => void) {
const db = openDb(':memory:');
const store = new RoomStore(db);
let now = 1_000_000;
const clock = { now: () => now, advance: (ms: number) => (now += ms) };
const users = { publicUser: (id: string) => botProfile(id) ?? { id, nickname: id, avatar: null, kind: 'guest' as const } };
const manager = new RoomManager({ store, games: GAMES, users, now: clock.now, botDelay: () => 0, log });
const conn = (userId: string) => {
const msgs: ServerMessage[] = [];
const c: Conn & { msgs: ServerMessage[] } = { userId, msgs, send: (m) => msgs.push(m), close: () => {} };
return c;
};
const create = (host: string, gameId = 'omok', visibility: 'private' | 'public' = 'private') => {
const r = manager.create(host, gameId, visibility);
if (typeof r === 'string') throw new Error(r);
rooms.push(r);
return r;
};
return { db, store, clock, manager, conn, create, users };
}
/** Plays every planned bot move (the delay is 0 in these tests) until the game waits on a human. */
function drive(room: Room, max = 500): number {
let n = 0;
while (room.bots.dueAt !== null && n < max) {
room.bots.fire();
n++;
}
return n;
}
const bots = (room: Room) => room.seatedIds().filter(isBotId);
describe('bot list', () => {
test('BOT_GAMES matches the strategies and leaves out real-time / talk games', () => {
expect([...BOT_GAMES].sort()).toEqual(Object.keys(BOT_STRATEGIES).sort());
for (const id of BOT_GAMES) expect(GAMES[id]).toBeDefined();
for (const id of ['dobble', 'halli-galli', 'catchmind', 'mafia']) expect(BOT_GAMES).not.toContain(id);
});
test('thinking time: short for forced moves, longer for choices, longest when people act in the same window', () => {
const r = () => 0.5;
expect(defaultBotDelay(1, false, r)).toBe(550);
expect(defaultBotDelay(5, false, r)).toBe(1100);
expect(defaultBotDelay(5, true, r)).toBe(2600);
});
test('protocol accepts addBot / removeBot', () => {
expect(ClientMessage.safeParse({ t: 'addBot' }).success).toBe(true);
expect(ClientMessage.safeParse({ t: 'addBot', seat: 1 }).success).toBe(true);
expect(ClientMessage.safeParse({ t: 'addBot', seat: -1 }).success).toBe(false);
expect(ClientMessage.safeParse({ t: 'removeBot', userId: 'bot:1:01J0000000000000000000000' }).success).toBe(true);
expect(ClientMessage.safeParse({ t: 'removeBot' }).success).toBe(false);
});
});
describe('addBot / removeBot', () => {
test('host only, lobby only, empty seats only; the room turns private and drops out of the public list', () => {
const { create, conn, manager } = harness();
const room = create('A', 'omok', 'public');
room.join(conn('A'), 'player');
room.join(conn('S'), 'spectator');
expect(manager.publicRooms('omok')).toHaveLength(1);
expect(room.addBot('S')).toBe('방장만 컴퓨터를 넣을 수 있어요.');
expect(room.addBot('A', 0)).toBe('이미 다른 사람이 앉은 자리예요.');
expect(room.addBot('A')).toBeNull();
const [bot] = bots(room);
expect(bot).toMatch(/^bot:1:/);
expect(room.addBot('A')).toBe('빈자리가 없어요.');
expect(room.config.visibility).toBe('private');
expect(manager.publicRooms('omok')).toHaveLength(0);
expect(room.configure('A', { visibility: 'public' })).toBe('컴퓨터가 있는 방은 공개할 수 없어요.');
expect(room.configure('A', { gameId: 'dobble' })).toContain('컴퓨터');
const seat = room.roomView().seats[1]!;
expect(seat).toMatchObject({ ready: true, connected: true, away: false });
expect(seat.user).toMatchObject({ nickname: '컴퓨터 1', kind: 'bot' });
expect(room.removeBot('S', bot!)).toBe('방장만 컴퓨터를 뺄 수 있어요.');
expect(room.removeBot('A', 'B')).toBe('없는 컴퓨터예요.');
expect(room.removeBot('A', bot!)).toBeNull();
expect(bots(room)).toHaveLength(0);
// Without computers the host may make it public again.
expect(room.configure('A', { visibility: 'public' })).toBeNull();
});
test('games without computer players refuse them', () => {
const { create, conn } = harness();
const room = create('A', 'dobble');
room.join(conn('A'), 'player');
expect(room.addBot('A')).toBe('이 게임은 컴퓨터와 할 수 없어요.');
});
test('bots get the lowest free number; kick removes a bot; the host is never passed to a bot', () => {
const { create, conn } = harness();
const room = create('A', 'one-card');
room.join(conn('A'), 'player');
room.addBot('A');
room.addBot('A');
room.addBot('A');
const [b1, b2] = bots(room);
expect(room.kick('A', b2!)).toBeNull();
room.addBot('A');
expect(bots(room).map((b) => botProfile(b)!.nickname).sort()).toEqual(['컴퓨터 1', '컴퓨터 2', '컴퓨터 3']);
expect(room.transferHost('A', b1!)).toBe('컴퓨터에게는 방장을 넘길 수 없어요.');
// A spectator joins, then the host leaves: the spectator becomes host, not a computer.
room.join(conn('S'), 'spectator');
room.leave('A');
expect(room.hostId).toBe('S');
});
test('a room needs a seated person to start', () => {
const { create, conn } = harness();
const room = create('A', 'omok');
room.join(conn('A'), 'spectator');
room.addBot('A');
room.addBot('A');
expect(room.start('A')).toBe('사람이 한 명은 앉아야 해요.');
});
test('bots cannot be added or removed during a game', () => {
const { create, conn } = harness();
const room = create('A', 'one-card');
room.join(conn('A'), 'player');
room.addBot('A');
expect(room.start('A')).toBeNull();
expect(room.addBot('A')).toBe('게임 중에는 바꿀 수 없어요.');
expect(room.removeBot('A', bots(room)[0]!)).toBe('게임 중에는 바꿀 수 없어요.');
});
});
describe('createPractice', () => {
test('a private room with just enough computers to start; never offered by quick play', () => {
const { manager, conn } = harness();
const expected: Record<string, number> = { omok: 1, mahjong: 2, bang: 3, dalmuti: 3, quoridor: 1, yacht: 1 };
for (const [gameId, n] of Object.entries(expected)) {
const room = manager.createPractice(`host-${gameId}`, gameId);
if (typeof room === 'string') throw new Error(room);
rooms.push(room);
expect(room.config.visibility).toBe('private');
expect(bots(room)).toHaveLength(n);
room.join(conn(`host-${gameId}`), 'player');
expect(room.start(`host-${gameId}`)).toBeNull();
expect(room.game!.players).toContain(`host-${gameId}`);
}
expect(manager.createPractice('X', 'mafia')).toBe('이 게임은 컴퓨터와 할 수 없어요.');
// Quick play never lands in a practice room.
const qp = manager.quickPlay('Q', 'omok');
if (typeof qp === 'string') throw new Error(qp);
rooms.push(qp);
expect(qp.hasBots()).toBe(false);
});
});
describe('playing with bots', () => {
function omokVsBot() {
const h = harness();
const room = h.manager.createPractice('A', 'omok') as Room;
rooms.push(room);
const a = h.conn('A');
room.join(a, 'player');
room.configure('A', { options: { ...(room.config.options as object), colorAssignment: 'hostBlack' } });
expect(room.start('A')).toBeNull();
return { ...h, room, a };
}
test('the bot answers each human move through the normal action path', () => {
const { room, a, store } = omokVsBot();
const moves = () => (room.game!.state as { moves: unknown[] }).moves.length;
expect(room.bots.dueAt).toBeNull(); // black (human) to move
room.act('A', 1, { type: 'place', x: 7, y: 7 });
expect(room.bots.dueAt).not.toBeNull();
expect(drive(room)).toBe(1);
expect(moves()).toBe(2);
expect(a.msgs.filter((m) => m.t === 'state').length).toBeGreaterThanOrEqual(3);
// Logged like any action, with the bot as actor.
const log = store.log(room.id);
expect(log.some((e) => e.kind === 'act' && e.actorId !== null && isBotId(e.actorId))).toBe(true);
});
test('a bot move planned before a state change re-reads the state when it fires', () => {
const { room } = omokVsBot();
room.act('A', 1, { type: 'place', x: 7, y: 7 });
const due = room.bots.dueAt;
room.scheduleTimer(); // e.g. a reconnect re-arms timers: the plan stays
expect(room.bots.dueAt).toBe(due);
drive(room);
expect((room.game!.state as { moves: unknown[] }).moves).toHaveLength(2);
expect(room.bots.dueAt).toBeNull();
});
test('a practice game is kept but not counted; rematch only needs the people', () => {
const { room, db } = omokVsBot();
// Human resigns; the game is recorded as practice.
room.act('A', 1, { type: 'resign' });
expect(room.status).toBe('finished');
const row = db.query<{ result_json: string }, []>('SELECT result_json FROM games').get()!;
expect(JSON.parse(row.result_json).practice).toBe(true);
expect(db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM user_stats').get()!.n).toBe(0);
expect(db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM game_players').get()!.n).toBe(2);
expect(room.rematch('A')).toBeNull();
expect(room.status).toBe('playing');
expect(room.gameNo).toBe(2);
});
test('when every person leaves mid-game, the game ends uncounted', () => {
const { room, db } = omokVsBot();
room.act('A', 1, { type: 'place', x: 7, y: 7 });
room.leave('A');
expect(room.game!.finished).toBe(true);
expect(room.bots.dueAt).toBeNull();
expect(db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM user_stats').get()!.n).toBe(0);
});
// The real driver + room timers for every bot game: the bots act on their own (reaction windows, simultaneous
// choices, "ready" between hands) and the only person just lets the clock run out each time.
test.each([...BOT_GAMES])('%s: a whole practice game with a person who only times out finishes', (gameId) => {
const logs: string[] = [];
const h = harness((level, msg) => level !== 'info' && logs.push(msg));
const room = h.manager.createPractice('A', gameId) as Room;
rooms.push(room);
room.join(h.conn('A'), 'player');
expect(room.start('A')).toBeNull();
for (let i = 0; i < 5000 && !room.game!.finished; i++) {
drive(room);
if (room.game!.finished) break;
const at = room.effectiveDeadline();
expect(at).not.toBeNull();
h.clock.advance(Math.max(0, at! - h.clock.now()));
room.onTimer();
}
expect(room.game!.finished).toBe(true);
expect(logs).toEqual([]);
});
});
describe('restore after restart', () => {
test('bots stay seated, have no reconnect grace, and the driver re-plans their move', () => {
const { manager, conn, store, clock, users } = harness();
const room = manager.createPractice('A', 'omok') as Room;
rooms.push(room);
room.join(conn('A'), 'player');
room.configure('A', { options: { ...(room.config.options as object), colorAssignment: 'hostBlack' } });
room.start('A');
room.act('A', 1, { type: 'place', x: 7, y: 7 });
// Crash before the bot answers.
room.clearTimer();
const { seq, stateJson } = store.openRooms()[0]!;
clock.advance(60_000);
const restored = Room.restore(room.id, room.code, seq, JSON.parse(stateJson) as RoomSnapshot, {
store,
games: GAMES,
users,
now: clock.now,
botDelay: () => 0,
});
rooms.push(restored);
expect(restored.hasBots()).toBe(true);
expect(restored.presence.has(bots(restored)[0]!)).toBe(false);
expect(restored.roomView().seats[1]).toMatchObject({ connected: true, ready: true });
expect(restored.bots.dueAt).not.toBeNull();
drive(restored);
expect((restored.game!.state as { moves: unknown[] }).moves).toHaveLength(2);
// The manager path too: restoreAll keeps bots out of the seat index (no "active room" for a bot).
const m2 = new RoomManager({ store, games: GAMES, users, now: clock.now, botDelay: () => 0 });
m2.restoreAll();
for (const r of m2.all()) rooms.push(r);
expect(m2.activeRoomFor('A')).toBe(room.code);
expect(m2.activeRoomFor(bots(restored)[0]!)).toBeNull();
});
});

View File

@@ -0,0 +1,122 @@
/**
* Computer players' driver (docs/06-lobby-rooms.md §12, docs/09-game-engine.md §9).
* After every state change the room calls `schedule()`: if the game waits on a bot, one action is planned after a
* short human-like pause and played through the room's normal action path (validated, logged as `act`).
*/
import type { AnyGameDefinition } from '@bg/engine';
import { isBotId } from '@bg/shared';
import { botCandidates, decideBotAction } from '@bg/games/bots';
export interface BotRoom {
readonly status: string;
readonly game: { state: unknown; players: string[]; finished: boolean } | null;
readonly def: AnyGameDefinition;
now(): number;
/** Runs the action as `actor` through the room's normal path; returns an error message on rejection. */
run(actor: string, action: unknown): string | null;
log?: (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => void;
}
/**
* Thinking time: a quick beat for forced moves, a little longer for real choices. When people may act in the same
* window (e.g. 원카드 외치기, 마작 울기, simultaneous choices) the computer waits longer so it never beats a person
* to a reaction just by being fast.
*/
export function defaultBotDelay(choices: number, withPeople = false, random: () => number = Math.random): number {
const base = choices <= 1 ? 400 + Math.floor(random() * 300) : 700 + Math.floor(random() * 800);
return withPeople ? base + 1500 : base;
}
/** Bot move budget (ms); slower decisions are logged. */
export const BOT_SLOW_MS = 50;
export class BotDriver {
private timer: ReturnType<typeof setTimeout> | null = null;
/** When the planned action is due (room clock); null when nothing is planned. */
dueAt: number | null = null;
/** Bot the planned action is for. */
private planned: string | null = null;
constructor(
private room: BotRoom,
private random: () => number = Math.random,
private delay: (choices: number, withPeople: boolean) => number = (n, w) => defaultBotDelay(n, w, random),
) {}
/** Plans the next bot action if the game is waiting on a bot. Keeps an already planned one (the bot is "thinking"). */
schedule(): void {
if (this.dueAt !== null) return;
const next = this.next();
if (!next) return;
this.dueAt = this.room.now() + this.delay(next.choices, next.withPeople);
this.planned = next.bot;
this.arm();
}
cancel(): void {
if (this.timer) clearTimeout(this.timer);
this.timer = null;
this.dueAt = null;
this.planned = null;
}
private arm(): void {
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => this.fire(), Math.max(0, (this.dueAt ?? 0) - this.room.now()));
}
/** Bot waited on by the game that has something to do, in seat order. */
private next(): { bot: string; choices: number; withPeople: boolean } | null {
const g = this.room.game;
if (!g || g.finished || this.room.status !== 'playing' || !g.players.some(isBotId)) return null;
const def = this.room.def;
const active = def.activePlayers(g.state);
const waiting = new Set([...active, ...(def.timeoutPlayers?.(g.state) ?? [])]);
const withPeople = active.some((p) => !isBotId(p));
for (const p of g.players) {
if (!isBotId(p) || !waiting.has(p)) continue;
const choices = botCandidates(def, g.state, p).length;
if (choices > 0) return { bot: p, choices, withPeople };
}
return null;
}
/** Plays the planned action when due. Public for tests (with a fake clock). */
fire(): void {
if (this.timer) clearTimeout(this.timer);
this.timer = null;
if (this.dueAt === null) return;
if (this.room.now() < this.dueAt) return this.arm();
const planned = this.planned;
this.dueAt = null;
this.planned = null;
const next = this.next();
const g = this.room.game;
if (!next || !g) return;
// Another bot's turn came up meanwhile: give it its own thinking time.
if (next.bot !== planned) return this.schedule();
const def = this.room.def;
const t0 = performance.now();
let action: unknown;
try {
action = decideBotAction(def, g.state, next.bot, { now: this.room.now(), random: this.random });
} catch (err) {
this.room.log?.('error', 'bot decision failed', { game: def.id, err: String(err) });
action = null;
}
const ms = performance.now() - t0;
if (ms > BOT_SLOW_MS) this.room.log?.('warn', 'slow bot move', { game: def.id, ms: Math.round(ms) });
let err = action == null ? 'no action' : this.room.run(next.bot, action);
if (err) {
this.room.log?.('warn', 'bot action rejected', { game: def.id, action, err });
// Last resort before the turn timer: the game's own timeout action.
try {
if (!this.room.game?.finished) err = this.room.run(next.bot, def.onTimeout(this.room.game!.state, next.bot));
} catch {
// keep err
}
if (err) return; // give up until the next state change; the room's deadline still applies
}
this.schedule();
}
}

View File

@@ -1,7 +1,7 @@
/** Creates, finds, restores and garbage-collects rooms (docs/06 §11, docs/04 §3). */ /** Creates, finds, restores and garbage-collects rooms (docs/06 §11, docs/04 §3). */
import { ulid } from 'ulid'; import { ulid } from 'ulid';
import type { AnyGameDefinition } from '@bg/engine'; 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 { Room, type RoomConfig, type RoomDeps, type RoomSnapshot } from './room';
import type { RoomStore } from './store'; import type { RoomStore } from './store';
import type { SettingsStore } from '../admin/settings'; import type { SettingsStore } from '../admin/settings';
@@ -19,6 +19,7 @@ export interface ManagerDeps {
log?: RoomDeps['log']; log?: RoomDeps['log'];
random?: () => number; random?: () => number;
settings?: SettingsStore; settings?: SettingsStore;
botDelay?: RoomDeps['botDelay'];
} }
export class RoomManager { export class RoomManager {
@@ -40,7 +41,9 @@ export class RoomManager {
now: this.now, now: this.now,
log: this.deps.log, log: this.deps.log,
settings: this.deps.settings, settings: this.deps.settings,
botDelay: this.deps.botDelay,
onSeatChange: (room, userId, seated) => { onSeatChange: (room, userId, seated) => {
if (isBotId(userId)) return;
const set = this.seatIndex.get(userId) ?? new Set<string>(); const set = this.seatIndex.get(userId) ?? new Set<string>();
if (seated) set.add(room.code); if (seated) set.add(room.code);
else set.delete(room.code); else set.delete(room.code);
@@ -112,6 +115,23 @@ export class RoomManager {
return room; 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. */ /** Rooms where the user holds a seat in an unfinished game. */
activeRoomFor(userId: string): string | null { activeRoomFor(userId: string): string | null {
for (const code of this.seatIndex.get(userId) ?? []) { 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 }[] { publicRooms(gameId: string | null): { code: string; gameId: string; host: string; seated: number; max: number; options: unknown }[] {
return this.all() 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) => ({ .map((r) => ({
code: r.code, code: r.code,
gameId: r.config.gameId, gameId: r.config.gameId,

View File

@@ -4,8 +4,9 @@
*/ */
import { ulid } from 'ulid'; import { ulid } from 'ulid';
import { SeededRng, createSeed, seedToHex, type AnyGameDefinition, type GameResult, type RngState } from '@bg/engine'; 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 { sha256Hex } from '../auth/sessions';
import { BotDriver } from './bots';
import type { LogEntry, RoomStore } from './store'; import type { LogEntry, RoomStore } from './store';
import { applyLocks, type EffectiveGame, type SiteSettings } from '../admin/settings'; import { applyLocks, type EffectiveGame, type SiteSettings } from '../admin/settings';
@@ -79,6 +80,8 @@ export interface RoomDeps {
onSeatChange?: (room: Room, userId: string, seated: boolean) => void; onSeatChange?: (room: Room, userId: string, seated: boolean) => void;
/** Admin settings (docs/14-admin.md). Optional so unit tests can run without them. */ /** Admin settings (docs/14-admin.md). Optional so unit tests can run without them. */
settings?: { game(id: string): EffectiveGame | null; getSite(): SiteSettings }; 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; const CHAT_HISTORY = 30;
@@ -113,6 +116,8 @@ export class Room {
private lastChatAt = new Map<string, number>(); private lastChatAt = new Map<string, number>();
private consecutiveErrors = 0; private consecutiveErrors = 0;
private readonly now: () => number; private readonly now: () => number;
/** Plays the computer players' moves (docs/06 §12). */
readonly bots: BotDriver;
constructor( constructor(
readonly id: string, readonly id: string,
@@ -125,6 +130,28 @@ export class Room {
this.seats = new Array(config.maxPlayers).fill(null); this.seats = new Array(config.maxPlayers).fill(null);
this.now = deps.now ?? Date.now; this.now = deps.now ?? Date.now;
this.lastActiveAt = this.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 { get def(): AnyGameDefinition {
@@ -169,6 +196,10 @@ export class Room {
return !!this.game && !this.game.finished && this.game.players.includes(userId); return !!this.game && !this.game.finished && this.game.players.includes(userId);
} }
hasBots(): boolean {
return this.seats.some((s) => !!s && isBotId(s.userId));
}
// ---------------------------------------------------------------- snapshot // ---------------------------------------------------------------- snapshot
snapshot(): RoomSnapshot { snapshot(): RoomSnapshot {
@@ -203,7 +234,8 @@ export class Room {
room.game = snap.game; room.game = snap.game;
const now = room.now(); const now = room.now();
for (const s of room.seats) { 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) { if (room.game && !room.game.finished) {
const def = room.def; 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 pub = (id: string): PublicUser => this.deps.users.publicUser(id) ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
const seats: (SeatView | null)[] = this.seats.map((s) => { const seats: (SeatView | null)[] = this.seats.map((s) => {
if (!s) return null; 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 p = this.presence.get(s.userId);
const connected = !!p?.connected; const connected = !!p?.connected;
const away = !connected && !!p && this.now() - p.since > this.config.graceSec * 1000; 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 }); this.presence.set(userId, { connected: false, since: 0, joinedAt: 0 });
const action = this.def.onLeave?.(this.game!.state, userId); const action = this.def.onLeave?.(this.game!.state, userId);
if (action) this.runAction(userId, action, undefined, true); 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 { } else {
this.seats[seat] = null; this.seats[seat] = null;
this.rematchVotes.delete(userId); this.rematchVotes.delete(userId);
@@ -434,7 +473,7 @@ export class Room {
const candidates = [...this.presence.entries()] const candidates = [...this.presence.entries()]
.filter(([u, p]) => this.seatOf(u) >= 0 && p.connected) .filter(([u, p]) => this.seatOf(u) >= 0 && p.connected)
.sort((a, b) => a[1].joinedAt - b[1].joinedAt); .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; if (next) this.hostId = next;
} }
@@ -503,6 +542,8 @@ export class Room {
next.options = eff?.defaultOptions ?? def.defaultOptions; next.options = eff?.defaultOptions ?? def.defaultOptions;
next.maxPlayers = eff?.maxPlayers ?? def.maxPlayers; 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]!; const def = this.deps.games[next.gameId]!;
if (patch.options !== undefined) { if (patch.options !== undefined) {
const parsed = def.optionsSchema.safeParse(patch.options); const parsed = def.optionsSchema.safeParse(patch.options);
@@ -545,6 +586,7 @@ export class Room {
kick(userId: string, target: string): string | null { kick(userId: string, target: string): string | null {
if (userId !== this.hostId) return '방장만 내보낼 수 있어요.'; if (userId !== this.hostId) return '방장만 내보낼 수 있어요.';
if (target === userId) return '자기 자신은 내보낼 수 없어요.'; if (target === userId) return '자기 자신은 내보낼 수 없어요.';
if (isBotId(target)) return this.removeBot(userId, target);
if (!this.isParticipant(target)) return '방에 없는 사람이에요.'; if (!this.isParticipant(target)) return '방에 없는 사람이에요.';
const conn = this.conns.get(target); const conn = this.conns.get(target);
this.kicked.add(target); this.kicked.add(target);
@@ -556,6 +598,7 @@ export class Room {
transferHost(userId: string, target: string): string | null { transferHost(userId: string, target: string): string | null {
if (userId !== this.hostId) return '방장만 넘길 수 있어요.'; if (userId !== this.hostId) return '방장만 넘길 수 있어요.';
if (isBotId(target)) return '컴퓨터에게는 방장을 넘길 수 없어요.';
if (this.seatOf(target) < 0 && !this.spectators.has(target)) return '방에 없는 사람이에요.'; if (this.seatOf(target) < 0 && !this.spectators.has(target)) return '방에 없는 사람이에요.';
this.hostId = target; this.hostId = target;
this.persist({ actorId: userId, kind: 'host', data: { target } }); this.persist({ actorId: userId, kind: 'host', data: { target } });
@@ -563,6 +606,39 @@ export class Room {
return null; 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 { chat(userId: string, text: string): string | null {
if (!this.config.chatEnabled) return '이 방은 채팅을 쓰지 않아요.'; if (!this.config.chatEnabled) return '이 방은 채팅을 쓰지 않아요.';
if (!this.isParticipant(userId)) return '방에 들어와야 채팅할 수 있어요.'; if (!this.isParticipant(userId)) return '방에 들어와야 채팅할 수 있어요.';
@@ -607,6 +683,8 @@ export class Room {
const range = this.playerRange(); const range = this.playerRange();
if (players.length < range.min) return `${range.min - players.length}명 더 필요해요.`; if (players.length < range.min) return `${range.min - players.length}명 더 필요해요.`;
if (players.length > range.max) return `최대 ${range.max}명까지 할 수 있어요.`; 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); return this.startGame(players);
} }
@@ -616,7 +694,7 @@ export class Room {
this.rematchVotes.add(userId); this.rematchVotes.add(userId);
const seated = this.seatedIds(); const seated = this.seatedIds();
const range = this.playerRange(); 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()); return this.startGame(this.orderedPlayers());
} }
this.persist(null); this.persist(null);
@@ -774,6 +852,7 @@ export class Room {
endReason: voided ? 'void' : result.reason, endReason: voided ? 'void' : result.reason,
result, result,
players, players,
practice: g.players.some(isBotId),
}); });
const allFirst = players.every((p) => p.rank === 1); const allFirst = players.every((p) => p.rank === 1);
if (!voided) { if (!voided) {
@@ -830,16 +909,21 @@ export class Room {
} }
scheduleTimer(): void { 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(); const at = this.effectiveDeadline();
if (at === null) return; if (at === null) return;
const delay = Math.max(0, at - this.now()); const delay = Math.max(0, at - this.now());
this.timer = setTimeout(() => this.onTimer(), Math.min(delay, 2 ** 31 - 1)); this.timer = setTimeout(() => this.onTimer(), Math.min(delay, 2 ** 31 - 1));
} }
/** Clears the turn timer and any planned computer move. */
clearTimer(): void { clearTimer(): void {
if (this.timer) clearTimeout(this.timer); if (this.timer) clearTimeout(this.timer);
this.timer = null; this.timer = null;
this.bots.cancel();
} }
/** Fires due timeouts. Public for tests. */ /** Fires due timeouts. Public for tests. */

View File

@@ -100,12 +100,15 @@ export class RoomStore {
endReason: string; endReason: string;
result: unknown; result: unknown;
players: { userId: string; seat: number; rank: number | null; score: number | null }[]; 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 { }): void {
this.db.transaction(() => { this.db.transaction(() => {
const result = g.practice ? { ...(g.result as object), practice: true } : g.result;
this.db this.db
.query('UPDATE games SET seed = ?, ended_at = ?, end_reason = ?, result_json = ? WHERE id = ?') .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); .run(g.seed, g.endedAt, g.endReason, JSON.stringify(result), g.id);
const counted = g.endReason !== 'void'; const counted = g.endReason !== 'void' && !g.practice;
const ranks = g.players.map((p) => p.rank).filter((r): r is number => r !== null); const ranks = g.players.map((p) => p.rank).filter((r): r is number => r !== null);
const firstCount = ranks.filter((r) => r === 1).length; const firstCount = ranks.filter((r) => r === 1).length;
for (const p of g.players) { for (const p of g.players) {

View File

@@ -3,6 +3,7 @@ import { existsSync } from 'node:fs';
import { join, normalize } from 'node:path'; import { join, normalize } from 'node:path';
import type { Server } from 'bun'; import type { Server } from 'bun';
import { GAMES } from '@bg/games'; import { GAMES } from '@bg/games';
import { botProfile } from '@bg/shared';
import type { Config } from './config'; import type { Config } from './config';
import { openDb } from './db'; import { openDb } from './db';
import { SESSION_COOKIE, Sessions } from './auth/sessions'; import { SESSION_COOKIE, Sessions } from './auth/sessions';
@@ -28,6 +29,8 @@ export interface StartOptions {
log?: LogFn; log?: LogFn;
staticDir?: string | null; staticDir?: string | null;
discordExchange?: HttpDeps['discordExchange']; discordExchange?: HttpDeps['discordExchange'];
/** Computer players' thinking time (tests shorten it). */
botDelay?: (choices: number, withPeople: boolean) => number;
} }
export function startServer(opts: StartOptions) { export function startServer(opts: StartOptions) {
@@ -38,6 +41,8 @@ export function startServer(opts: StartOptions) {
const sessions = new Sessions(db); const sessions = new Sessions(db);
const store = new RoomStore(db); const store = new RoomStore(db);
const publicUser = (id: string) => { const publicUser = (id: string) => {
const bot = botProfile(id);
if (bot) return bot;
const u = users.get(id); const u = users.get(id);
return u ? toPublicUser(u) : null; return u ? toPublicUser(u) : null;
}; };
@@ -45,7 +50,7 @@ export function startServer(opts: StartOptions) {
const roles = new Roles(db, config.superadminDiscordIds); const roles = new Roles(db, config.superadminDiscordIds);
const audit = new Audit(db); const audit = new Audit(db);
const startedAt = Date.now(); 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(); const restored = rooms.restoreAll();
log('info', 'rooms restored', restored); log('info', 'rooms restored', restored);
const gateway = new Gateway({ rooms, me: publicUser, log }); const gateway = new Gateway({ rooms, me: publicUser, log });

View File

@@ -173,6 +173,12 @@ export class Gateway {
case 'host': case 'host':
e = room.transferHost(u, m.userId); e = room.transferHost(u, m.userId);
break; break;
case 'addBot':
e = room.addBot(u, m.seat);
break;
case 'removeBot':
e = room.removeBot(u, m.userId);
break;
case 'act': case 'act':
room.act(u, m.cs, m.a); room.act(u, m.cs, m.a);
return; return;

View File

@@ -1,7 +1,7 @@
/** Shared UI primitives (docs/07 §4). */ /** Shared UI primitives (docs/07 §4). */
import { useEffect, useRef, useState, type ReactNode } from 'react'; import { useEffect, useRef, useState, type ReactNode } from 'react';
import type { PublicUser } from '@bg/shared/lite'; 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 { useToasts, toast } from '../store/toast';
import { useRoom } from '../net/socket'; import { useRoom } from '../net/socket';
import { haptic } from '../lib/feedback'; import { haptic } from '../lib/feedback';
@@ -15,7 +15,7 @@ export function Avatar({ user, size = 40 }: { user: Pick<PublicUser, 'id' | 'nic
const bg = AVATAR_COLORS[h % AVATAR_COLORS.length]; const bg = AVATAR_COLORS[h % AVATAR_COLORS.length];
return ( return (
<span className="avatar" style={{ width: size, height: size, background: bg, fontSize: size * 0.45 }} aria-hidden> <span className="avatar" style={{ width: size, height: size, background: bg, fontSize: size * 0.45 }} aria-hidden>
{user.avatar ? <img src={user.avatar} alt="" loading="lazy" referrerPolicy="no-referrer" /> : [...user.nickname][0]} {user.avatar ? <img src={user.avatar} alt="" loading="lazy" referrerPolicy="no-referrer" /> : isBotId(user.id) ? '🤖' : [...user.nickname][0]}
</span> </span>
); );
} }

View File

@@ -70,6 +70,8 @@ export const api = {
call<{ games: MyGameSummary[]; next: string | null }>('GET', `/api/me/games${before ? `?before=${encodeURIComponent(before)}` : ''}`), call<{ games: MyGameSummary[]; next: string | null }>('GET', `/api/me/games${before ? `?before=${encodeURIComponent(before)}` : ''}`),
replay: (gamePk: string) => call<ReplayData>('GET', `/api/replays/${encodeURIComponent(gamePk)}`), replay: (gamePk: string) => call<ReplayData>('GET', `/api/replays/${encodeURIComponent(gamePk)}`),
createRoom: (gameId: string, visibility?: 'private' | 'public') => call<{ code: string }>('POST', '/api/rooms', { gameId, visibility }), 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}`, {}), quickPlay: (gameId: string) => call<{ code: string }>('POST', `/api/quick/${gameId}`, {}),
room: (code: string) => call<RoomPreview>('GET', `/api/rooms/${code}`), room: (code: string) => call<RoomPreview>('GET', `/api/rooms/${code}`),
allPublicRooms: () => call<{ rooms: { code: string; gameId: string; host: string; seated: number; max: number }[] }>('GET', '/api/public-rooms'), allPublicRooms: () => call<{ rooms: { code: string; gameId: string; host: string; seated: number; max: number }[] }>('GET', '/api/public-rooms'),

View File

@@ -1,7 +1,7 @@
/** 게임 소개 + 방 만들기 / 빠른 시작 / 규칙 / 공개 방 목록 (docs/06 §2, §7, §10). */ /** 게임 소개 + 방 만들기 / 빠른 시작 / 규칙 / 공개 방 목록 (docs/06 §2, §7, §10). */
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router'; 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 { Sheet, TopBar } from '../components/ui';
import { NicknameForm } from '../components/NicknameForm'; import { NicknameForm } from '../components/NicknameForm';
import { api } from '../net/api'; import { api } from '../net/api';
@@ -64,6 +64,11 @@ export default function GamePage() {
<button className="btn btn-secondary btn-block" disabled={busy} onClick={() => run(() => api.quickPlay(g.id))}> <button className="btn btn-secondary btn-block" disabled={busy} onClick={() => run(() => api.quickPlay(g.id))}>
⚡ 아무나와 빠른 시작 ⚡ 아무나와 빠른 시작
</button> </button>
{botsAllowed(g.id) && (
<button className="btn btn-secondary btn-block" disabled={busy} onClick={() => run(() => api.practice(g.id))}>
🤖 컴퓨터와 연습하기
</button>
)}
<Link className="btn btn-ghost btn-block" to={`/rules/${g.id}`}> <Link className="btn btn-ghost btn-block" to={`/rules/${g.id}`}>
📖 규칙 보기 📖 규칙 보기
</Link> </Link>

View File

@@ -1,7 +1,7 @@
/** 홈 (PC 기준, docs/07-ui-ux.md §3.3): 큰 행동 줄 + 게임 격자 + 열린 공개 방. */ /** 홈 (PC 기준, docs/07-ui-ux.md §3.3): 큰 행동 줄 + 게임 격자 + 열린 공개 방. */
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router'; 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 { Sheet } from '../components/ui';
import { NicknameForm } from '../components/NicknameForm'; import { NicknameForm } from '../components/NicknameForm';
import { CodeInput } from '../components/CodeInput'; import { CodeInput } from '../components/CodeInput';
@@ -17,12 +17,13 @@ export function Home() {
const { me, activeRoom } = useSession(); const { me, activeRoom } = useSession();
const catalog = useSession((s) => (s.catalog.length ? s.catalog : CATALOG)); const catalog = useSession((s) => (s.catalog.length ? s.catalog : CATALOG));
const nav = useNavigate(); const nav = useNavigate();
const [picker, setPicker] = useState(false); const [picker, setPicker] = useState<null | 'room' | 'practice'>(null);
const [needName, setNeedName] = useState<null | (() => void)>(null); const [needName, setNeedName] = useState<null | (() => void)>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [rooms, setRooms] = useState<{ code: string; gameId: string; host: string; seated: number; max: number }[]>([]); const [rooms, setRooms] = useState<{ code: string; gameId: string; host: string; seated: number; max: number }[]>([]);
const [codeKey, setCodeKey] = useState(0); const [codeKey, setCodeKey] = useState(0);
const available = catalog.filter((g) => g.available); const available = catalog.filter((g) => g.available);
const practiceGames = available.filter((g) => botsAllowed(g.id));
useEffect(() => { useEffect(() => {
const load = () => api.allPublicRooms().then((r) => setRooms(r.rooms), () => {}); 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 withName = (fn: () => void) => (me ? fn() : setNeedName(() => fn));
const create = (gameId: string) => const create = (gameId: string, practice = false) =>
withName(async () => { withName(async () => {
setBusy(true); setBusy(true);
try { try {
const { code } = await api.createRoom(gameId); const { code } = await (practice ? api.practice(gameId) : api.createRoom(gameId));
nav(`/r/${code}`); nav(`/r/${code}`);
} catch (e) { } catch (e) {
toast((e as Error).message, 'error'); toast((e as Error).message, 'error');
@@ -71,7 +72,7 @@ export function Home() {
className="btn btn-primary btn-block" className="btn btn-primary btn-block"
style={{ minHeight: 72, fontSize: '1.3rem' }} style={{ minHeight: 72, fontSize: '1.3rem' }}
disabled={busy} disabled={busy}
onClick={() => (available.length === 1 ? create(available[0]!.id) : setPicker(true))} onClick={() => (available.length === 1 ? create(available[0]!.id) : setPicker('room'))}
> >
+ 방 만들기 + 방 만들기
</button> </button>
@@ -81,6 +82,15 @@ export function Home() {
<p className="muted">받은 숫자 6자리를 넣으면 바로 들어가요.</p> <p className="muted">받은 숫자 6자리를 넣으면 바로 들어가요.</p>
<CodeInput key={codeKey} onComplete={joinCode} autoFocus={false} /> <CodeInput key={codeKey} onComplete={joinCode} autoFocus={false} />
</div> </div>
{practiceGames.length > 0 && (
<div className="card stack" style={{ padding: 24, justifyContent: 'center' }}>
<h2>혼자 연습하기</h2>
<p className="muted">컴퓨터와 바로 한 판 해 봐요. 기록에는 남지 않아요.</p>
<button className="btn btn-secondary btn-block" style={{ minHeight: 60 }} disabled={busy} onClick={() => setPicker('practice')}>
🤖 컴퓨터와 연습하기
</button>
</div>
)}
</section> </section>
<div className="two-col" style={{ gridTemplateColumns: undefined }}> <div className="two-col" style={{ gridTemplateColumns: undefined }}>
@@ -114,10 +124,10 @@ export function Home() {
<InstallCard /> <InstallCard />
{picker && ( {picker && (
<Sheet onClose={() => setPicker(false)} label="게임 고르기"> <Sheet onClose={() => setPicker(null)} label="게임 고르기">
<h2>어떤 게임을 할까요?</h2> <h2>{picker === 'practice' ? '컴퓨터와 어떤 게임을 할까요?' : '어떤 게임을 할까요?'}</h2>
{available.map((g) => ( {(picker === 'practice' ? practiceGames : available).map((g) => (
<button key={g.id} className="btn btn-secondary btn-block" onClick={() => (setPicker(false), create(g.id))}> <button key={g.id} className="btn btn-secondary btn-block" onClick={() => (setPicker(null), create(g.id, picker === 'practice'))}>
{GAME_ICON[g.id]} {g.nameKo} {GAME_ICON[g.id]} {g.nameKo}
</button> </button>
))} ))}

View File

@@ -1,7 +1,7 @@
/** 방: 대기실 ↔ 게임 ↔ 결과를 한 주소에서 전환 (docs/06 §5–§9). */ /** 방: 대기실 ↔ 게임 ↔ 결과를 한 주소에서 전환 (docs/06 §5–§9). */
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router'; 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 { Avatar, CodeLabel, ConfirmSheet, CopyButton, ShareButton, Sheet, TopBar } from '../components/ui';
import { NicknameForm } from '../components/NicknameForm'; import { NicknameForm } from '../components/NicknameForm';
import { GameNotice, gameName } from '../components/GameNotice'; 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 shareText = `${host}님이 ${gName} 방에 초대했어요! 코드 ${formatRoomCode(room.code)}`;
const notReady = room.seats.filter((s) => s && !s.ready && s.user.id !== room.hostId).length; const notReady = room.seats.filter((s) => s && !s.ready && s.user.id !== room.hostId).length;
const [askStart, setAskStart] = useState(false); 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 wide = useWide();
const invite = ( const invite = (
@@ -294,7 +299,22 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
</h2> </h2>
<div style={{ display: 'grid', gridTemplateColumns: wide && room.seats.length > 4 ? '1fr 1fr' : '1fr', gap: 10 }}> <div style={{ display: 'grid', gridTemplateColumns: wide && room.seats.length > 4 ? '1fr 1fr' : '1fr', gap: 10 }}>
{room.seats.map((s, i) => {room.seats.map((s, i) =>
s ? ( s && isBotId(s.user.id) ? (
<div key={i} className="card row" style={{ gap: 12, flexWrap: 'nowrap' }}>
<Avatar user={s.user} size={44} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700 }}>{s.user.nickname}</div>
<div className="row small" style={{ gap: 6 }}>
<span className="badge">🤖 컴퓨터</span>
</div>
</div>
{isHost && (
<button className="btn btn-ghost" onClick={() => socket.send({ t: 'removeBot', userId: s.user.id })} aria-label={`${s.user.nickname} 빼기`}>
✕
</button>
)}
</div>
) : s ? (
<div key={i} className="card row" style={{ gap: 12, flexWrap: 'nowrap' }}> <div key={i} className="card row" style={{ gap: 12, flexWrap: 'nowrap' }}>
<Avatar user={s.user} size={44} /> <Avatar user={s.user} size={44} />
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
@@ -312,9 +332,16 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
{isHost && s.user.id !== me.id && <HostMenu room={room} target={s.user} />} {isHost && s.user.id !== me.id && <HostMenu room={room} target={s.user} />}
</div> </div>
) : ( ) : (
<button key={i} className="card row" style={{ border: '2px dashed var(--border)', boxShadow: 'none', cursor: 'pointer', minHeight: 76, justifyContent: 'center', color: 'var(--text-sub)' }} onClick={() => (mySeat < 0 ? socket.send({ t: 'seat', seat: i }) : setQr(true))}> <div key={i} className="row" style={{ gap: 8, flexWrap: 'nowrap' }}>
<button className="card row" style={{ flex: 1, border: '2px dashed var(--border)', boxShadow: 'none', cursor: 'pointer', minHeight: 76, justifyContent: 'center', color: 'var(--text-sub)' }} onClick={() => (mySeat < 0 ? socket.send({ t: 'seat', seat: i }) : setQr(true))}>
{mySeat < 0 ? '+ 여기 앉기' : '+ 빈자리 · 친구 초대하기'} {mySeat < 0 ? '+ 여기 앉기' : '+ 빈자리 · 친구 초대하기'}
</button> </button>
{canAddBot && i === firstEmpty && (
<button className="btn btn-secondary" style={{ minHeight: 76 }} onClick={() => socket.send({ t: 'addBot', seat: i })}>
🤖 컴퓨터 넣기
</button>
)}
</div>
), ),
)} )}
</div> </div>
@@ -340,6 +367,7 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
)} )}
</div> </div>
<p className="muted small">{room.visibility === 'public' ? '공개 방 (누구나 들어올 수 있어요)' : '친구만 (코드·링크로만 들어와요)'}</p> <p className="muted small">{room.visibility === 'public' ? '공개 방 (누구나 들어올 수 있어요)' : '친구만 (코드·링크로만 들어와요)'}</p>
{practice && <p className="small">🤖 연습 게임이에요. 기록에는 남지 않아요.</p>}
{room.lockedOptions.length > 0 && <p className="muted small">🔒 일부 규칙은 관리자가 정해 두어서 바꿀 수 없어요.</p>} {room.lockedOptions.length > 0 && <p className="muted small">🔒 일부 규칙은 관리자가 정해 두어서 바꿀 수 없어요.</p>}
<GameNotice gameId={room.gameId} /> <GameNotice gameId={room.gameId} />
{isHost && wide && <SettingsForm room={room} ui={ui} inline />} {isHost && wide && <SettingsForm room={room} ui={ui} inline />}
@@ -479,7 +507,11 @@ function SettingsForm({ room, ui, inline, onDone }: { room: RoomView; ui: GameUI
<div style={{ display: 'grid', gridTemplateColumns: inline ? 'repeat(3, minmax(0, 1fr))' : '1fr', gap: 12 }}> <div style={{ display: 'grid', gridTemplateColumns: inline ? 'repeat(3, minmax(0, 1fr))' : '1fr', gap: 12 }}>
<div className="stack" style={{ gap: 6 }}> <div className="stack" style={{ gap: 6 }}>
<strong>방 공개</strong> <strong>방 공개</strong>
{seg(visibility === 'public', (v) => setVisibility(v ? 'public' : 'private'), '누구나', '친구만')} {room.seats.some((s) => s && isBotId(s.user.id)) ? (
<p className="muted small">컴퓨터가 있는 방은 친구만 들어와요.</p>
) : (
seg(visibility === 'public', (v) => setVisibility(v ? 'public' : 'private'), '누구나', '친구만')
)}
</div> </div>
<div className="stack" style={{ gap: 6 }}> <div className="stack" style={{ gap: 6 }}>
<strong>관전</strong> <strong>관전</strong>
@@ -571,8 +603,10 @@ function ResultPanel({ room, me, players, onLeave }: { room: RoomView; me: Publi
const result = useRoom((s) => s.result)!; const result = useRoom((s) => s.result)!;
const [hidden, setHidden] = useState(false); const [hidden, setHidden] = useState(false);
const played = useRef(false); const played = useRef(false);
const seated = room.seats.filter(Boolean).map((s) => s!.user.id); const seatedAll = room.seats.filter(Boolean).map((s) => s!.user.id);
const amSeated = seated.includes(me.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); const voted = room.rematchVotes.includes(me.id);
// Solo games (e.g. 요트 다이스) have one ranked player: neither a draw nor a win. // Solo games (e.g. 요트 다이스) have one ranked player: neither a draw nor a win.
const solo = result.ranking.flat().length === 1; const solo = result.ranking.flat().length === 1;
@@ -600,6 +634,7 @@ function ResultPanel({ room, me, players, onLeave }: { room: RoomView; me: Publi
<Sheet onClose={() => setHidden(true)} label="게임 결과"> <Sheet onClose={() => setHidden(true)} label="게임 결과">
<h1 style={{ textAlign: 'center' }}>{allFirst ? '무승부예요' : won ? '🎉 이겼어요!' : lost ? '아쉽게 졌어요' : '게임이 끝났어요'}</h1> <h1 style={{ textAlign: 'center' }}>{allFirst ? '무승부예요' : won ? '🎉 이겼어요!' : lost ? '아쉽게 졌어요' : '게임이 끝났어요'}</h1>
<p style={{ textAlign: 'center' }}>{result.summary}</p> <p style={{ textAlign: 'center' }}>{result.summary}</p>
{seatedAll.length > seated.length && <p className="muted small" style={{ textAlign: 'center' }}>🤖 연습 게임이라 기록에는 남지 않아요.</p>}
<div className="stack"> <div className="stack">
{result.ranking.map((group, i) => {result.ranking.map((group, i) =>
group.map((u) => ( group.map((u) => (

View File

@@ -28,6 +28,8 @@
| `start` | — | 방장: 시작 | | `start` | — | 방장: 시작 |
| `kick` | `userId` | 방장: 내보내기 | | `kick` | `userId` | 방장: 내보내기 |
| `host` | `userId` | 방장 넘기기 | | `host` | `userId` | 방장 넘기기 |
| `addBot` | `seat?: number` | 방장: 빈자리에 컴퓨터 넣기(대기실에서만, 06 §12) |
| `removeBot` | `userId` | 방장: 컴퓨터 빼기(대기실에서만) |
| `act` | `cs: number`, `a: Action` | 게임 행동. `cs`는 클라이언트 행동 번호(1부터 증가) | | `act` | `cs: number`, `a: Action` | 게임 행동. `cs`는 클라이언트 행동 번호(1부터 증가) |
| `chat` | `text`(1~200자) | 채팅 | | `chat` | `text`(1~200자) | 채팅 |
| `emote` | `id` | 빠른 반응(👍, 😂, "잘했어요!" 등 고정 목록) | | `emote` | `id` | 빠른 반응(👍, 😂, "잘했어요!" 등 고정 목록) |

View File

@@ -95,3 +95,12 @@ interface Room {
``` ```
- 한 사용자는 동시에 한 방에만 플레이어로 있을 수 있다(다른 방에 들어가면 이전 방 대기실 자리에서 빠짐. 이전 방이 게임 중이면 "진행 중인 게임이 있어요. 그래도 이동할까요?"). - 한 사용자는 동시에 한 방에만 플레이어로 있을 수 있다(다른 방에 들어가면 이전 방 대기실 자리에서 빠짐. 이전 방이 게임 중이면 "진행 중인 게임이 있어요. 그래도 이동할까요?").
- 제한: 서버 전체 방 2,000개, 사용자당 동시에 만든 방 3개. - 제한: 서버 전체 방 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를 막고, 오셀로는 귀를 잡고, 카드 게임은 패 세기에 맞게).
- 사람이 모두 나가면 게임은 무효로 끝난다.

View File

@@ -86,13 +86,13 @@ CREATE TABLE games (
started_at INTEGER NOT NULL, started_at INTEGER NOT NULL,
ended_at INTEGER, ended_at INTEGER,
end_reason TEXT, -- 'normal' | 'resign' | 'abandoned' | 'void' end_reason TEXT, -- 'normal' | 'resign' | 'abandoned' | 'void'
result_json TEXT result_json TEXT -- 컴퓨터와 한 연습 게임은 "practice": true
); );
CREATE INDEX games_room ON games(room_id); CREATE INDEX games_room ON games(room_id);
CREATE TABLE game_players ( CREATE TABLE game_players (
game_pk TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE, game_pk TEXT NOT NULL REFERENCES games(id) ON DELETE CASCADE,
user_id TEXT NOT NULL, user_id TEXT NOT NULL, -- 컴퓨터는 'bot:<번호>:<ULID>'(users에 없음)
seat INTEGER NOT NULL, seat INTEGER NOT NULL,
rank INTEGER, -- 1 = 1등, 무승부는 같은 순위 rank INTEGER, -- 1 = 1등, 무승부는 같은 순위
score REAL, score REAL,
@@ -117,7 +117,7 @@ CREATE TABLE user_stats (
| 대기실 변경(자리, 옵션, 준비) | `room_state` update(+ `room_log`) | | 대기실 변경(자리, 옵션, 준비) | `room_state` update(+ `room_log`) |
| 게임 시작 | `games` insert, `room_state`, `room_log` | | 게임 시작 | `games` insert, `room_state`, `room_log` |
| 게임 행동 / 시간 초과 | `room_log` insert + `room_state` update (한 트랜잭션) | | 게임 행동 / 시간 초과 | `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` 삭제 | | 방 닫힘 | `rooms.status='closed'`, `room_state` 삭제 |
## 4. 정리 작업(매시간) ## 4. 정리 작업(매시간)

View File

@@ -129,3 +129,8 @@ export interface GameUI<V> {
- **API**: `GET /api/me/games?before=&limit=`(내가 끝까지 한 게임, 최신순), `GET /api/replays/:gamePk`. 다시 만든 결과는 잠깐(10분, 32개) 캐시하고, 한 번에 하나씩 조금씩 끊어 만들어 방 처리를 막지 않는다. - **API**: `GET /api/me/games?before=&limit=`(내가 끝까지 한 게임, 최신순), `GET /api/replays/:gamePk`. 다시 만든 결과는 잠깐(10분, 32개) 캐시하고, 한 번에 하나씩 조금씩 끊어 만들어 방 처리를 막지 않는다.
- **화면**: 게임의 `Board`/`Side`를 그대로 쓰되 읽기 전용(`act`는 아무것도 안 함, 판 안의 누르기·키 입력은 막음). 처음 / 이전 / 재생·멈춤 / 다음 / 끝 버튼, 슬라이더, "n번째 / 전체 n번", 장면 설명(누가 움직였는지, 자동 진행). 키보드 ←/→/Home/End. 들어가는 곳: 결과 화면 [처음부터 다시 보기](`RoomView.lastGamePk`), 내 정보 › 내 게임 기록 [다시 보기]. - **화면**: 게임의 `Board`/`Side`를 그대로 쓰되 읽기 전용(`act`는 아무것도 안 함, 판 안의 누르기·키 입력은 막음). 처음 / 이전 / 재생·멈춤 / 다음 / 끝 버튼, 슬라이더, "n번째 / 전체 n번", 장면 설명(누가 움직였는지, 자동 진행). 키보드 ←/→/Home/End. 들어가는 곳: 결과 화면 [처음부터 다시 보기](`RoomView.lastGamePk`), 내 정보 › 내 게임 기록 [다시 보기].
- **테스트**: 모든 게임을 실제 `Room`으로 끝까지(무작위 행동, 시간 초과, 나가기) 둔 뒤 다시 만든 상태·결과가 같은지, 모든 장면이 그 플레이어가 받은 view와 같은지(`replay.test.ts`), 권한(`replay-api.test.ts`), 브라우저(`e2e/replay.e2e.ts`). - **테스트**: 모든 게임을 실제 `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명은 시간 초과만, 나머지는 컴퓨터"와 "컴퓨터끼리" 판을 끝까지 돌려, 모든 수가 합법이고 컴퓨터 때문에 멈추지 않으며 한 수가 충분히 빠른지 본다.

170
e2e/bots.e2e.ts Normal file
View File

@@ -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<void>) => {
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<Set<number>> {
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);

View File

@@ -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<BadukOptions> = {}): 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' });
});
});

View File

@@ -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<BadukView, Act> = ({ 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<number>(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<number>();
const seen = new Set<number>();
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;
};

View File

@@ -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<string>();
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<BangOptions> = {}): 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<string, unknown>;
};
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<BangOptions>[] = [
{ 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);
}
}
});
});

View File

@@ -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<CardKind, number> = {
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<CardKind>(['bang', 'duel', 'panic', 'catBalou', 'jail']);
const GEAR: CardKind[] = ['barrel', 'mustang', 'scope', 'volcanic', 'schofield', 'remington', 'carabine', 'winchester'];
type Play = Extract<BangAction, { type: 'play' }>;
export const bangBot: BotStrategy<BangView, BangAction> = (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<PlayerId, number>();
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<BangAction, { type: 'kitKeep' }>[];
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<BangAction, { type: 'draw' }>[];
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));
};

View File

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

View File

@@ -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<string, BotStrategy> = {
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;
}

View File

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

View File

@@ -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 = <T>(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<A>(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<A>(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<A>(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<A>({ legal, random }: BotInput<unknown, A>): A {
return answerRequests(legal) ?? pick(playable(legal), random);
}
/** Random action, but "pass"-like actions only when nothing else is possible. */
export function preferPlay<A>(input: BotInput<unknown, A>, 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);
}

View File

@@ -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<V = any, A = any> {
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<V = any, A = any> = (input: BotInput<V, A>) => A;

View File

@@ -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<ChessAction, { type: 'move' }>;
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 });
});
});

View File

@@ -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<string, PieceSymbol> = { N: 'n', B: 'b', R: 'r', Q: 'q', K: 'k' };
export const chessBot: BotStrategy<ChessView> = ({ 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);
};

View File

@@ -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<string, Kind[]>, fronts: Record<string, Kind[]> = {}, 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<Kind, number>;
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<string, unknown>;
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<CockroachOptions>][] = [
['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);
}
});
}
});

View File

@@ -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<CockroachAction, { type: 'offer' }>;
type Pass = Extract<CockroachAction, { type: 'pass' }>;
/** 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<CockroachView, CockroachAction> = ({ 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');
};

View File

@@ -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<DalmutiOptions> = {}): 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<DalmutiOptions>][] = [
['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);
}
});
}
});

View File

@@ -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<DalmutiAction, { type: 'returnTax' }>;
export const dalmutiBot: BotStrategy<DalmutiView, DalmutiAction> = ({ 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;
};

View File

@@ -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<string, string[]>, opts: Partial<DavinciOptions> = {}): 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<string>();
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<DavinciOptions>[] = [{ 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);
});
});

View File

@@ -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<PlayerId, (TileValue[] | null)[]>;
/** 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<string>();
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<DavinciView, DavinciAction> = (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<DavinciAction, { type: 'chooseStart' }> => 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<DavinciAction, { type: 'revealOwn' }> => 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);
};

View File

@@ -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<GostopOptions> = {}): 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<GostopOptions>][] = [
[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);
}
});
});

View File

@@ -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<number, number>();
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<GostopView, GostopAction> = ({ 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<GostopAction, { type: 'chooseFlip' }> => 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);
};

View File

@@ -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<JanggiOptions> = {}, 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');
});
});

View File

@@ -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<string, number> {
const seen = new Map<string, number>();
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<JanggiView> = ({ 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);
};

View File

@@ -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<LasVegasOptions> = {}): 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);
}
});
}
});

View File

@@ -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<LasVegasAction, { type: 'PLACE' }>;
export const lasVegasBot: BotStrategy<LasVegasView, LasVegasAction> = ({ 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<Record<Owner, number>>): 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);
};

View File

@@ -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>): 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<TileId>();
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 = <T,>(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<MahjongOptions>][] = [
[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);
}
});
});

View File

@@ -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<number>(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<string, Int8Array>();
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<MahjongView, MahjongAction> = ({ 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<MahjongAction, { type: 'discard' }> => 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<string, { sh: number; uke: number }>();
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<MahjongAction, { type: 'discard' }>): 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);
};

View File

@@ -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<MarbleOptions> = {}): 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);
}
});
}
});

View File

@@ -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<T extends MarbleAction['type']> = Extract<MarbleAction, { type: T }>;
export const marbleBot: BotStrategy<MarbleView, MarbleAction> = ({ me, view, legal, random }) => {
const req = answerRequests(legal);
if (req) return req;
const find = <T extends MarbleAction['type']>(t: T) => legal.find((a) => a.type === t) as Act<T> | 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);
};

View File

@@ -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<NNOptions> = {}): 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<string, unknown>;
const labelOf = (s: NinetyNineState, a: Record<string, unknown>) => 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<NNOptions>][] = [
['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);
}
});
}
});

View File

@@ -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<CardKind, number> = { 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<NinetyNineView, NNAction> = ({ 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);
};

View File

@@ -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<OmokOptions> = {}): 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);
}
});
});

View File

@@ -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<OmokView> = ({ 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,
);
};

View File

@@ -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<OneCardOptions> = {}, 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<string, string[]>, 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<string, unknown>;
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<OneCardOptions>][] = [
['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);
}
});
}
});

View File

@@ -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<K extends string>(keys: K[], all: K[], random: () => number): K {
const count = new Map<K, number>();
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<OneCardView, OneCardAction> = ({ 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<Suit>(suits, SUITS, random) };
}
if (a.chooseColor) {
const colors = rest.map(colorOf).filter((c): c is Color => !!c);
return { ...a, chooseColor: majority<Color>(colors, COLORS, random) };
}
return a;
}

View File

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

View File

@@ -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<OthelloView> = ({ 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);
};

View File

@@ -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<PokerOptions>, 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<PokerAction, { type: 'choose' }>;
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<PokerAction, { type: 'draw' }>;
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<PokerOptions>][] = [
["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);
}
});
});

View File

@@ -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<string, number> = {};
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<CardId>([...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<PokerView, PokerAction> = ({ 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);
};

View File

@@ -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<Record<Seat, string>>, 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);
});
});

View File

@@ -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<QuoridorView> = ({ 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,
);
};

View File

@@ -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<RummikubOptions> }): 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<RummikubOptions>[] = [{}, { 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);
}
});
});

View File

@@ -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<string, JokerAs> };
/** 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<string>();
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<RummikubView, RummikubAction> = ({ 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 };
};

View File

@@ -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<SeotdaOptions> = {}, 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<string, number[]>, 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<SeotdaAction, { type: 'choose' }>;
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);
}
});
});

View File

@@ -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<SeotdaView, SeotdaAction> = ({ 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<SeotdaAction, { type: 'choose' }> => 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);
};

View File

@@ -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>): Tokens => ({ ...emptyTokens(), ...t });
/** Two players, A to move, a fixed board row 1 / 2 and the given nobles. */
function mk(opts: Partial<SplendorOptions> = {}): 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<SplendorOptions>[] = [{ 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);
});
});

View File

@@ -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<SplendorView, SplendorAction> = (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<Color, number> => {
const need = needFor(card, bonuses);
const out = {} as Record<Color, number>;
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);

View File

@@ -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<YachtOptions> = {}, 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);
}
});
}
});

View File

@@ -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<Record<Category, number>>> = {
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<number, number>();
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<YachtView, YachtAction> = ({ 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<number, number>();
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<number, number>();
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<YachtAction, { type: 'score' }>[];
if (!scores.length) return roll ?? pick(playable(legal), random);
return best(scores, (a) => catValue(dice, a.category), random);
};

View File

@@ -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<YutOptions> = {}): 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);
}
});
}
});

View File

@@ -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<YutView, YutAction> = ({ view, legal, random }) => {
const req = answerRequests(legal);
if (req) return req;
const moves = legal.filter((a) => a.type === 'MOVE') as Extract<YutAction, { type: 'MOVE' }>[];
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);
};

View File

@@ -0,0 +1,53 @@
/** Computer players for solo practice (docs/06-lobby-rooms.md §12). Shared by server and web (no runtime deps). */
import type { PublicUser } from './types';
/** Bot user ids look like `bot:<n>:<ulid>`; real user ids are plain ULIDs, so they can never collide. */
export const BOT_PREFIX = 'bot:';
export const isBotId = (id: string): boolean => id.startsWith(BOT_PREFIX);
export const makeBotId = (n: number, unique: string): string => `${BOT_PREFIX}${n}:${unique}`;
/** Number shown in the bot's name ("컴퓨터 2"). */
export function botNumber(id: string): number | null {
if (!isBotId(id)) return null;
const n = Number(id.slice(BOT_PREFIX.length).split(':')[0]);
return Number.isInteger(n) && n > 0 ? n : null;
}
/** Public profile of a bot id, or null for anything else. */
export function botProfile(id: string): PublicUser | null {
const n = botNumber(id);
return n === null ? null : { id, nickname: `컴퓨터 ${n}`, avatar: null, kind: 'bot' };
}
/**
* Games that offer computer players. Real-time and talk-based games (도블, 할리갈리, 그림 맞히기, 마피아)
* don't: a bot there is either unbeatable or meaningless.
*/
export const BOT_GAMES: readonly string[] = [
'omok',
'othello',
'chess',
'janggi',
'baduk',
'seotda',
'poker',
'yacht',
'yut',
'one-card',
'rummikub',
'gostop',
'bang',
'davinci-code',
'mahjong',
'marble',
'dalmuti',
'splendor',
'quoridor',
'las-vegas',
'cockroach-poker',
'ninety-nine',
];
export const botsAllowed = (gameId: string): boolean => BOT_GAMES.includes(gameId);

View File

@@ -4,3 +4,4 @@ export * from './nickname';
export * from './room-code'; export * from './room-code';
export * from './catalog'; export * from './catalog';
export * from './view-patch'; export * from './view-patch';
export * from './bots';

View File

@@ -27,6 +27,8 @@ export const ClientMessage = z.discriminatedUnion('t', [
z.object({ t: z.literal('start') }), z.object({ t: z.literal('start') }),
z.object({ t: z.literal('kick'), userId }), z.object({ t: z.literal('kick'), userId }),
z.object({ t: z.literal('host'), 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('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('chat'), text: z.string().min(1).max(CHAT_MAX) }),
z.object({ t: z.literal('emote'), id: z.string().max(20) }), z.object({ t: z.literal('emote'), id: z.string().max(20) }),

View File

@@ -19,7 +19,7 @@ export interface PublicUser {
id: string; id: string;
nickname: string; nickname: string;
avatar: string | null; avatar: string | null;
kind: 'guest' | 'member'; kind: 'guest' | 'member' | 'bot';
} }
export interface SeatView { export interface SeatView {