Files
joke-app/packages/games/src/bang/index.ts
EJClaw d6496dc1fa 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>
2026-10-05 13:38:00 +09:00

440 lines
15 KiB
TypeScript

/** 뱅! (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 };