M6: 뱅 규칙·화면·테스트

- packages/games/src/bang: 뱅!(4~7명) 규칙 모듈. 카드 80장·캐릭터 16종 데이터, 거리/사거리,
  효과·반응 스택(뱅!/개틀링/인디언!/결투/잡화점/빈사/킷 칼슨/choose2), 다이너마이트·감옥 판정,
  처치 보상·보안관 벌칙·벌처 샘, 승리 판정, 역할·손패·덱 비공개 view, onTimeout/onLeave,
  legalActions·secretsOf. 하우스 룰 옵션 4종.
- 테스트 62개: 문서 §10 시나리오 전부, 시드 1,000개 4~7명 무작위 대국(카드 총수 보존, 생명 범위,
  정보 유출, onTimeout 합법, 결정성) + 엔진 randomPlayout 실행, 재생 결정성.
- apps/web/src/games/bang: 원형 테이블(생명 탄환·장착·거리·역할 배지), 꾹 눌러 보는 내 역할 카드,
  카드 선택 → 대상 강조 → 확인, 반응 시트와 타이머, 기록·카드 도감, PC/휴대폰, 라이트/다크.
- 등록: GAMES, 카탈로그(available), 웹 레지스트리, 아이콘(UPCOMING에서 '뱅' 제거).
- e2e: bang 플랜(PC 방장 + 휴대폰 3명, 반응 25초, 실제 턴 10번: 뱅!·기타 카드·반응·버리기).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-05 13:38:00 +09:00
parent 6321ea8c0d
commit d6496dc1fa
13 changed files with 4727 additions and 1 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
'one-card': () => import('./one-card'),
rummikub: () => import('./rummikub'),
'davinci-code': () => import('./davinci-code'),
bang: () => import('./bang'),
};
const cache = new Map<string, GameUI>();

View File

@@ -12,7 +12,8 @@ export const GAME_ICON: Record<string, string> = {
'one-card': '♣',
rummikub: '🔢',
'davinci-code': '🔐',
bang: '🤠',
};
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
export const UPCOMING = ['마작', '고스톱', '뱅', '할리갈리'];
export const UPCOMING = ['마작', '고스톱', '할리갈리'];

View File

@@ -249,6 +249,114 @@ const PLANS: Record<string, Plan> = {
console.log(` one-card: ${turns} turns (${plays} plays, ${draws} draws)${finished ? ', game finished' : ''}`);
},
},
bang: {
// PC host + 3 phone guests, reaction time 25s: real turns through the UI — draw, play usable cards
// (뱅! at a highlighted target → confirm, other untargeted cards by double tap), answer reaction
// sheets (빗나감!/맥주/결투/인디언!/잡화점), discard down to the hand limit, end the turn.
players: 4,
lobby: async (host) => {
await host.getByRole('group', { name: '반응 제한 시간' }).getByRole('button', { name: '25초' }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/반응 25초/).first().waitFor();
},
drive: async (pages) => {
const stats = { turns: 0, bangs: 0, plays: 0, reactions: 0, discards: 0 };
let finished = false;
let shot = false;
let midShot = false;
const tryClick = (l: ReturnType<Page['locator']>) => l.click({ timeout: 1500 }).then(() => true, () => false);
/** One UI step on this page; true when something was pressed. */
const step = async (p: Page): Promise<boolean> => {
const modal = p.locator('.bg-modal');
if (await modal.count()) {
const store = modal.locator('button.bg-card');
const title = (await modal.locator('h2').innerText().catch(() => '')).trim();
if (title.startsWith('3장 중')) {
await store.nth(0).click();
await store.nth(1).click();
await modal.getByRole('button', { name: '이 2장 받기' }).click();
} else if (title.startsWith('잡화점')) await store.first().click();
else {
const use = modal.getByRole('button', { name: /(내기|마시기)$/ });
if (!(await use.count()) || !(await tryClick(use.first()))) {
if (!(await tryClick(modal.getByRole('button', { name: /^(포기하고 맞기|맞기|쓰러지기)/ }).first()))) return false;
}
}
stats.reactions++;
return true;
}
const draw = p.getByRole('button', { name: '카드 더미에서 2장 받기' });
if (await draw.count()) return tryClick(draw);
const discardBtn = p.getByRole('button', { name: /장 버리기$/ });
if (await discardBtn.count()) {
const need = Number((await p.locator('.bg-actions').innerText()).match(/버릴 카드 (\d+)장/)?.[1] ?? 0);
const cards = p.locator('.bg-me .bg-hand button.bg-card');
for (let i = 0; i < need; i++) await cards.nth(i).click();
await discardBtn.click();
stats.discards++;
return true;
}
const end = p.getByRole('button', { name: '턴 마치기' });
if (!(await end.count()) || !(await end.isEnabled())) return false;
// Shoot first (once per turn is enough), then other untargeted cards, then end the turn.
const bang = p.locator('.bg-me button.bg-card[data-kind="bang"][data-ok="true"]').first();
if (await bang.count()) {
await bang.click();
const target = p.locator('button.bg-seat[data-target="ok"]').first();
await target.waitFor({ timeout: 2000 });
await target.click();
const fire = p.getByRole('button', { name: /에게 뱅!$/ });
await fire.click();
stats.bangs++;
if (!shot) {
shot = true;
await p.waitForTimeout(400);
for (const [i, q] of pages.entries()) await q.screenshot({ path: join(SHOTS, `bang-3-shot-${i === 0 ? 'pc' : `phone${i}`}.png`) });
}
return true;
}
const simple = ['beer', 'stagecoach', 'wellsFargo', 'saloon', 'barrel', 'mustang', 'scope', 'schofield', 'remington', 'carabine', 'winchester', 'volcanic', 'indians', 'gatling', 'generalStore'];
for (const k of simple) {
const c = p.locator(`.bg-me button.bg-card[data-kind="${k}"][data-ok="true"]`).first();
if (!(await c.count())) continue;
await c.click();
await c.click();
stats.plays++;
return true;
}
await end.click();
stats.turns++;
return true;
};
const until = Date.now() + 240_000;
while (Date.now() < until && stats.turns < 10) {
if (await pages[0]!.getByRole('dialog', { name: '게임 결과' }).count()) {
finished = true;
break;
}
let acted = false;
for (const p of pages) {
if (await step(p)) {
acted = true;
await p.waitForTimeout(250);
break;
}
}
if (stats.turns >= 4 && !midShot) {
midShot = true;
await pages[0]!.screenshot({ path: join(SHOTS, 'bang-4-mid-pc.png') });
await pages[1]!.screenshot({ path: join(SHOTS, 'bang-4-mid-phone.png'), fullPage: true });
await pages[1]!.emulateMedia({ colorScheme: 'dark' });
await pages[1]!.screenshot({ path: join(SHOTS, 'bang-4-mid-phone-dark.png') });
await pages[1]!.emulateMedia({ colorScheme: 'light' });
}
if (!acted) await pages[0]!.waitForTimeout(150);
}
console.log(` bang: ${stats.turns} turns, ${stats.bangs} 뱅!, ${stats.plays} other cards, ${stats.reactions} reactions, ${stats.discards} discards${finished ? ', game finished' : ''}`);
if (!finished && stats.turns < 8) throw new Error(`only ${stats.turns} turns were played`);
if (stats.bangs < 1) throw new Error('no 뱅! was played');
},
},
'davinci-code': {
// 2 players, default options (choose colours): both pick 2+2 start tiles, then several real turns:
// draw from a pile, tap an opponent's hidden tile, pick a number, confirm; after a hit press "여기서 멈추기".

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,167 @@
/** 뱅! cards, characters and roles (docs/games/bang.md §3, §4). */
export type Suit = 'S' | 'H' | 'D' | 'C';
export type Rank = 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 'J' | 'Q' | 'K' | 'A';
export type CardKind =
| 'bang'
| 'missed'
| 'beer'
| 'saloon'
| 'stagecoach'
| 'wellsFargo'
| 'generalStore'
| 'panic'
| 'catBalou'
| 'duel'
| 'indians'
| 'gatling'
| 'barrel'
| 'mustang'
| 'scope'
| 'jail'
| 'dynamite'
| 'volcanic'
| 'schofield'
| 'remington'
| 'carabine'
| 'winchester';
/** Card ids are strings "c0".."c79" (fixed per card) so hidden-info checks can look for them in views. */
export interface Card {
id: string;
kind: CardKind;
suit: Suit;
rank: Rank;
}
/** A card without its id (logs, draw! results, stolen-card notes). */
export type CardFace = Omit<Card, 'id'>;
export type Role = 'sheriff' | 'deputy' | 'outlaw' | 'renegade';
export type CharacterId =
| 'bart'
| 'blackJack'
| 'calamity'
| 'elGringo'
| 'jesse'
| 'jourdonnais'
| 'kit'
| 'lucky'
| 'paul'
| 'pedro'
| 'rose'
| 'sid'
| 'slab'
| 'suzy'
| 'vulture'
| 'willy';
export const CHARACTER_IDS: CharacterId[] = [
'bart',
'blackJack',
'calamity',
'elGringo',
'jesse',
'jourdonnais',
'kit',
'lucky',
'paul',
'pedro',
'rose',
'sid',
'slab',
'suzy',
'vulture',
'willy',
];
export const CHARACTER_LIFE: Record<CharacterId, number> = {
bart: 4,
blackJack: 4,
calamity: 4,
elGringo: 3,
jesse: 4,
jourdonnais: 4,
kit: 4,
lucky: 4,
paul: 3,
pedro: 4,
rose: 4,
sid: 4,
slab: 4,
suzy: 4,
vulture: 4,
willy: 4,
};
/** §3.3 table: rank+suit per copy. */
const DECK_SPEC: [CardKind, string][] = [
['bang', 'AS 2D 3D 4D 5D 6D 7D 8D 9D 10D JD QD KD AD 2C 3C 4C 5C 6C 7C 8C 9C QH KH AH'],
['missed', '10C JC QC KC AC 2S 3S 4S 5S 6S 7S 8S'],
['beer', '6H 7H 8H 9H 10H JH'],
['panic', 'JH QH AH 8D'],
['catBalou', 'KH 9D 10D JD'],
['duel', 'QD JS 8C'],
['generalStore', '9C QS'],
['indians', 'KD AD'],
['stagecoach', '9S 9S'],
['gatling', '10H'],
['saloon', '5H'],
['wellsFargo', '3H'],
['barrel', 'QS KS'],
['mustang', '8H 9H'],
['scope', 'AS'],
['jail', 'JS 10S 4H'],
['dynamite', '2H'],
['volcanic', '10S 10C'],
['schofield', 'JC QC KS'],
['remington', 'KC'],
['carabine', 'AC'],
['winchester', '8S'],
];
function parseRank(r: string): Rank {
if (r === 'J' || r === 'Q' || r === 'K' || r === 'A') return r;
return Number(r) as Rank;
}
/** The 80 cards in a fixed order; `CARDS[n].id === "c" + n`. */
export const CARDS: Card[] = (() => {
const out: Card[] = [];
for (const [kind, list] of DECK_SPEC) {
for (const code of list.split(' ')) {
out.push({ id: `c${out.length}`, kind, suit: code.slice(-1) as Suit, rank: parseRank(code.slice(0, -1)) });
}
}
return out;
})();
const BY_ID = new Map(CARDS.map((c) => [c.id, c]));
export function cardById(id: string): Card {
const c = BY_ID.get(id);
if (!c) throw new Error(`bang: unknown card ${id}`);
return c;
}
export const isCardId = (id: string): boolean => BY_ID.has(id);
export const kindOf = (id: string): CardKind => cardById(id).kind;
export function faceOf(id: string): CardFace {
const { id: _id, ...face } = cardById(id);
return face;
}
export const BLUE_KINDS: CardKind[] = ['barrel', 'mustang', 'scope', 'jail', 'dynamite', 'volcanic', 'schofield', 'remington', 'carabine', 'winchester'];
export const WEAPON_RANGE: Partial<Record<CardKind, number>> = { volcanic: 1, schofield: 2, remington: 3, carabine: 4, winchester: 5 };
export const isBlue = (k: CardKind): boolean => BLUE_KINDS.includes(k);
export const isWeapon = (k: CardKind): boolean => WEAPON_RANGE[k] !== undefined;
/** Dynamite explodes on ♠2–♠9. */
export const explodes = (f: CardFace): boolean => f.suit === 'S' && typeof f.rank === 'number' && f.rank >= 2 && f.rank <= 9;
export const isHeart = (f: CardFace): boolean => f.suit === 'H';
/** §4 role table. */
export function rolesFor(n: number, deputyCount7 = false): Role[] {
const table: Record<number, [number, number, number]> = { 4: [0, 2, 1], 5: [1, 2, 1], 6: [1, 3, 1], 7: deputyCount7 ? [1, 4, 1] : [2, 3, 1] };
const row = table[n];
if (!row) throw new Error(`bang: ${n} players not supported`);
const [dep, out, ren] = row;
return ['sheriff', ...Array<Role>(dep).fill('deputy'), ...Array<Role>(out).fill('outlaw'), ...Array<Role>(ren).fill('renegade')];
}

View File

@@ -0,0 +1,43 @@
/** Distance and range (docs/games/bang.md §5.1). */
import type { PlayerId } from '@bg/engine';
import { WEAPON_RANGE, kindOf, type CardKind } from './cards';
import type { BangState, PlayerState } from './types';
export const hasEquip = (p: PlayerState, kind: CardKind): boolean => p.inPlay.some((id) => kindOf(id) === kind);
export function weaponOf(p: PlayerState): CardKind | null {
for (const id of p.inPlay) {
const k = kindOf(id);
if (WEAPON_RANGE[k] !== undefined) return k;
}
return null;
}
/** Weapon range, or 1 for the default Colt .45. */
export function rangeOf(p: PlayerState): number {
const w = weaponOf(p);
return w ? WEAPON_RANGE[w]! : 1;
}
/** Seats between two living players, the shorter way round, counting living players only. */
export function seatDistance(s: BangState, a: PlayerId, b: PlayerId): number {
const alive = s.players.filter((p) => p.alive);
const ia = alive.findIndex((p) => p.id === a);
const ib = alive.findIndex((p) => p.id === b);
if (ia < 0 || ib < 0) return Infinity;
const d = Math.abs(ia - ib);
return Math.min(d, alive.length - d);
}
/** distance(A→B) = max(1, seats + B's mustang/Paul Regret − A's scope/Rose Doolan). */
export function distance(s: BangState, a: PlayerId, b: PlayerId): number {
if (a === b) return 0;
const pa = s.players.find((p) => p.id === a)!;
const pb = s.players.find((p) => p.id === b)!;
let d = seatDistance(s, a, b);
if (hasEquip(pb, 'mustang')) d += 1;
if (pb.character === 'paul' && !pb.characterOffer) d += 1;
if (hasEquip(pa, 'scope')) d -= 1;
if (pa.character === 'rose' && !pa.characterOffer) d -= 1;
return Math.max(1, d);
}

View File

@@ -0,0 +1,829 @@
/**
* 뱅! effects and the turn / reaction state machine (docs/games/bang.md §5, §8).
* Every function mutates the (already copied) state passed in; randomness only via `ctx.rng`.
*/
import type { GameEvent, PlayerId, Rng } from '@bg/engine';
import { CHARACTER_LIFE, cardById, explodes, faceOf, isBlue, isHeart, isWeapon, kindOf, type CardFace, type CharacterId } from './cards';
import { hasEquip } from './distance';
import type { BangFinal, BangState, CheckPurpose, LogEntry, PlayAction, PlayerState, Reaction, Team } from './types';
export interface Ctx {
rng: Rng;
now: number;
events: GameEvent[];
}
export const AWAY_MS = 5000;
export const AWAY_AFTER = 3;
export const DISCARD_MIN_MS = 20_000;
const LOG_MAX = 60;
// ---------------------------------------------------------------- lookups
export const P = (s: BangState, id: PlayerId): PlayerState => {
const p = s.players.find((x) => x.id === id);
if (!p) throw new Error(`bang: no player ${id}`);
return p;
};
export const findPlayer = (s: BangState, id: PlayerId): PlayerState | undefined => s.players.find((x) => x.id === id);
export const living = (s: BangState): PlayerState[] => s.players.filter((p) => p.alive);
/** Character ability is active (picked, not still a hidden choose2 offer). */
export const is = (p: PlayerState, c: CharacterId): boolean => p.character === c && !p.characterOffer;
/** Who must answer this reaction. */
export function responder(r: Reaction): PlayerId {
switch (r.kind) {
case 'bang':
case 'indians':
return r.target;
case 'duel':
return r.actor;
case 'store':
return r.picker;
case 'dying':
case 'kitKeep':
case 'choose2':
return r.player;
}
}
export const reactionOf = (s: BangState, p: PlayerId): Reaction | undefined => s.reactions.find((r) => responder(r) === p);
/** Next living player clockwise after `from` (which may be dead). */
export function nextAlive(s: BangState, from: PlayerId): PlayerId {
const n = s.players.length;
const i = s.players.findIndex((p) => p.id === from);
for (let k = 1; k <= n; k++) {
const p = s.players[(i + k) % n]!;
if (p.alive) return p.id;
}
return from;
}
/** Living players clockwise starting with `from` (included when alive). */
export function clockwiseFrom(s: BangState, from: PlayerId): PlayerId[] {
const n = s.players.length;
const i = s.players.findIndex((p) => p.id === from);
const out: PlayerId[] = [];
for (let k = 0; k < n; k++) {
const p = s.players[(i + k) % n]!;
if (p.alive) out.push(p.id);
}
return out;
}
export function reactionMs(s: BangState, p: PlayerState): number {
return p.away ? AWAY_MS : s.options.reactionSeconds * 1000;
}
export function turnMs(s: BangState, p: PlayerState): number {
return p.away ? AWAY_MS : s.options.turnSeconds * 1000;
}
/** Cards kept inside reactions (general store, Kit Carlson) — counted for conservation. */
export function limboCards(s: BangState): string[] {
const out: string[] = [];
for (const r of s.reactions) {
if (r.kind === 'store') out.push(...r.revealed);
if (r.kind === 'kitKeep') out.push(...r.options);
}
return out;
}
// ---------------------------------------------------------------- log
type LogBody = LogEntry extends infer E ? (E extends LogEntry ? Omit<E, 'seq'> : never) : never;
export function log(s: BangState, ctx: Ctx, e: LogBody): void {
const entry = { seq: s.seq, ...e } as LogEntry;
s.log.push(entry);
if (s.log.length > LOG_MAX) s.log.splice(0, s.log.length - LOG_MAX);
// Events go to everyone: private card faces are dropped.
const { seq: _seq, t, ...rest } = entry as LogEntry & { vis?: PlayerId[]; card?: CardFace };
if ('vis' in rest) delete (rest as { card?: CardFace }).card;
ctx.events.push({ type: t, ...rest });
}
// ---------------------------------------------------------------- cards
function refill(s: BangState, ctx: Ctx): void {
if (s.deck.length > 0 || s.discard.length === 0) return;
const n = s.discard.length;
s.deck = ctx.rng.shuffle(s.discard);
s.discard = [];
log(s, ctx, { t: 'reshuffle', n });
}
/** Top card of the deck (refilling from the discard pile once); null when both are empty (§5.7). */
export function drawTop(s: BangState, ctx: Ctx): string | null {
if (s.deck.length === 0) refill(s, ctx);
return s.deck.pop() ?? null;
}
export function drawCards(s: BangState, ctx: Ctx, p: PlayerState, n: number, why: Extract<LogEntry, { t: 'draw' }>['why']): number {
let got = 0;
for (let i = 0; i < n; i++) {
const id = drawTop(s, ctx);
if (!id) break;
p.hand.push(id);
got++;
}
if (got > 0) log(s, ctx, { t: 'draw', p: p.id, n: got, why });
return got;
}
/** Removes cards from a hand; Suzy Lafayette draws one as soon as her hand is empty (§3.2). */
export function removeFromHand(s: BangState, ctx: Ctx, p: PlayerState, ids: string[]): void {
for (const id of ids) {
const i = p.hand.indexOf(id);
if (i < 0) throw new Error(`bang: ${id} not in ${p.id}'s hand`);
p.hand.splice(i, 1);
}
suzyCheck(s, ctx, p);
}
export function suzyCheck(s: BangState, ctx: Ctx, p: PlayerState): void {
if (p.alive && is(p, 'suzy') && p.hand.length === 0 && !s.winner) drawCards(s, ctx, p, 1, 'suzy');
}
/**
* "판정"(draw!): reveal the top card (Lucky Duke: two, keeping the better one — always the favourable card,
* so it is chosen automatically), then discard. `empty` is the result when no card is left at all.
*/
export function drawCheck(s: BangState, ctx: Ctx, p: PlayerState, purpose: CheckPurpose, good: (f: CardFace) => boolean, empty: boolean): boolean {
const n = is(p, 'lucky') ? 2 : 1;
const ids: string[] = [];
for (let i = 0; i < n; i++) {
const id = drawTop(s, ctx);
if (id) ids.push(id);
}
if (ids.length === 0) return empty;
const best = ids.find((id) => good(faceOf(id))) ?? ids[0]!;
const success = good(faceOf(best));
// The chosen card ends on top of the discard pile.
s.discard.push(...ids.filter((id) => id !== best), best);
const cards = ids.map(faceOf);
s.lastCheck = { player: p.id, purpose, cards, success, seq: s.seq };
log(s, ctx, { t: 'check', p: p.id, purpose, cards, success });
return success;
}
// ---------------------------------------------------------------- life
export function heal(s: BangState, ctx: Ctx, p: PlayerState, n: number, why: 'beer' | 'saloon' | 'sid', cards?: CardFace[]): void {
const before = p.life;
p.life = Math.min(p.maxLife, p.life + n);
if (p.life !== before || why !== 'saloon') log(s, ctx, { t: 'heal', p: p.id, n: p.life - before, why, ...(cards ? { cards } : {}) });
}
/** Bart Cassidy / El Gringo after losing `n` life to `source` (§5.5). */
function lossAbilities(s: BangState, ctx: Ctx, t: PlayerState, n: number, source: PlayerId | null): void {
if (!t.alive) return;
if (is(t, 'bart')) drawCards(s, ctx, t, n, 'bart');
if (is(t, 'elGringo') && source && source !== t.id) {
const src = P(s, source);
for (let i = 0; i < n && src.alive && src.hand.length > 0; i++) {
const id = src.hand[ctx.rng.int(src.hand.length)]!;
removeFromHand(s, ctx, src, [id]);
t.hand.push(id);
log(s, ctx, { t: 'take', p: t.id, from: src.id, how: 'gringo', card: faceOf(id), vis: [t.id, src.id] });
}
}
}
/** Loses `n` life because of `source`'s card (null = dynamite). */
export function damage(s: BangState, ctx: Ctx, targetId: PlayerId, n: number, source: PlayerId | null): void {
const t = P(s, targetId);
if (!t.alive || s.winner) return;
t.life -= n;
log(s, ctx, { t: 'hit', p: t.id, n, by: source, life: t.life });
if (s.options.drawBeforeDeath) lossAbilities(s, ctx, t, n, source);
if (t.life <= 0) openDying(s, ctx, t, source, n);
else if (!s.options.drawBeforeDeath) lossAbilities(s, ctx, t, n, source);
}
export const beerWorks = (s: BangState): boolean => living(s).length > 2;
/** Most life the player could still recover while dying (beers, Sid Ketchum's 2-card heals). */
export function maxRecovery(s: BangState, p: PlayerState): number {
const beers = beerWorks(s) ? p.hand.filter((id) => kindOf(id) === 'beer').length : 0;
if (!is(p, 'sid')) return beers;
return beers + Math.floor((p.hand.length - beers) / 2);
}
function openDying(s: BangState, ctx: Ctx, t: PlayerState, killer: PlayerId | null, lost: number): void {
if (maxRecovery(s, t) >= 1 - t.life) {
s.reactions.push({ kind: 'dying', player: t.id, killer, lost, source: killer, deadline: ctx.now + reactionMs(s, t) });
} else {
eliminate(s, ctx, t.id, killer);
}
}
/** The dying player is back to 1+ life. */
export function survived(s: BangState, ctx: Ctx, r: Extract<Reaction, { kind: 'dying' }>): void {
removeReaction(s, r);
if (!s.options.drawBeforeDeath) lossAbilities(s, ctx, P(s, r.player), r.lost, r.source);
}
export function removeReaction(s: BangState, r: Reaction): void {
const i = s.reactions.indexOf(r);
if (i >= 0) s.reactions.splice(i, 1);
}
// ---------------------------------------------------------------- elimination and the end
/** Reactions that cannot go on without `id` (§5.6). */
function dropReactionsOf(s: BangState, ctx: Ctx, id: PlayerId): void {
for (const r of [...s.reactions]) {
switch (r.kind) {
case 'bang':
case 'indians':
if (r.target === id) removeReaction(s, r);
break;
case 'duel':
if (r.actor === id || r.other === id) removeReaction(s, r);
break;
case 'dying':
case 'choose2':
if (r.player === id) removeReaction(s, r);
break;
case 'kitKeep':
if (r.player === id) {
s.discard.push(...r.options);
removeReaction(s, r);
}
break;
case 'store':
r.order = r.order.filter((x) => x !== id);
if (r.picker === id) storeAdvance(s, ctx, r);
break;
}
}
}
export function eliminate(s: BangState, ctx: Ctx, id: PlayerId, killer: PlayerId | null, left = false): void {
const v = P(s, id);
if (!v.alive) return;
v.alive = false;
v.life = Math.min(v.life, 0);
s.deathOrder.push(id);
log(s, ctx, { t: 'dead', p: id, role: v.role, by: killer, ...(left ? { left: true } : {}) });
dropReactionsOf(s, ctx, id);
const k = killer && killer !== id ? findPlayer(s, killer) : undefined;
if (k?.alive) {
if (v.role === 'outlaw') {
log(s, ctx, { t: 'reward', p: k.id, n: 3 });
drawCards(s, ctx, k, 3, 'reward');
}
if (k.role === 'sheriff' && v.role === 'deputy') {
log(s, ctx, { t: 'penalty', p: k.id });
const hand = [...k.hand];
s.discard.push(...k.inPlay);
k.inPlay = [];
if (hand.length) {
removeFromHand(s, ctx, k, hand);
s.discard.push(...hand);
}
}
}
const cards = [...v.hand, ...v.inPlay];
v.hand = [];
v.inPlay = [];
const vulture = living(s).find((p) => is(p, 'vulture'));
if (vulture && cards.length) {
vulture.hand.push(...cards);
log(s, ctx, { t: 'vulture', p: vulture.id, from: id, n: cards.length });
} else {
s.discard.push(...cards);
}
if (checkWin(s, ctx)) return;
if (s.turn.player === id && s.turn.phase !== 'draft') s.turn.phase = 'ended';
}
/** §6: checked after every death. */
export function checkWin(s: BangState, ctx: Ctx): boolean {
if (s.winner) return true;
const alive = living(s);
const sheriff = s.players.find((p) => p.role === 'sheriff')!;
let team: Team | null = null;
if (!sheriff.alive) team = alive.length === 1 && alive[0]!.role === 'renegade' ? 'renegade' : 'outlaws';
else if (!alive.some((p) => p.role === 'outlaw' || p.role === 'renegade')) team = 'law';
if (!team) return false;
finish(s, ctx, team);
return true;
}
const TEAM_ROLES: Record<Team, string[]> = { law: ['sheriff', 'deputy'], outlaws: ['outlaw'], renegade: ['renegade'] };
function finish(s: BangState, ctx: Ctx, team: Team): void {
s.winner = team;
for (const c of limboCards(s)) s.discard.push(c);
s.reactions = [];
s.turn.pausedAt = null;
const order = (a: PlayerState, b: PlayerState) =>
Number(b.alive) - Number(a.alive) || s.deathOrder.indexOf(b.id) - s.deathOrder.indexOf(a.id) || a.seat - b.seat;
const won = (p: PlayerState) => TEAM_ROLES[team].includes(p.role);
const winners = s.players.filter(won).sort(order);
const losers = s.players.filter((p) => !won(p)).sort(order);
const summary =
team === 'law'
? `보안관 팀 승리! ${s.round}라운드에 무법자와 배신자를 모두 잡았어요.`
: team === 'outlaws'
? `무법자 승리! 보안관이 ${s.round}라운드에 쓰러졌어요.`
: `배신자 승리! ${s.round}라운드에 보안관까지 쓰러뜨리고 혼자 살아남았어요.`;
const final: BangFinal = {
winnerTeam: team,
players: s.players.map((p) => ({
id: p.id,
role: p.role,
character: p.character,
alive: p.alive,
won: won(p),
deathOrder: p.alive ? null : s.deathOrder.indexOf(p.id) + 1,
})),
ranking: [...winners, ...losers].map((p) => p.id),
summary,
};
s.final = final;
log(s, ctx, { t: 'end', winner: team });
}
// ---------------------------------------------------------------- turn flow
export function startTurn(s: BangState, ctx: Ctx, id: PlayerId): void {
const p = P(s, id);
if (p.role === 'sheriff') s.round += 1;
s.turn = { player: id, phase: 'start', bangsPlayed: 0, deadline: ctx.now + turnMs(s, p), pausedAt: null, dynamiteChecked: false, jailChecked: false };
log(s, ctx, { t: 'turn', p: id, round: s.round });
}
/** Deals the starting hands after characters are fixed and gives the sheriff the first turn (§4). */
export function beginGame(s: BangState, ctx: Ctx): void {
for (const p of s.players) {
p.maxLife = CHARACTER_LIFE[p.character] + (p.role === 'sheriff' ? 1 : 0);
if (!p.alive) continue;
p.life = p.maxLife;
}
// One card at a time from the sheriff, clockwise.
const sheriff = s.players.find((p) => p.role === 'sheriff')!;
const order = clockwiseFrom(s, sheriff.id).map((id) => P(s, id));
const most = Math.max(...order.map((p) => p.life));
for (let r = 0; r < most; r++) for (const p of order) if (p.hand.length < p.life) p.hand.push(s.deck.pop()!);
startTurn(s, ctx, sheriff.alive ? sheriff.id : nextAlive(s, sheriff.id));
}
/** Turn start: dynamite, then jail (§5.2). */
function runStart(s: BangState, ctx: Ctx): void {
const p = P(s, s.turn.player);
if (!s.turn.dynamiteChecked) {
s.turn.dynamiteChecked = true;
const dyn = p.inPlay.find((id) => kindOf(id) === 'dynamite');
if (dyn) {
const safe = drawCheck(s, ctx, p, 'dynamite', (f) => !explodes(f), true);
p.inPlay = p.inPlay.filter((x) => x !== dyn);
if (!safe) {
s.discard.push(dyn);
log(s, ctx, { t: 'dynamite', p: p.id, exploded: true });
damage(s, ctx, p.id, 3, null);
return;
}
const to = P(s, nextAlive(s, p.id));
to.inPlay.push(dyn);
log(s, ctx, { t: 'dynamite', p: p.id, exploded: false, to: to.id });
}
}
if (!p.alive) return;
if (!s.turn.jailChecked) {
s.turn.jailChecked = true;
const jail = p.inPlay.find((id) => kindOf(id) === 'jail');
if (jail) {
const escaped = drawCheck(s, ctx, p, 'jail', isHeart, true);
p.inPlay = p.inPlay.filter((x) => x !== jail);
s.discard.push(jail);
log(s, ctx, { t: 'jail', p: p.id, escaped });
if (!escaped) {
s.turn.phase = 'ended';
return;
}
}
}
s.turn.phase = 'draw';
}
/** Jesse Jones / Pedro Ramirez have a real choice in phase 1; everyone else draws automatically. */
export function drawChoice(s: BangState, p: PlayerState): { jesse: PlayerId[]; pedro: boolean } | null {
const jesse = is(p, 'jesse') ? living(s).filter((x) => x.id !== p.id && x.hand.length > 0).map((x) => x.id) : [];
const pedro = is(p, 'pedro') && s.discard.length > 0;
return jesse.length || pedro ? { jesse, pedro } : null;
}
export function doDraw(s: BangState, ctx: Ctx, a: { jesseFrom?: PlayerId; pedroDiscard?: boolean }): void {
const p = P(s, s.turn.player);
s.turn.phase = 'play';
if (is(p, 'kit')) {
const options: string[] = [];
for (let i = 0; i < 3; i++) {
const id = drawTop(s, ctx);
if (id) options.push(id);
}
if (options.length <= 2) {
p.hand.push(...options);
if (options.length) log(s, ctx, { t: 'draw', p: p.id, n: options.length, why: 'kit' });
} else {
s.reactions.push({ kind: 'kitKeep', player: p.id, options, deadline: ctx.now + reactionMs(s, p) });
}
return;
}
if (a.jesseFrom) {
const src = P(s, a.jesseFrom);
const id = src.hand[ctx.rng.int(src.hand.length)]!;
removeFromHand(s, ctx, src, [id]);
p.hand.push(id);
log(s, ctx, { t: 'take', p: p.id, from: src.id, how: 'jesse', card: faceOf(id), vis: [p.id, src.id] });
} else if (a.pedroDiscard) {
const id = s.discard.pop()!;
p.hand.push(id);
log(s, ctx, { t: 'pedro', p: p.id, card: faceOf(id) });
}
const special = !!(a.jesseFrom || a.pedroDiscard);
if (is(p, 'blackJack')) {
drawCards(s, ctx, p, 1, 'phase');
const second = drawTop(s, ctx);
if (second) {
p.hand.push(second);
const f = faceOf(second);
const extra = f.suit === 'H' || f.suit === 'D';
log(s, ctx, { t: 'blackJack', p: p.id, card: f, extra });
if (extra) drawCards(s, ctx, p, 1, 'phase');
}
return;
}
drawCards(s, ctx, p, special ? 1 : 2, 'phase');
}
/**
* Runs the automatic parts of the turn until a decision is needed: pauses the turn timer while reactions
* are open, finishes the draft, starts turns, resolves dynamite / jail and automatic draws.
*/
export function settle(s: BangState, ctx: Ctx): void {
for (let guard = 0; guard < 500; guard++) {
if (s.winner) return;
if (s.reactions.length > 0) {
if (s.turn.pausedAt === null && (s.turn.phase === 'play' || s.turn.phase === 'discard')) s.turn.pausedAt = ctx.now;
return;
}
if (s.turn.pausedAt !== null) {
s.turn.deadline += ctx.now - s.turn.pausedAt;
s.turn.pausedAt = null;
}
const p = P(s, s.turn.player);
switch (s.turn.phase) {
case 'draft':
beginGame(s, ctx);
continue;
case 'ended':
startTurn(s, ctx, nextAlive(s, s.turn.player));
continue;
case 'start':
runStart(s, ctx);
continue;
case 'draw':
if (drawChoice(s, p)) return;
doDraw(s, ctx, {});
continue;
case 'discard':
if (p.hand.length <= p.life) {
s.turn.phase = 'ended';
continue;
}
return;
case 'play':
return;
}
}
throw new Error('bang: settle did not converge');
}
// ---------------------------------------------------------------- playing cards
function openBang(s: BangState, ctx: Ctx, source: PlayerId, target: PlayerId, via: 'bang' | 'gatling'): void {
const t = P(s, target);
const r: Extract<Reaction, { kind: 'bang' }> = {
kind: 'bang',
target,
source,
via,
needed: via === 'bang' && is(P(s, source), 'slab') ? 2 : 1,
barrelTried: false,
jourTried: false,
deadline: ctx.now + reactionMs(s, t),
};
if (s.options.autoBarrel) {
while (r.needed > 0 && tryBarrel(s, ctx, r));
if (r.needed <= 0) {
log(s, ctx, { t: 'barrelSave', p: target });
return;
}
}
s.reactions.push(r);
}
export function barrelLeft(s: BangState, r: Extract<Reaction, { kind: 'bang' }>): boolean {
const t = P(s, r.target);
return (!r.barrelTried && hasEquip(t, 'barrel')) || (!r.jourTried && is(t, 'jourdonnais'));
}
/** One barrel draw! (the real barrel first, then Jourdonnais). False when none is left to try. */
export function tryBarrel(s: BangState, ctx: Ctx, r: Extract<Reaction, { kind: 'bang' }>): boolean {
const t = P(s, r.target);
let purpose: CheckPurpose;
if (!r.barrelTried && hasEquip(t, 'barrel')) {
r.barrelTried = true;
purpose = 'barrel';
} else if (!r.jourTried && is(t, 'jourdonnais')) {
r.jourTried = true;
purpose = 'jourdonnais';
} else return false;
if (drawCheck(s, ctx, t, purpose, isHeart, false)) r.needed -= 1;
return true;
}
function openStore(s: BangState, ctx: Ctx, actor: PlayerId): void {
const order = clockwiseFrom(s, actor);
const revealed: string[] = [];
for (let i = 0; i < order.length; i++) {
const id = drawTop(s, ctx);
if (id) revealed.push(id);
}
log(s, ctx, { t: 'storeOpen', p: actor, cards: revealed.map(faceOf) });
const r: Extract<Reaction, { kind: 'store' }> = { kind: 'store', picker: actor, order, revealed, source: actor, deadline: 0 };
s.reactions.push(r);
storeAdvance(s, ctx, r);
}
/** Next picker in order; the last card goes to the next picker automatically; leftovers are discarded. */
export function storeAdvance(s: BangState, ctx: Ctx, r: Extract<Reaction, { kind: 'store' }>): void {
for (;;) {
if (r.revealed.length === 0) {
removeReaction(s, r);
return;
}
const next = r.order.shift();
if (!next) {
s.discard.push(...r.revealed);
r.revealed = [];
removeReaction(s, r);
return;
}
const p = P(s, next);
if (!p.alive) continue;
if (r.revealed.length === 1) {
const id = r.revealed.pop()!;
p.hand.push(id);
log(s, ctx, { t: 'store', p: p.id, card: faceOf(id) });
continue;
}
r.picker = next;
r.deadline = ctx.now + reactionMs(s, p);
return;
}
}
export function storePick(s: BangState, ctx: Ctx, r: Extract<Reaction, { kind: 'store' }>, cardId: string): void {
const p = P(s, r.picker);
r.revealed = r.revealed.filter((x) => x !== cardId);
p.hand.push(cardId);
log(s, ctx, { t: 'store', p: p.id, card: faceOf(cardId) });
storeAdvance(s, ctx, r);
}
export function playCard(s: BangState, ctx: Ctx, actor: PlayerId, a: PlayAction): void {
const p = P(s, actor);
const card = cardById(a.cardId);
const kind = a.as ?? card.kind;
const face = faceOf(card.id);
removeFromHand(s, ctx, p, [card.id]);
if (isBlue(kind)) {
const owner = kind === 'jail' ? P(s, a.target!) : p;
let replaced: CardFace | undefined;
if (isWeapon(kind)) {
const old = owner.inPlay.find((id) => isWeapon(kindOf(id)));
if (old) {
owner.inPlay = owner.inPlay.filter((x) => x !== old);
s.discard.push(old);
replaced = faceOf(old);
}
}
owner.inPlay.push(card.id);
log(s, ctx, { t: 'equip', p: actor, card: face, ...(kind === 'jail' ? { target: owner.id } : {}), ...(replaced ? { replaced } : {}) });
return;
}
s.discard.push(card.id);
log(s, ctx, { t: 'play', p: actor, card: face, ...(a.as ? { as: a.as } : {}), ...(a.target ? { target: a.target } : {}) });
switch (kind) {
case 'bang':
s.turn.bangsPlayed += 1;
openBang(s, ctx, actor, a.target!, 'bang');
break;
case 'beer':
if (beerWorks(s)) heal(s, ctx, p, 1, 'beer');
break;
case 'saloon':
for (const x of living(s)) heal(s, ctx, x, 1, 'saloon');
break;
case 'stagecoach':
drawCards(s, ctx, p, 2, 'card');
break;
case 'wellsFargo':
drawCards(s, ctx, p, 3, 'card');
break;
case 'generalStore':
openStore(s, ctx, actor);
break;
case 'panic':
case 'catBalou': {
const t = P(s, a.target!);
let taken: string;
if (a.targetCard === 'hand') {
taken = t.hand[ctx.rng.int(t.hand.length)]!;
removeFromHand(s, ctx, t, [taken]);
} else {
taken = a.targetCard!;
t.inPlay = t.inPlay.filter((x) => x !== taken);
}
const fromHand = a.targetCard === 'hand';
if (kind === 'panic') {
p.hand.push(taken);
log(s, ctx, fromHand ? { t: 'take', p: actor, from: t.id, how: 'panic', card: faceOf(taken), vis: [actor, t.id] } : { t: 'take', p: actor, from: t.id, how: 'panic', equip: faceOf(taken) });
} else {
s.discard.push(taken);
log(s, ctx, { t: 'discarded', p: t.id, by: actor, card: faceOf(taken), fromHand });
}
break;
}
case 'duel': {
const t = P(s, a.target!);
s.reactions.push({ kind: 'duel', actor: t.id, other: actor, source: actor, deadline: ctx.now + reactionMs(s, t) });
break;
}
case 'indians':
for (const id of clockwiseFrom(s, actor)) {
if (id === actor) continue;
s.reactions.push({ kind: 'indians', target: id, source: actor, deadline: ctx.now + reactionMs(s, P(s, id)) });
}
break;
case 'gatling':
for (const id of clockwiseFrom(s, actor)) if (id !== actor) openBang(s, ctx, actor, id, 'gatling');
break;
default:
throw new Error(`bang: cannot play ${kind}`);
}
}
// ---------------------------------------------------------------- reactions
export function respond(s: BangState, ctx: Ctx, actor: PlayerId, cardIds: string[]): void {
const r = reactionOf(s, actor)!;
const p = P(s, actor);
const faces = cardIds.map(faceOf);
removeFromHand(s, ctx, p, cardIds);
s.discard.push(...cardIds);
switch (r.kind) {
case 'bang':
removeReaction(s, r);
log(s, ctx, { t: 'missed', p: actor, cards: faces });
break;
case 'indians':
removeReaction(s, r);
log(s, ctx, { t: 'respond', p: actor, cards: faces, why: 'indians' });
break;
case 'duel': {
log(s, ctx, { t: 'respond', p: actor, cards: faces, why: 'duel' });
const next = r.other;
r.other = r.actor;
r.actor = next;
r.deadline = ctx.now + reactionMs(s, P(s, next));
break;
}
case 'dying':
heal(s, ctx, p, cardIds.length, 'beer', faces);
if (p.life >= 1) survived(s, ctx, r);
break;
default:
throw new Error(`bang: cannot respond to ${r.kind}`);
}
}
export function takeHit(s: BangState, ctx: Ctx, actor: PlayerId): void {
const r = reactionOf(s, actor)!;
removeReaction(s, r);
switch (r.kind) {
case 'bang':
case 'indians':
damage(s, ctx, actor, 1, r.source);
break;
case 'duel':
damage(s, ctx, actor, 1, r.other);
break;
default:
throw new Error(`bang: cannot take a hit in ${r.kind}`);
}
}
export function sidHeal(s: BangState, ctx: Ctx, actor: PlayerId, cardIds: string[]): void {
const p = P(s, actor);
const faces = cardIds.map(faceOf);
removeFromHand(s, ctx, p, cardIds);
s.discard.push(...cardIds);
heal(s, ctx, p, 1, 'sid', faces);
const r = reactionOf(s, actor);
if (r?.kind === 'dying' && p.life >= 1) survived(s, ctx, r);
}
/** The dying player recovers with whatever works (timeout / auto, §8.5). */
export function autoSave(s: BangState, ctx: Ctx, actor: PlayerId): void {
const p = P(s, actor);
const r = reactionOf(s, actor) as Extract<Reaction, { kind: 'dying' }>;
const needed = 1 - p.life;
const beers = beerWorks(s) ? p.hand.filter((id) => kindOf(id) === 'beer') : [];
if (beers.length >= needed) {
respond(s, ctx, actor, beers.slice(0, needed));
return;
}
if (maxRecovery(s, p) >= needed) {
// Sid Ketchum: 2-card heals with non-beer cards first, beers for the rest.
while (p.alive && p.life <= 0) {
const beer = beerWorks(s) ? p.hand.find((id) => kindOf(id) === 'beer') : undefined;
const others = p.hand.filter((id) => kindOf(id) !== 'beer');
if (others.length >= 2) sidHeal(s, ctx, actor, others.slice(0, 2));
else if (beer) {
removeFromHand(s, ctx, p, [beer]);
s.discard.push(beer);
heal(s, ctx, p, 1, 'beer', [faceOf(beer)]);
if (p.life >= 1) survived(s, ctx, r);
} else if (p.hand.length >= 2) sidHeal(s, ctx, actor, p.hand.slice(0, 2));
else break;
}
if (p.life >= 1) return;
}
removeReaction(s, r);
eliminate(s, ctx, actor, r.killer);
}
export function kitKeep(s: BangState, ctx: Ctx, actor: PlayerId, keep: string[]): void {
const r = reactionOf(s, actor) as Extract<Reaction, { kind: 'kitKeep' }>;
const p = P(s, actor);
removeReaction(s, r);
p.hand.push(...keep);
for (const id of r.options) if (!keep.includes(id)) s.deck.push(id);
log(s, ctx, { t: 'draw', p: actor, n: keep.length, why: 'kit' });
}
export function pickCharacter(s: BangState, ctx: Ctx, actor: PlayerId, c: CharacterId): void {
const p = P(s, actor);
const r = reactionOf(s, actor);
if (r) removeReaction(s, r);
p.character = c;
p.characterOffer = null;
p.maxLife = CHARACTER_LIFE[c] + (p.role === 'sheriff' ? 1 : 0);
p.life = p.maxLife;
log(s, ctx, { t: 'character', p: actor, character: c });
}
/** Ends the play phase: the hand limit is the current life (§5.2 step 5). */
export function endPlay(s: BangState, ctx: Ctx, actor: PlayerId): void {
const p = P(s, actor);
if (p.hand.length > p.life) {
s.turn.phase = 'discard';
s.turn.deadline = Math.max(s.turn.deadline, ctx.now + DISCARD_MIN_MS);
} else s.turn.phase = 'ended';
}
export function discardDown(s: BangState, ctx: Ctx, actor: PlayerId, cardIds: string[]): void {
const p = P(s, actor);
removeFromHand(s, ctx, p, cardIds);
s.discard.push(...cardIds);
log(s, ctx, { t: 'limit', p: actor, cards: cardIds.map(faceOf) });
s.turn.phase = 'ended';
}
export function randomDiscard(s: BangState, ctx: Ctx, actor: PlayerId): void {
const p = P(s, actor);
const n = p.hand.length - p.life;
if (n <= 0) {
s.turn.phase = 'ended';
return;
}
const ids = ctx.rng.shuffle(p.hand).slice(0, n);
discardDown(s, ctx, actor, ids);
}
/** A player leaves the room: they are out of the game (no killer, cards discarded or to Vulture Sam). */
export function leave(s: BangState, ctx: Ctx, actor: PlayerId): void {
const p = P(s, actor);
if (s.turn.phase === 'draft' && p.characterOffer) {
const r = reactionOf(s, actor);
if (r) removeReaction(s, r);
p.character = p.characterOffer[0]!;
p.characterOffer = null;
}
eliminate(s, ctx, actor, null, true);
}

View File

@@ -0,0 +1,439 @@
/** 뱅! (docs/games/bang.md). Pure and deterministic; state is JSON. */
import { type GameDefinition, type GameResult, type PlayerId } from '@bg/engine';
import { CARDS, CHARACTER_IDS, CHARACTER_LIFE, cardById, kindOf, rolesFor } from './cards';
import { distance, rangeOf } from './distance';
import {
AWAY_AFTER,
P,
autoSave,
discardDown,
doDraw,
eliminate,
log,
endPlay,
kitKeep,
leave,
limboCards,
living,
pickCharacter,
playCard,
randomDiscard,
reactionOf,
respond,
responder,
settle,
sidHeal,
storePick,
takeHit,
tryBarrel,
type Ctx,
} from './effects';
import { bangLike, missedLike, playOptions, promptFor, sidHealContext, unlimitedBang, validateAction, waiting } from './rules';
import {
bangActionSchema,
bangOptionsSchema,
type BangAction,
type BangOptions,
type BangState,
type BangView,
type LogEntry,
type PlayerState,
type Reaction,
} from './types';
export * from './cards';
export * from './types';
export { distance, rangeOf, seatDistance, hasEquip, weaponOf } from './distance';
export { checkPlay, playOptions, promptFor, waiting } from './rules';
const LOG_VIEW = 30;
/** Copy for `apply`: containers are copied; log entries and options are never mutated and are shared. */
function cloneState(s: BangState): BangState {
return {
...s,
players: s.players.map((p) => ({ ...p, hand: [...p.hand], inPlay: [...p.inPlay], characterOffer: p.characterOffer ? [...p.characterOffer] : null })),
deck: [...s.deck],
discard: [...s.discard],
turn: { ...s.turn },
reactions: s.reactions.map((r) => {
if (r.kind === 'store') return { ...r, order: [...r.order], revealed: [...r.revealed] };
if (r.kind === 'kitKeep') return { ...r, options: [...r.options] };
return { ...r };
}),
deathOrder: [...s.deathOrder],
log: [...s.log],
};
}
/** Every card is in exactly one place (test invariant). */
export function allCards(s: BangState): string[] {
return [...s.deck, ...s.discard, ...s.players.flatMap((p) => [...p.hand, ...p.inPlay]), ...limboCards(s)];
}
function deadlineOf(s: BangState): number | null {
if (s.winner) return null;
if (s.reactions.length) return Math.min(...s.reactions.map((r) => r.deadline));
return s.turn.deadline;
}
/** Responders whose own deadline is the earliest (a later reaction must not be cut short). */
function timed(s: BangState): PlayerId[] {
if (s.winner) return [];
if (!s.reactions.length) return [s.turn.player];
const d = deadlineOf(s)!;
return [...new Set(s.reactions.filter((r) => r.deadline === d).map(responder))];
}
/** §8.5 automatic choice for a player who did not answer in time. */
function onTimeoutApply(s: BangState, ctx: Ctx, actor: PlayerId): void {
const p = P(s, actor);
const r = reactionOf(s, actor);
if (r) {
switch (r.kind) {
case 'choose2':
pickCharacter(s, ctx, actor, p.characterOffer![0]!);
return;
case 'kitKeep':
kitKeep(s, ctx, actor, r.options.slice(0, 2));
return;
case 'store':
storePick(s, ctx, r, r.revealed[0]!);
return;
case 'bang': {
if (!s.options.autoDefend) return takeHit(s, ctx, actor);
while (r.needed > 0 && tryBarrel(s, ctx, r));
if (r.needed <= 0) {
s.reactions.splice(s.reactions.indexOf(r), 1);
return;
}
const cards = p.hand.filter((id) => missedLike(p, id));
// Calamity Janet keeps 뱅! when a 빗나감! does the job.
cards.sort((a, b) => Number(kindOf(a) !== 'missed') - Number(kindOf(b) !== 'missed'));
if (cards.length >= r.needed) return respond(s, ctx, actor, cards.slice(0, r.needed));
return takeHit(s, ctx, actor);
}
case 'indians':
case 'duel': {
const card = s.options.autoDefend ? p.hand.find((id) => bangLike(p, id)) : undefined;
if (card) return respond(s, ctx, actor, [card]);
return takeHit(s, ctx, actor);
}
case 'dying':
return autoSave(s, ctx, actor);
}
}
switch (s.turn.phase) {
case 'draw':
return doDraw(s, ctx, {});
case 'play':
endPlay(s, ctx, actor);
if ((s.turn.phase as string) === 'discard') randomDiscard(s, ctx, actor);
return;
case 'discard':
return randomDiscard(s, ctx, actor);
default:
throw new Error(`bang: timeout in ${s.turn.phase}`);
}
}
function roleVisible(s: BangState, p: PlayerState, viewer: PlayerId | null): boolean {
return p.role === 'sheriff' || !p.alive || p.id === viewer || !!s.winner;
}
function stripPrivate(e: LogEntry, viewer: PlayerId | null, over: boolean): LogEntry {
if (e.t !== 'take' || !e.vis || over || (viewer && e.vis.includes(viewer))) return e;
const { card: _card, ...rest } = e;
return rest;
}
type Def = GameDefinition<BangState, BangAction, BangView, BangOptions>;
export const bang: Def = {
id: 'bang',
nameKo: '뱅!',
minPlayers: 4,
maxPlayers: 7,
stateVersion: 1,
defaultOptions: bangOptionsSchema.parse({}),
optionsSchema: bangOptionsSchema as unknown as Def['optionsSchema'],
actionSchema: bangActionSchema as unknown as Def['actionSchema'],
setup({ players, options, rng, now }) {
if (players.length < 4 || players.length > 7) throw new Error(`bang: ${players.length} players not allowed`);
// §4: seats, roles, characters, then the deck.
const seats = rng.shuffle(players);
const roles = rng.shuffle(rolesFor(players.length, options.deputyCount7));
const chars = rng.shuffle(CHARACTER_IDS);
const draft = options.characterDraft === 'choose2';
const ps: PlayerState[] = seats.map((id, i) => {
const offer = draft ? [chars[2 * i]!, chars[2 * i + 1]!] : null;
const character = draft ? offer![0]! : chars[i]!;
const maxLife = CHARACTER_LIFE[character] + (roles[i] === 'sheriff' ? 1 : 0);
return { id, seat: i, role: roles[i]!, character, characterOffer: offer, maxLife, life: maxLife, alive: true, hand: [], inPlay: [], timeoutsInRow: 0, away: false };
});
const s: BangState = {
options,
players: ps,
deck: rng.shuffle(CARDS.map((c) => c.id)),
discard: [],
turn: { player: ps.find((p) => p.role === 'sheriff')!.id, phase: 'draft', bangsPlayed: 0, deadline: now, pausedAt: null, dynamiteChecked: false, jailChecked: false },
reactions: draft ? ps.map((p) => ({ kind: 'choose2' as const, player: p.id, deadline: now + 30_000 })) : [],
lastCheck: null,
winner: null,
deathOrder: [],
round: 0,
seq: 0,
log: [],
final: null,
};
settle(s, { rng, now, events: [] });
return s;
},
validate: validateAction,
apply(prev, actor, a, { rng, now }) {
const s = cloneState(prev);
const ctx: Ctx = { rng, now, events: [] };
s.seq += 1;
const p = P(s, actor);
if (a.type !== 'timeout' && a.type !== 'leave') {
p.timeoutsInRow = 0;
p.away = false;
}
switch (a.type) {
case 'timeout':
p.timeoutsInRow += 1;
if (p.timeoutsInRow >= AWAY_AFTER) p.away = true;
log(s, ctx, { t: 'timeout', p: actor });
onTimeoutApply(s, ctx, actor);
break;
case 'leave':
leave(s, ctx, actor);
break;
case 'pickCharacter':
pickCharacter(s, ctx, actor, a.character);
break;
case 'draw':
doDraw(s, ctx, a);
break;
case 'kitKeep':
kitKeep(s, ctx, actor, a.keep);
break;
case 'play':
playCard(s, ctx, actor, a);
break;
case 'respond':
respond(s, ctx, actor, a.cardIds);
break;
case 'useBarrel': {
const r = reactionOf(s, actor) as Extract<Reaction, { kind: 'bang' }>;
tryBarrel(s, ctx, r);
if (r.needed <= 0) s.reactions.splice(s.reactions.indexOf(r), 1);
break;
}
case 'takeHit':
takeHit(s, ctx, actor);
break;
case 'giveUp': {
const r = reactionOf(s, actor) as Extract<Reaction, { kind: 'dying' }>;
s.reactions.splice(s.reactions.indexOf(r), 1);
eliminate(s, ctx, actor, r.killer);
break;
}
case 'storePick':
storePick(s, ctx, reactionOf(s, actor) as Extract<Reaction, { kind: 'store' }>, a.cardId);
break;
case 'sidHeal':
sidHeal(s, ctx, actor, a.cardIds);
break;
case 'endPlay':
endPlay(s, ctx, actor);
break;
case 'discard':
discardDown(s, ctx, actor, a.cardIds);
break;
}
settle(s, ctx);
return { state: s, events: ctx.events };
},
view(s, viewer) {
const me = viewer && s.players.some((p) => p.id === viewer) ? viewer : null;
const meP = me ? P(s, me) : null;
const over = !!s.winner;
const reactingOf = new Map(s.reactions.map((r) => [responder(r), r.kind]));
const players = s.players.map((p) => {
const hiddenChar = !!p.characterOffer && p.id !== me && !over;
const dist = meP && meP.alive && p.alive && p.id !== me && !p.characterOffer && !meP.characterOffer ? distance(s, me!, p.id) : null;
return {
id: p.id,
seat: p.seat,
character: hiddenChar || p.characterOffer ? null : p.character,
role: roleVisible(s, p, me) ? p.role : null,
maxLife: p.maxLife,
life: p.life,
alive: p.alive,
handCount: p.hand.length,
inPlay: p.inPlay.map(cardById),
isTurn: !over && s.turn.player === p.id && s.turn.phase !== 'draft',
reacting: reactingOf.get(p.id) ?? null,
away: p.away,
distance: dist,
inRange: dist !== null && dist <= rangeOf(meP!),
};
});
return {
options: s.options,
me,
players,
current: s.turn.player,
phase: s.turn.phase,
round: s.round,
bangsPlayed: s.turn.bangsPlayed,
deckCount: s.deck.length,
discardCount: s.discard.length,
discardTop: s.discard.slice(-4).map(cardById),
reactions: s.reactions.map((r) => ({
kind: r.kind,
who: responder(r),
...('source' in r && r.source ? { source: r.source } : {}),
...(r.kind === 'bang' ? { via: r.via, needed: r.needed } : {}),
...(r.kind === 'store' ? { revealed: r.revealed.map(cardById) } : {}),
deadline: r.deadline,
})),
lastCheck: s.lastCheck,
my: meP
? {
role: meP.role,
character: meP.characterOffer ? null : meP.character,
hand: meP.hand.map(cardById),
range: rangeOf(meP),
unlimitedBang: unlimitedBang(meP),
plays: playOptions(s, me!),
canEndPlay: !over && s.turn.player === me && s.turn.phase === 'play' && s.reactions.length === 0 && meP.alive,
canSidHeal: sidHealContext(s, me!) && meP.life < meP.maxLife && meP.hand.length >= 2,
prompt: promptFor(s, me!),
}
: null,
turnDeadline: s.turn.deadline,
deadlineTotalMs: s.options.turnSeconds * 1000,
reactionTotalMs: s.options.reactionSeconds * 1000,
log: s.log.slice(-LOG_VIEW).map((e) => stripPrivate(e, me, over)),
winner: s.winner,
result: s.final,
};
},
activePlayers: waiting,
deadline: deadlineOf,
timeoutPlayers: timed,
onTimeout() {
return { type: 'timeout' };
},
onLeave(s, player) {
const p = s.players.find((x) => x.id === player);
if (!p || !p.alive || s.winner) return null;
return { type: 'leave' };
},
result(s): GameResult | null {
const f = s.final;
if (!f) return null;
const winners = f.ranking.filter((id) => f.players.find((p) => p.id === id)!.won);
const losers = f.ranking.filter((id) => !winners.includes(id));
return {
ranking: [winners, ...losers.map((id) => [id])],
scores: Object.fromEntries(f.players.map((p) => [p.id, p.won ? 1 : 0])),
summary: f.summary,
reason: 'normal',
};
},
legalActions(s, player) {
const p = s.players.find((x) => x.id === player);
if (!p || !p.alive || s.winner) return [];
const out: BangAction[] = [];
const r = reactionOf(s, player);
const sidPairs = (): BangAction[] => (p.hand.length >= 2 ? [{ type: 'sidHeal', cardIds: [p.hand[0]!, p.hand[1]!] }] : []);
if (r) {
switch (r.kind) {
case 'choose2':
for (const c of p.characterOffer ?? []) out.push({ type: 'pickCharacter', character: c });
break;
case 'kitKeep': {
const o = r.options;
for (let i = 0; i < o.length; i++) for (let j = i + 1; j < o.length; j++) out.push({ type: 'kitKeep', keep: [o[i]!, o[j]!] });
break;
}
case 'store':
for (const id of r.revealed) out.push({ type: 'storePick', cardId: id });
break;
case 'bang': {
const cards = p.hand.filter((id) => missedLike(p, id));
if (cards.length >= r.needed) out.push({ type: 'respond', cardIds: cards.slice(0, r.needed) });
out.push({ type: 'takeHit' }, { type: 'useBarrel' }, ...sidPairs());
break;
}
case 'indians':
case 'duel': {
const card = p.hand.find((id) => bangLike(p, id));
if (card) out.push({ type: 'respond', cardIds: [card] });
out.push({ type: 'takeHit' }, ...sidPairs());
break;
}
case 'dying': {
const beers = p.hand.filter((id) => kindOf(id) === 'beer');
const need = 1 - p.life;
if (beers.length >= need) out.push({ type: 'respond', cardIds: beers.slice(0, need) });
out.push({ type: 'giveUp' }, ...sidPairs());
break;
}
}
return out.filter((a) => validateAction(s, player, a).ok);
}
if (s.reactions.length || s.turn.player !== player) return [];
switch (s.turn.phase) {
case 'draw':
out.push({ type: 'draw' }, { type: 'draw', pedroDiscard: true });
for (const q of living(s)) out.push({ type: 'draw', jesseFrom: q.id });
break;
case 'play':
for (const o of playOptions(s, player)) {
if (!o.ok) continue;
const base = { type: 'play' as const, cardId: o.cardId, ...(o.as ? { as: o.as } : {}) };
if (!o.targets) out.push(base);
else
for (const t of o.targets) {
if (!t.ok) continue;
if (t.cards) for (const tc of t.cards) out.push({ ...base, target: t.id, targetCard: tc });
else out.push({ ...base, target: t.id });
}
}
out.push({ type: 'endPlay' }, ...sidPairs());
break;
case 'discard': {
const n = p.hand.length - p.life;
if (n > 0) out.push({ type: 'discard', cardIds: p.hand.slice(0, n) }, { type: 'discard', cardIds: p.hand.slice(-n) });
out.push(...sidPairs());
break;
}
}
return out.filter((a) => validateAction(s, player, a).ok);
},
/** Own hand, own Kit Carlson / choose2 offers, and the deck — ids exactly as they would appear in a view. */
secretsOf(s, player) {
const p = s.players.find((x) => x.id === player);
if (!p) return [];
const kit = s.reactions.flatMap((r) => (r.kind === 'kitKeep' && r.player === player ? r.options : []));
return [...p.hand, ...kit, ...s.deck, ...(p.characterOffer ?? [])];
},
};
export const _internal = { cloneState, timed, deadlineOf };

View File

@@ -0,0 +1,274 @@
/** 뱅! legality checks (docs/games/bang.md §5.2–§5.4, §8.2) and what the view needs from them. */
import { fail, ok, type PlayerId, type Validation } from '@bg/engine';
import { cardById, isBlue, kindOf, type CardKind } from './cards';
import { distance, hasEquip, rangeOf } from './distance';
import { barrelLeft, beerWorks, drawChoice, findPlayer, is, living, P, reactionOf, responder } from './effects';
import type { BangAction, BangState, PlayAction, PlayOption, PlayerState, Prompt, Reaction, TargetOption } from './types';
const TARGETED: CardKind[] = ['bang', 'panic', 'catBalou', 'duel', 'jail'];
export const needsTarget = (k: CardKind): boolean => TARGETED.includes(k);
export const unlimitedBang = (p: PlayerState): boolean => is(p, 'willy') || hasEquip(p, 'volcanic');
/** Cards that answer a 뱅! (빗나감!, or 뱅! for Calamity Janet). */
export const missedLike = (p: PlayerState, id: string): boolean => kindOf(id) === 'missed' || (is(p, 'calamity') && kindOf(id) === 'bang');
/** Cards that answer 인디언! / 결투 (뱅!, or 빗나감! for Calamity Janet). */
export const bangLike = (p: PlayerState, id: string): boolean => kindOf(id) === 'bang' || (is(p, 'calamity') && kindOf(id) === 'missed');
export const myPlayTime = (s: BangState, p: PlayerId): boolean => !s.winner && s.turn.player === p && s.turn.phase === 'play' && s.reactions.length === 0;
/** null = the play is legal; otherwise the Korean reason. */
export function checkPlay(s: BangState, actor: PlayerId, a: PlayAction): string | null {
const p = P(s, actor);
if (s.turn.player !== actor || s.turn.phase !== 'play') return '지금은 카드를 낼 수 없어요.';
if (s.reactions.length > 0) return '다른 사람의 반응을 기다리고 있어요.';
if (!p.hand.includes(a.cardId)) return '손에 없는 카드예요.';
const card = cardById(a.cardId);
if (a.as !== undefined) {
if (!is(p, 'calamity')) return '캘러미티 자넷만 카드를 바꿔 낼 수 있어요.';
if (a.as !== 'bang' || card.kind !== 'missed') return '내 차례에는 빗나감!을 뱅!으로만 바꿔 낼 수 있어요.';
}
const kind: CardKind = a.as ?? card.kind;
if (kind === 'missed') return '빗나감!은 공격을 받았을 때만 낼 수 있어요.';
if (a.targetCard !== undefined && kind !== 'panic' && kind !== 'catBalou') return '이 카드는 카드를 고르지 않아요.';
if (!needsTarget(kind)) {
if (a.target !== undefined) return '이 카드는 대상을 고르지 않아요.';
} else {
if (!a.target) return '대상을 골라 주세요.';
const t = findPlayer(s, a.target);
if (!t) return '없는 플레이어예요.';
if (!t.alive) return '이미 쓰러진 사람이에요.';
}
const t = a.target ? P(s, a.target) : null;
switch (kind) {
case 'bang': {
if (t!.id === actor) return '자기 자신은 쏠 수 없어요.';
if (s.turn.bangsPlayed >= 1 && !unlimitedBang(p)) return '이번 턴에는 뱅!을 이미 썼어요.';
const d = distance(s, actor, t!.id);
const r = rangeOf(p);
if (d > r) return `사거리가 닿지 않아요(거리 ${d}, 사거리 ${r}).`;
return null;
}
case 'beer':
if (s.options.beerAtFullHealth) return null;
if (!beerWorks(s)) return '두 명만 남으면 맥주는 효과가 없어요.';
if (p.life >= p.maxLife) return '생명이 가득 차서 맥주를 쓸 수 없어요.';
return null;
case 'panic':
case 'catBalou': {
const self = t!.id === actor;
if (self && !s.options.selfTargetCatBalou) return '자기 자신에게는 쓸 수 없어요.';
const tc = a.targetCard;
if (!tc) return '가져올 카드를 골라 주세요.';
if (tc === 'hand') {
if (self) return '내 손패에는 쓸 수 없어요.';
if (t!.hand.length === 0) return '손패가 없는 사람이에요.';
} else if (!t!.inPlay.includes(tc)) return '그 사람 앞에 없는 카드예요.';
if (kind === 'panic' && !self) {
const d = distance(s, actor, t!.id);
if (d > 1) return `패닉!은 거리 1인 사람에게만 쓸 수 있어요(거리 ${d}).`;
}
return null;
}
case 'duel':
return t!.id === actor ? '자기 자신과는 결투할 수 없어요.' : null;
case 'jail':
if (t!.id === actor) return '자기 자신은 감옥에 넣을 수 없어요.';
if (t!.role === 'sheriff') return '보안관은 감옥에 넣을 수 없어요.';
if (hasEquip(t!, 'jail')) return '이미 감옥에 있는 사람이에요.';
return null;
default:
if (isBlue(kind) && hasEquip(p, kind)) return '같은 카드를 이미 앞에 놓았어요.';
return null;
}
}
/** Sid Ketchum may heal on his own turn or while answering a reaction aimed at him. */
export function sidHealContext(s: BangState, actor: PlayerId): boolean {
const p = P(s, actor);
if (!is(p, 'sid') || !p.alive || s.winner) return false;
const r = reactionOf(s, actor);
if (r) return r.kind === 'bang' || r.kind === 'indians' || r.kind === 'duel' || r.kind === 'dying';
return s.turn.player === actor && s.reactions.length === 0 && (s.turn.phase === 'play' || s.turn.phase === 'discard');
}
function distinctInHand(p: PlayerState, ids: string[]): string | null {
if (new Set(ids).size !== ids.length) return '같은 카드를 두 번 고를 수 없어요.';
if (ids.some((id) => !p.hand.includes(id))) return '손에 없는 카드예요.';
return null;
}
function checkRespond(s: BangState, p: PlayerState, r: Reaction, ids: string[]): string | null {
const bad = distinctInHand(p, ids);
if (bad) return bad;
switch (r.kind) {
case 'bang':
if (ids.length !== r.needed) return r.needed > 1 ? `빗나감! ${r.needed}장을 한꺼번에 내야 막을 수 있어요.` : '빗나감! 1장을 골라 주세요.';
if (!ids.every((id) => missedLike(p, id))) return is(p, 'calamity') ? '빗나감!이나 뱅!만 낼 수 있어요.' : '빗나감!만 낼 수 있어요.';
return null;
case 'indians':
case 'duel':
if (ids.length !== 1) return '뱅! 1장을 골라 주세요.';
if (!bangLike(p, ids[0]!)) return is(p, 'calamity') ? '뱅!이나 빗나감!만 낼 수 있어요.' : '뱅!만 낼 수 있어요.';
return null;
case 'dying': {
if (!beerWorks(s)) return '두 명만 남으면 맥주로 살아날 수 없어요.';
const need = 1 - p.life;
if (ids.length !== need) return `맥주 ${need}장이 필요해요.`;
if (!ids.every((id) => kindOf(id) === 'beer')) return '맥주만 낼 수 있어요.';
return null;
}
default:
return '지금은 카드를 낼 수 없어요.';
}
}
/** Everyone the game is waiting for (reactions first; otherwise the current player). */
export function waiting(s: BangState): PlayerId[] {
if (s.winner) return [];
if (s.reactions.length > 0) return [...new Set(s.reactions.map(responder))];
return [s.turn.player];
}
export function validateAction(s: BangState, actor: PlayerId, a: BangAction): Validation {
const p = findPlayer(s, actor);
if (!p) return fail('이 게임의 플레이어가 아니에요.');
if (s.winner) return fail('게임이 이미 끝났어요.');
if (a.type === 'leave') return p.alive ? ok : fail('이미 게임에서 빠졌어요.');
if (!p.alive) return fail('쓰러진 사람은 행동할 수 없어요.');
const r = reactionOf(s, actor);
const why = (reason: string | null): Validation => (reason ? fail(reason) : ok);
switch (a.type) {
case 'timeout':
return waiting(s).includes(actor) ? ok : fail('시간 초과 대상이 아니에요.');
case 'pickCharacter':
if (r?.kind !== 'choose2' || !p.characterOffer) return fail('지금은 캐릭터를 고를 때가 아니에요.');
return p.characterOffer.includes(a.character) ? ok : fail('받은 캐릭터 중에서 골라 주세요.');
case 'draw': {
if (s.turn.player !== actor || s.turn.phase !== 'draw' || s.reactions.length) return fail('지금은 카드를 받을 때가 아니에요.');
if (a.jesseFrom !== undefined && a.pedroDiscard) return fail('한 가지 방법만 고를 수 있어요.');
const choice = drawChoice(s, p);
if (a.jesseFrom !== undefined) {
if (!is(p, 'jesse')) return fail('제시 존스만 다른 사람 손패에서 받을 수 있어요.');
if (!choice?.jesse.includes(a.jesseFrom)) return fail('손패가 있는 다른 사람을 골라 주세요.');
}
if (a.pedroDiscard) {
if (!is(p, 'pedro')) return fail('페드로 라미레스만 버린 카드 더미에서 받을 수 있어요.');
if (!choice?.pedro) return fail('버린 카드 더미가 비어 있어요.');
}
return ok;
}
case 'kitKeep': {
if (r?.kind !== 'kitKeep') return fail('지금은 카드를 고를 때가 아니에요.');
const [x, y] = a.keep;
if (x === y) return fail('서로 다른 카드 2장을 골라 주세요.');
return r.options.includes(x) && r.options.includes(y) ? ok : fail('보이는 카드 중에서 골라 주세요.');
}
case 'play':
return why(checkPlay(s, actor, a));
case 'respond':
if (!r || !['bang', 'indians', 'duel', 'dying'].includes(r.kind)) return fail('지금은 낼 수 있는 반응이 없어요.');
return why(checkRespond(s, p, r, a.cardIds));
case 'useBarrel':
if (r?.kind !== 'bang') return fail('뱅!을 받았을 때만 술통 판정을 해요.');
if (s.options.autoBarrel) return fail('술통 판정은 자동으로 해요.');
return barrelLeft(s, r) ? ok : fail('더 할 수 있는 술통 판정이 없어요.');
case 'takeHit':
return r && (r.kind === 'bang' || r.kind === 'indians' || r.kind === 'duel') ? ok : fail('지금은 맞을 공격이 없어요.');
case 'giveUp':
return r?.kind === 'dying' ? ok : fail('지금은 쓰러질 상황이 아니에요.');
case 'storePick':
if (r?.kind !== 'store') return fail('지금은 잡화점 카드를 고를 차례가 아니에요.');
return r.revealed.includes(a.cardId) ? ok : fail('펼쳐진 카드 중에서 골라 주세요.');
case 'sidHeal': {
if (!is(p, 'sid')) return fail('시드 케첨만 쓸 수 있는 능력이에요.');
if (!sidHealContext(s, actor)) return fail('지금은 능력을 쓸 수 없어요.');
if (p.life >= p.maxLife) return fail('생명이 이미 가득 찼어요.');
return why(distinctInHand(p, a.cardIds));
}
case 'endPlay':
return myPlayTime(s, actor) ? ok : fail('지금은 턴을 마칠 수 없어요.');
case 'discard': {
if (s.turn.player !== actor || s.turn.phase !== 'discard' || s.reactions.length) return fail('지금은 카드를 버릴 때가 아니에요.');
const need = p.hand.length - p.life;
if (a.cardIds.length !== need) return fail(`카드 ${need}장을 골라 버려 주세요.`);
return why(distinctInHand(p, a.cardIds));
}
}
}
// ---------------------------------------------------------------- precomputed options for the view
export function playOptions(s: BangState, me: PlayerId): PlayOption[] {
if (!myPlayTime(s, me)) return [];
const p = P(s, me);
const out: PlayOption[] = [];
const others = living(s).filter((x) => x.id !== me);
for (const cardId of p.hand) {
const card = cardById(cardId);
const as: 'bang' | undefined = is(p, 'calamity') && card.kind === 'missed' ? 'bang' : undefined;
const kind = as ?? card.kind;
const base: PlayAction = { type: 'play', cardId, ...(as ? { as } : {}) };
if (!needsTarget(kind)) {
const reason = checkPlay(s, me, base);
out.push({ cardId, ...(as ? { as } : {}), ok: !reason, ...(reason ? { reason } : {}) });
continue;
}
const cands = [...others];
if ((kind === 'panic' || kind === 'catBalou') && s.options.selfTargetCatBalou && p.inPlay.length > 0) cands.push(p);
const targets: TargetOption[] = cands.map((t) => {
if (kind === 'panic' || kind === 'catBalou') {
const choices = ['hand', ...t.inPlay];
const reasons = choices.map((tc) => checkPlay(s, me, { ...base, target: t.id, targetCard: tc }));
const cards = choices.filter((_, i) => !reasons[i]);
return cards.length ? { id: t.id, ok: true, cards } : { id: t.id, ok: false, reason: reasons[0] ?? reasons.find(Boolean)! };
}
const reason = checkPlay(s, me, { ...base, target: t.id });
return reason ? { id: t.id, ok: false, reason } : { id: t.id, ok: true };
});
const any = targets.some((t) => t.ok);
const reason = any ? undefined : (targets[0]?.reason ?? '쓸 수 있는 대상이 없어요.');
out.push({ cardId, ...(as ? { as } : {}), ok: any, ...(reason ? { reason } : {}), targets });
}
return out;
}
export function promptFor(s: BangState, me: PlayerId): Prompt | null {
if (s.winner) return null;
const p = P(s, me);
if (!p.alive) return null;
const r = reactionOf(s, me);
if (r) {
switch (r.kind) {
case 'bang':
return {
kind: 'bang',
source: r.source,
via: r.via,
needed: r.needed,
options: p.hand.filter((id) => missedLike(p, id)),
canBarrel: !s.options.autoBarrel && barrelLeft(s, r),
};
case 'indians':
return { kind: 'indians', source: r.source, options: p.hand.filter((id) => bangLike(p, id)) };
case 'duel':
return { kind: 'duel', opponent: r.other, source: r.source, options: p.hand.filter((id) => bangLike(p, id)) };
case 'dying':
return { kind: 'dying', needed: 1 - p.life, beers: p.hand.filter((id) => kindOf(id) === 'beer'), beerAllowed: beerWorks(s), killer: r.killer };
case 'store':
return { kind: 'store', revealed: r.revealed.map(cardById) };
case 'kitKeep':
return { kind: 'kitKeep', options: r.options.map(cardById) };
case 'choose2':
return { kind: 'choose2', options: p.characterOffer ?? [] };
}
}
if (s.reactions.length || s.turn.player !== me) return null;
if (s.turn.phase === 'draw') {
const c = drawChoice(s, p);
const top = s.discard[s.discard.length - 1];
return { kind: 'draw', jesseTargets: c?.jesse ?? [], pedro: c?.pedro && top ? (({ id: _id, ...f }) => f)(cardById(top)) : null };
}
if (s.turn.phase === 'discard') return { kind: 'discard', count: Math.max(0, p.hand.length - p.life) };
return null;
}

View File

@@ -0,0 +1,255 @@
/** 뱅! options, actions, state and view types (docs/games/bang.md §2, §7, §8). */
import { z } from 'zod';
import type { PlayerId } from '@bg/engine';
import { CHARACTER_IDS, type Card, type CardFace, type CardKind, type CharacterId, type Role } from './cards';
const lit = <T extends number>(...vals: T[]) =>
z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral<T>, z.ZodLiteral<T>, ...z.ZodLiteral<T>[]]);
export const bangOptionsSchema = z.object({
characterDraft: z
.enum(['random', 'choose2'])
.default('random')
.meta({ title: '캐릭터 정하기', labels: { random: '무작위 1장', choose2: '2장 중 1장 고르기' } }),
turnSeconds: lit(30, 60, 90, 120)
.default(60)
.meta({ title: '내 턴 제한 시간', labels: { 30: '30초', 60: '60초', 90: '90초', 120: '120초' } }),
reactionSeconds: lit(8, 15, 25)
.default(15)
.meta({ title: '반응 제한 시간(빗나감!·맥주·결투·잡화점)', labels: { 8: '8초', 15: '15초', 25: '25초' } }),
autoDefend: z.boolean().default(true).meta({ title: '반응 시간이 지나면 막을 카드를 자동으로 내기' }),
autoBarrel: z.boolean().default(true).meta({ title: '술통·주르도네 판정을 묻지 않고 자동으로 하기' }),
showDistanceHelper: z.boolean().default(true).meta({ title: '다른 사람까지 거리와 사거리 표시' }),
beginnerHints: z.boolean().default(true).meta({ title: '카드를 쓸 수 있는 이유·없는 이유 알려 주기' }),
drawBeforeDeath: z.boolean().default(false).meta({ title: '(하우스 룰) 바트 캐시디·엘 그링고가 빈사 판정 전에 카드 받기' }),
beerAtFullHealth: z.boolean().default(false).meta({ title: '(하우스 룰) 효과가 없어도 맥주 내기(버리기 용도)' }),
selfTargetCatBalou: z.boolean().default(false).meta({ title: '(하우스 룰) 캣 벌루·패닉!을 내 장착 카드에 쓰기' }),
deputyCount7: z.boolean().default(false).meta({ title: '(하우스 룰) 7명일 때 부관 1 + 무법자 4' }),
});
export type BangOptions = z.infer<typeof bangOptionsSchema>;
const id = z.string().max(40);
export const bangActionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('pickCharacter'), character: z.enum(CHARACTER_IDS as [CharacterId, ...CharacterId[]]) }),
z.object({ type: z.literal('draw'), jesseFrom: id.optional(), pedroDiscard: z.boolean().optional() }),
z.object({ type: z.literal('kitKeep'), keep: z.tuple([id, id]) }),
z.object({
type: z.literal('play'),
cardId: id,
as: z.enum(['bang', 'missed']).optional(),
target: id.optional(),
targetCard: id.optional(),
}),
z.object({ type: z.literal('respond'), cardIds: z.array(id).min(1).max(4) }),
z.object({ type: z.literal('useBarrel') }),
z.object({ type: z.literal('takeHit') }),
z.object({ type: z.literal('giveUp') }),
z.object({ type: z.literal('storePick'), cardId: id }),
z.object({ type: z.literal('sidHeal'), cardIds: z.tuple([id, id]) }),
z.object({ type: z.literal('endPlay') }),
z.object({ type: z.literal('discard'), cardIds: z.array(id).min(1).max(40) }),
]);
/** Client actions plus the system-only ones (timer, leaving) that are not in the schema. */
export type BangAction = z.infer<typeof bangActionSchema> | { type: 'timeout' } | { type: 'leave' };
export type PlayAction = Extract<BangAction, { type: 'play' }>;
export interface PlayerState {
id: PlayerId;
seat: number;
role: Role;
character: CharacterId;
/** choose2: the two offers until one is picked (secret to everyone else). */
characterOffer: CharacterId[] | null;
maxLife: number;
life: number;
alive: boolean;
hand: string[];
/** Equipment in front of the player (weapons included). */
inPlay: string[];
timeoutsInRow: number;
away: boolean;
}
export type Reaction =
| { kind: 'bang'; target: PlayerId; source: PlayerId; via: 'bang' | 'gatling'; needed: number; barrelTried: boolean; jourTried: boolean; deadline: number }
| { kind: 'indians'; target: PlayerId; source: PlayerId; deadline: number }
| { kind: 'duel'; actor: PlayerId; other: PlayerId; source: PlayerId; deadline: number }
| { kind: 'store'; picker: PlayerId; order: PlayerId[]; revealed: string[]; source: PlayerId; deadline: number }
/** `lost` / `source`: the life loss that caused it (Bart / El Gringo trigger after surviving, §5.5). */
| { kind: 'dying'; player: PlayerId; killer: PlayerId | null; lost: number; source: PlayerId | null; deadline: number }
| { kind: 'kitKeep'; player: PlayerId; options: string[]; deadline: number }
| { kind: 'choose2'; player: PlayerId; deadline: number };
export type ReactionKind = Reaction['kind'];
export type TurnPhase = 'draft' | 'start' | 'draw' | 'play' | 'discard' | 'ended';
export interface TurnState {
player: PlayerId;
phase: TurnPhase;
bangsPlayed: number;
deadline: number;
/** Turn timer is paused while reactions run during the play phase. */
pausedAt: number | null;
dynamiteChecked: boolean;
jailChecked: boolean;
}
export type CheckPurpose = 'dynamite' | 'jail' | 'barrel' | 'jourdonnais';
/** Public log line (structured; the screen turns it into Korean with nicknames). `card` with `vis` is private to those players. */
export type LogEntry = { seq: number } & (
| { t: 'turn'; p: PlayerId; round: number }
| { t: 'play'; p: PlayerId; card: CardFace; as?: CardKind; target?: PlayerId }
| { t: 'equip'; p: PlayerId; card: CardFace; target?: PlayerId; replaced?: CardFace }
| { t: 'check'; p: PlayerId; purpose: CheckPurpose; cards: CardFace[]; success: boolean }
| { t: 'dynamite'; p: PlayerId; exploded: boolean; to?: PlayerId }
| { t: 'jail'; p: PlayerId; escaped: boolean }
| { t: 'missed'; p: PlayerId; cards: CardFace[] }
| { t: 'respond'; p: PlayerId; cards: CardFace[]; why: 'indians' | 'duel' }
| { t: 'barrelSave'; p: PlayerId }
| { t: 'hit'; p: PlayerId; n: number; by: PlayerId | null; life: number }
| { t: 'heal'; p: PlayerId; n: number; why: 'beer' | 'saloon' | 'sid'; cards?: CardFace[] }
| { t: 'draw'; p: PlayerId; n: number; why: 'phase' | 'card' | 'bart' | 'reward' | 'suzy' | 'kit' }
| { t: 'blackJack'; p: PlayerId; card: CardFace; extra: boolean }
| { t: 'pedro'; p: PlayerId; card: CardFace }
| { t: 'take'; p: PlayerId; from: PlayerId; how: 'panic' | 'gringo' | 'jesse'; card?: CardFace; vis?: PlayerId[]; equip?: CardFace }
| { t: 'discarded'; p: PlayerId; by: PlayerId; card: CardFace; fromHand: boolean }
| { t: 'limit'; p: PlayerId; cards: CardFace[] }
| { t: 'store'; p: PlayerId; card: CardFace }
| { t: 'storeOpen'; p: PlayerId; cards: CardFace[] }
| { t: 'dead'; p: PlayerId; role: Role; by: PlayerId | null; left?: boolean }
| { t: 'reward'; p: PlayerId; n: number }
| { t: 'penalty'; p: PlayerId }
| { t: 'vulture'; p: PlayerId; from: PlayerId; n: number }
| { t: 'character'; p: PlayerId; character: CharacterId }
| { t: 'timeout'; p: PlayerId }
| { t: 'reshuffle'; n: number }
| { t: 'end'; winner: Team }
);
export type LogKind = LogEntry['t'];
export type Team = 'law' | 'outlaws' | 'renegade';
export interface BangFinal {
winnerTeam: Team;
players: { id: PlayerId; role: Role; character: CharacterId; alive: boolean; won: boolean; deathOrder: number | null }[];
/** Winners (alive first) → losers (alive first, then the latest death first). */
ranking: PlayerId[];
summary: string;
}
export interface BangState {
options: BangOptions;
players: PlayerState[];
/** Top = last element. Secret. */
deck: string[];
/** Top = last element. Public. */
discard: string[];
turn: TurnState;
reactions: Reaction[];
lastCheck: { player: PlayerId; purpose: CheckPurpose; cards: CardFace[]; success: boolean; seq: number } | null;
winner: Team | null;
deathOrder: PlayerId[];
round: number;
/** Applied actions. */
seq: number;
log: LogEntry[];
final: BangFinal | null;
}
// ---------------------------------------------------------------- view
export interface TargetOption {
id: PlayerId;
ok: boolean;
reason?: string;
/** Panic! / Cat Balou: "hand" and/or equipment card ids that may be chosen. */
cards?: string[];
}
/** What a hand card can do now (my turn, play phase). */
export interface PlayOption {
cardId: string;
as?: 'bang';
ok: boolean;
reason?: string;
/** Targeted cards list every other living player (ok or not, with the reason). */
targets?: TargetOption[];
}
export type Prompt =
| { kind: 'bang'; source: PlayerId; via: 'bang' | 'gatling'; needed: number; options: string[]; canBarrel: boolean }
| { kind: 'indians'; source: PlayerId; options: string[] }
| { kind: 'duel'; opponent: PlayerId; source: PlayerId; options: string[] }
| { kind: 'dying'; needed: number; beers: string[]; beerAllowed: boolean; killer: PlayerId | null }
| { kind: 'store'; revealed: Card[] }
| { kind: 'kitKeep'; options: Card[] }
| { kind: 'choose2'; options: CharacterId[] }
| { kind: 'draw'; jesseTargets: PlayerId[]; pedro: CardFace | null }
| { kind: 'discard'; count: number };
export interface PlayerView {
id: PlayerId;
seat: number;
/** null while a choose2 pick is still hidden. */
character: CharacterId | null;
/** Sheriff, dead players, myself, or everyone after the game. */
role: Role | null;
maxLife: number;
life: number;
alive: boolean;
handCount: number;
inPlay: Card[];
isTurn: boolean;
/** Waiting for this player's reaction. */
reacting: ReactionKind | null;
away: boolean;
/** From me (null for me, dead players or spectators). */
distance: number | null;
/** My 뱅! reaches this player. */
inRange: boolean;
}
export interface ReactionView {
kind: ReactionKind;
who: PlayerId;
source?: PlayerId;
via?: 'bang' | 'gatling';
needed?: number;
revealed?: Card[];
deadline: number;
}
export interface BangView {
options: BangOptions;
me: PlayerId | null;
players: PlayerView[];
current: PlayerId;
phase: TurnPhase;
round: number;
bangsPlayed: number;
deckCount: number;
discardCount: number;
/** Last few discards, top last. */
discardTop: Card[];
reactions: ReactionView[];
lastCheck: BangState['lastCheck'];
my: {
role: Role;
character: CharacterId | null;
hand: Card[];
range: number;
/** Unlimited 뱅! (볼캐닉 / 윌리 더 키드). */
unlimitedBang: boolean;
plays: PlayOption[];
canEndPlay: boolean;
canSidHeal: boolean;
prompt: Prompt | null;
} | null;
turnDeadline: number;
deadlineTotalMs: number;
reactionTotalMs: number;
log: LogEntry[];
winner: Team | null;
result: BangFinal | null;
}

View File

@@ -11,6 +11,7 @@ import { yut } from './yut';
import { oneCard } from './one-card';
import { rummikub } from './rummikub';
import { davinci } from './davinci-code';
import { bang } from './bang';
export const GAMES: Record<string, AnyGameDefinition> = {
[omok.id]: omok,
@@ -25,4 +26,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
[oneCard.id]: oneCard,
[rummikub.id]: rummikub,
[davinci.id]: davinci,
[bang.id]: bang,
};

View File

@@ -22,6 +22,7 @@ export const CATALOG: CatalogEntry[] = [
{ id: 'yut', nameKo: '윷놀이', minPlayers: 2, maxPlayers: 8, minutes: '15~25', level: '쉬움', blurb: '도·개·걸·윷·모! 개인전 또는 팀전', available: true },
{ id: 'one-card', nameKo: '원카드', minPlayers: 2, maxPlayers: 10, minutes: '10~15', level: '쉬움', blurb: '같은 무늬·숫자를 내고 공격 카드로 먹이기! · 색깔 카드 모드', available: true },
{ id: 'rummikub', nameKo: '숫자 타일', minPlayers: 2, maxPlayers: 4, minutes: '30~60', level: '보통', blurb: '같은 숫자, 이어지는 숫자로 묶어 먼저 다 내려놓기!', available: true },
{ id: 'bang', nameKo: '뱅!', minPlayers: 4, maxPlayers: 7, minutes: '20~40', level: '어려움', blurb: '보안관과 무법자, 숨은 정체를 추리하며 쏘고 피하기!', available: true },
{ id: 'davinci-code', nameKo: '다빈치 코드', minPlayers: 2, maxPlayers: 4, minutes: '10~15', level: '쉬움', blurb: '검은·흰 숫자 타일의 숫자를 추리해 맞히기', available: true },
];