Merge branch 'm9/bots' into codex/owner/grp_discord_joke-app
This commit is contained in:
112
apps/server/src/bots.test.ts
Normal file
112
apps/server/src/bots.test.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/** Computer players over the real HTTP/WS stack (docs/06-lobby-rooms.md §12). */
|
||||||
|
import { afterEach, describe, expect, test } from 'bun:test';
|
||||||
|
import { Client, api, boot, guest, sleep } from './test-utils';
|
||||||
|
|
||||||
|
type Booted = ReturnType<typeof boot>;
|
||||||
|
const running: Booted[] = [];
|
||||||
|
const clients: Client[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const c of clients.splice(0)) {
|
||||||
|
try {
|
||||||
|
c.close();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
for (const s of running.splice(0)) {
|
||||||
|
try {
|
||||||
|
await s.stop();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const fastBots = { botDelay: () => 30 };
|
||||||
|
|
||||||
|
function start(dbPath?: string): Booted {
|
||||||
|
const s = boot(dbPath, fastBots);
|
||||||
|
running.push(s);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function client(s: Booted, nick: string, cookie?: { cookie: string; id: string }): Promise<Client> {
|
||||||
|
const g = cookie ?? (await guest(s.base, nick));
|
||||||
|
const c = await new Client(s.base, g.cookie, g.id).connect();
|
||||||
|
clients.push(c);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function practiceOmok(s: Booted, a: Client): Promise<string> {
|
||||||
|
const r = await api(s.base, '/api/rooms', { method: 'POST', cookie: a.cookie, body: JSON.stringify({ gameId: 'omok', practice: true }) });
|
||||||
|
expect(r.res.status).toBe(200);
|
||||||
|
const code = r.body.code as string;
|
||||||
|
a.send({ t: 'join', code, as: 'player' });
|
||||||
|
await a.next('room');
|
||||||
|
a.send({ t: 'config', options: { colorAssignment: 'hostBlack', ruleSet: 'free' } });
|
||||||
|
await a.next('room', (m) => (m.room.options as any).colorAssignment === 'hostBlack');
|
||||||
|
const st = a.next('state');
|
||||||
|
a.send({ t: 'start' });
|
||||||
|
await st;
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('computer players', () => {
|
||||||
|
test('practice room: host + computer seated, private, not listed; the computer answers moves', async () => {
|
||||||
|
const s = start();
|
||||||
|
const a = await client(s, '호랑이');
|
||||||
|
const code = await practiceOmok(s, a);
|
||||||
|
const room = a.lastOf('room')!.room;
|
||||||
|
expect(room.visibility).toBe('private');
|
||||||
|
expect(room.seats.map((x) => x?.user.kind)).toEqual(['guest', 'bot']);
|
||||||
|
expect(room.seats[1]!.user.nickname).toBe('컴퓨터 1');
|
||||||
|
const pub = await api(s.base, '/api/games/omok/rooms');
|
||||||
|
expect(pub.body.rooms.find((r: { code: string }) => r.code === code)).toBeUndefined();
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const answered = a.next('state', (m) => (m.view as any).moveCount === 2 * i + 2);
|
||||||
|
const board = (a.lastOf('state')!.view as any).board as number[];
|
||||||
|
const free = board.indexOf(0);
|
||||||
|
a.act({ type: 'place', x: free % 15, y: Math.floor(free / 15) });
|
||||||
|
await answered;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addBot / removeBot over the socket: host only, errors for others', async () => {
|
||||||
|
const s = start();
|
||||||
|
const a = await client(s, '호랑이');
|
||||||
|
const b = await client(s, '고양이');
|
||||||
|
const r = await api(s.base, '/api/rooms', { method: 'POST', cookie: a.cookie, body: JSON.stringify({ gameId: 'one-card', visibility: 'public' }) });
|
||||||
|
const code = r.body.code as string;
|
||||||
|
a.send({ t: 'join', code, as: 'player' });
|
||||||
|
await a.next('room');
|
||||||
|
b.send({ t: 'join', code, as: 'player' });
|
||||||
|
await b.next('room');
|
||||||
|
b.send({ t: 'addBot' });
|
||||||
|
expect((await b.next('error')).message).toBe('방장만 컴퓨터를 넣을 수 있어요.');
|
||||||
|
a.send({ t: 'addBot', seat: 3 });
|
||||||
|
const withBot = await a.next('room', (m) => m.room.seats.some((x) => x?.user.kind === 'bot'));
|
||||||
|
expect(withBot.room.visibility).toBe('private');
|
||||||
|
const botId = withBot.room.seats[3]!.user.id;
|
||||||
|
a.send({ t: 'removeBot', userId: botId });
|
||||||
|
await a.next('room', (m) => !m.room.seats.some((x) => x?.user.kind === 'bot'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restart with the computer to move: after restore it plays on its own', async () => {
|
||||||
|
const s1 = boot(undefined, fastBots);
|
||||||
|
const a = await client(s1, '호랑이');
|
||||||
|
const code = await practiceOmok(s1, a);
|
||||||
|
// Stop the bot before it answers, then crash.
|
||||||
|
for (const r of s1.rooms.all()) r.clearTimer();
|
||||||
|
a.act({ type: 'place', x: 7, y: 7 });
|
||||||
|
await a.next('state', (m) => (m.view as any).moveCount === 1);
|
||||||
|
for (const r of s1.rooms.all()) r.clearTimer();
|
||||||
|
await Promise.race([s1.server.stop(true), sleep(300)]);
|
||||||
|
for (const r of s1.rooms.all()) r.clearTimer();
|
||||||
|
s1.db.close();
|
||||||
|
const s2 = start(s1.dbPath);
|
||||||
|
await sleep(200); // the restored driver plays without anyone connected
|
||||||
|
const room = s2.rooms.get(code)!;
|
||||||
|
expect((room.game!.state as any).moves.length).toBe(2);
|
||||||
|
const a2 = await client(s2, '', { cookie: a.cookie, id: a.id });
|
||||||
|
a2.send({ t: 'join', code, as: 'player' });
|
||||||
|
const st = await a2.next('state');
|
||||||
|
expect((st.view as any).moveCount).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import { Hono, type Context } from 'hono';
|
import { 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,
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|||||||
286
apps/server/src/rooms/bots.room.test.ts
Normal file
286
apps/server/src/rooms/bots.room.test.ts
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
/** Computer players in rooms (docs/06-lobby-rooms.md §12): seating rules, practice rooms, driving, records, restore. */
|
||||||
|
import { afterEach, describe, expect, test } from 'bun:test';
|
||||||
|
import { GAMES } from '@bg/games';
|
||||||
|
import { BOT_STRATEGIES } from '@bg/games/bots';
|
||||||
|
import { BOT_GAMES, ClientMessage, botProfile, isBotId, type ServerMessage } from '@bg/shared';
|
||||||
|
import { openDb } from '../db';
|
||||||
|
import { defaultBotDelay } from './bots';
|
||||||
|
import { RoomManager } from './manager';
|
||||||
|
import { Room, type Conn, type RoomSnapshot } from './room';
|
||||||
|
import { RoomStore } from './store';
|
||||||
|
|
||||||
|
const rooms: Room[] = [];
|
||||||
|
afterEach(() => {
|
||||||
|
for (const r of rooms.splice(0)) r.clearTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
function harness(log?: (level: 'info' | 'warn' | 'error', msg: string) => void) {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const store = new RoomStore(db);
|
||||||
|
let now = 1_000_000;
|
||||||
|
const clock = { now: () => now, advance: (ms: number) => (now += ms) };
|
||||||
|
const users = { publicUser: (id: string) => botProfile(id) ?? { id, nickname: id, avatar: null, kind: 'guest' as const } };
|
||||||
|
const manager = new RoomManager({ store, games: GAMES, users, now: clock.now, botDelay: () => 0, log });
|
||||||
|
const conn = (userId: string) => {
|
||||||
|
const msgs: ServerMessage[] = [];
|
||||||
|
const c: Conn & { msgs: ServerMessage[] } = { userId, msgs, send: (m) => msgs.push(m), close: () => {} };
|
||||||
|
return c;
|
||||||
|
};
|
||||||
|
const create = (host: string, gameId = 'omok', visibility: 'private' | 'public' = 'private') => {
|
||||||
|
const r = manager.create(host, gameId, visibility);
|
||||||
|
if (typeof r === 'string') throw new Error(r);
|
||||||
|
rooms.push(r);
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
return { db, store, clock, manager, conn, create, users };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plays every planned bot move (the delay is 0 in these tests) until the game waits on a human. */
|
||||||
|
function drive(room: Room, max = 500): number {
|
||||||
|
let n = 0;
|
||||||
|
while (room.bots.dueAt !== null && n < max) {
|
||||||
|
room.bots.fire();
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bots = (room: Room) => room.seatedIds().filter(isBotId);
|
||||||
|
|
||||||
|
describe('bot list', () => {
|
||||||
|
test('BOT_GAMES matches the strategies and leaves out real-time / talk games', () => {
|
||||||
|
expect([...BOT_GAMES].sort()).toEqual(Object.keys(BOT_STRATEGIES).sort());
|
||||||
|
for (const id of BOT_GAMES) expect(GAMES[id]).toBeDefined();
|
||||||
|
for (const id of ['dobble', 'halli-galli', 'catchmind', 'mafia']) expect(BOT_GAMES).not.toContain(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('thinking time: short for forced moves, longer for choices, longest when people act in the same window', () => {
|
||||||
|
const r = () => 0.5;
|
||||||
|
expect(defaultBotDelay(1, false, r)).toBe(550);
|
||||||
|
expect(defaultBotDelay(5, false, r)).toBe(1100);
|
||||||
|
expect(defaultBotDelay(5, true, r)).toBe(2600);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('protocol accepts addBot / removeBot', () => {
|
||||||
|
expect(ClientMessage.safeParse({ t: 'addBot' }).success).toBe(true);
|
||||||
|
expect(ClientMessage.safeParse({ t: 'addBot', seat: 1 }).success).toBe(true);
|
||||||
|
expect(ClientMessage.safeParse({ t: 'addBot', seat: -1 }).success).toBe(false);
|
||||||
|
expect(ClientMessage.safeParse({ t: 'removeBot', userId: 'bot:1:01J0000000000000000000000' }).success).toBe(true);
|
||||||
|
expect(ClientMessage.safeParse({ t: 'removeBot' }).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addBot / removeBot', () => {
|
||||||
|
test('host only, lobby only, empty seats only; the room turns private and drops out of the public list', () => {
|
||||||
|
const { create, conn, manager } = harness();
|
||||||
|
const room = create('A', 'omok', 'public');
|
||||||
|
room.join(conn('A'), 'player');
|
||||||
|
room.join(conn('S'), 'spectator');
|
||||||
|
expect(manager.publicRooms('omok')).toHaveLength(1);
|
||||||
|
expect(room.addBot('S')).toBe('방장만 컴퓨터를 넣을 수 있어요.');
|
||||||
|
expect(room.addBot('A', 0)).toBe('이미 다른 사람이 앉은 자리예요.');
|
||||||
|
expect(room.addBot('A')).toBeNull();
|
||||||
|
const [bot] = bots(room);
|
||||||
|
expect(bot).toMatch(/^bot:1:/);
|
||||||
|
expect(room.addBot('A')).toBe('빈자리가 없어요.');
|
||||||
|
expect(room.config.visibility).toBe('private');
|
||||||
|
expect(manager.publicRooms('omok')).toHaveLength(0);
|
||||||
|
expect(room.configure('A', { visibility: 'public' })).toBe('컴퓨터가 있는 방은 공개할 수 없어요.');
|
||||||
|
expect(room.configure('A', { gameId: 'dobble' })).toContain('컴퓨터');
|
||||||
|
const seat = room.roomView().seats[1]!;
|
||||||
|
expect(seat).toMatchObject({ ready: true, connected: true, away: false });
|
||||||
|
expect(seat.user).toMatchObject({ nickname: '컴퓨터 1', kind: 'bot' });
|
||||||
|
expect(room.removeBot('S', bot!)).toBe('방장만 컴퓨터를 뺄 수 있어요.');
|
||||||
|
expect(room.removeBot('A', 'B')).toBe('없는 컴퓨터예요.');
|
||||||
|
expect(room.removeBot('A', bot!)).toBeNull();
|
||||||
|
expect(bots(room)).toHaveLength(0);
|
||||||
|
// Without computers the host may make it public again.
|
||||||
|
expect(room.configure('A', { visibility: 'public' })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('games without computer players refuse them', () => {
|
||||||
|
const { create, conn } = harness();
|
||||||
|
const room = create('A', 'dobble');
|
||||||
|
room.join(conn('A'), 'player');
|
||||||
|
expect(room.addBot('A')).toBe('이 게임은 컴퓨터와 할 수 없어요.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bots get the lowest free number; kick removes a bot; the host is never passed to a bot', () => {
|
||||||
|
const { create, conn } = harness();
|
||||||
|
const room = create('A', 'one-card');
|
||||||
|
room.join(conn('A'), 'player');
|
||||||
|
room.addBot('A');
|
||||||
|
room.addBot('A');
|
||||||
|
room.addBot('A');
|
||||||
|
const [b1, b2] = bots(room);
|
||||||
|
expect(room.kick('A', b2!)).toBeNull();
|
||||||
|
room.addBot('A');
|
||||||
|
expect(bots(room).map((b) => botProfile(b)!.nickname).sort()).toEqual(['컴퓨터 1', '컴퓨터 2', '컴퓨터 3']);
|
||||||
|
expect(room.transferHost('A', b1!)).toBe('컴퓨터에게는 방장을 넘길 수 없어요.');
|
||||||
|
// A spectator joins, then the host leaves: the spectator becomes host, not a computer.
|
||||||
|
room.join(conn('S'), 'spectator');
|
||||||
|
room.leave('A');
|
||||||
|
expect(room.hostId).toBe('S');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a room needs a seated person to start', () => {
|
||||||
|
const { create, conn } = harness();
|
||||||
|
const room = create('A', 'omok');
|
||||||
|
room.join(conn('A'), 'spectator');
|
||||||
|
room.addBot('A');
|
||||||
|
room.addBot('A');
|
||||||
|
expect(room.start('A')).toBe('사람이 한 명은 앉아야 해요.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bots cannot be added or removed during a game', () => {
|
||||||
|
const { create, conn } = harness();
|
||||||
|
const room = create('A', 'one-card');
|
||||||
|
room.join(conn('A'), 'player');
|
||||||
|
room.addBot('A');
|
||||||
|
expect(room.start('A')).toBeNull();
|
||||||
|
expect(room.addBot('A')).toBe('게임 중에는 바꿀 수 없어요.');
|
||||||
|
expect(room.removeBot('A', bots(room)[0]!)).toBe('게임 중에는 바꿀 수 없어요.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createPractice', () => {
|
||||||
|
test('a private room with just enough computers to start; never offered by quick play', () => {
|
||||||
|
const { manager, conn } = harness();
|
||||||
|
const expected: Record<string, number> = { omok: 1, mahjong: 2, bang: 3, dalmuti: 3, quoridor: 1, yacht: 1 };
|
||||||
|
for (const [gameId, n] of Object.entries(expected)) {
|
||||||
|
const room = manager.createPractice(`host-${gameId}`, gameId);
|
||||||
|
if (typeof room === 'string') throw new Error(room);
|
||||||
|
rooms.push(room);
|
||||||
|
expect(room.config.visibility).toBe('private');
|
||||||
|
expect(bots(room)).toHaveLength(n);
|
||||||
|
room.join(conn(`host-${gameId}`), 'player');
|
||||||
|
expect(room.start(`host-${gameId}`)).toBeNull();
|
||||||
|
expect(room.game!.players).toContain(`host-${gameId}`);
|
||||||
|
}
|
||||||
|
expect(manager.createPractice('X', 'mafia')).toBe('이 게임은 컴퓨터와 할 수 없어요.');
|
||||||
|
// Quick play never lands in a practice room.
|
||||||
|
const qp = manager.quickPlay('Q', 'omok');
|
||||||
|
if (typeof qp === 'string') throw new Error(qp);
|
||||||
|
rooms.push(qp);
|
||||||
|
expect(qp.hasBots()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('playing with bots', () => {
|
||||||
|
function omokVsBot() {
|
||||||
|
const h = harness();
|
||||||
|
const room = h.manager.createPractice('A', 'omok') as Room;
|
||||||
|
rooms.push(room);
|
||||||
|
const a = h.conn('A');
|
||||||
|
room.join(a, 'player');
|
||||||
|
room.configure('A', { options: { ...(room.config.options as object), colorAssignment: 'hostBlack' } });
|
||||||
|
expect(room.start('A')).toBeNull();
|
||||||
|
return { ...h, room, a };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the bot answers each human move through the normal action path', () => {
|
||||||
|
const { room, a, store } = omokVsBot();
|
||||||
|
const moves = () => (room.game!.state as { moves: unknown[] }).moves.length;
|
||||||
|
expect(room.bots.dueAt).toBeNull(); // black (human) to move
|
||||||
|
room.act('A', 1, { type: 'place', x: 7, y: 7 });
|
||||||
|
expect(room.bots.dueAt).not.toBeNull();
|
||||||
|
expect(drive(room)).toBe(1);
|
||||||
|
expect(moves()).toBe(2);
|
||||||
|
expect(a.msgs.filter((m) => m.t === 'state').length).toBeGreaterThanOrEqual(3);
|
||||||
|
// Logged like any action, with the bot as actor.
|
||||||
|
const log = store.log(room.id);
|
||||||
|
expect(log.some((e) => e.kind === 'act' && e.actorId !== null && isBotId(e.actorId))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a bot move planned before a state change re-reads the state when it fires', () => {
|
||||||
|
const { room } = omokVsBot();
|
||||||
|
room.act('A', 1, { type: 'place', x: 7, y: 7 });
|
||||||
|
const due = room.bots.dueAt;
|
||||||
|
room.scheduleTimer(); // e.g. a reconnect re-arms timers: the plan stays
|
||||||
|
expect(room.bots.dueAt).toBe(due);
|
||||||
|
drive(room);
|
||||||
|
expect((room.game!.state as { moves: unknown[] }).moves).toHaveLength(2);
|
||||||
|
expect(room.bots.dueAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a practice game is kept but not counted; rematch only needs the people', () => {
|
||||||
|
const { room, db } = omokVsBot();
|
||||||
|
// Human resigns; the game is recorded as practice.
|
||||||
|
room.act('A', 1, { type: 'resign' });
|
||||||
|
expect(room.status).toBe('finished');
|
||||||
|
const row = db.query<{ result_json: string }, []>('SELECT result_json FROM games').get()!;
|
||||||
|
expect(JSON.parse(row.result_json).practice).toBe(true);
|
||||||
|
expect(db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM user_stats').get()!.n).toBe(0);
|
||||||
|
expect(db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM game_players').get()!.n).toBe(2);
|
||||||
|
expect(room.rematch('A')).toBeNull();
|
||||||
|
expect(room.status).toBe('playing');
|
||||||
|
expect(room.gameNo).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when every person leaves mid-game, the game ends uncounted', () => {
|
||||||
|
const { room, db } = omokVsBot();
|
||||||
|
room.act('A', 1, { type: 'place', x: 7, y: 7 });
|
||||||
|
room.leave('A');
|
||||||
|
expect(room.game!.finished).toBe(true);
|
||||||
|
expect(room.bots.dueAt).toBeNull();
|
||||||
|
expect(db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM user_stats').get()!.n).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The real driver + room timers for every bot game: the bots act on their own (reaction windows, simultaneous
|
||||||
|
// choices, "ready" between hands) and the only person just lets the clock run out each time.
|
||||||
|
test.each([...BOT_GAMES])('%s: a whole practice game with a person who only times out finishes', (gameId) => {
|
||||||
|
const logs: string[] = [];
|
||||||
|
const h = harness((level, msg) => level !== 'info' && logs.push(msg));
|
||||||
|
const room = h.manager.createPractice('A', gameId) as Room;
|
||||||
|
rooms.push(room);
|
||||||
|
room.join(h.conn('A'), 'player');
|
||||||
|
expect(room.start('A')).toBeNull();
|
||||||
|
for (let i = 0; i < 5000 && !room.game!.finished; i++) {
|
||||||
|
drive(room);
|
||||||
|
if (room.game!.finished) break;
|
||||||
|
const at = room.effectiveDeadline();
|
||||||
|
expect(at).not.toBeNull();
|
||||||
|
h.clock.advance(Math.max(0, at! - h.clock.now()));
|
||||||
|
room.onTimer();
|
||||||
|
}
|
||||||
|
expect(room.game!.finished).toBe(true);
|
||||||
|
expect(logs).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('restore after restart', () => {
|
||||||
|
test('bots stay seated, have no reconnect grace, and the driver re-plans their move', () => {
|
||||||
|
const { manager, conn, store, clock, users } = harness();
|
||||||
|
const room = manager.createPractice('A', 'omok') as Room;
|
||||||
|
rooms.push(room);
|
||||||
|
room.join(conn('A'), 'player');
|
||||||
|
room.configure('A', { options: { ...(room.config.options as object), colorAssignment: 'hostBlack' } });
|
||||||
|
room.start('A');
|
||||||
|
room.act('A', 1, { type: 'place', x: 7, y: 7 });
|
||||||
|
// Crash before the bot answers.
|
||||||
|
room.clearTimer();
|
||||||
|
const { seq, stateJson } = store.openRooms()[0]!;
|
||||||
|
clock.advance(60_000);
|
||||||
|
const restored = Room.restore(room.id, room.code, seq, JSON.parse(stateJson) as RoomSnapshot, {
|
||||||
|
store,
|
||||||
|
games: GAMES,
|
||||||
|
users,
|
||||||
|
now: clock.now,
|
||||||
|
botDelay: () => 0,
|
||||||
|
});
|
||||||
|
rooms.push(restored);
|
||||||
|
expect(restored.hasBots()).toBe(true);
|
||||||
|
expect(restored.presence.has(bots(restored)[0]!)).toBe(false);
|
||||||
|
expect(restored.roomView().seats[1]).toMatchObject({ connected: true, ready: true });
|
||||||
|
expect(restored.bots.dueAt).not.toBeNull();
|
||||||
|
drive(restored);
|
||||||
|
expect((restored.game!.state as { moves: unknown[] }).moves).toHaveLength(2);
|
||||||
|
|
||||||
|
// The manager path too: restoreAll keeps bots out of the seat index (no "active room" for a bot).
|
||||||
|
const m2 = new RoomManager({ store, games: GAMES, users, now: clock.now, botDelay: () => 0 });
|
||||||
|
m2.restoreAll();
|
||||||
|
for (const r of m2.all()) rooms.push(r);
|
||||||
|
expect(m2.activeRoomFor('A')).toBe(room.code);
|
||||||
|
expect(m2.activeRoomFor(bots(restored)[0]!)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
122
apps/server/src/rooms/bots.ts
Normal file
122
apps/server/src/rooms/bots.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Computer players' driver (docs/06-lobby-rooms.md §12, docs/09-game-engine.md §9).
|
||||||
|
* After every state change the room calls `schedule()`: if the game waits on a bot, one action is planned after a
|
||||||
|
* short human-like pause and played through the room's normal action path (validated, logged as `act`).
|
||||||
|
*/
|
||||||
|
import type { AnyGameDefinition } from '@bg/engine';
|
||||||
|
import { isBotId } from '@bg/shared';
|
||||||
|
import { botCandidates, decideBotAction } from '@bg/games/bots';
|
||||||
|
|
||||||
|
export interface BotRoom {
|
||||||
|
readonly status: string;
|
||||||
|
readonly game: { state: unknown; players: string[]; finished: boolean } | null;
|
||||||
|
readonly def: AnyGameDefinition;
|
||||||
|
now(): number;
|
||||||
|
/** Runs the action as `actor` through the room's normal path; returns an error message on rejection. */
|
||||||
|
run(actor: string, action: unknown): string | null;
|
||||||
|
log?: (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thinking time: a quick beat for forced moves, a little longer for real choices. When people may act in the same
|
||||||
|
* window (e.g. 원카드 외치기, 마작 울기, simultaneous choices) the computer waits longer so it never beats a person
|
||||||
|
* to a reaction just by being fast.
|
||||||
|
*/
|
||||||
|
export function defaultBotDelay(choices: number, withPeople = false, random: () => number = Math.random): number {
|
||||||
|
const base = choices <= 1 ? 400 + Math.floor(random() * 300) : 700 + Math.floor(random() * 800);
|
||||||
|
return withPeople ? base + 1500 : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bot move budget (ms); slower decisions are logged. */
|
||||||
|
export const BOT_SLOW_MS = 50;
|
||||||
|
|
||||||
|
export class BotDriver {
|
||||||
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
/** When the planned action is due (room clock); null when nothing is planned. */
|
||||||
|
dueAt: number | null = null;
|
||||||
|
/** Bot the planned action is for. */
|
||||||
|
private planned: string | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private room: BotRoom,
|
||||||
|
private random: () => number = Math.random,
|
||||||
|
private delay: (choices: number, withPeople: boolean) => number = (n, w) => defaultBotDelay(n, w, random),
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Plans the next bot action if the game is waiting on a bot. Keeps an already planned one (the bot is "thinking"). */
|
||||||
|
schedule(): void {
|
||||||
|
if (this.dueAt !== null) return;
|
||||||
|
const next = this.next();
|
||||||
|
if (!next) return;
|
||||||
|
this.dueAt = this.room.now() + this.delay(next.choices, next.withPeople);
|
||||||
|
this.planned = next.bot;
|
||||||
|
this.arm();
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(): void {
|
||||||
|
if (this.timer) clearTimeout(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
this.dueAt = null;
|
||||||
|
this.planned = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private arm(): void {
|
||||||
|
if (this.timer) clearTimeout(this.timer);
|
||||||
|
this.timer = setTimeout(() => this.fire(), Math.max(0, (this.dueAt ?? 0) - this.room.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bot waited on by the game that has something to do, in seat order. */
|
||||||
|
private next(): { bot: string; choices: number; withPeople: boolean } | null {
|
||||||
|
const g = this.room.game;
|
||||||
|
if (!g || g.finished || this.room.status !== 'playing' || !g.players.some(isBotId)) return null;
|
||||||
|
const def = this.room.def;
|
||||||
|
const active = def.activePlayers(g.state);
|
||||||
|
const waiting = new Set([...active, ...(def.timeoutPlayers?.(g.state) ?? [])]);
|
||||||
|
const withPeople = active.some((p) => !isBotId(p));
|
||||||
|
for (const p of g.players) {
|
||||||
|
if (!isBotId(p) || !waiting.has(p)) continue;
|
||||||
|
const choices = botCandidates(def, g.state, p).length;
|
||||||
|
if (choices > 0) return { bot: p, choices, withPeople };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plays the planned action when due. Public for tests (with a fake clock). */
|
||||||
|
fire(): void {
|
||||||
|
if (this.timer) clearTimeout(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
if (this.dueAt === null) return;
|
||||||
|
if (this.room.now() < this.dueAt) return this.arm();
|
||||||
|
const planned = this.planned;
|
||||||
|
this.dueAt = null;
|
||||||
|
this.planned = null;
|
||||||
|
const next = this.next();
|
||||||
|
const g = this.room.game;
|
||||||
|
if (!next || !g) return;
|
||||||
|
// Another bot's turn came up meanwhile: give it its own thinking time.
|
||||||
|
if (next.bot !== planned) return this.schedule();
|
||||||
|
const def = this.room.def;
|
||||||
|
const t0 = performance.now();
|
||||||
|
let action: unknown;
|
||||||
|
try {
|
||||||
|
action = decideBotAction(def, g.state, next.bot, { now: this.room.now(), random: this.random });
|
||||||
|
} catch (err) {
|
||||||
|
this.room.log?.('error', 'bot decision failed', { game: def.id, err: String(err) });
|
||||||
|
action = null;
|
||||||
|
}
|
||||||
|
const ms = performance.now() - t0;
|
||||||
|
if (ms > BOT_SLOW_MS) this.room.log?.('warn', 'slow bot move', { game: def.id, ms: Math.round(ms) });
|
||||||
|
let err = action == null ? 'no action' : this.room.run(next.bot, action);
|
||||||
|
if (err) {
|
||||||
|
this.room.log?.('warn', 'bot action rejected', { game: def.id, action, err });
|
||||||
|
// Last resort before the turn timer: the game's own timeout action.
|
||||||
|
try {
|
||||||
|
if (!this.room.game?.finished) err = this.room.run(next.bot, def.onTimeout(this.room.game!.state, next.bot));
|
||||||
|
} catch {
|
||||||
|
// keep err
|
||||||
|
}
|
||||||
|
if (err) return; // give up until the next state change; the room's deadline still applies
|
||||||
|
}
|
||||||
|
this.schedule();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/** Creates, finds, restores and garbage-collects rooms (docs/06 §11, docs/04 §3). */
|
/** 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,
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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' }}>
|
||||||
{mySeat < 0 ? '+ 여기 앉기' : '+ 빈자리 · 친구 초대하기'}
|
<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))}>
|
||||||
</button>
|
{mySeat < 0 ? '+ 여기 앉기' : '+ 빈자리 · 친구 초대하기'}
|
||||||
|
</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) => (
|
||||||
|
|||||||
@@ -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` | 빠른 반응(👍, 😂, "잘했어요!" 등 고정 목록) |
|
||||||
|
|||||||
@@ -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를 막고, 오셀로는 귀를 잡고, 카드 게임은 패 세기에 맞게).
|
||||||
|
- 사람이 모두 나가면 게임은 무효로 끝난다.
|
||||||
|
|||||||
@@ -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. 정리 작업(매시간)
|
||||||
|
|||||||
@@ -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
170
e2e/bots.e2e.ts
Normal 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);
|
||||||
69
packages/games/src/baduk/bot.test.ts
Normal file
69
packages/games/src/baduk/bot.test.ts
Normal 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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
137
packages/games/src/baduk/bot.ts
Normal file
137
packages/games/src/baduk/bot.ts
Normal 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;
|
||||||
|
};
|
||||||
127
packages/games/src/bang/bot.test.ts
Normal file
127
packages/games/src/bang/bot.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
167
packages/games/src/bang/bot.ts
Normal file
167
packages/games/src/bang/bot.ts
Normal 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));
|
||||||
|
};
|
||||||
50
packages/games/src/bots/bots.test.ts
Normal file
50
packages/games/src/bots/bots.test.ts
Normal 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();
|
||||||
|
});
|
||||||
94
packages/games/src/bots/index.ts
Normal file
94
packages/games/src/bots/index.ts
Normal 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;
|
||||||
|
}
|
||||||
72
packages/games/src/bots/playout.ts
Normal file
72
packages/games/src/bots/playout.ts
Normal 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;
|
||||||
|
}
|
||||||
58
packages/games/src/bots/policy.ts
Normal file
58
packages/games/src/bots/policy.ts
Normal 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);
|
||||||
|
}
|
||||||
22
packages/games/src/bots/types.ts
Normal file
22
packages/games/src/bots/types.ts
Normal 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;
|
||||||
58
packages/games/src/chess/bot.test.ts
Normal file
58
packages/games/src/chess/bot.test.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
74
packages/games/src/chess/bot.ts
Normal file
74
packages/games/src/chess/bot.ts
Normal 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);
|
||||||
|
};
|
||||||
65
packages/games/src/cockroach-poker/bot.test.ts
Normal file
65
packages/games/src/cockroach-poker/bot.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
69
packages/games/src/cockroach-poker/bot.ts
Normal file
69
packages/games/src/cockroach-poker/bot.ts
Normal 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');
|
||||||
|
};
|
||||||
79
packages/games/src/dalmuti/bot.test.ts
Normal file
79
packages/games/src/dalmuti/bot.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
58
packages/games/src/dalmuti/bot.ts
Normal file
58
packages/games/src/dalmuti/bot.ts
Normal 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;
|
||||||
|
};
|
||||||
98
packages/games/src/davinci-code/bot.test.ts
Normal file
98
packages/games/src/davinci-code/bot.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
106
packages/games/src/davinci-code/bot.ts
Normal file
106
packages/games/src/davinci-code/bot.ts
Normal 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);
|
||||||
|
};
|
||||||
100
packages/games/src/gostop/bot.test.ts
Normal file
100
packages/games/src/gostop/bot.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
100
packages/games/src/gostop/bot.ts
Normal file
100
packages/games/src/gostop/bot.ts
Normal 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);
|
||||||
|
};
|
||||||
72
packages/games/src/janggi/bot.test.ts
Normal file
72
packages/games/src/janggi/bot.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
138
packages/games/src/janggi/bot.ts
Normal file
138
packages/games/src/janggi/bot.ts
Normal 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);
|
||||||
|
};
|
||||||
72
packages/games/src/las-vegas/bot.test.ts
Normal file
72
packages/games/src/las-vegas/bot.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
51
packages/games/src/las-vegas/bot.ts
Normal file
51
packages/games/src/las-vegas/bot.ts
Normal 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);
|
||||||
|
};
|
||||||
140
packages/games/src/mahjong/bot.test.ts
Normal file
140
packages/games/src/mahjong/bot.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
256
packages/games/src/mahjong/bot.ts
Normal file
256
packages/games/src/mahjong/bot.ts
Normal 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);
|
||||||
|
};
|
||||||
99
packages/games/src/marble/bot.test.ts
Normal file
99
packages/games/src/marble/bot.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
95
packages/games/src/marble/bot.ts
Normal file
95
packages/games/src/marble/bot.ts
Normal 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);
|
||||||
|
};
|
||||||
62
packages/games/src/ninety-nine/bot.test.ts
Normal file
62
packages/games/src/ninety-nine/bot.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
51
packages/games/src/ninety-nine/bot.ts
Normal file
51
packages/games/src/ninety-nine/bot.ts
Normal 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);
|
||||||
|
};
|
||||||
66
packages/games/src/omok/bot.test.ts
Normal file
66
packages/games/src/omok/bot.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
74
packages/games/src/omok/bot.ts
Normal file
74
packages/games/src/omok/bot.ts
Normal 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,
|
||||||
|
);
|
||||||
|
};
|
||||||
80
packages/games/src/one-card/bot.test.ts
Normal file
80
packages/games/src/one-card/bot.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
105
packages/games/src/one-card/bot.ts
Normal file
105
packages/games/src/one-card/bot.ts
Normal 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;
|
||||||
|
}
|
||||||
48
packages/games/src/othello/bot.test.ts
Normal file
48
packages/games/src/othello/bot.test.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
48
packages/games/src/othello/bot.ts
Normal file
48
packages/games/src/othello/bot.ts
Normal 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);
|
||||||
|
};
|
||||||
96
packages/games/src/poker/bot.test.ts
Normal file
96
packages/games/src/poker/bot.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
278
packages/games/src/poker/bot.ts
Normal file
278
packages/games/src/poker/bot.ts
Normal 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);
|
||||||
|
};
|
||||||
46
packages/games/src/quoridor/bot.test.ts
Normal file
46
packages/games/src/quoridor/bot.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
55
packages/games/src/quoridor/bot.ts
Normal file
55
packages/games/src/quoridor/bot.ts
Normal 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,
|
||||||
|
);
|
||||||
|
};
|
||||||
83
packages/games/src/rummikub/bot.test.ts
Normal file
83
packages/games/src/rummikub/bot.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
62
packages/games/src/rummikub/bot.ts
Normal file
62
packages/games/src/rummikub/bot.ts
Normal 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 };
|
||||||
|
};
|
||||||
70
packages/games/src/seotda/bot.test.ts
Normal file
70
packages/games/src/seotda/bot.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
112
packages/games/src/seotda/bot.ts
Normal file
112
packages/games/src/seotda/bot.ts
Normal 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);
|
||||||
|
};
|
||||||
95
packages/games/src/splendor/bot.test.ts
Normal file
95
packages/games/src/splendor/bot.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
91
packages/games/src/splendor/bot.ts
Normal file
91
packages/games/src/splendor/bot.ts
Normal 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);
|
||||||
67
packages/games/src/yacht/bot.test.ts
Normal file
67
packages/games/src/yacht/bot.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
114
packages/games/src/yacht/bot.ts
Normal file
114
packages/games/src/yacht/bot.ts
Normal 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);
|
||||||
|
};
|
||||||
90
packages/games/src/yut/bot.test.ts
Normal file
90
packages/games/src/yut/bot.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
61
packages/games/src/yut/bot.ts
Normal file
61
packages/games/src/yut/bot.ts
Normal 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);
|
||||||
|
};
|
||||||
53
packages/shared/src/bots.ts
Normal file
53
packages/shared/src/bots.ts
Normal 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);
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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) }),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user