M9: 컴퓨터 상대 — 게임 22종 컴퓨터 전략, 서버 구동(BotDriver), 연습 방
- packages/shared/src/bots.ts: 컴퓨터 id(bot:<n>:<ulid>)·프로필(컴퓨터 n)·BOT_GAMES
(도블·할리갈리·그림 맞히기·마피아 제외)
- packages/games/src/bots: 전략 계약(자기 view + 합법 수만), decideBotAction(validate 재검사),
게임별 <게임>/bot.ts 22개 + 행동 테스트, 전 게임 판 끝까지 돌리는 공통 테스트
- 서버: Room.addBot/removeBot(방장·대기실만, 방은 친구만), RoomManager.createPractice
(POST /api/rooms {practice:true}), BotDriver(사람 같은 지연, 사람과 같은 창이면 더 천천히,
재시작 후 다시 예약), 연습 게임은 전적 제외(result_json.practice), 사람이 모두 나가면 무효
- 프로토콜 addBot/removeBot, 문서 03·06 §12·08·09 §8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
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 §8). `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 §8). */
|
||||
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);
|
||||
@@ -3,3 +3,4 @@ export * from './types';
|
||||
export * from './nickname';
|
||||
export * from './room-code';
|
||||
export * from './catalog';
|
||||
export * from './bots';
|
||||
|
||||
@@ -27,6 +27,8 @@ export const ClientMessage = z.discriminatedUnion('t', [
|
||||
z.object({ t: z.literal('start') }),
|
||||
z.object({ t: z.literal('kick'), userId }),
|
||||
z.object({ t: z.literal('host'), userId }),
|
||||
z.object({ t: z.literal('addBot'), seat: z.number().int().min(0).max(31).optional() }),
|
||||
z.object({ t: z.literal('removeBot'), userId }),
|
||||
z.object({ t: z.literal('act'), cs: z.number().int().min(1), a: z.unknown() }),
|
||||
z.object({ t: z.literal('chat'), text: z.string().min(1).max(CHAT_MAX) }),
|
||||
z.object({ t: z.literal('emote'), id: z.string().max(20) }),
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface PublicUser {
|
||||
id: string;
|
||||
nickname: string;
|
||||
avatar: string | null;
|
||||
kind: 'guest' | 'member';
|
||||
kind: 'guest' | 'member' | 'bot';
|
||||
}
|
||||
|
||||
export interface SeatView {
|
||||
|
||||
Reference in New Issue
Block a user