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:
EJClaw
2026-10-06 13:32:28 +09:00
parent c27cb0a11a
commit a3f81ded37
67 changed files with 5113 additions and 18 deletions

View File

@@ -0,0 +1,127 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, seedN } from '@bg/engine';
import { decideBotAction } from '../bots/index';
import { botPlayout } from '../bots/playout';
import { CARDS, CHARACTER_LIFE, bang, faceOf, type BangOptions, type BangState, type CardKind, type CharacterId, type Role } from './index';
interface PSpec {
id: string;
role: Role;
ch: CharacterId;
life?: number;
hand?: string[];
inPlay?: string[];
}
/** Card picker for one rigged game: `c('bang')` = any unused 뱅!. */
function pool() {
const used = new Set<string>();
return (kind: CardKind): string => {
const card = CARDS.find((x) => x.kind === kind && !used.has(x.id))!;
used.add(card.id);
return card.id;
};
}
/** Players in seat order; the first one is in its play phase. */
function rig(specs: PSpec[], options: Partial<BangOptions> = {}): BangState {
const players = specs.map((p, i) => {
const maxLife = CHARACTER_LIFE[p.ch] + (p.role === 'sheriff' ? 1 : 0);
return { id: p.id, seat: i, role: p.role, character: p.ch, characterOffer: null, maxLife, life: p.life ?? maxLife, alive: true, hand: p.hand ?? [], inPlay: p.inPlay ?? [], timeoutsInRow: 0, away: false };
});
const placed = new Set(players.flatMap((p) => [...p.hand, ...p.inPlay]));
return {
options: bang.optionsSchema.parse(options),
players,
deck: CARDS.map((c) => c.id).filter((id) => !placed.has(id)),
discard: [],
turn: { player: specs[0]!.id, phase: 'play', bangsPlayed: 0, deadline: 60_000, pausedAt: null, dynamiteChecked: true, jailChecked: true },
reactions: [],
lastCheck: null,
winner: null,
deathOrder: [],
round: 1,
seq: 0,
log: [],
final: null,
};
}
const decide = (s: BangState, p: string, seed = 1) => {
const r = SeededRng.fromSeed(seedN(seed));
return decideBotAction(bang, s, p, { now: 1000, random: () => r.next() }) as Record<string, unknown>;
};
describe('bang bot', () => {
test('an outlaw shoots the sheriff; a deputy never does and shoots the sheriff’s attacker', () => {
const c = pool();
const bangA = c('bang');
const s = rig([
{ id: 'O', role: 'outlaw', ch: 'bart', hand: [bangA] },
{ id: 'S', role: 'sheriff', ch: 'bart' },
{ id: 'D', role: 'deputy', ch: 'bart', hand: [c('bang')] },
{ id: 'R', role: 'renegade', ch: 'bart' },
]);
// O sits next to S and R (4 seats: distance 1 to both).
for (let seed = 1; seed <= 4; seed++) expect(decide(s, 'O', seed)).toEqual({ type: 'play', cardId: bangA, target: 'S' });
// D's turn: the public log shows O shooting the sheriff, so O is the target (never the sheriff).
s.turn.player = 'D';
s.log.push({ seq: 1, t: 'play', p: 'O', card: faceOf(bangA), target: 'S' });
s.players.find((p) => p.id === 'D')!.inPlay = [c('winchester')];
for (let seed = 1; seed <= 4; seed++) expect(decide(s, 'D', seed)).toMatchObject({ type: 'play', target: 'O' });
});
test('answers a 뱅! with 빗나감!, takes the hit without one, drinks beer when dying', () => {
const c = pool();
const missed = c('missed');
const s = rig([
{ id: 'A', role: 'sheriff', ch: 'bart' },
{ id: 'B', role: 'outlaw', ch: 'bart', hand: [c('bang'), missed] },
{ id: 'C', role: 'outlaw', ch: 'bart', hand: [c('bang')] },
{ id: 'D', role: 'renegade', ch: 'bart', life: 0, hand: [c('beer'), c('bang')] },
]);
const shot = (target: string) => ({ kind: 'bang' as const, target, source: 'A', via: 'bang' as const, needed: 1, barrelTried: false, jourTried: false, deadline: 9_000 });
s.reactions = [shot('B')];
expect(decide(s, 'B')).toEqual({ type: 'respond', cardIds: [missed] });
s.reactions = [shot('C')];
expect(decide(s, 'C')).toEqual({ type: 'takeHit' });
s.reactions = [{ kind: 'dying', player: 'D', killer: 'A', lost: 1, source: 'A', deadline: 9_000 }];
expect(decide(s, 'D')).toMatchObject({ type: 'respond' });
});
test('on its turn: drinks beer when hurt, equips a longer gun, then ends the turn', () => {
const c = pool();
const beer = c('beer');
const gun = c('remington');
let s = rig([
{ id: 'A', role: 'deputy', ch: 'bart', life: 2, hand: [beer, gun, c('missed')] },
{ id: 'S', role: 'sheriff', ch: 'bart' },
{ id: 'C', role: 'outlaw', ch: 'bart' },
{ id: 'D', role: 'outlaw', ch: 'bart' },
]);
expect(decide(s, 'A')).toEqual({ type: 'play', cardId: beer });
s = bang.apply(s, 'A', { type: 'play', cardId: beer }, { rng: SeededRng.fromSeed(seedN(1)), now: 1000 }).state;
expect(decide(s, 'A')).toEqual({ type: 'play', cardId: gun });
s = bang.apply(s, 'A', { type: 'play', cardId: gun }, { rng: SeededRng.fromSeed(seedN(1)), now: 1000 }).state;
expect(decide(s, 'A')).toEqual({ type: 'endPlay' });
});
test('option modes: draft, manual barrels, no auto-defence, house rules, 7 players finish without bot timeouts', () => {
const modes: Partial<BangOptions>[] = [
{ characterDraft: 'choose2', autoBarrel: false },
{ autoDefend: false, beerAtFullHealth: true, selfTargetCatBalou: true, drawBeforeDeath: true },
{ deputyCount7: true },
];
for (const [i, m] of modes.entries()) {
for (const n of [5, 7]) {
const r = botPlayout(bang as never, {
players: Array.from({ length: n }, (_, k) => (k === 0 && i === 0 ? 'human' : `bot:${k}`)),
options: bang.optionsSchema.parse(m),
seed: seedN(70 + i * 3 + n),
isBot: (p) => p.startsWith('bot:'),
});
expect(r.finished).toBe(true);
expect(r.botTimeouts).toBe(0);
}
}
});
});

View File

@@ -0,0 +1,167 @@
/**
* 뱅! computer player. Knows only its own role and hand plus public information: the sheriff, dead
* players' roles, equipment, life and the recent public log (who attacked whom). From that it rates how
* hostile each player is to its side and only attacks players rated hostile:
* - sheriff / deputy: players who attacked the sheriff (or defenders of attackers less);
* - outlaw: the sheriff first, then whoever attacked the sheriff's attackers;
* - renegade: the outlaws' side first, the sheriff only when the two of them are left.
* Always answers reaction windows (missed / bang / beer), drinks beer when hurt, equips better gear.
*/
import type { PlayerId } from '@bg/engine';
import type { BotStrategy } from '../bots/types';
import { best, randomPolicy } from '../bots/policy';
import { WEAPON_RANGE, kindOf, type BangAction, type BangView, type CardKind, type CharacterId } from './index';
/** Rough worth of a card kept in hand. */
const KEEP: Record<CardKind, number> = {
missed: 7, beer: 7, bang: 6, wellsFargo: 8, stagecoach: 7, panic: 6, catBalou: 5, gatling: 5, barrel: 6, mustang: 5,
scope: 4, indians: 4, duel: 4, saloon: 4, jail: 3, generalStore: 3, remington: 3, carabine: 3, winchester: 3,
schofield: 2, volcanic: 2, dynamite: 1,
};
const CHAR_PREF: CharacterId[] = ['willy', 'slab', 'jourdonnais', 'paul', 'rose', 'sid', 'lucky', 'calamity', 'bart', 'blackJack', 'kit', 'jesse', 'suzy', 'elGringo', 'pedro', 'vulture'];
const ATTACKS = new Set<CardKind>(['bang', 'duel', 'panic', 'catBalou', 'jail']);
const GEAR: CardKind[] = ['barrel', 'mustang', 'scope', 'volcanic', 'schofield', 'remington', 'carabine', 'winchester'];
type Play = Extract<BangAction, { type: 'play' }>;
export const bangBot: BotStrategy<BangView, BangAction> = (input) => {
const { me, view: v, legal, random } = input;
const my = v.my;
const self = v.players.find((p) => p.id === me);
if (!my || !self) return randomPolicy(input);
const role = my.role;
const alive = v.players.filter((p) => p.alive);
const sheriff = v.players.find((p) => p.role === 'sheriff')!;
const first = (t: string) => legal.find((a) => a.type === t);
const keep = (id: string) => KEEP[kindOf(id)] + (kindOf(id) === 'beer' && self.life <= 2 ? 3 : 0);
const worst = (n: number) => [...my.hand].sort((a, b) => keep(a.id) - keep(b.id)).slice(0, n).map((c) => c.id);
// Public suspicion: attacks on the sheriff (+1) and on those attackers (−0.5) in the visible log.
const attacked: [PlayerId, PlayerId][] = [];
for (const e of v.log) {
if (e.t !== 'play' && e.t !== 'equip') continue;
const kind = e.t === 'play' ? (e.as ?? e.card.kind) : e.card.kind;
if (e.target && ATTACKS.has(kind)) attacked.push([e.p, e.target]);
}
const outlawish = new Map<PlayerId, number>();
for (const [a, t] of attacked) if (t === sheriff.id) outlawish.set(a, (outlawish.get(a) ?? 0) + 1);
for (const [a, t] of attacked) if ((outlawish.get(t) ?? 0) > 0) outlawish.set(a, (outlawish.get(a) ?? 0) - 0.5);
/** > 0: worth attacking. */
const hostility = (id: PlayerId): number => {
if (id === me) return -9;
const p = v.players.find((x) => x.id === id)!;
const o = outlawish.get(id) ?? 0;
if (role === 'sheriff') return 0.3 + o;
if (role === 'deputy') return p.role === 'sheriff' ? -9 : 0.2 + o;
if (role === 'outlaw') return p.role === 'sheriff' ? 2 : -0.2 - o;
// Renegade: hit the outlaws' side, keep the sheriff for a final showdown.
if (p.role === 'sheriff') return alive.length <= 2 ? 2 : -1;
return 0.3 + o * 0.5;
};
const prompt = my.prompt;
switch (prompt?.kind) {
case 'choose2':
return { type: 'pickCharacter', character: best(prompt.options, (c) => -CHAR_PREF.indexOf(c), random) };
case 'kitKeep': {
const opts = legal.filter((a) => a.type === 'kitKeep') as Extract<BangAction, { type: 'kitKeep' }>[];
return best(opts, (a) => keep(a.keep[0]) + keep(a.keep[1]), random);
}
case 'store':
return { type: 'storePick', cardId: best(prompt.revealed, (c) => keep(c.id), random).id };
case 'bang': {
if (prompt.canBarrel && first('useBarrel')) return { type: 'useBarrel' };
if (prompt.options.length >= prompt.needed) {
// Calamity Janet: spend 빗나감! before 뱅!.
const cards = [...prompt.options].sort((a, b) => Number(kindOf(a) !== 'missed') - Number(kindOf(b) !== 'missed'));
return { type: 'respond', cardIds: cards.slice(0, prompt.needed) };
}
return { type: 'takeHit' };
}
case 'indians':
case 'duel':
return prompt.options.length ? { type: 'respond', cardIds: [prompt.options[0]!] } : { type: 'takeHit' };
case 'dying': {
if (prompt.beerAllowed && prompt.beers.length >= prompt.needed) return { type: 'respond', cardIds: prompt.beers.slice(0, prompt.needed) };
if (first('sidHeal')) return { type: 'sidHeal', cardIds: worst(2) as [string, string] };
return { type: 'giveUp' };
}
case 'draw': {
// Jesse Jones robs the most hostile player with cards; Pedro takes a useful discard.
const jesse = legal.filter((a) => a.type === 'draw' && a.jesseFrom) as Extract<BangAction, { type: 'draw' }>[];
if (jesse.length) {
const j = best(jesse, (a) => hostility(a.jesseFrom!), random);
if (hostility(j.jesseFrom!) > 0) return j;
}
if (prompt.pedro && ['missed', 'beer', 'bang'].includes(prompt.pedro.kind) && first('draw')) {
const pd = legal.find((a) => a.type === 'draw' && a.pedroDiscard);
if (pd) return pd;
}
return { type: 'draw' };
}
case 'discard': {
if (prompt.count >= 2 && first('sidHeal') && self.life < self.maxLife) return { type: 'sidHeal', cardIds: worst(2) as [string, string] };
return { type: 'discard', cardIds: worst(prompt.count) };
}
}
if (!my.plays.length) return randomPolicy(input);
// Play phase: score every playable card; end the turn when nothing is worth it.
const hurt = self.life < self.maxLife;
const bangsInHand = my.hand.filter((c) => c.kind === 'bang').length;
const myWeapon = (self.inPlay.find((c) => WEAPON_RANGE[c.kind] !== undefined)?.kind ?? null) as CardKind | null;
const others = alive.filter((p) => p.id !== me);
const spread = others.reduce((a, p) => a + Math.sign(hostility(p.id)), 0);
const score = (a: BangAction): number => {
if (a.type === 'endPlay') return 0;
if (a.type === 'sidHeal') return self.life <= 2 && my.hand.length >= 4 ? 30 : -1;
if (a.type !== 'play') return -1;
const kind = (a as Play).as ?? kindOf(a.cardId);
const h = a.target ? hostility(a.target) : 0;
const tgt = a.target ? v.players.find((p) => p.id === a.target) : null;
switch (kind) {
case 'stagecoach':
case 'wellsFargo':
return 100;
case 'beer':
return hurt && alive.length > 2 ? 80 : -1;
case 'saloon':
return hurt ? 60 : -1;
case 'barrel':
case 'mustang':
case 'scope':
return 70;
case 'volcanic':
return bangsInHand >= 2 && myWeapon !== 'volcanic' ? 66 : -1;
case 'schofield':
case 'remington':
case 'carabine':
case 'winchester':
return WEAPON_RANGE[kind]! > my.range || (myWeapon === 'volcanic' && bangsInHand < 2) ? 65 + WEAPON_RANGE[kind]! : -1;
case 'dynamite':
return self.life >= 4 ? 5 : -1;
case 'jail':
return h > 0 ? 50 + h * 10 : -1;
case 'panic':
case 'catBalou': {
if (h <= 0) return -1;
const tc = (a as Play).targetCard!;
const gear = tc === 'hand' ? 0 : GEAR.includes(kindOf(tc)) ? 8 : -20;
return (kind === 'panic' ? 45 : 40) + h * 10 + gear;
}
case 'bang':
return h > 0 ? 40 + h * 10 + (tgt && tgt.life <= 1 ? 10 : 0) - ((a as Play).as ? 5 : 0) : -1;
case 'duel':
return h > 0 && bangsInHand >= 1 ? 30 + h * 10 : -1;
case 'gatling':
case 'indians':
return spread > 0 ? 35 : -1;
case 'generalStore':
return 20;
default:
return -1;
}
};
const choice = best(legal, score, random);
return score(choice) >= 0 ? choice : (first('endPlay') ?? randomPolicy(input));
};