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:
439
packages/games/src/bang/index.ts
Normal file
439
packages/games/src/bang/index.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user