- 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>
440 lines
15 KiB
TypeScript
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 };
|