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:
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));
|
||||
};
|
||||
Reference in New Issue
Block a user