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