diff --git a/apps/web/src/games/bang/index.tsx b/apps/web/src/games/bang/index.tsx new file mode 100644 index 0000000..3feca9c --- /dev/null +++ b/apps/web/src/games/bang/index.tsx @@ -0,0 +1,1285 @@ +/** 뱅! 화면 (docs/games/bang.md §9). */ +import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react'; +import type { BangOptions, BangView, Card, CardFace, CardKind, CharacterId, LogEntry, PlayOption, PlayerView, Prompt, Role, Team } from '@bg/games/bang'; +import type { PublicUser } from '@bg/shared/lite'; +import { Avatar } from '../../components/ui'; +import { TurnTimer } from '../../components/TurnTimer'; +import { haptic, sounds } from '../../lib/feedback'; +import type { BoardProps, GameUI, OptionsProps } from '../types'; + +// ---------------------------------------------------------------- names (display names live only here, docs §12) + +const CARD_INFO: Record = { + bang: { name: '뱅!', icon: '💥', text: '사거리 안 1명을 쏴요' }, + missed: { name: '빗나감!', icon: '💨', text: '뱅! 1발을 피해요' }, + beer: { name: '맥주', icon: '🍺', text: '생명 +1' }, + saloon: { name: '살롱', icon: '🍻', text: '모두 생명 +1' }, + stagecoach: { name: '역마차', icon: '🐴', text: '카드 2장 받기' }, + wellsFargo: { name: '웰스 파고', icon: '📦', text: '카드 3장 받기' }, + generalStore: { name: '잡화점', icon: '🏪', text: '모두 1장씩 골라요' }, + panic: { name: '패닉!', icon: '😱', text: '거리 1, 카드 1장 뺏기' }, + catBalou: { name: '캣 벌루', icon: '🧹', text: '누구든 카드 1장 버리게' }, + duel: { name: '결투', icon: '⚔️', text: '번갈아 뱅!, 못 내면 −1' }, + indians: { name: '인디언!', icon: '🏹', text: '모두 뱅! 내거나 −1' }, + gatling: { name: '개틀링', icon: '🔥', text: '다른 모두에게 뱅!' }, + barrel: { name: '술통', icon: '🛢️', text: '♥ 판정이면 피해요' }, + mustang: { name: '머스탱', icon: '🐎', text: '남이 볼 때 거리 +1' }, + scope: { name: '조준경', icon: '🔭', text: '내가 볼 때 거리 −1' }, + jail: { name: '감옥', icon: '🔒', text: '♥ 아니면 턴을 쉬어요' }, + dynamite: { name: '다이너마이트', icon: '🧨', text: '♠2~9 판정이면 −3' }, + volcanic: { name: '연발 권총', icon: '🔫', text: '사거리 1, 뱅! 무제한' }, + schofield: { name: '리볼버', icon: '🔫', text: '사거리 2' }, + remington: { name: '장총', icon: '🔫', text: '사거리 3' }, + carabine: { name: '카빈총', icon: '🔫', text: '사거리 4' }, + winchester: { name: '라이플', icon: '🔫', text: '사거리 5' }, +}; +const BLUE: CardKind[] = ['barrel', 'mustang', 'scope', 'jail', 'dynamite', 'volcanic', 'schofield', 'remington', 'carabine', 'winchester']; + +const CHAR_INFO: Record = { + bart: { name: '바트 캐시디', text: '생명을 1 잃을 때마다 카드 1장을 받아요.' }, + blackJack: { name: '블랙 잭', text: '두 번째로 받는 카드를 보여 주고 ♥·♦면 1장 더 받아요.' }, + calamity: { name: '캘러미티 자넷', text: '뱅!과 빗나감!을 서로 바꿔 쓸 수 있어요.' }, + elGringo: { name: '엘 그링고', text: '남의 카드로 생명을 잃으면 그 사람 손패 1장을 가져와요.' }, + jesse: { name: '제시 존스', text: '첫 카드를 다른 사람 손패에서 받을 수 있어요.' }, + jourdonnais: { name: '주르도네', text: '늘 술통이 있는 것처럼 판정해요.' }, + kit: { name: '킷 칼슨', text: '카드 3장을 보고 2장을 골라 받아요.' }, + lucky: { name: '럭키 듀크', text: '판정할 때 2장을 보고 좋은 쪽을 써요.' }, + paul: { name: '폴 리그레트', text: '남이 나를 볼 때 거리 +1이에요.' }, + pedro: { name: '페드로 라미레스', text: '첫 카드를 버린 카드 맨 위에서 받을 수 있어요.' }, + rose: { name: '로즈 둘란', text: '내가 남을 볼 때 거리 −1이에요.' }, + sid: { name: '시드 케첨', text: '카드 2장을 버리고 생명 1을 회복해요(언제든).' }, + slab: { name: '슬랩 더 킬러', text: '내 뱅!을 막으려면 빗나감! 2장이 필요해요.' }, + suzy: { name: '수지 라파예트', text: '손패가 0장이 되면 바로 1장 받아요.' }, + vulture: { name: '벌처 샘', text: '누가 쓰러지면 그 사람 카드를 모두 가져와요.' }, + willy: { name: '윌리 더 키드', text: '뱅!을 몇 장이든 낼 수 있어요.' }, +}; + +const ROLE_INFO: Record = { + sheriff: { name: '보안관', icon: '⭐', goal: '무법자와 배신자를 모두 잡으면 이겨요.', tip: '모두가 보안관을 알아요. 부관을 찾아 함께 버티세요.', color: '#b45309' }, + deputy: { name: '부관', icon: '🛡️', goal: '보안관과 함께 무법자와 배신자를 잡으면 이겨요.', tip: '부관은 보안관을 지켜야 해요. 정체를 너무 빨리 들키지 마세요.', color: '#2563eb' }, + outlaw: { name: '무법자', icon: '🎯', goal: '보안관을 쓰러뜨리면 이겨요.', tip: '다른 무법자와 힘을 모아 보안관을 노리세요.', color: '#b91c1c' }, + renegade: { name: '배신자', icon: '🃏', goal: '마지막에 혼자 남으면 이겨요.', tip: '처음엔 보안관 편인 척하고, 끝에 보안관과 1대1로 겨루세요.', color: '#7c3aed' }, +}; +const TEAM_KO: Record = { law: '보안관 팀', outlaws: '무법자', renegade: '배신자' }; + +const SUIT_SYMBOL = { S: '♠', H: '♥', D: '♦', C: '♣' } as const; +const SUIT_KO = { S: '스페이드', H: '하트', D: '다이아몬드', C: '클로버' } as const; + +const nick = (players: Record, id: string) => players[id]?.nickname ?? '알 수 없음'; +const userOf = (players: Record, id: string): PublicUser => players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' }; +const sr = (f: CardFace) => `${f.suit === 'H' || f.suit === 'D' ? 'r' : 'b'}`; +const faceShort = (f: CardFace) => `${CARD_INFO[f.kind].name}(${SUIT_SYMBOL[f.suit]}${f.rank})`; +const faceAria = (f: CardFace) => `${CARD_INFO[f.kind].name}, ${SUIT_KO[f.suit]} ${f.rank}, ${CARD_INFO[f.kind].text}`; + +// ---------------------------------------------------------------- styles + +const CSS = ` +.bg { width: 100%; max-width: 1180px; display: flex; flex-direction: column; gap: 12px; align-self: center; } +.bg-table { position: relative; width: 100%; aspect-ratio: 16 / 8.2; min-height: 460px; border-radius: 200px; border: 10px solid var(--wood); + background: radial-gradient(ellipse at center, rgba(255,255,255,.13), rgba(255,255,255,0) 62%), var(--felt); box-shadow: var(--shadow); } +.bg-seat { position: absolute; left: var(--x); top: var(--y); transform: translate(-50%, -50%); width: var(--sw, 178px); z-index: 1; + background: var(--surface); color: var(--text); border-radius: 12px; padding: 6px 8px; box-shadow: 0 2px 8px rgba(0,0,0,.28); + border: 3px solid transparent; display: flex; flex-direction: column; gap: 3px; font-size: .84rem; text-align: left; font: inherit; font-size: .84rem; } +button.bg-seat { cursor: pointer; } +.bg-seat.turn { border-color: var(--focus); } +.bg-seat.react { border-color: #f97316; border-style: dashed; } +.bg-seat.me { border-color: var(--primary); } +.bg-seat.dead { opacity: .62; filter: grayscale(.7); } +.bg-seat[data-target='ok'] { border-color: var(--success); box-shadow: 0 0 0 4px rgba(21,128,61,.45), 0 2px 8px rgba(0,0,0,.28); } +.bg-seat[data-target='ok'][aria-pressed='true'] { box-shadow: 0 0 0 6px var(--success), 0 2px 8px rgba(0,0,0,.28); } +.bg-seat[data-target='no'] { opacity: .55; } +.bg-seat .nm { font-weight: 800; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; } +.bg-row { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; min-width: 0; } +.bg-row.nw { flex-wrap: nowrap; } +.bg-char { font-weight: 700; font-size: .8rem; color: var(--text-sub); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.bg-role { display: inline-flex; align-items: center; gap: 2px; border-radius: 999px; padding: 0 7px; font-weight: 800; font-size: .74rem; line-height: 1.6; color: #fff; white-space: nowrap; } +.bg-role.hidden { background: var(--surface-2); color: var(--text-sub); border: 1px dashed var(--border); } +.bg-hp { display: inline-flex; gap: 2px; align-items: center; } +.bg-hp i { width: 9px; height: 15px; border-radius: 5px 5px 2px 2px; background: #dc2626; border: 1px solid #7f1d1d; display: inline-block; } +.bg-hp i.e { background: transparent; border: 1px dashed var(--text-sub); } +.bg-cnt { display: inline-flex; align-items: center; gap: 3px; font-weight: 800; font-variant-numeric: tabular-nums; } +.bg-cnt b { display: inline-block; width: 11px; height: 15px; border-radius: 2px; background: #7c2d12; border: 1.5px solid #fdba74; } +.bg-eqs { display: flex; flex-wrap: wrap; gap: 3px; } +.bg-eq { display: inline-flex; align-items: center; gap: 2px; border-radius: 6px; padding: 0 5px; font-size: .72rem; font-weight: 700; line-height: 1.55; + background: #dbeafe; color: #1e3a8a; border: 1px solid #93c5fd; white-space: nowrap; } +.bg-eq[data-k='jail'], .bg-eq[data-k='dynamite'] { background: #fee2e2; color: #7f1d1d; border-color: #fca5a5; } +.bg-dist { font-size: .74rem; font-weight: 700; color: var(--text-sub); } +.bg-dist.in { color: var(--success); } +.bg-why { font-size: .72rem; font-weight: 700; color: var(--danger); line-height: 1.25; } +.bg-badge { display: inline-block; border-radius: 999px; padding: 0 7px; font-weight: 800; font-size: .72rem; line-height: 1.6; color: #fff; } +.bg-center { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: 44%; display: flex; flex-direction: column; align-items: center; gap: 6px; + color: #fff; text-align: center; text-shadow: 0 1px 2px rgba(0,0,0,.55); } +.bg-piles { display: flex; gap: 12px; align-items: center; justify-content: center; } +.bg-back { width: 58px; height: 82px; border-radius: 8px; background: repeating-linear-gradient(45deg, #7c2d12 0 6px, #9a3412 6px 12px); border: 3px solid #fdba74; + display: grid; place-items: center; font-weight: 900; color: #fff; font-size: 1rem; box-shadow: 0 2px 6px rgba(0,0,0,.35); } +.bg-chip { display: inline-flex; align-items: center; gap: 6px; background: rgba(0,0,0,.38); border-radius: 999px; padding: 2px 10px; font-weight: 700; font-size: .88rem; } +.bg-chip.ok { background: rgba(21,128,61,.85); } +.bg-chip.bad { background: rgba(185,28,28,.85); } +.bg-card { position: relative; flex: none; width: var(--cw, 96px); height: calc(var(--cw, 96px) * 1.42); border-radius: 10px; background: #fffaf0; color: #1f2937; + border: 4px solid #92400e; box-shadow: 0 2px 6px rgba(0,0,0,.25); display: flex; flex-direction: column; align-items: center; justify-content: space-between; + padding: 4px 4px 5px; text-align: center; overflow: hidden; font: inherit; } +.bg-card[data-blue='true'] { border-color: #1d4ed8; } +.bg-card .ix { align-self: flex-start; font-weight: 900; font-size: .82rem; line-height: 1; } +.bg-card .ix.r { color: #dc2626; } +.bg-card .ic { font-size: calc(var(--cw, 96px) * .34); line-height: 1; } +.bg-card .cn { font-weight: 900; font-size: .9rem; line-height: 1.1; word-break: keep-all; } +.bg-card .ct { font-size: .68rem; line-height: 1.15; color: #4b5563; font-weight: 600; word-break: keep-all; } +.bg-card.sm { --cw: 62px; } +.bg-card.sm .cn { font-size: .74rem; } +.bg-card.sm .ct { display: none; } +.bg-card.sm .ix { font-size: .7rem; } +button.bg-card { cursor: pointer; transition: transform .12s ease, opacity .12s ease; } +button.bg-card[data-ok='false'] { opacity: .55; filter: grayscale(.35); } +button.bg-card[data-ok='true'] { box-shadow: 0 0 0 3px var(--success), 0 2px 6px rgba(0,0,0,.25); } +button.bg-card[aria-pressed='true'] { transform: translateY(-12px); box-shadow: 0 0 0 4px var(--primary), 0 4px 10px rgba(0,0,0,.3); } +button.bg-card:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } +.bg-me { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; } +.bg-mehead { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } +.bg-rolecard { min-height: 48px; min-width: 112px; border-radius: 12px; border: 2px dashed var(--border); background: var(--surface-2); color: var(--text); + font-weight: 800; padding: 4px 10px; cursor: pointer; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; touch-action: none; font: inherit; font-weight: 800; } +.bg-rolecard[data-show='true'] { color: #fff; border-style: solid; } +.bg-hand { display: flex; gap: 8px; overflow-x: auto; padding: 16px 4px 8px; scrollbar-width: thin; } +.bg-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +.bg-actions .btn { flex: 1 1 auto; min-height: 52px; } +.bg-hint { margin: 0; font-size: .92rem; } +.bg-picks { display: flex; flex-wrap: wrap; gap: 8px; } +.bg-picks .btn { min-height: 48px; } +.bg-modal-back { position: fixed; inset: 0; background: rgba(0,0,0,.45); z-index: 40; display: flex; align-items: center; justify-content: center; padding: 12px; } +.bg-modal { width: min(560px, 100%); max-height: calc(100vh - 24px); overflow: auto; background: var(--surface); color: var(--text); border-radius: 18px; + box-shadow: 0 10px 40px rgba(0,0,0,.45); padding: 16px; display: flex; flex-direction: column; gap: 10px; border: 4px solid #f97316; } +.bg-modal h2 { margin: 0; font-size: 1.3rem; } +.bg-modal .btn { min-height: 56px; } +.bg-modal .bg-hand { padding-top: 14px; } +.bg-chars { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.bg-chars button { min-height: 120px; border-radius: 14px; border: 3px solid var(--border); background: var(--surface-2); color: var(--text); padding: 10px; text-align: left; cursor: pointer; font: inherit; } +.bg-chars button strong { display: block; font-size: 1.1rem; } +.bg-log { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 3px; max-height: 300px; overflow: auto; } +.bg-rules td, .bg-rules th { padding: 4px 6px; text-align: left; vertical-align: middle; border-bottom: 1px solid var(--border); } +.bg-last { font-size: .86rem; max-width: 100%; overflow: hidden; text-overflow: ellipsis; } +@media (max-width: 719px) { + .bg-table { aspect-ratio: auto; min-height: 0; border-radius: 18px; border-width: 6px; padding: 8px; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; } + .bg-seat { position: static; transform: none; width: auto; padding: 4px 5px; font-size: .76rem; gap: 2px; border-width: 2px; } + .bg-seat.me { display: none; } + .bg-seat .nm { font-size: .8rem; } + .bg-char { font-size: .7rem; } + .bg-row.rc { flex-wrap: wrap; row-gap: 2px; } + .bg-hp i { width: 7px; height: 12px; } + .bg-eq { font-size: .64rem; padding: 0 3px; } + .bg-center { position: static; transform: none; width: auto; grid-column: 1 / -1; order: -1; flex-direction: row; flex-wrap: wrap; justify-content: center; gap: 6px; padding: 2px 0 4px; } + .bg-back { width: 40px; height: 56px; font-size: .85rem; } + .bg-center .bg-card.sm { --cw: 40px; } + .bg-center .bg-card.sm .cn { display: none; } + .bg-chip { font-size: .78rem; } + .bg-card { --cw: 86px; } + .bg-me { padding: 8px; } +} +@media (prefers-reduced-motion: reduce) { button.bg-card { transition: none; } } +`; + +// ---------------------------------------------------------------- small parts + +function CardArt({ card, small, label }: { card: CardFace; small?: boolean; label?: boolean }) { + const info = CARD_INFO[card.kind]; + return ( + + + + ); +} + +function CardFaceInner({ card, info }: { card: CardFace; info: (typeof CARD_INFO)[CardKind] }) { + return ( + <> + + {card.rank} + {SUIT_SYMBOL[card.suit]} + + + {info.icon} + + {info.name} + {info.text} + + ); +} + +function Hp({ life, max }: { life: number; max: number }) { + const n = Math.max(0, life); + return ( + + {Array.from({ length: max }, (_, i) => ( + + ))} + + ); +} + +function RoleBadge({ role, alive }: { role: Role | null; alive: boolean }) { + if (!role) return ? 역할 비밀; + const r = ROLE_INFO[role]; + return ( + + {r.icon} {r.name} + {!alive ? '(공개)' : ''} + + ); +} + +function Equip({ cards }: { cards: Card[] }) { + if (!cards.length) return null; + return ( + + {cards.map((c) => ( + + {CARD_INFO[c.kind].icon} {CARD_INFO[c.kind].name} + + ))} + + ); +} + +const REACT_KO: Record = { + bang: '빗나감! 고르는 중', + indians: '인디언! 막는 중', + duel: '결투 중', + store: '잡화점 고르는 중', + dying: '쓰러지기 직전!', + kitKeep: '카드 고르는 중', + choose2: '캐릭터 고르는 중', +}; + +/** My own hidden role stays off the table (only the hold-to-see role card shows it, docs §9). */ +function shownRole(p: PlayerView, v: BangView, me: string | null): Role | null { + if (p.id === me && p.alive && !v.winner && p.role !== 'sheriff') return null; + return p.role; +} + +function seatLabel(p: PlayerView, players: Record, me: string | null, role: Role | null): string { + const parts = [`${nick(players, p.id)}${p.id === me ? '(나)' : ''}`]; + if (p.character) parts.push(CHAR_INFO[p.character].name); + parts.push(role ? ROLE_INFO[role].name : '역할 비밀'); + if (!p.alive) parts.push('쓰러짐'); + else parts.push(`생명 ${p.life}/${p.maxLife}`, `카드 ${p.handCount}장`); + if (p.inPlay.length) parts.push(`장착: ${p.inPlay.map((c) => CARD_INFO[c.kind].name).join(', ')}`); + if (p.distance !== null) parts.push(`거리 ${p.distance}${p.inRange ? ', 사거리 안' : ''}`); + if (p.isTurn) parts.push('차례'); + if (p.reacting) parts.push(REACT_KO[p.reacting] ?? ''); + if (p.away) parts.push('자리 비움'); + return parts.join(', '); +} + +interface TargetMark { + ok: boolean; + reason?: string; + selected: boolean; +} + +function Seat({ + p, + v, + players, + me, + mark, + onPick, + style, +}: { + p: PlayerView; + v: BangView; + players: Record; + me: string | null; + mark: TargetMark | null; + onPick: (id: string) => void; + style?: CSSProperties; +}) { + const u = userOf(players, p.id); + const r = v.reactions.find((x) => x.who === p.id); + const timer = r ? ( + + ) : p.isTurn && !v.reactions.length && !v.winner ? ( + + ) : null; + const cls = `bg-seat${p.isTurn ? ' turn' : ''}${r ? ' react' : ''}${p.id === me ? ' me' : ''}${!p.alive ? ' dead' : ''}`; + const body = ( + <> + + + + {u.nickname} + {p.id === me ? ' (나)' : ''} + + {timer} + + + + {p.character ? CHAR_INFO[p.character].name : '캐릭터 고르는 중'} + + {p.alive ? ( + + + + + {p.handCount} + + {p.distance !== null && v.options.showDistanceHelper && 거리 {p.distance}} + + ) : ( + + 쓰러짐 + + )} + + {(p.away || (r && p.id !== me)) && ( + + {r && p.id !== me && {REACT_KO[r.kind]}} + {p.away && 자리 비움} + + )} + {mark && !mark.ok && mark.reason && {shortReason(mark.reason)}} + + ); + if (mark?.ok) { + return ( + + ); + } + return ( +
+ {body} +
+ ); +} + +/** "사거리가 닿지 않아요(거리 3, 사거리 2)." → "거리 3 / 사거리 2" */ +function shortReason(r: string): string { + const m = /거리 (\d+), 사거리 (\d+)/.exec(r); + if (m) return `거리 ${m[1]} / 사거리 ${m[2]}`; + return r.replace(/\.$/, ''); +} + +// ---------------------------------------------------------------- table + +function Center({ v, players }: { v: BangView; players: Record }) { + const top = v.discardTop[v.discardTop.length - 1]; + const chk = v.lastCheck; + const PURPOSE: Record = { dynamite: '다이너마이트', jail: '감옥', barrel: '술통', jourdonnais: '주르도네' }; + const waitingFor = v.reactions.length ? [...new Set(v.reactions.map((r) => r.who))] : []; + const last = v.log[v.log.length - 1]; + return ( +
+
+ + {v.deckCount} + + {top ? : 버린 카드 없음} +
+ {v.phase !== 'draft' && {v.round}라운드} + {chk && ( + + {nick(players, chk.player)}님 {PURPOSE[chk.purpose]} 판정: {chk.cards.map((c) => `${SUIT_SYMBOL[c.suit]}${c.rank}`).join(' · ')} → {chk.success ? '성공' : '실패'} + + )} + {waitingFor.length > 0 && {waitingFor.map((id) => `${nick(players, id)}님`).join(', ')} 기다리는 중…} + {last && {logText(last, players)}} +
+ ); +} + +// ---------------------------------------------------------------- my area + +function useHold(): [boolean, Record] { + const [show, setShow] = useState(false); + const timer = useRef | null>(null); + useEffect(() => () => void (timer.current && clearTimeout(timer.current)), []); + return [ + show, + { + onPointerDown: () => setShow(true), + onPointerUp: () => setShow(false), + onPointerLeave: () => setShow(false), + onPointerCancel: () => setShow(false), + onContextMenu: (e: React.MouseEvent) => e.preventDefault(), + onKeyDown: (e: KeyboardEvent) => { + if (e.key !== 'Enter' && e.key !== ' ') return; + e.preventDefault(); + setShow(true); + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(() => setShow(false), 2500); + }, + }, + ]; +} + +function RoleCard({ role, hints, always }: { role: Role; hints: boolean; always: boolean }) { + const [held, handlers] = useHold(); + const show = held || always; + const r = ROLE_INFO[role]; + return ( + + + {show && ( + + {r.goal} + {hints ? ` ${r.tip}` : ''} + + )} + + ); +} + +interface Sel { + cardId: string; + as?: 'bang'; + target?: string; + targetCard?: string; +} + +function playLabel(o: PlayOption, card: Card, sel: Sel | null, players: Record, me: PlayerView | undefined): string { + const name = o.as ? `${CARD_INFO[card.kind].name}을 뱅!으로` : CARD_INFO[card.kind].name; + if (!o.targets) return BLUE.includes(card.kind) ? `${name} 앞에 놓기` : `${name} 내기`; + if (!sel?.target) return '대상을 골라 주세요'; + const t = `${nick(players, sel.target)}님`; + if (card.kind === 'panic' || card.kind === 'catBalou') { + if (!sel.targetCard) return '카드를 골라 주세요'; + const what = sel.targetCard === 'hand' ? '손패 1장' : CARD_INFO[me?.inPlay.find((c) => c.id === sel.targetCard)?.kind ?? 'bang'].name; + return `${t} ${what}에 ${name}`; + } + return o.as || card.kind === 'bang' ? `${t}에게 뱅!` : card.kind === 'jail' ? `${t}을 감옥에` : `${t}에게 ${name}`; +} + +function HandCards({ + hand, + okOf, + picked, + onTap, + label, +}: { + hand: Card[]; + okOf?: (c: Card) => boolean | undefined; + picked: (c: Card) => boolean; + onTap: (c: Card) => void; + label: string; +}) { + const onKey = (e: KeyboardEvent) => { + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; + const btns = Array.from(e.currentTarget.querySelectorAll('button.bg-card')); + const i = btns.indexOf(document.activeElement as HTMLButtonElement); + if (i < 0) return; + e.preventDefault(); + btns[(i + (e.key === 'ArrowRight' ? 1 : btns.length - 1)) % btns.length]?.focus(); + }; + return ( +
+ {hand.map((c) => { + const ok = okOf?.(c); + return ( + + ); + })} + {hand.length === 0 && 손에 카드가 없어요.} +
+ ); +} + +function MyArea({ + v, + act, + pending, + players, + sel, + setSel, +}: { + v: BangView; + act: (a: unknown) => void; + pending: boolean; + players: Record; + sel: Sel | null; + setSel: (s: Sel | null) => void; +}) { + const my = v.my!; + const meP = v.players.find((p) => p.id === v.me); + const [multi, setMulti] = useState([]); + const [sidMode, setSidMode] = useState(false); + const [note, setNote] = useState(''); + const prompt = my.prompt; + const discarding = prompt?.kind === 'discard'; + const modal = prompt && prompt.kind !== 'discard' && prompt.kind !== 'draw'; + const key = `${v.current}-${v.phase}-${v.reactions.length}-${my.hand.length}`; + useEffect(() => { + setMulti([]); + setSidMode(false); + setNote(''); + }, [key]); + const optOf = (id: string) => my.plays.find((o) => o.cardId === id); + const hints = v.options.beginnerHints; + const playing = my.plays.length > 0; + + const tap = (c: Card) => { + if (pending) return; + if (discarding || sidMode) { + const need = discarding ? (prompt as Extract).count : 2; + setMulti((m) => (m.includes(c.id) ? m.filter((x) => x !== c.id) : m.length < need ? [...m, c.id] : m)); + return; + } + if (!playing) return; + const o = optOf(c.id); + if (!o) return; + if (!o.ok) { + setSel(null); + setNote(`${CARD_INFO[c.kind].name}: ${o.reason ?? '지금은 쓸 수 없어요.'}`); + return; + } + setNote(''); + if (sel?.cardId === c.id && !o.targets) { + // Second tap on an untargeted card plays it. + act({ type: 'play', cardId: c.id, ...(o.as ? { as: o.as } : {}) }); + setSel(null); + return; + } + setSel(sel?.cardId === c.id ? null : { cardId: c.id, ...(o.as ? { as: o.as } : {}) }); + }; + + const selCard = sel ? my.hand.find((c) => c.id === sel.cardId) : undefined; + const selOpt = sel ? optOf(sel.cardId) : undefined; + const tOpt = sel?.target ? selOpt?.targets?.find((t) => t.id === sel.target) : undefined; + const needsCard = !!selCard && (selCard.kind === 'panic' || selCard.kind === 'catBalou'); + const ready = !!selOpt?.ok && (!selOpt.targets || (!!tOpt?.ok && (!needsCard || !!sel?.targetCard))); + const targetPlayer = sel?.target ? v.players.find((p) => p.id === sel.target) : undefined; + + const confirm = () => { + if (!sel || !ready) return; + act({ type: 'play', cardId: sel.cardId, ...(sel.as ? { as: sel.as } : {}), ...(sel.target ? { target: sel.target } : {}), ...(sel.targetCard ? { targetCard: sel.targetCard } : {}) }); + setSel(null); + }; + + let hint = note; + if (!hint && selCard && selOpt) { + if (selOpt.targets && !sel?.target) hint = `${CARD_INFO[selCard.kind].name}: 초록색 테두리인 사람을 눌러 대상을 골라 주세요.`; + else if (hints) hint = `${CARD_INFO[selCard.kind].name}: ${CARD_INFO[selCard.kind].text}`; + } + if (!hint && playing && hints) hint = '초록 테두리 카드를 쓸 수 있어요. 흐린 카드를 누르면 쓸 수 없는 이유를 알려 줘요.'; + + return ( +
+
+ + + + {my.character ? CHAR_INFO[my.character].name : '캐릭터를 고르는 중'} + {meP && meP.alive && ( + <> + {' '} + + + )} + + {my.character && {CHAR_INFO[my.character].text}} + + 사거리 {my.range} + {my.unlimitedBang ? ' · 뱅! 무제한' : v.current === v.me && v.phase === 'play' ? ` · 이번 턴 뱅! ${v.bangsPlayed ? '사용함' : '1번 가능'}` : ''} + + + {meP && } +
+ {meP && !meP.alive && !v.winner && 쓰러졌어요. 남은 게임을 지켜봐요.} + {prompt?.kind === 'draw' && } + {meP?.alive && ( + (discarding || sidMode ? undefined : playing ? !!optOf(c.id)?.ok : undefined)} + picked={(c) => (discarding || sidMode ? multi.includes(c.id) : sel?.cardId === c.id)} + onTap={tap} + /> + )} + {needsCard && tOpt?.ok && tOpt.cards && targetPlayer && ( +
+ {tOpt.cards.map((tc) => { + const eq = targetPlayer.inPlay.find((c) => c.id === tc); + return ( + + ); + })} +
+ )} + {hint && !modal && ( +

+ {hint} +

+ )} +
+ {discarding && ( + <> + 손패가 생명보다 많아요. 버릴 카드 {(prompt as Extract).count}장을 골라 주세요. + + + )} + {sidMode && ( + <> + + + + )} + {playing && !sidMode && ( + <> + {sel && ( + + )} + {sel && ( + + )} + + + )} + {my.canSidHeal && !sidMode && !modal && ( + + )} +
+
+ ); +} + +function DrawPanel({ prompt, act, pending, players }: { prompt: Extract; act: (a: unknown) => void; pending: boolean; players: Record }) { + return ( +
+ 내 차례예요. 첫 카드를 어디서 받을까요? +
+ + {prompt.pedro && ( + + )} + {prompt.jesseTargets.map((id) => ( + + ))} +
+
+ ); +} + +// ---------------------------------------------------------------- reaction prompt + +function PromptModal({ v, act, pending, players }: { v: BangView; act: (a: unknown) => void; pending: boolean; players: Record }) { + const my = v.my!; + const p = my.prompt; + const [picked, setPicked] = useState([]); + const [sid, setSid] = useState(false); + const ref = useRef(null); + const key = p ? `${p.kind}-${v.reactions.length}-${my.hand.length}` : ''; + useEffect(() => { + setPicked([]); + setSid(false); + }, [key]); + useEffect(() => { + if (p) ref.current?.querySelector('button:not([disabled])')?.focus(); + }, [p?.kind]); // eslint-disable-line react-hooks/exhaustive-deps + if (!p || p.kind === 'discard' || p.kind === 'draw') return null; + const r = v.reactions.find((x) => x.who === v.me); + const timer = r ? : null; + const n = (id: string) => `${nick(players, id)}님`; + const hand = my.hand; + const sidBox = my.canSidHeal && ( + <> + {!sid ? ( + + ) : ( + <> + 버릴 카드 2장을 골라 주세요. + picked.includes(c.id)} onTap={(c) => setPicked((m) => (m.includes(c.id) ? m.filter((x) => x !== c.id) : m.length < 2 ? [...m, c.id] : m))} label="버릴 카드" /> + + + )} + + ); + let title = ''; + let body: ReactNode = null; + switch (p.kind) { + case 'bang': { + const cards = p.options + .map((id) => hand.find((c) => c.id === id)!) + .sort((a, b) => Number(a.kind !== 'missed') - Number(b.kind !== 'missed')) + .slice(0, p.needed); + const can = p.options.length >= p.needed; + title = p.via === 'gatling' ? `${n(p.source)}의 개틀링!` : `${n(p.source)}이 뱅!을 쐈어요`; + body = ( + <> +

+ {p.needed > 1 ? `빗나감! ${p.needed}장이 필요해요(슬랩 더 킬러). ` : ''} + {can ? '빗나감!을 낼까요?' : p.options.length > 0 ? '빗나감!이 모자라서 막을 수 없어요.' : '막을 카드가 없어요.'} +

+
+ {can && ( + + )} + {p.canBarrel && ( + + )} + +
+ {sidBox} + + ); + break; + } + case 'indians': + case 'duel': { + const card = p.options.map((id) => hand.find((c) => c.id === id)!).sort((a, b) => Number(a.kind !== 'bang') - Number(b.kind !== 'bang'))[0]; + title = p.kind === 'indians' ? `${n(p.source)}의 인디언!` : `결투 — ${n(p.opponent)}과 겨루는 중`; + body = ( + <> +

{p.kind === 'indians' ? '뱅! 1장을 버리거나 생명 1을 잃어요.' : '뱅! 1장을 내면 상대 차례가 돼요. 못 내면 생명 1을 잃어요.'}

+
+ {card && ( + + )} + +
+ {sidBox} + + ); + break; + } + case 'dying': { + const can = p.beerAllowed && p.beers.length >= p.needed; + title = '쓰러지기 직전이에요!'; + body = ( + <> +

+ {p.beerAllowed ? `맥주 ${p.needed}장을 마시면 살아나요.` : '두 명만 남아서 맥주로는 살아날 수 없어요.'} + {p.beerAllowed && !can ? ' 맥주가 모자라요.' : ''} +

+
+ {can && ( + + )} + +
+ {sidBox} + + ); + break; + } + case 'store': + title = '잡화점: 카드 1장을 골라 주세요'; + body = false} onTap={(c) => !pending && act({ type: 'storePick', cardId: c.id })} label="잡화점 카드" />; + break; + case 'kitKeep': + title = '3장 중 2장을 골라 주세요'; + body = ( + <> + 고르지 않은 1장은 카드 더미 맨 위로 돌아가요. + picked.includes(c.id)} + onTap={(c) => setPicked((m) => (m.includes(c.id) ? m.filter((x) => x !== c.id) : m.length < 2 ? [...m, c.id] : m))} + label="킷 칼슨 카드" + /> + + + ); + break; + case 'choose2': + title = '캐릭터를 골라 주세요'; + body = ( +
+ {p.options.map((c) => ( + + ))} +
+ ); + break; + } + return ( +
+
+
+

{title}

+ {timer} +
+ {body} +
+
+ ); +} + +// ---------------------------------------------------------------- log + +const PURPOSE_KO: Record = { dynamite: '다이너마이트', jail: '감옥', barrel: '술통', jourdonnais: '주르도네' }; +const ROLE_KO = (r: Role) => ROLE_INFO[r].name; + +function logText(e: LogEntry, players: Record): string { + const n = (id: string) => `${nick(players, id)}님`; + switch (e.t) { + case 'turn': + return `${e.round}라운드 · ${n(e.p)} 차례`; + case 'play': + return `${n(e.p)}: ${CARD_INFO[e.card.kind].name}${e.as ? '(뱅!으로)' : ''}${e.target ? ` → ${n(e.target)}` : ''}`; + case 'equip': + return e.target ? `${n(e.p)}이 ${n(e.target)}을 감옥에 넣었어요` : `${n(e.p)}이 ${CARD_INFO[e.card.kind].name}을 앞에 놓았어요${e.replaced ? `(${CARD_INFO[e.replaced.kind].name} 버림)` : ''}`; + case 'check': + return `${n(e.p)} ${PURPOSE_KO[e.purpose]} 판정: ${e.cards.map((c) => `${SUIT_SYMBOL[c.suit]}${c.rank}`).join(' · ')} → ${e.success ? '성공' : '실패'}`; + case 'dynamite': + return e.exploded ? `다이너마이트가 ${n(e.p)} 앞에서 터졌어요!` : `다이너마이트가 ${n(e.to!)}에게 넘어갔어요`; + case 'jail': + return e.escaped ? `${n(e.p)}이 감옥에서 나왔어요` : `${n(e.p)}은 감옥에서 이번 턴을 쉬어요`; + case 'missed': + return `${n(e.p)}이 ${e.cards.map((c) => CARD_INFO[c.kind].name).join(' + ')}로 피했어요`; + case 'respond': + return e.why === 'indians' ? `${n(e.p)}이 뱅!을 버려 인디언!을 막았어요` : `${n(e.p)}이 결투에서 뱅!을 냈어요`; + case 'barrelSave': + return `${n(e.p)}이 판정 성공으로 피했어요`; + case 'hit': + return `${n(e.p)}이 생명 ${e.n}을 잃었어요 (남은 생명 ${Math.max(0, e.life)})`; + case 'heal': + return e.why === 'saloon' ? `${n(e.p)} 생명 +${e.n} (살롱)` : e.why === 'sid' ? `${n(e.p)}이 카드 2장을 버려 생명 +1` : `${n(e.p)}이 맥주로 생명 +${e.n}`; + case 'draw': + return `${n(e.p)}이 카드 ${e.n}장을 받았어요${e.why === 'reward' ? ' (무법자 처치 보상)' : e.why === 'bart' ? ' (바트 캐시디)' : e.why === 'suzy' ? ' (수지 라파예트)' : ''}`; + case 'blackJack': + return `${n(e.p)}이 두 번째 카드 ${faceShort(e.card)}를 보여 줬어요${e.extra ? ' → 1장 더!' : ''}`; + case 'pedro': + return `${n(e.p)}이 버린 카드 ${faceShort(e.card)}를 가져갔어요`; + case 'take': { + const what = e.equip ? CARD_INFO[e.equip.kind].name : e.card ? `손패 ${faceShort(e.card)}` : '손패 1장'; + const how = e.how === 'panic' ? '패닉!으로 ' : e.how === 'gringo' ? '(엘 그링고) ' : '(제시 존스) '; + return `${n(e.p)}이 ${how}${n(e.from)}의 ${what}을 가져갔어요`; + } + case 'discarded': + return `${n(e.by)}의 캣 벌루로 ${n(e.p)}의 ${e.fromHand ? '손패 ' : ''}${faceShort(e.card)}를 버렸어요`; + case 'limit': + return `${n(e.p)}이 카드 ${e.cards.length}장을 버렸어요`; + case 'store': + return `${n(e.p)}이 잡화점에서 ${faceShort(e.card)}를 골랐어요`; + case 'storeOpen': + return `잡화점: ${e.cards.map((c) => CARD_INFO[c.kind].name).join(', ')}`; + case 'dead': + return e.left ? `${n(e.p)}이 나가서 탈락했어요 — ${ROLE_KO(e.role)}` : `${n(e.p)}이 쓰러졌어요 — ${ROLE_KO(e.role)}였어요${e.by ? ` (${n(e.by)})` : ''}`; + case 'reward': + return `${n(e.p)}이 무법자를 잡아 카드 ${e.n}장을 받아요`; + case 'penalty': + return `보안관 ${n(e.p)}이 부관을 쏴서 카드를 모두 버려요`; + case 'vulture': + return `${n(e.p)}이 ${n(e.from)}의 카드 ${e.n}장을 가져갔어요 (벌처 샘)`; + case 'character': + return `${n(e.p)}이 ${CHAR_INFO[e.character].name}을 골랐어요`; + case 'timeout': + return `${n(e.p)}의 시간이 다 됐어요`; + case 'reshuffle': + return `버린 카드 ${e.n}장을 섞어 더미를 채웠어요`; + case 'end': + return `${TEAM_KO[e.winner]} 승리!`; + } +} + +// ---------------------------------------------------------------- board + +function BangBoard({ view: v, me, players, act, pending }: BoardProps) { + const [sel, setSel] = useState(null); + const [announce, setAnnounce] = useState(''); + const mine = isMyTurn(v, me); + const prevMine = useRef(false); + const last = v.log[v.log.length - 1]; + const turnKey = `${v.current}-${v.phase}-${v.reactions.length}-${v.my?.hand.length ?? 0}-${last?.seq ?? 0}`; + useEffect(() => setSel(null), [turnKey]); + useEffect(() => { + if (mine && !prevMine.current) { + haptic(40); + sounds.myTurn(); + setAnnounce(statusText(v, me, players)); + } + prevMine.current = mine; + }, [mine]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + if (last && !mine) setAnnounce(logText(last, players)); + }, [last?.seq, last?.t]); // eslint-disable-line react-hooks/exhaustive-deps + + const n = v.players.length; + const myIdx = v.players.findIndex((p) => p.id === me); + const base = myIdx < 0 ? 0 : myIdx; + const selOpt = sel ? v.my?.plays.find((o) => o.cardId === sel.cardId) : undefined; + const markOf = (id: string): TargetMark | null => { + if (!selOpt?.targets) return null; + const t = selOpt.targets.find((x) => x.id === id); + if (!t) return null; + return { ok: t.ok, reason: t.reason, selected: sel?.target === id }; + }; + const pick = (id: string) => { + if (!sel) return; + const t = selOpt?.targets?.find((x) => x.id === id); + const only = t?.cards?.length === 1 ? t.cards[0] : undefined; + setSel({ cardId: sel.cardId, ...(sel.as ? { as: sel.as } : {}), target: id, ...(only ? { targetCard: only } : {}) }); + }; + return ( +
+ +
= 7 ? '164px' : '178px' } as CSSProperties}> + {v.players.map((p, i) => { + const rel = (i - base + n) % n; + const ang = Math.PI / 2 + (rel * 2 * Math.PI) / n; + return ( + + ); + })} +
+
+ {v.my && } + {v.my && } +

+ {announce} +

+
+ ); +} + +// ---------------------------------------------------------------- side + +function BangSide({ view: v, me, players }: BoardProps) { + return ( + <> + +
+ + 뱅! {v.phase === 'draft' ? '· 캐릭터 고르기' : `· ${v.round}라운드`} + {v.winner ? ' · 게임 끝' : ''} + + + 카드 더미 {v.deckCount}장 · 버린 카드 {v.discardCount}장 · 반응 {v.options.reactionSeconds}초 · 턴 {v.options.turnSeconds}초 + +
    + {v.players.map((p) => ( +
  1. + + + {p.isTurn ? '▶ ' : ''} + {nick(players, p.id)} + {p.id === me ? ' (나)' : ''} + + + {p.alive ? : 쓰러짐} +
  2. + ))} +
+
+ {v.result && ( +
+ {TEAM_KO[v.result.winnerTeam]} 승리 + {v.result.ranking.map((id) => { + const r = v.result!.players.find((x) => x.id === id)!; + return ( + + {r.won ? '🏆 ' : ''} + {nick(players, id)} · {ROLE_INFO[r.role].name} · {CHAR_INFO[r.character].name} + {r.alive ? ' · 생존' : ` · ${r.deathOrder}번째로 쓰러짐`} + + ); + })} + {v.result.summary} +
+ )} + {v.log.length > 0 && ( +
+ 기록 +
    + {[...v.log].reverse().map((e, i) => ( +
  1. + {logText(e, players)} +
  2. + ))} +
+
+ )} +
+ 카드 도감 + +
+ + ); +} + +// ---------------------------------------------------------------- status + +function isMyTurn(v: BangView, me: string | null): boolean { + if (!me || !v.my || v.winner) return false; + return !!v.my.prompt || (v.current === me && v.phase === 'play' && v.reactions.length === 0); +} + +function statusText(v: BangView, me: string | null, players: Record): string { + if (v.result) return v.result.summary; + const p = v.my?.prompt; + if (p) { + switch (p.kind) { + case 'choose2': + return '캐릭터를 골라 주세요'; + case 'bang': + return p.via === 'gatling' ? '개틀링 공격을 받았어요. 빗나감!을 낼까요?' : '뱅! 공격을 받았어요. 빗나감!을 낼까요?'; + case 'indians': + return '인디언! 뱅!을 버릴까요?'; + case 'duel': + return '결투 중이에요. 뱅!을 낼까요?'; + case 'dying': + return '쓰러지기 직전이에요! 맥주를 마실까요?'; + case 'store': + return '잡화점에서 카드를 골라 주세요'; + case 'kitKeep': + return '받을 카드 2장을 골라 주세요'; + case 'draw': + return '내 차례예요 · 첫 카드를 어디서 받을지 골라 주세요'; + case 'discard': + return `내 차례예요 · 카드 ${p.count}장을 버려 주세요`; + } + } + if (v.phase === 'draft') return '다른 사람이 캐릭터를 고르는 중이에요'; + const meP = v.players.find((x) => x.id === me); + if (me && v.current === me && v.reactions.length === 0) return '내 차례예요 · 카드를 내거나 턴을 마쳐 주세요'; + if (v.reactions.length) { + const who = [...new Set(v.reactions.map((r) => r.who))]; + return `${who.map((id) => `${nick(players, id)}님`).join(', ')}의 반응을 기다리는 중이에요`; + } + if (meP && !meP.alive) return `쓰러졌어요 · ${nick(players, v.current)}님 차례예요`; + return `${nick(players, v.current)}님 차례예요`; +} + +// ---------------------------------------------------------------- options + +function Seg({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) { + return ( +
+ {options.map(([k, l]) => ( + + ))} +
+ ); +} + +function Field({ label, children, help }: { label: string; children: ReactNode; help?: string }) { + return ( +
+ {label} + {children} + {help && {help}} +
+ ); +} + +const onOff: [boolean, string][] = [ + [true, '켜기'], + [false, '끄기'], +]; + +function Options({ value: o, onChange }: OptionsProps) { + const [more, setMore] = useState(false); + const set = (patch: Partial) => onChange({ ...o, ...patch }); + return ( +
+ + set({ characterDraft })} /> + + + set({ turnSeconds })} /> + + + set({ reactionSeconds })} /> + + + {more && ( + <> + + set({ autoDefend })} /> + + + set({ autoBarrel })} /> + + + set({ showDistanceHelper })} /> + + + set({ beginnerHints })} /> + + + set({ drawBeforeDeath })} /> + + + set({ beerAtFullHealth })} /> + + + set({ selfTargetCatBalou })} /> + + + set({ deputyCount7 })} /> + + + )} +
+ ); +} + +function optionsSummary(o: BangOptions): string { + const parts = [o.characterDraft === 'choose2' ? '캐릭터 고르기' : '캐릭터 무작위', `턴 ${o.turnSeconds}초`, `반응 ${o.reactionSeconds}초`]; + if (!o.autoDefend) parts.push('자동 방어 끔'); + if (o.drawBeforeDeath || o.beerAtFullHealth || o.selfTargetCatBalou || o.deputyCount7) parts.push('하우스 룰'); + return parts.join(' · '); +} + +// ---------------------------------------------------------------- rules + +function CardTable() { + const kinds = Object.keys(CARD_INFO) as CardKind[]; + return ( + + + {kinds.map((k) => ( + + + + + + ))} + +
+ {CARD_INFO[k].icon} + + {CARD_INFO[k].name} + {BLUE.includes(k) ? ' (장착)' : ''} + {CARD_INFO[k].text}
+ ); +} + +function Rules() { + return ( +
+

+ 4~7명이 비밀 역할을 받아요. 보안관만 정체를 밝히고, 나머지(부관·무법자·배신자)는 숨겨요. 보안관 팀은 무법자와 배신자를 모두 잡으면, 무법자는 보안관을 쓰러뜨리면, 배신자는 마지막에 혼자 + 남으면 이겨요. +

+

+ 내 차례: 카드 2장을 받고 → 원하는 만큼 카드를 내고 → 손패가 생명보다 많으면 남는 만큼 버려요. 뱅!은 한 턴에 1번, 사거리 안의 사람에게만 쏠 수 있어요. 맞은 사람은 + 빗나감!으로 피하거나 생명 1을 잃어요. +

+

+ 거리: 살아 있는 사람만 세어 왼쪽·오른쪽 중 가까운 쪽 자리 수예요. 머스탱은 남이 볼 때 +1, 조준경은 내가 볼 때 −1. 무기가 없으면 사거리는 1이에요. +

+

+ 생명이 0이 되면 맥주로 살아날 수 있어요(2명 남으면 안 돼요). 무법자를 쓰러뜨리면 카드 3장을 받고, 보안관이 부관을 쓰러뜨리면 카드를 모두 버려요. 쓰러진 사람의 역할은 공개돼요. +

+

반응 시간이 지나면 막을 카드가 있을 때 자동으로 내요(설정에서 끌 수 있어요). 내 역할은 역할 카드를 꾹 누르고 있을 때만 보여요.

+ + + + {(Object.keys(CHAR_INFO) as CharacterId[]).map((c) => ( + + + + + ))} + +
+ {CHAR_INFO[c].name} + {CHAR_INFO[c].text}
+
+ ); +} + +const ui: GameUI = { + Board: BangBoard, + Side: BangSide, + statusText, + isMyTurn, + Options, + optionsSummary, + Rules, +}; + +export default ui; diff --git a/apps/web/src/games/registry.ts b/apps/web/src/games/registry.ts index 570d217..ec144cc 100644 --- a/apps/web/src/games/registry.ts +++ b/apps/web/src/games/registry.ts @@ -14,6 +14,7 @@ export const GAME_UI: Record Promise<{ default: GameUI }>> = { 'one-card': () => import('./one-card'), rummikub: () => import('./rummikub'), 'davinci-code': () => import('./davinci-code'), + bang: () => import('./bang'), }; const cache = new Map(); diff --git a/apps/web/src/lib/catalog-ui.ts b/apps/web/src/lib/catalog-ui.ts index 0124b59..05cd243 100644 --- a/apps/web/src/lib/catalog-ui.ts +++ b/apps/web/src/lib/catalog-ui.ts @@ -12,7 +12,8 @@ export const GAME_ICON: Record = { '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 = ['마작', '고스톱', '할리갈리']; diff --git a/e2e/games.e2e.ts b/e2e/games.e2e.ts index 8a6fd27..a83674d 100644 --- a/e2e/games.e2e.ts +++ b/e2e/games.e2e.ts @@ -249,6 +249,114 @@ const PLANS: Record = { 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) => l.click({ timeout: 1500 }).then(() => true, () => false); + /** One UI step on this page; true when something was pressed. */ + const step = async (p: Page): Promise => { + 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 "여기서 멈추기". diff --git a/packages/games/src/bang/bang.test.ts b/packages/games/src/bang/bang.test.ts new file mode 100644 index 0000000..2dfd609 --- /dev/null +++ b/packages/games/src/bang/bang.test.ts @@ -0,0 +1,1321 @@ +import { describe, expect, test } from 'bun:test'; +import { SeededRng, randomPlayout, seedN, type GameEvent, type PlayerId, type RngState } from '@bg/engine'; +import { + CARDS, + CHARACTER_LIFE, + allCards, + bang, + cardById, + distance, + type BangAction, + type BangOptions, + type BangState, + type CardKind, + type CharacterId, + type Role, + type TurnPhase, +} from './index'; + +// ---------------------------------------------------------------- helpers + +let clock = 1_000_000; +const rng = () => SeededRng.fromSeed([7, 7, 7, 7]); + +interface PSpec { + id: string; + role: Role; + ch: CharacterId; + life?: number; + hand?: string[]; + inPlay?: string[]; + alive?: boolean; +} + +/** Card picker for one rigged game: `c('bang')` = any unused 뱅!, `c('missed', '10C')` = that exact card. */ +function pool() { + const used = new Set(); + return (kind: CardKind, code?: string): string => { + const card = CARDS.find((x) => x.kind === kind && !used.has(x.id) && (!code || `${x.rank}${x.suit}` === code)); + if (!card) throw new Error(`no unused ${kind} ${code ?? ''}`); + used.add(card.id); + return card.id; + }; +} + +/** + * Builds a state directly: players in seat order, the current player in the play phase (unless `phase`), + * `deckTop` = cards drawn next (first element first); every other card goes under them. + */ +function rig(o: { players: PSpec[]; turn?: string; phase?: TurnPhase; deckTop?: string[]; discard?: string[]; options?: Partial }): BangState { + const options = bang.optionsSchema.parse(o.options ?? {}); + const players = o.players.map((p, i) => { + const maxLife = CHARACTER_LIFE[p.ch] + (p.role === 'sheriff' ? 1 : 0); + return { + id: p.id, + seat: i, + role: p.role, + character: p.ch, + characterOffer: null, + maxLife, + life: p.life ?? maxLife, + alive: p.alive ?? true, + hand: p.hand ?? [], + inPlay: p.inPlay ?? [], + timeoutsInRow: 0, + away: false, + }; + }); + const placed = new Set([...players.flatMap((p) => [...p.hand, ...p.inPlay]), ...(o.deckTop ?? []), ...(o.discard ?? [])]); + const rest = CARDS.map((c) => c.id).filter((id) => !placed.has(id)); + const turn = o.turn ?? players[0]!.id; + return { + options, + players, + deck: [...rest, ...[...(o.deckTop ?? [])].reverse()], + discard: o.discard ?? [], + turn: { player: turn, phase: o.phase ?? 'play', bangsPlayed: 0, deadline: clock + 60_000, pausedAt: null, dynamiteChecked: false, jailChecked: false }, + reactions: [], + lastCheck: null, + winner: null, + deathOrder: players.filter((p) => !p.alive).map((p) => p.id), + round: 1, + seq: 0, + log: [], + final: null, + }; +} + +function actE(s: BangState, p: string, a: BangAction): { state: BangState; events: GameEvent[] } { + const v = bang.validate(s, p, a); + if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`); + return bang.apply(s, p, a, { rng: rng(), now: (clock += 1000) }); +} +const act = (s: BangState, p: string, a: BangAction) => actE(s, p, a).state; +const reject = (s: BangState, p: string, a: BangAction): string | null => { + const v = bang.validate(s, p, a); + return v.ok ? null : v.reason; +}; +const pl = (s: BangState, id: string) => s.players.find((p) => p.id === id)!; +const play = (s: BangState, p: string, cardId: string, extra: Partial> = {}) => act(s, p, { type: 'play', cardId, ...extra }); +const kinds = (ids: string[]) => ids.map((id) => cardById(id).kind); +const conserve = (s: BangState) => { + const all = allCards(s); + expect(all.length).toBe(80); + expect(new Set(all).size).toBe(80); +}; + +/** Four players A (sheriff) B C D around the table with plain characters. */ +const basic = (over: Partial>> = {}): PSpec[] => + ( + [ + { id: 'A', role: 'sheriff', ch: 'sid' }, + { id: 'B', role: 'outlaw', ch: 'bart' }, + { id: 'C', role: 'renegade', ch: 'pedro' }, + { id: 'D', role: 'outlaw', ch: 'willy' }, + ] as PSpec[] + ).map((p) => ({ ...p, ...(over[p.id] ?? {}) })); + +// ---------------------------------------------------------------- deck and setup + +const TABLE: Record = { + 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', +}; + +describe('덱과 셋업 (§3, §4)', () => { + test('80장: 종류별 장수와 무늬·숫자가 3.3 표와 정확히 같다; 갈색 63 + 파랑 17', () => { + expect(CARDS).toHaveLength(80); + expect(new Set(CARDS.map((c) => c.id)).size).toBe(80); + for (const [kind, codes] of Object.entries(TABLE)) { + const got = CARDS.filter((c) => c.kind === kind).map((c) => `${c.rank}${c.suit}`); + expect(got.sort(), kind).toEqual(codes.split(' ').sort()); + } + const blue = ['barrel', 'mustang', 'scope', 'jail', 'dynamite', 'volcanic', 'schofield', 'remington', 'carabine', 'winchester']; + expect(CARDS.filter((c) => blue.includes(c.kind))).toHaveLength(17); + expect(CARDS.filter((c) => !blue.includes(c.kind))).toHaveLength(63); + }); + + test.each([ + [4, { sheriff: 1, deputy: 0, outlaw: 2, renegade: 1 }, false], + [5, { sheriff: 1, deputy: 1, outlaw: 2, renegade: 1 }, false], + [6, { sheriff: 1, deputy: 1, outlaw: 3, renegade: 1 }, false], + [7, { sheriff: 1, deputy: 2, outlaw: 3, renegade: 1 }, false], + [7, { sheriff: 1, deputy: 1, outlaw: 4, renegade: 1 }, true], + ] as const)('%i명 역할 분배, 보안관 생명 +1, 시작 손패 = 생명 (deputyCount7=%p)', (n, roles, dc7) => { + for (let seed = 1; seed <= 20; seed++) { + const players = Array.from({ length: n }, (_, k) => `P${k}`); + const s = bang.setup({ players, options: bang.optionsSchema.parse({ deputyCount7: dc7 }), rng: SeededRng.fromSeed(seedN(seed)), now: 0 }); + const count = { sheriff: 0, deputy: 0, outlaw: 0, renegade: 0 }; + for (const p of s.players) count[p.role]++; + expect(count).toEqual(roles); + expect(new Set(s.players.map((p) => p.character)).size).toBe(n); + for (const p of s.players) { + expect(p.maxLife).toBe(CHARACTER_LIFE[p.character] + (p.role === 'sheriff' ? 1 : 0)); + expect(p.life).toBe(p.maxLife); + // The sheriff starts and has already drawn (automatically, unless Jesse/Pedro choose or Kit keeps). + const drawn = s.log.filter((e) => e.t === 'draw' && e.p === p.id).reduce((a, e) => a + (e.t === 'draw' ? e.n : 0), 0); + const pedro = s.log.filter((e) => (e.t === 'pedro' || e.t === 'take' || e.t === 'blackJack') && e.p === p.id).length; + expect(p.hand.length - drawn - pedro).toBe(p.life); + } + expect(s.turn.player).toBe(s.players.find((p) => p.role === 'sheriff')!.id); + expect(s.round).toBe(1); + conserve(s); + } + }); + + test('좌석은 RNG로 섞이고 같은 시드면 같은 결과', () => { + const players = ['A', 'B', 'C', 'D', 'E']; + const a = bang.setup({ players, options: bang.defaultOptions, rng: SeededRng.fromSeed(seedN(3)), now: 0 }); + const b = bang.setup({ players, options: bang.defaultOptions, rng: SeededRng.fromSeed(seedN(3)), now: 0 }); + expect(JSON.stringify(a)).toBe(JSON.stringify(b)); + const orders = new Set(); + for (let i = 1; i <= 20; i++) orders.add(bang.setup({ players, options: bang.defaultOptions, rng: SeededRng.fromSeed(seedN(i)), now: 0 }).players.map((p) => p.id).join('')); + expect(orders.size).toBeGreaterThan(5); + expect(() => bang.setup({ players: ['A', 'B', 'C'], options: bang.defaultOptions, rng: rng(), now: 0 })).toThrow(); + expect(() => bang.setup({ players: 'ABCDEFGH'.split(''), options: bang.defaultOptions, rng: rng(), now: 0 })).toThrow(); + }); + + test('choose2: 2장 중 1장 고르기, 남의 후보는 비공개, 시간 초과 시 첫 후보', () => { + const players = ['A', 'B', 'C', 'D']; + let s = bang.setup({ players, options: bang.optionsSchema.parse({ characterDraft: 'choose2' }), rng: SeededRng.fromSeed(seedN(9)), now: 0 }); + expect(s.turn.phase).toBe('draft'); + expect(s.reactions.filter((r) => r.kind === 'choose2')).toHaveLength(4); + expect(bang.activePlayers(s).sort()).toEqual(players); + const offerA = pl(s, 'A').characterOffer!; + const vB = JSON.stringify(bang.view(s, 'B')); + for (const c of offerA) expect(vB).not.toContain(`"${c}"`); + const vA = bang.view(s, 'A'); + expect(vA.my!.prompt).toEqual({ kind: 'choose2', options: offerA }); + expect(reject(s, 'A', { type: 'pickCharacter', character: pl(s, 'B').characterOffer![0]! })).toBe('받은 캐릭터 중에서 골라 주세요.'); + s = act(s, 'A', { type: 'pickCharacter', character: offerA[1]! }); + expect(pl(s, 'A').character).toBe(offerA[1]!); + for (const p of ['B', 'C']) s = act(s, p, { type: 'pickCharacter', character: pl(s, p).characterOffer![0]! }); + const offerD = pl(s, 'D').characterOffer!; + s = act(s, 'D', { type: 'timeout' }); + expect(pl(s, 'D').character).toBe(offerD[0]!); + expect(s.turn.phase).not.toBe('draft'); + for (const p of s.players) if (p.id !== s.turn.player) expect(p.hand).toHaveLength(p.life); + conserve(s); + }); +}); + +// ---------------------------------------------------------------- distance + +describe('거리와 사거리 (§5.1)', () => { + test('5인 A-B-C-D-E: A→C 2, 머스탱 3, 조준경 2; B 사망 후 1; 레밍턴이면 사격 가능, 스코필드면 불가', () => { + const c = pool(); + const ps: PSpec[] = [ + { id: 'A', role: 'sheriff', ch: 'bart', hand: [c('bang')] }, + { id: 'B', role: 'deputy', ch: 'kit' }, + { id: 'C', role: 'outlaw', ch: 'suzy' }, + { id: 'D', role: 'outlaw', ch: 'vulture' }, + { id: 'E', role: 'renegade', ch: 'willy' }, + ]; + const s = rig({ players: ps }); + expect(distance(s, 'A', 'C')).toBe(2); + expect(distance(s, 'A', 'D')).toBe(2); + expect(distance(s, 'A', 'E')).toBe(1); + pl(s, 'C').inPlay = [c('mustang')]; + expect(distance(s, 'A', 'C')).toBe(3); + pl(s, 'A').inPlay = [c('scope')]; + expect(distance(s, 'A', 'C')).toBe(2); + pl(s, 'A').inPlay = [c('schofield')]; + const bangId = pl(s, 'A').hand[0]!; + expect(reject(s, 'A', { type: 'play', cardId: bangId, target: 'C' })).toBe('사거리가 닿지 않아요(거리 3, 사거리 2).'); + pl(s, 'A').inPlay = [c('remington')]; + expect(reject(s, 'A', { type: 'play', cardId: bangId, target: 'C' })).toBeNull(); + pl(s, 'C').inPlay = []; + pl(s, 'B').alive = false; + expect(distance(s, 'A', 'C')).toBe(1); + }); + + test('머스탱 + 폴 리그레트 = +2, 조준경 + 로즈 둘란 = −2, 최소 1', () => { + const c = pool(); + const s = rig({ + players: [ + { id: 'A', role: 'sheriff', ch: 'rose', inPlay: [c('scope')] }, + { id: 'B', role: 'deputy', ch: 'kit' }, + { id: 'C', role: 'outlaw', ch: 'paul', inPlay: [c('mustang')] }, + { id: 'D', role: 'outlaw', ch: 'suzy' }, + { id: 'E', role: 'renegade', ch: 'willy' }, + { id: 'F', role: 'outlaw', ch: 'bart' }, + { id: 'G', role: 'deputy', ch: 'sid' }, + ], + }); + // Seats A→C = 2, +2 (mustang + Paul) −2 (scope + Rose) = 2. + expect(distance(s, 'A', 'C')).toBe(2); + expect(distance(s, 'B', 'C')).toBe(3); + expect(distance(s, 'C', 'A')).toBe(2); + // A→B: 1 − 2 → minimum 1. + expect(distance(s, 'A', 'B')).toBe(1); + expect(distance(s, 'A', 'D')).toBe(1); + expect(distance(s, 'A', 'E')).toBe(1); + // Distance is the shorter way round. + expect(distance(s, 'B', 'F')).toBe(3); + expect(distance(s, 'E', 'B')).toBe(3); + }); + + test('view: 거리와 사거리 안 여부를 미리 계산해 준다', () => { + const c = pool(); + const s = rig({ players: basic({ A: { hand: [c('bang')] }, C: { inPlay: [c('mustang')] } }) }); + const v = bang.view(s, 'A'); + const by = Object.fromEntries(v.players.map((p) => [p.id, p])); + expect(by.B!.distance).toBe(1); + expect(by.B!.inRange).toBe(true); + expect(by.C!.distance).toBe(3); + expect(by.C!.inRange).toBe(false); + expect(by.A!.distance).toBeNull(); + const opt = v.my!.plays[0]!; + expect(opt.ok).toBe(true); + expect(opt.targets!.map((t) => [t.id, t.ok])).toEqual([ + ['B', true], + ['C', false], + ['D', true], + ]); + expect(opt.targets![1]!.reason).toBe('사거리가 닿지 않아요(거리 3, 사거리 1).'); + }); +}); + +// ---------------------------------------------------------------- bang! limits and responses + +describe('뱅!과 빗나감! (§5.2, §5.4)', () => { + test('뱅! 턴당 1회: 두 번째 거부; 볼캐닉·윌리 더 키드는 허용', () => { + const c = pool(); + const b1 = c('bang'); + const b2 = c('bang'); + let s = rig({ players: basic({ A: { hand: [b1, b2] } }) }); + s = play(s, 'A', b1, { target: 'B' }); + expect(s.reactions).toHaveLength(1); + s = act(s, 'B', { type: 'takeHit' }); + expect(pl(s, 'B').life).toBe(3); + expect(reject(s, 'A', { type: 'play', cardId: b2, target: 'B' })).toBe('이번 턴에는 뱅!을 이미 썼어요.'); + expect(bang.view(s, 'A').my!.plays.find((o) => o.cardId === b2)!.reason).toBe('이번 턴에는 뱅!을 이미 썼어요.'); + // Volcanic + pl(s, 'A').inPlay = [c('volcanic')]; + expect(reject(s, 'A', { type: 'play', cardId: b2, target: 'B' })).toBeNull(); + // Willy the Kid + const w1 = c('bang'); + const w2 = c('bang'); + let t = rig({ players: basic({ D: { hand: [w1, w2] } }), turn: 'D' }); + t = play(t, 'D', w1, { target: 'A' }); + t = act(t, 'A', { type: 'takeHit' }); + t = play(t, 'D', w2, { target: 'A' }); + t = act(t, 'A', { type: 'takeHit' }); + expect(pl(t, 'A').life).toBe(3); + }); + + test('빗나감!은 내 차례에 낼 수 없고, 맞을 때 1장으로 막는다; 맞기 / 시간 초과(autoDefend)', () => { + const c = pool(); + const m = c('missed'); + const b = c('bang'); + const b2 = c('bang'); + let s = rig({ players: basic({ A: { hand: [b, b2, c('missed')] }, B: { hand: [m] } }), options: { autoDefend: true } }); + expect(reject(s, 'A', { type: 'play', cardId: pl(s, 'A').hand[2]!, target: 'B' })).toBe('빗나감!은 공격을 받았을 때만 낼 수 있어요.'); + s = play(s, 'A', b, { target: 'B' }); + expect(bang.view(s, 'B').my!.prompt).toMatchObject({ kind: 'bang', source: 'A', needed: 1, options: [m] }); + expect(reject(s, 'A', { type: 'endPlay' })).toBe('지금은 턴을 마칠 수 없어요.'); + s = act(s, 'B', { type: 'timeout' }); + expect(pl(s, 'B').life).toBe(4); + expect(pl(s, 'B').hand).toEqual([]); + expect(s.discard).toContain(m); + expect(s.log.some((e) => e.t === 'missed' && e.p === 'B')).toBe(true); + // autoDefend off: timeout = take the hit. + let t = rig({ players: basic({ A: { hand: [b2] }, B: { hand: [c('missed')] } }), options: { autoDefend: false } }); + t = play(t, 'A', b2, { target: 'B' }); + t = act(t, 'B', { type: 'timeout' }); + expect(pl(t, 'B').life).toBe(3); + }); + + test('캘러미티 자넷: 빗나감!을 뱅!으로(1회 제한 포함), 뱅!을 빗나감!으로; 결투·인디언!에도', () => { + const c = pool(); + const m1 = c('missed'); + const b1 = c('bang'); + let s = rig({ players: basic({ A: { ch: 'calamity', hand: [m1, b1] }, B: { hand: [c('bang')] } }) }); + expect(reject(s, 'A', { type: 'play', cardId: m1, target: 'B' })).toBe('빗나감!은 공격을 받았을 때만 낼 수 있어요.'); + expect(reject(s, 'B', { type: 'play', cardId: m1, as: 'bang', target: 'A' })).toBe('지금은 카드를 낼 수 없어요.'); + const opt = bang.view(s, 'A').my!.plays.find((o) => o.cardId === m1)!; + expect(opt.as).toBe('bang'); + s = play(s, 'A', m1, { as: 'bang', target: 'B' }); + expect(s.turn.bangsPlayed).toBe(1); + // B answers with... a 뱅! is not a 빗나감! for B. + expect(reject(s, 'B', { type: 'respond', cardIds: [pl(s, 'B').hand[0]!] })).toBe('빗나감!만 낼 수 있어요.'); + s = act(s, 'B', { type: 'takeHit' }); + expect(reject(s, 'A', { type: 'play', cardId: b1, target: 'B' })).toBe('이번 턴에는 뱅!을 이미 썼어요.'); + // Calamity answers a 뱅! with a 뱅!, and Indians with a 빗나감!. + const bb = c('bang'); + const mm = c('missed'); + const ind = c('indians'); + let t = rig({ players: basic({ B: { ch: 'calamity', hand: [bb, mm] }, A: { hand: [c('bang'), ind] } }) }); + t = play(t, 'A', pl(t, 'A').hand[0]!, { target: 'B' }); + t = act(t, 'B', { type: 'respond', cardIds: [bb] }); + expect(pl(t, 'B').life).toBe(4); + t = play(t, 'A', ind); + t = act(t, 'B', { type: 'respond', cardIds: [mm] }); + expect(pl(t, 'B').life).toBe(4); + expect(t.log.some((e) => e.t === 'respond' && e.p === 'B' && e.why === 'indians')).toBe(true); + }); + + test('슬랩 더 킬러: 빗나감! 1장 제출 거부, 술통 성공 1 + 빗나감! 1로 회피; 슬랩의 개틀링은 1장', () => { + const c = pool(); + const b = c('bang'); + const m1 = c('missed'); + const m2 = c('missed'); + let s = rig({ players: basic({ A: { ch: 'slab', hand: [b] }, B: { hand: [m1] } }) }); + s = play(s, 'A', b, { target: 'B' }); + expect(bang.view(s, 'B').my!.prompt).toMatchObject({ kind: 'bang', needed: 2 }); + expect(reject(s, 'B', { type: 'respond', cardIds: [m1] })).toBe('빗나감! 2장을 한꺼번에 내야 막을 수 있어요.'); + s = act(s, 'B', { type: 'takeHit' }); + expect(pl(s, 'B').life).toBe(3); + // Barrel ♥ success + one 빗나감!. + const b2 = c('bang'); + let t = rig({ players: basic({ A: { ch: 'slab', hand: [b2] }, B: { hand: [m2], inPlay: [c('barrel')] } }), deckTop: [c('beer', '6H')] }); + t = play(t, 'A', b2, { target: 'B' }); + expect(t.lastCheck).toMatchObject({ player: 'B', purpose: 'barrel', success: true }); + expect(t.reactions[0]).toMatchObject({ kind: 'bang', needed: 1 }); + t = act(t, 'B', { type: 'respond', cardIds: [m2] }); + expect(pl(t, 'B').life).toBe(4); + expect(t.reactions).toEqual([]); + // Slab's gatling: one 빗나감! is enough. + const g = c('gatling'); + const m3 = c('missed'); + let u = rig({ players: basic({ A: { ch: 'slab', hand: [g] }, B: { hand: [m3] } }) }); + u = play(u, 'A', g); + expect(u.reactions.find((r) => r.kind === 'bang' && r.target === 'B')).toMatchObject({ needed: 1, via: 'gatling' }); + u = act(u, 'B', { type: 'respond', cardIds: [m3] }); + expect(pl(u, 'B').life).toBe(4); + }); + + test('술통: ♥ 판정이면 회피, 아니면 반응 대기; autoBarrel 끄면 useBarrel로 직접', () => { + const c = pool(); + const b = c('bang'); + let s = rig({ players: basic({ A: { hand: [b] }, B: { inPlay: [c('barrel')] } }), deckTop: [c('missed', '2S')] }); + s = play(s, 'A', b, { target: 'B' }); + expect(s.lastCheck).toMatchObject({ purpose: 'barrel', success: false }); + expect(s.reactions).toHaveLength(1); + s = act(s, 'B', { type: 'takeHit' }); + expect(pl(s, 'B').life).toBe(3); + // Manual barrel. + const b2 = c('bang'); + let t = rig({ players: basic({ A: { hand: [b2] }, B: { inPlay: [c('barrel')] } }), deckTop: [c('beer', '7H')], options: { autoBarrel: false } }); + t = play(t, 'A', b2, { target: 'B' }); + expect(t.lastCheck).toBeNull(); + expect(bang.view(t, 'B').my!.prompt).toMatchObject({ kind: 'bang', canBarrel: true }); + t = act(t, 'B', { type: 'useBarrel' }); + expect(t.reactions).toEqual([]); + expect(pl(t, 'B').life).toBe(4); + expect(reject(t, 'B', { type: 'useBarrel' })).toBe('뱅!을 받았을 때만 술통 판정을 해요.'); + }); + + test('주르도네 + 술통: 판정 2회(각 1장 공개); 럭키 듀크는 2장 공개 후 좋은 카드', () => { + const c = pool(); + const b = c('bang'); + // Real barrel fails (♠), Jourdonnais succeeds (♥). + let s = rig({ players: basic({ A: { hand: [b] }, B: { ch: 'jourdonnais', inPlay: [c('barrel')] } }), deckTop: [c('missed', '3S'), c('beer', '8H')] }); + s = play(s, 'A', b, { target: 'B' }); + const checks = s.log.filter((e) => e.t === 'check'); + expect(checks.map((e) => (e.t === 'check' ? [e.purpose, e.cards.length, e.success] : null))).toEqual([ + ['barrel', 1, false], + ['jourdonnais', 1, true], + ]); + expect(s.reactions).toEqual([]); + expect(pl(s, 'B').life).toBe(4); + // Lucky Duke with a barrel: ♠ and ♥ revealed, the ♥ counts. + const b2 = c('bang'); + let t = rig({ players: basic({ A: { hand: [b2] }, B: { ch: 'lucky', inPlay: [c('barrel')] } }), deckTop: [c('missed', '4S'), c('beer', '9H')] }); + t = play(t, 'A', b2, { target: 'B' }); + expect(t.lastCheck).toMatchObject({ purpose: 'barrel', success: true }); + expect(t.lastCheck!.cards).toHaveLength(2); + expect(t.reactions).toEqual([]); + expect(t.discard.slice(-2).map((id) => cardById(id).suit)).toEqual(['S', 'H']); + }); +}); + +// ---------------------------------------------------------------- turn start checks + +describe('다이너마이트와 감옥 (§5.2)', () => { + test('다이너마이트: ♠2~♠9 폭발(3 피해); ♠10·♥는 다음 생존자로; 다이너마이트 → 감옥 순서', () => { + for (const [code, kind, boom] of [ + ['2S', 'missed', true], + ['9S', 'stagecoach', true], + ['8S', 'missed', true], + ['10S', 'volcanic', false], + ['6H', 'beer', false], + ['AS', 'bang', false], + ] as const) { + const c = pool(); + const dyn = c('dynamite'); + const jail = c('jail'); + const s0 = rig({ + players: basic({ B: { ch: 'willy', inPlay: [dyn, jail], hand: [] } }), + turn: 'A', + deckTop: [c(kind, code), c('beer', '7H')], + }); + // Ending A's turn starts B's turn. + const s = act(s0, 'A', { type: 'endPlay' }); + const b = pl(s, 'B'); + const checks = s.log.filter((e) => e.t === 'check').map((e) => (e.t === 'check' ? e.purpose : '')); + expect(checks).toEqual(['dynamite', 'jail']); + if (boom) { + expect(b.life).toBe(1); + expect(s.discard).toContain(dyn); + expect(pl(s, 'C').inPlay).not.toContain(dyn); + } else { + expect(b.life).toBe(4); + expect(pl(s, 'C').inPlay).toContain(dyn); + } + // ♥7 → escaped from jail, normal turn (drew 2). + expect(b.inPlay).not.toContain(jail); + expect(s.turn.player).toBe('B'); + expect(s.turn.phase).toBe('play'); + expect(b.hand.length).toBe(2); + conserve(s); + } + }); + + test('다이너마이트 폭발로 무법자가 죽으면 처치 보상 없음; 죽은 사람 턴은 넘어간다', () => { + const c = pool(); + const dyn = c('dynamite'); + const s0 = rig({ players: basic({ B: { inPlay: [dyn], life: 2, ch: 'suzy' } }), turn: 'A', deckTop: [c('missed', '5S')] }); + const s = act(s0, 'A', { type: 'endPlay' }); + expect(pl(s, 'B').alive).toBe(false); + expect(s.log.some((e) => e.t === 'reward')).toBe(false); + expect(s.log.find((e) => e.t === 'dead')).toMatchObject({ p: 'B', role: 'outlaw', by: null }); + expect(s.turn.player).toBe('C'); + }); + + test('감옥: 보안관 대상 거부; ♥ 탈출 시 정상 턴; 실패하면 1단계 포함 턴 전체를 건너뜀', () => { + const c = pool(); + const j = c('jail'); + let s = rig({ players: basic({ B: { hand: [j] } }), turn: 'B' }); + expect(reject(s, 'B', { type: 'play', cardId: j, target: 'A' })).toBe('보안관은 감옥에 넣을 수 없어요.'); + expect(reject(s, 'B', { type: 'play', cardId: j, target: 'B' })).toBe('자기 자신은 감옥에 넣을 수 없어요.'); + s = play(s, 'B', j, { target: 'C' }); + expect(pl(s, 'C').inPlay).toContain(j); + const j2 = c('jail'); + pl(s, 'B').hand.push(j2); + expect(reject(s, 'B', { type: 'play', cardId: j2, target: 'C' })).toBe('이미 감옥에 있는 사람이에요.'); + // C's turn: a ♠ → stays in jail, the whole turn is skipped (no cards drawn). + s.deck.push(c('missed', '6S')); + const handBefore = pl(s, 'C').hand.length; + s = act(s, 'B', { type: 'endPlay' }); + expect(s.log.find((e) => e.t === 'jail')).toMatchObject({ p: 'C', escaped: false }); + expect(pl(s, 'C').hand.length).toBe(handBefore); + expect(pl(s, 'C').inPlay).not.toContain(j); + expect(s.turn.player).toBe('D'); + // Escape on ♥. + const j3 = c('jail'); + let t = rig({ players: basic({ C: { inPlay: [j3] } }), turn: 'B', deckTop: [c('beer', '8H')] }); + t = act(t, 'B', { type: 'endPlay' }); + expect(t.turn.player).toBe('C'); + expect(t.turn.phase).toBe('draw'); // Pedro Ramirez may choose where to draw from + expect(pl(t, 'C').inPlay).toEqual([]); + }); +}); + +// ---------------------------------------------------------------- life, beer, death + +describe('맥주·살롱·빈사 (§5.3, §5.4)', () => { + test('맥주: 생명 가득이면 거부; 2명 남으면 거부; 살롱은 2명에서도 회복', () => { + const c = pool(); + const beer = c('beer'); + let s = rig({ players: basic({ A: { hand: [beer] } }) }); + expect(reject(s, 'A', { type: 'play', cardId: beer })).toBe('생명이 가득 차서 맥주를 쓸 수 없어요.'); + pl(s, 'A').life = 3; + s = play(s, 'A', beer); + expect(pl(s, 'A').life).toBe(4); + const b2 = c('beer'); + const sal = c('saloon'); + let t = rig({ players: basic({ A: { hand: [b2, sal], life: 2 }, B: { alive: false }, C: { alive: false }, D: { life: 1 } }) }); + expect(reject(t, 'A', { type: 'play', cardId: b2 })).toBe('두 명만 남으면 맥주는 효과가 없어요.'); + t = play(t, 'A', sal); + expect([pl(t, 'A').life, pl(t, 'D').life]).toEqual([3, 2]); + // House rule: beer at full health is allowed (no effect). + const b3 = c('beer'); + const u = rig({ players: basic({ A: { hand: [b3] } }), options: { beerAtFullHealth: true } }); + expect(reject(u, 'A', { type: 'play', cardId: b3 })).toBeNull(); + }); + + test('빈사: 생명 −1이면 맥주 2장 필요; 1장 제출 거부; 2장으로 생존', () => { + const c = pool(); + const dyn = c('dynamite'); + const beers = [c('beer'), c('beer')]; + const s0 = rig({ players: basic({ B: { inPlay: [dyn], life: 2, hand: beers, ch: 'suzy' } }), turn: 'A', deckTop: [c('missed', '7S')] }); + let s = act(s0, 'A', { type: 'endPlay' }); + expect(pl(s, 'B').life).toBe(-1); + expect(bang.view(s, 'B').my!.prompt).toMatchObject({ kind: 'dying', needed: 2, beers, beerAllowed: true }); + expect(reject(s, 'B', { type: 'respond', cardIds: [beers[0]!] })).toBe('맥주 2장이 필요해요.'); + s = act(s, 'B', { type: 'respond', cardIds: beers }); + expect(pl(s, 'B').life).toBe(1); + expect(pl(s, 'B').alive).toBe(true); + // B's turn continues (start → draw → play). + expect(s.turn.player).toBe('B'); + expect(s.turn.phase).toBe('play'); + }); + + test('2명 남았을 때 빈사에서 맥주를 쓸 수 없어 바로 제거', () => { + const c = pool(); + const b = c('bang'); + const s0 = rig({ players: basic({ A: { hand: [b] }, B: { alive: false }, C: { life: 1, hand: [c('beer')] }, D: { alive: false } }), turn: 'A' }); + let s = play(s0, 'A', b, { target: 'C' }); + s = act(s, 'C', { type: 'takeHit' }); + expect(pl(s, 'C').alive).toBe(false); + expect(s.winner).toBe('law'); + }); + + test('빈사 시간 초과: 맥주로 살 수 있으면 자동 사용, 아니면 포기', () => { + const c = pool(); + const b = c('bang'); + const beer = c('beer'); + let s = rig({ players: basic({ A: { hand: [b] }, B: { life: 1, hand: [beer] } }) }); + s = play(s, 'A', b, { target: 'B' }); + s = act(s, 'B', { type: 'takeHit' }); + expect(s.reactions[0]).toMatchObject({ kind: 'dying', player: 'B' }); + s = act(s, 'B', { type: 'timeout' }); + expect(pl(s, 'B')).toMatchObject({ alive: true, life: 1 }); + // giveUp → eliminated, killer A (sheriff kills an outlaw → 3 cards). + const b2 = c('bang'); + let t = rig({ players: basic({ A: { hand: [b2] }, B: { life: 1, hand: [c('beer')] } }) }); + t = play(t, 'A', b2, { target: 'B' }); + t = act(t, 'B', { type: 'takeHit' }); + t = act(t, 'B', { type: 'giveUp' }); + expect(pl(t, 'B').alive).toBe(false); + expect(t.log.find((e) => e.t === 'reward')).toMatchObject({ p: 'A', n: 3 }); + }); +}); + +// ---------------------------------------------------------------- duel, indians, gatling, store + +describe('결투·인디언!·개틀링·잡화점 (§5.4)', () => { + test('결투 왕복: A→B, B 뱅! → A 뱅! → B 포기 → B 1 손실(처치자 A); A가 먼저 포기하면 A 손실(처치자 B)', () => { + const c = pool(); + const duel = c('duel'); + const a1 = c('bang'); + const b1 = c('bang'); + let s = rig({ players: basic({ A: { hand: [duel, a1] }, B: { hand: [b1], life: 1, ch: 'suzy' }, C: { ch: 'kit' } }) }); + s = play(s, 'A', duel, { target: 'B' }); + expect(s.reactions[0]).toMatchObject({ kind: 'duel', actor: 'B', other: 'A' }); + expect(reject(s, 'A', { type: 'respond', cardIds: [a1] })).toBe('지금은 낼 수 있는 반응이 없어요.'); + s = act(s, 'B', { type: 'respond', cardIds: [b1] }); + // Suzy: her last card went into the duel → she draws one at once. + expect(pl(s, 'B').hand).toHaveLength(1); + expect(s.reactions[0]).toMatchObject({ kind: 'duel', actor: 'A', other: 'B' }); + s = act(s, 'A', { type: 'respond', cardIds: [a1] }); + expect(s.turn.bangsPlayed).toBe(0); // duel bangs do not count + s = act(s, 'B', { type: 'takeHit' }); + expect(pl(s, 'B').alive).toBe(false); + expect(s.log.find((e) => e.t === 'dead')).toMatchObject({ p: 'B', by: 'A' }); + expect(s.log.find((e) => e.t === 'reward')).toMatchObject({ p: 'A' }); + // A gives up first. + const d2 = c('duel'); + let t = rig({ players: basic({ A: { hand: [d2], life: 1 }, B: { hand: [c('bang')] }, C: { role: 'deputy' } }), turn: 'A' }); + t = play(t, 'A', d2, { target: 'B' }); + t = act(t, 'B', { type: 'respond', cardIds: [pl(t, 'B').hand[0]!] }); + t = act(t, 'A', { type: 'takeHit' }); + expect(t.log.find((e) => e.t === 'hit')).toMatchObject({ p: 'A', by: 'B' }); + expect(t.winner).toBe('outlaws'); + }); + + test('인디언!: 3명이 서로 다른 순서로 응답, 도착 순서대로 처리, 모두 끝나기 전 play 거부', () => { + const c = pool(); + const ind = c('indians'); + const bang1 = c('bang'); + const other = c('bang'); + let s = rig({ players: basic({ A: { hand: [ind, other] }, B: { hand: [] }, C: { hand: [bang1] }, D: { hand: [c('missed')] } }) }); + s = play(s, 'A', ind); + expect(s.reactions.map((r) => r.kind)).toEqual(['indians', 'indians', 'indians']); + expect(bang.activePlayers(s).sort()).toEqual(['B', 'C', 'D']); + expect(reject(s, 'A', { type: 'play', cardId: other, target: 'B' })).toBe('다른 사람의 반응을 기다리고 있어요.'); + s = act(s, 'D', { type: 'takeHit' }); + expect(reject(s, 'D', { type: 'takeHit' })).toBe('지금은 맞을 공격이 없어요.'); + s = act(s, 'C', { type: 'respond', cardIds: [bang1] }); + s = act(s, 'B', { type: 'timeout' }); + const hits = s.log.filter((e) => e.t === 'hit').map((e) => (e.t === 'hit' ? e.p : '')); + expect(hits).toEqual(['D', 'B']); + expect([pl(s, 'B').life, pl(s, 'C').life, pl(s, 'D').life]).toEqual([3, 4, 3]); + expect(s.reactions).toEqual([]); + expect(reject(s, 'A', { type: 'play', cardId: other, target: 'B' })).toBeNull(); + }); + + test('개틀링: 다른 모두에게 뱅!(거리 무관, 턴 제한에 포함 안 됨), 각자 빗나감!/술통 가능', () => { + const c = pool(); + const g = c('gatling'); + const b = c('bang'); + const mC = c('missed'); + let s = rig({ + players: basic({ A: { hand: [g, b] }, B: { inPlay: [c('barrel')] }, C: { hand: [mC], inPlay: [c('mustang')] } }), + deckTop: [c('beer', '9H')], + }); + s = play(s, 'A', g); + // B's barrel (♥) saved him at once; C and D must answer. + expect(s.reactions.map((r) => (r.kind === 'bang' ? r.target : ''))).toEqual(['C', 'D']); + s = act(s, 'C', { type: 'respond', cardIds: [mC] }); + s = act(s, 'D', { type: 'takeHit' }); + expect([pl(s, 'B').life, pl(s, 'C').life, pl(s, 'D').life]).toEqual([4, 4, 3]); + expect(s.turn.bangsPlayed).toBe(0); + expect(reject(s, 'A', { type: 'play', cardId: b, target: 'B' })).toBeNull(); + }); + + test('잡화점: 생존자 수만큼 공개, 낸 사람부터 시계 방향, 시간 초과 시 첫 장, 마지막 장은 자동', () => { + const c = pool(); + const gs = c('generalStore'); + const top = [c('bang'), c('beer'), c('missed'), c('panic')]; + let s = rig({ players: basic({ C: { hand: [gs] }, B: { alive: false } }), turn: 'C', deckTop: [...top, c('duel')] }); + s = play(s, 'C', gs); + const r = s.reactions[0]!; + expect(r.kind).toBe('store'); + if (r.kind !== 'store') return; + expect(r.revealed).toEqual(top.slice(0, 3)); + expect(r.picker).toBe('C'); + expect(bang.view(s, 'A').reactions[0]!.revealed!.map((x) => x.id)).toEqual(top.slice(0, 3)); + expect(reject(s, 'D', { type: 'storePick', cardId: top[0]! })).toBe('지금은 잡화점 카드를 고를 차례가 아니에요.'); + s = act(s, 'C', { type: 'storePick', cardId: top[1]! }); + expect(s.reactions[0]).toMatchObject({ picker: 'D' }); + s = act(s, 'D', { type: 'timeout' }); + // A gets the last card without choosing. + expect(s.reactions).toEqual([]); + expect(pl(s, 'C').hand).toEqual([top[1]!]); + expect(pl(s, 'D').hand).toEqual([top[0]!]); + expect(pl(s, 'A').hand).toEqual([top[2]!]); + conserve(s); + }); +}); + +// ---------------------------------------------------------------- other brown and blue cards + +describe('나머지 카드 (§5.3)', () => { + test('역마차 2장, 웰스 파고 3장', () => { + const c = pool(); + const st = c('stagecoach'); + const wf = c('wellsFargo'); + let s = rig({ players: basic({ A: { hand: [st, wf] } }) }); + s = play(s, 'A', st); + expect(pl(s, 'A').hand).toHaveLength(3); + s = play(s, 'A', wf); + expect(pl(s, 'A').hand).toHaveLength(5); + }); + + test('패닉!: 거리 1 이내만, 손패 무작위 1장 또는 장착 카드 지정; 가져간 카드는 두 사람에게만 보임', () => { + const c = pool(); + const pn = c('panic'); + const pn2 = c('panic'); + const bHand = c('beer'); + const mus = c('barrel'); + let s = rig({ players: basic({ A: { hand: [pn, pn2] }, B: { hand: [bHand], inPlay: [mus] }, C: { hand: [c('bang')] } }) }); + expect(reject(s, 'A', { type: 'play', cardId: pn, target: 'C', targetCard: 'hand' })).toBe('패닉!은 거리 1인 사람에게만 쓸 수 있어요(거리 2).'); + expect(reject(s, 'A', { type: 'play', cardId: pn, target: 'B' })).toBe('가져올 카드를 골라 주세요.'); + expect(reject(s, 'A', { type: 'play', cardId: pn, target: 'A', targetCard: 'hand' })).toBe('자기 자신에게는 쓸 수 없어요.'); + const opt = bang.view(s, 'A').my!.plays.find((o) => o.cardId === pn)!; + expect(opt.targets!.find((t) => t.id === 'B')!.cards).toEqual(['hand', mus]); + s = play(s, 'A', pn, { target: 'B', targetCard: mus }); + expect(pl(s, 'A').hand).toContain(mus); + expect(pl(s, 'B').inPlay).toEqual([]); + s = play(s, 'A', pn2, { target: 'B', targetCard: 'hand' }); + expect(pl(s, 'A').hand).toContain(bHand); + const entry = s.log.filter((e) => e.t === 'take').pop()!; + expect(entry).toMatchObject({ how: 'panic', vis: ['A', 'B'] }); + const seen = (v: string) => JSON.stringify(bang.view(s, v).log.filter((e) => e.t === 'take').pop()); + expect(seen('A')).toContain('"beer"'); + expect(seen('B')).toContain('"beer"'); + expect(seen('C')).not.toContain('"beer"'); + expect(JSON.stringify(bang.view(s, null).log.filter((e) => e.t === 'take').pop())).not.toContain('"beer"'); + }); + + test('캣 벌루: 거리 무관, 손패 무작위 1장(공개 버림) 또는 장착 카드; 하우스 룰로 자기 다이너마이트 제거', () => { + const c = pool(); + const cb = c('catBalou'); + const cHand = c('missed'); + let s = rig({ players: basic({ A: { hand: [cb] }, C: { hand: [cHand] } }) }); + s = play(s, 'A', cb, { target: 'C', targetCard: 'hand' }); + expect(pl(s, 'C').hand).toEqual([]); + expect(s.discard).toContain(cHand); + expect(s.log.filter((e) => e.t === 'discarded').pop()).toMatchObject({ p: 'C', by: 'A', fromHand: true }); + const cb2 = c('catBalou'); + const dyn = c('dynamite'); + const t = rig({ players: basic({ A: { hand: [cb2], inPlay: [dyn] } }) }); + expect(reject(t, 'A', { type: 'play', cardId: cb2, target: 'A', targetCard: dyn })).toBe('자기 자신에게는 쓸 수 없어요.'); + const u = rig({ players: basic({ A: { hand: [cb2], inPlay: [dyn] } }), options: { selfTargetCatBalou: true } }); + expect(reject(u, 'A', { type: 'play', cardId: cb2, target: 'A', targetCard: 'hand' })).toBe('내 손패에는 쓸 수 없어요.'); + const u2 = play(u, 'A', cb2, { target: 'A', targetCard: dyn }); + expect(pl(u2, 'A').inPlay).toEqual([]); + }); + + test('파란 카드: 같은 이름은 1장, 무기는 1개(새 무기를 내면 기존 무기 버림), 다이너마이트는 내 앞에', () => { + const c = pool(); + const sch = c('schofield'); + const sch2 = c('schofield'); + const win = c('winchester'); + const mus = c('mustang'); + const mus2 = c('mustang'); + const dyn = c('dynamite'); + let s = rig({ players: basic({ A: { hand: [sch, sch2, win, mus, mus2, dyn] } }) }); + s = play(s, 'A', sch); + expect(reject(s, 'A', { type: 'play', cardId: sch2 })).toBe('같은 카드를 이미 앞에 놓았어요.'); + s = play(s, 'A', win); + expect(pl(s, 'A').inPlay).toEqual([win]); + expect(s.discard).toContain(sch); + expect(bang.view(s, 'A').my!.range).toBe(5); + s = play(s, 'A', mus); + expect(reject(s, 'A', { type: 'play', cardId: mus2 })).toBe('같은 카드를 이미 앞에 놓았어요.'); + s = play(s, 'A', dyn); + expect(pl(s, 'A').inPlay).toEqual([win, mus, dyn]); + }); +}); + +// ---------------------------------------------------------------- death rewards, vulture, win + +describe('처치 보상·벌칙·벌처 샘·승리 (§5.6, §6)', () => { + test('무법자 처치 시 처치자가 3장(처치자가 무법자여도)', () => { + const c = pool(); + const b = c('bang'); + let s = rig({ players: basic({ B: { hand: [b] }, C: { role: 'outlaw', life: 1 }, D: { role: 'renegade' } }), turn: 'B' }); + s = play(s, 'B', b, { target: 'C' }); + s = act(s, 'C', { type: 'takeHit' }); + expect(pl(s, 'C').alive).toBe(false); + expect(pl(s, 'B').hand).toHaveLength(3); + expect(bang.view(s, 'D').players.find((p) => p.id === 'C')!.role).toBe('outlaw'); + }); + + test('보안관이 부관을 처치하면 손패·장착 전부 버림; 벌처 샘은 죽은 사람 카드를 전부 손패로 (동시에)', () => { + const c = pool(); + const b = c('bang'); + const keep = [c('beer'), c('missed')]; + const sheriffGear = [c('barrel'), c('scope')]; + const depHand = [c('panic'), c('duel')]; + const depGear = [c('mustang')]; + let s = rig({ + players: [ + // The scope makes up for the deputy's mustang. + { id: 'A', role: 'sheriff', ch: 'bart', hand: [b, ...keep], inPlay: sheriffGear }, + { id: 'B', role: 'deputy', ch: 'kit', life: 1, hand: depHand, inPlay: depGear }, + { id: 'C', role: 'outlaw', ch: 'vulture' }, + { id: 'D', role: 'outlaw', ch: 'pedro' }, + { id: 'E', role: 'renegade', ch: 'rose' }, + ], + }); + s = play(s, 'A', b, { target: 'B' }); + s = act(s, 'B', { type: 'takeHit' }); + expect(pl(s, 'B').alive).toBe(false); + expect(s.log.some((e) => e.t === 'penalty' && e.p === 'A')).toBe(true); + expect(pl(s, 'A').hand).toEqual([]); + expect(pl(s, 'A').inPlay).toEqual([]); + for (const id of [...keep, ...sheriffGear]) expect(s.discard).toContain(id); + expect(pl(s, 'C').hand.sort()).toEqual([...depHand, ...depGear].sort()); + conserve(s); + }); + + test('승리: 보안관·부관·배신자 생존 중 보안관 사망 → 무법자 승리(이미 죽은 무법자도)', () => { + const c = pool(); + const b = c('bang'); + let s = rig({ + players: [ + { id: 'A', role: 'sheriff', ch: 'bart', life: 1 }, + { id: 'B', role: 'deputy', ch: 'kit' }, + { id: 'C', role: 'renegade', ch: 'suzy', hand: [b] }, + { id: 'D', role: 'outlaw', ch: 'pedro', alive: false }, + { id: 'E', role: 'outlaw', ch: 'rose', alive: false }, + ], + turn: 'C', + }); + s = play(s, 'C', b, { target: 'A' }); + s = act(s, 'A', { type: 'takeHit' }); + expect(s.winner).toBe('outlaws'); + const r = bang.result(s)!; + expect(r.ranking[0]!.sort()).toEqual(['D', 'E']); + expect(r.scores).toEqual({ A: 0, B: 0, C: 0, D: 1, E: 1 }); + expect(r.summary).toContain('무법자 승리'); + expect(r.ranking.flat().sort()).toEqual(['A', 'B', 'C', 'D', 'E']); + }); + + test('배신자 1대1에서 보안관 처치 → 배신자 승리; 무법자·배신자 모두 쓰러지면 보안관 팀 승리', () => { + const c = pool(); + const b = c('bang'); + let s = rig({ players: basic({ A: { life: 1 }, B: { alive: false }, C: { hand: [b] }, D: { alive: false } }), turn: 'C' }); + s = play(s, 'C', b, { target: 'A' }); + s = act(s, 'A', { type: 'takeHit' }); + expect(s.winner).toBe('renegade'); + expect(bang.result(s)!.ranking[0]).toEqual(['C']); + expect(bang.result(s)!.summary).toContain('배신자 승리'); + // Law: the last outlaw falls. + const b2 = c('bang'); + let t = rig({ players: basic({ A: { hand: [b2] }, B: { life: 1 }, C: { alive: false }, D: { alive: false } }) }); + t = play(t, 'A', b2, { target: 'B' }); + t = act(t, 'B', { type: 'takeHit' }); + expect(t.winner).toBe('law'); + expect(bang.result(t)!.ranking[0]).toEqual(['A']); + // After the end every role is visible and actions are refused. + expect(bang.view(t, null).players.every((p) => p.role !== null)).toBe(true); + expect(reject(t, 'A', { type: 'endPlay' })).toBe('게임이 이미 끝났어요.'); + }); +}); + +// ---------------------------------------------------------------- characters + +describe('캐릭터 능력 (§3.2)', () => { + test('바트 캐시디: 잃은 생명만큼 1장씩', () => { + const c = pool(); + const b = c('bang'); + let s = rig({ players: basic({ A: { hand: [b] }, B: { ch: 'bart', hand: [] } }) }); + s = play(s, 'A', b, { target: 'B' }); + s = act(s, 'B', { type: 'takeHit' }); + expect(pl(s, 'B').hand).toHaveLength(1); + expect(s.log.some((e) => e.t === 'draw' && e.p === 'B' && e.why === 'bart')).toBe(true); + }); + + test('블랙 잭: 두 번째 카드를 공개하고 ♥/♦면 1장 더', () => { + for (const [second, extra] of [ + [['bang', 'QH'], true], + [['bang', '2D'], true], + [['missed', '2S'], false], + ] as const) { + const c = pool(); + let s = rig({ players: basic({ B: { ch: 'blackJack' } }), deckTop: [c('beer'), c(second[0], second[1]), c('panic')] }); + s = act(s, 'A', { type: 'endPlay' }); + expect(s.log.find((e) => e.t === 'blackJack')).toMatchObject({ p: 'B', extra }); + expect(pl(s, 'B').hand).toHaveLength(extra ? 3 : 2); + } + }); + + test('엘 그링고: 뱅!으로 1 피해 시 공격자 손패 무작위 1장; 공격자 손패 0이면 없음; 다이너마이트 피해에는 없음', () => { + const c = pool(); + const b = c('bang'); + const aCard = c('beer'); + let s = rig({ players: basic({ A: { hand: [b, aCard] }, B: { ch: 'elGringo', hand: [] } }) }); + s = play(s, 'A', b, { target: 'B' }); + s = act(s, 'B', { type: 'takeHit' }); + expect(pl(s, 'B').hand).toEqual([aCard]); + expect(pl(s, 'A').hand).toEqual([]); + const b2 = c('bang'); + let t = rig({ players: basic({ A: { hand: [b2] }, B: { ch: 'elGringo', hand: [] } }) }); + t = play(t, 'A', b2, { target: 'B' }); + t = act(t, 'B', { type: 'takeHit' }); + expect(pl(t, 'B').hand).toEqual([]); + // Dynamite: El Gringo (sheriff, 4 life) loses 3 and takes nothing. + const dyn = c('dynamite'); + const u0 = rig({ players: basic({ A: { ch: 'elGringo', inPlay: [dyn] }, D: { hand: [c('missed')] } }), turn: 'D', deckTop: [c('missed', '2S')] }); + const u = act(u0, 'D', { type: 'endPlay' }); + expect(pl(u, 'A').life).toBe(1); + expect(pl(u, 'D').hand).toHaveLength(1); + expect(u.log.some((e) => e.t === 'take')).toBe(false); + }); + + test('제시 존스: 첫 장을 다른 사람 손패에서 무작위로(손패 있는 사람만)', () => { + const c = pool(); + const cCard = c('beer'); + let s = rig({ players: basic({ B: { ch: 'jesse' }, C: { hand: [cCard] }, D: { hand: [] } }) }); + s = act(s, 'A', { type: 'endPlay' }); + expect(s.turn).toMatchObject({ player: 'B', phase: 'draw' }); + expect(bang.view(s, 'B').my!.prompt).toEqual({ kind: 'draw', jesseTargets: ['C'], pedro: null }); + expect(reject(s, 'B', { type: 'draw', jesseFrom: 'D' })).toBe('손패가 있는 다른 사람을 골라 주세요.'); + expect(reject(s, 'B', { type: 'draw', pedroDiscard: true })).toBe('페드로 라미레스만 버린 카드 더미에서 받을 수 있어요.'); + s = act(s, 'B', { type: 'draw', jesseFrom: 'C' }); + expect(pl(s, 'B').hand).toHaveLength(2); + expect(pl(s, 'B').hand).toContain(cCard); + expect(pl(s, 'C').hand).toEqual([]); + expect(s.turn.phase).toBe('play'); + }); + + test('킷 칼슨: 3장 보고 2장, 나머지는 덱 맨 위; 후보는 본인만 보임; 시간 초과 = 위 2장', () => { + const c = pool(); + const top = [c('bang'), c('beer'), c('missed')]; + let s = rig({ players: basic({ B: { ch: 'kit' } }), deckTop: top }); + s = act(s, 'A', { type: 'endPlay' }); + expect(s.reactions[0]).toMatchObject({ kind: 'kitKeep', player: 'B', options: top }); + expect(JSON.stringify(bang.view(s, 'C'))).not.toContain(`"${top[0]}"`); + expect(bang.view(s, 'B').my!.prompt).toMatchObject({ kind: 'kitKeep' }); + s = act(s, 'B', { type: 'kitKeep', keep: [top[0]!, top[2]!] }); + expect(pl(s, 'B').hand.sort()).toEqual([top[0]!, top[2]!].sort()); + expect(s.deck[s.deck.length - 1]).toBe(top[1]!); + let t = rig({ players: basic({ B: { ch: 'kit' } }), deckTop: top }); + t = act(t, 'A', { type: 'endPlay' }); + t = act(t, 'B', { type: 'timeout' }); + expect(pl(t, 'B').hand).toEqual([top[0]!, top[1]!]); + }); + + test('페드로 라미레스: 첫 장을 버림 더미 맨 위에서', () => { + const c = pool(); + const top = c('beer'); + let s = rig({ players: basic({ B: { ch: 'pedro' } }), discard: [c('bang'), top] }); + s = act(s, 'A', { type: 'endPlay' }); + expect(bang.view(s, 'B').my!.prompt).toMatchObject({ kind: 'draw', pedro: { kind: 'beer' } }); + s = act(s, 'B', { type: 'draw', pedroDiscard: true }); + expect(pl(s, 'B').hand).toContain(top); + expect(pl(s, 'B').hand).toHaveLength(2); + // Timeout → normal draw. + let t = rig({ players: basic({ B: { ch: 'pedro' } }), discard: [c('missed')] }); + t = act(t, 'A', { type: 'endPlay' }); + t = act(t, 'B', { type: 'timeout' }); + expect(t.turn.phase).toBe('play'); + expect(pl(t, 'B').hand).toHaveLength(2); + }); + + test('럭키 듀크: 감옥 판정에서 2장 공개 후 ♥로 탈출', () => { + const c = pool(); + const j = c('jail'); + let s = rig({ players: basic({ B: { ch: 'lucky', inPlay: [j] } }), deckTop: [c('missed', '2S'), c('beer', '10H')] }); + s = act(s, 'A', { type: 'endPlay' }); + expect(s.lastCheck).toMatchObject({ player: 'B', purpose: 'jail', success: true }); + expect(s.lastCheck!.cards).toHaveLength(2); + expect(s.turn).toMatchObject({ player: 'B', phase: 'play' }); + }); + + test('시드 케첨: 손패 2장으로 1 회복(자기 턴·반응 중·빈사), 최대 생명 초과 불가', () => { + const c = pool(); + const h = [c('mustang'), c('scope'), c('barrel'), c('volcanic'), c('jail'), c('dynamite')]; + let s = rig({ players: basic({ A: { ch: 'sid', life: 3, hand: h } }) }); + expect(reject(s, 'A', { type: 'sidHeal', cardIds: [h[0]!, h[0]!] })).toBe('같은 카드를 두 번 고를 수 없어요.'); + s = act(s, 'A', { type: 'sidHeal', cardIds: [h[0]!, h[1]!] }); + s = act(s, 'A', { type: 'sidHeal', cardIds: [h[2]!, h[3]!] }); + expect(pl(s, 'A').life).toBe(5); + expect(reject(s, 'A', { type: 'sidHeal', cardIds: [h[4]!, h[5]!] })).toBe('생명이 이미 가득 찼어요.'); + expect(reject(s, 'B', { type: 'sidHeal', cardIds: [h[2]!, h[3]!] })).toBe('시드 케첨만 쓸 수 있는 능력이에요.'); + // Dying at 0 with 2 cards and no beer: heals back to 1. + const b = c('bang'); + const two: [string, string] = [c('schofield'), c('remington')]; + let t = rig({ players: basic({ A: { hand: [b] }, B: { ch: 'sid', life: 1, hand: two } }) }); + t = play(t, 'A', b, { target: 'B' }); + expect(reject(t, 'C', { type: 'sidHeal', cardIds: two })).not.toBeNull(); + t = act(t, 'B', { type: 'takeHit' }); + expect(t.reactions[0]).toMatchObject({ kind: 'dying', player: 'B' }); + t = act(t, 'B', { type: 'sidHeal', cardIds: two }); + expect(pl(t, 'B')).toMatchObject({ alive: true, life: 1 }); + expect(t.reactions).toEqual([]); + // Not on someone else's turn without a reaction. + const u = rig({ players: basic({ B: { ch: 'sid', life: 2, hand: [c('carabine'), c('winchester')] } }) }); + expect(reject(u, 'B', { type: 'sidHeal', cardIds: pl(u, 'B').hand as [string, string] })).toBe('지금은 능력을 쓸 수 없어요.'); + }); + + test('수지 라파예트: 손패가 0장이 되는 순간 1장', () => { + const c = pool(); + const st = c('stagecoach'); + let s = rig({ players: basic({ A: { ch: 'suzy', hand: [st] } }) }); + s = play(s, 'A', st); + expect(pl(s, 'A').hand).toHaveLength(3); + expect(s.log.some((e) => e.t === 'draw' && e.why === 'suzy')).toBe(true); + }); + + test('폴 리그레트·로즈 둘란: 거리 ±1 (머스탱·조준경 없이)', () => { + const s = rig({ players: basic({ C: { ch: 'paul' }, A: { ch: 'rose' } }) }); + expect(distance(s, 'B', 'C')).toBe(2); + expect(distance(s, 'A', 'C')).toBe(2); + expect(distance(s, 'A', 'B')).toBe(1); + expect(distance(s, 'D', 'C')).toBe(2); + }); +}); + +// ---------------------------------------------------------------- timeouts, away, leaving + +describe('시간 초과·자리 비움·나가기 (§8.5)', () => { + test('턴 중 무응답 → endPlay → 손패 초과분 자동 버림', () => { + const c = pool(); + const hand = [c('mustang'), c('scope'), c('barrel'), c('volcanic'), c('jail'), c('winchester')]; + let s = rig({ players: basic({ A: { hand, life: 3 } }) }); + expect(bang.deadline!(s)).toBe(s.turn.deadline); + expect(bang.timeoutPlayers!(s)).toEqual(['A']); + s = act(s, 'A', { type: 'timeout' }); + expect(pl(s, 'A').hand).toHaveLength(3); + expect(s.log.some((e) => e.t === 'limit' && e.p === 'A' && e.cards.length === 3)).toBe(true); + expect(s.turn.player).toBe('B'); + }); + + test('버리기 단계: 정확한 장수만, 시간 초과 시 무작위', () => { + const c = pool(); + const hand = [c('mustang'), c('scope'), c('barrel'), c('volcanic'), c('jail')]; + let s = rig({ players: basic({ A: { hand, life: 4 } }) }); + s = act(s, 'A', { type: 'endPlay' }); + expect(s.turn.phase).toBe('discard'); + expect(bang.view(s, 'A').my!.prompt).toEqual({ kind: 'discard', count: 1 }); + expect(reject(s, 'A', { type: 'discard', cardIds: hand.slice(0, 2) })).toBe('카드 1장을 골라 버려 주세요.'); + s = act(s, 'A', { type: 'discard', cardIds: [hand[0]!] }); + expect(s.turn.player).toBe('B'); + }); + + test('반응 중에는 턴 시계가 멈춘다; 늦게 열린 반응은 먼저 끝나지 않는다', () => { + const c = pool(); + const g = c('gatling'); + let s = rig({ players: basic({ A: { hand: [g] }, B: { life: 1, hand: [c('beer')] } }) }); + const before = s.turn.deadline; + s = play(s, 'A', g); + const paused = clock; + s = act(s, 'B', { type: 'takeHit' }); + // B's dying window opened later than C's and D's bang windows. + expect(bang.timeoutPlayers!(s).sort()).toEqual(['C', 'D']); + s = act(s, 'C', { type: 'takeHit' }); + s = act(s, 'D', { type: 'takeHit' }); + expect(bang.timeoutPlayers!(s)).toEqual(['B']); + s = act(s, 'B', { type: 'respond', cardIds: pl(s, 'B').hand.filter((id) => cardById(id).kind === 'beer') }); + expect(s.reactions).toEqual([]); + expect(s.turn.deadline).toBe(before + (clock - paused)); + }); + + test('3번 연속 시간 초과 → 자리 비움(5초 제한), 직접 행동하면 복귀', () => { + const c = pool(); + let s = rig({ players: basic({ A: { hand: [c('bang')] } }) }); + for (let k = 0; k < 3; k++) { + const p = s.turn.player; + s = act(s, p, { type: 'timeout' }); + } + // A, B, C each timed out once; nobody is away yet. + expect(s.players.some((p) => p.away)).toBe(false); + const d = rig({ players: basic({ A: { hand: [c('bang')] } }) }); + pl(d, 'A').timeoutsInRow = 2; + let x = act(d, 'A', { type: 'timeout' }); + expect(pl(x, 'A').away).toBe(true); + // Next time it is A's turn the limit is 5 s. + for (const p of ['B', 'C', 'D']) x = act(x, p, { type: 'timeout' }); + expect(x.turn.player).toBe('A'); + expect(x.turn.deadline - clock).toBe(5000); + x = act(x, 'A', { type: 'endPlay' }); + expect(pl(x, 'A').away).toBe(false); + }); + + test('나가기: 그 사람은 쓰러진 것으로(역할 공개, 처치자 없음), 차례면 다음 사람; 보안관이 나가면 무법자 승리', () => { + const c = pool(); + let s = rig({ players: basic({ B: { hand: [c('beer')] } }), turn: 'B' }); + expect(bang.onLeave!(s, 'B')).toEqual({ type: 'leave' }); + s = act(s, 'B', { type: 'leave' }); + expect(pl(s, 'B').alive).toBe(false); + expect(s.log.find((e) => e.t === 'dead')).toMatchObject({ p: 'B', by: null, left: true }); + expect(s.turn.player).toBe('C'); + expect(bang.onLeave!(s, 'B')).toBeNull(); + s = act(s, 'A', { type: 'leave' }); + expect(s.winner).toBe('outlaws'); + expect(bang.onLeave!(s, 'C')).toBeNull(); + }); + + test('나가기: 반응 대기 중인 사람이 나가면 그 반응은 정리된다', () => { + const c = pool(); + const ind = c('indians'); + let s = rig({ players: basic({ A: { hand: [ind] } }) }); + s = play(s, 'A', ind); + s = act(s, 'C', { type: 'leave' }); + expect(s.reactions.map((r) => (r.kind === 'indians' ? r.target : ''))).toEqual(['B', 'D']); + }); + + test('onTimeout은 모든 상태에서 합법 행동', () => { + for (let seed = 1; seed <= 40; seed++) { + const players = ['A', 'B', 'C', 'D', 'E']; + let s = bang.setup({ players, options: bang.optionsSchema.parse({ characterDraft: seed % 2 ? 'choose2' : 'random' }), rng: SeededRng.fromSeed(seedN(seed)), now: 0 }); + const r = SeededRng.fromSeed(seedN(seed + 1000)); + for (let step = 0; step < 400 && !s.winner; step++) { + for (const p of bang.timeoutPlayers!(s)) expect(bang.validate(s, p, bang.onTimeout(s, p)).ok).toBe(true); + const p = bang.timeoutPlayers!(s)[0]!; + s = bang.apply(s, p, bang.onTimeout(s, p), { rng: r, now: (clock += 1000) }).state; + } + } + }); +}); + +// ---------------------------------------------------------------- schema, views, leaks + +describe('스키마·화면 정보 (§8.3)', () => { + test('옵션에 한국어 메타; 시스템 행동은 스키마에 없음', () => { + const shape = (bang.optionsSchema as unknown as { shape: Record }).shape; + for (const [k, v] of Object.entries(shape)) expect(v.meta()?.title, k).toBeTruthy(); + expect(bang.actionSchema.safeParse({ type: 'timeout' }).success).toBe(false); + expect(bang.actionSchema.safeParse({ type: 'leave' }).success).toBe(false); + expect(bang.actionSchema.safeParse({ type: 'play', cardId: 'c1', target: 'B' }).success).toBe(true); + expect(bang.actionSchema.safeParse({ type: 'kitKeep', keep: ['c1'] }).success).toBe(false); + expect(bang.defaultOptions).toMatchObject({ characterDraft: 'random', turnSeconds: 60, reactionSeconds: 15, autoDefend: true, autoBarrel: true }); + }); + + test('남의 차례·손에 없는 카드는 거부되고 상태는 그대로', () => { + const c = pool(); + const b = c('bang'); + const s = rig({ players: basic({ A: { hand: [b] }, B: { hand: [c('bang')] } }) }); + const json = JSON.stringify(s); + expect(reject(s, 'B', { type: 'play', cardId: pl(s, 'B').hand[0]!, target: 'A' })).toBe('지금은 카드를 낼 수 없어요.'); + expect(reject(s, 'A', { type: 'play', cardId: pl(s, 'B').hand[0]!, target: 'B' })).toBe('손에 없는 카드예요.'); + expect(reject(s, 'X', { type: 'endPlay' })).toBe('이 게임의 플레이어가 아니에요.'); + expect(reject(s, 'A', { type: 'play', cardId: b })).toBe('대상을 골라 주세요.'); + expect(JSON.stringify(s)).toBe(json); + }); + + test('view: 보안관·나·죽은 사람만 역할 공개, 남 손패는 장수만, 덱은 장수만', () => { + const s = bang.setup({ players: ['A', 'B', 'C', 'D', 'E', 'F', 'G'], options: bang.defaultOptions, rng: SeededRng.fromSeed(seedN(5)), now: 0 }); + for (const viewer of [...s.players.map((p) => p.id), null]) { + const v = bang.view(s, viewer); + for (const p of v.players) { + const real = pl(s, p.id); + expect(p.role).toBe(real.role === 'sheriff' || p.id === viewer ? real.role : null); + expect(p.handCount).toBe(real.hand.length); + } + expect(v.deckCount).toBe(s.deck.length); + expect(JSON.stringify(v)).not.toContain('"deck"'); + } + }); +}); + +// ---------------------------------------------------------------- properties + +const TOKEN = /"[^"\\]*"/g; + +/** Every JSON string token of a view is compared with the other players' secret ids; roles of hidden living players never show. */ +function checkLeaks(s: BangState, viewers: (PlayerId | null)[]): void { + const owners = new Map(); + for (const p of s.players) + for (const secret of bang.secretsOf!(s, p.id)) { + const k = `"${secret}"`; + const list = owners.get(k); + if (list) list.push(p.id); + else owners.set(k, [p.id]); + } + for (const viewer of viewers) { + const v = bang.view(s, viewer); + for (const tok of JSON.stringify(v).match(TOKEN) ?? []) { + const of = owners.get(tok); + if (of && of.some((o) => o !== viewer)) throw new Error(`leak ${tok} of ${of.join(',')} to ${viewer}`); + } + for (const p of v.players) { + const real = pl(s, p.id); + if (p.role && !s.winner && real.alive && real.role !== 'sheriff' && p.id !== viewer) throw new Error(`role of ${p.id} leaked to ${viewer}`); + } + } +} + +function invariants(s: BangState): void { + const all = allCards(s); + if (all.length !== 80 || new Set(all).size !== 80) throw new Error(`cards not conserved: ${all.length}`); + for (const p of s.players) { + if (p.life > p.maxLife) throw new Error(`${p.id} life above max`); + if (p.alive && p.life < 1 && !s.reactions.some((r) => r.kind === 'dying' && r.player === p.id)) throw new Error(`${p.id} alive at ${p.life} without dying window`); + if (!p.alive && (p.hand.length || p.inPlay.length)) throw new Error(`dead ${p.id} holds cards`); + if (p.inPlay.filter((id) => cardById(id).kind in { volcanic: 1, schofield: 1, remington: 1, carabine: 1, winchester: 1 }).length > 1) throw new Error('two weapons'); + const names = p.inPlay.map((id) => cardById(id).kind); + if (new Set(names).size !== names.length) throw new Error('duplicate blue card'); + } + if (s.deathOrder.length !== s.players.filter((p) => !p.alive).length) throw new Error('death order out of sync'); + if (!s.winner) { + if (!pl(s, s.turn.player).alive && s.reactions.length === 0) throw new Error('dead current player'); + if (bang.activePlayers(s).length === 0) throw new Error('nobody to act'); + } +} + +interface FastReport { + steps: number; + finished: boolean; + trace: string; + final: BangState; +} + +/** Same chooser/action selection as `randomPlayout`, with cheaper scheduled checks so 1,000 games stay fast. */ +function fastPlayout(players: PlayerId[], options: BangOptions, seed: RngState, maxSteps: number): FastReport { + const r = SeededRng.fromSeed(seed); + const chooser = SeededRng.fromSeed([seed[3], seed[2], seed[1], seed[0] ^ 0x9e3779b9]); + let now = 1_700_000_000_000; + let s = bang.setup({ players, options, rng: r, now }); + const trace: string[] = []; + for (let step = 0; step < maxSteps; step++) { + invariants(s); + if (step % 10 === 0) checkLeaks(s, [null, players[(step / 10) % players.length]!]); + if (step % 50 === 0 && JSON.stringify(JSON.parse(JSON.stringify(s))) !== JSON.stringify(s)) throw new Error('not JSON round-trippable'); + for (const p of bang.timeoutPlayers!(s)) { + const v = bang.validate(s, p, bang.onTimeout(s, p)); + if (!v.ok) throw new Error(`onTimeout rejected for ${p}: ${v.reason}`); + } + if (bang.result(s)) { + checkLeaks(s, [...players, null]); + return { steps: step, finished: true, trace: trace.join(';'), final: s }; + } + const actives = bang.activePlayers(s); + const actor = actives[chooser.int(actives.length)]!; + const legal = bang.legalActions!(s, actor); + // Now and then let the timer act instead (auto defence, auto discard, …). + const action = legal.length > 0 && chooser.int(8) > 0 ? legal[chooser.int(legal.length)]! : bang.onTimeout(s, actor); + const v = bang.validate(s, actor, action); + if (!v.ok) throw new Error(`generated action rejected: ${v.reason} ${JSON.stringify(action)}`); + now += 1000; + const out = bang.apply(s, actor, action, { rng: r, now }); + if (step % 10 === 5) { + const hidden = new Set([...out.state.deck, ...out.state.players.flatMap((p) => p.hand)].map((id) => `"${id}"`)); + for (const tok of JSON.stringify(out.events).match(TOKEN) ?? []) if (hidden.has(tok)) throw new Error(`event leaks ${tok}`); + } + s = out.state; + trace.push(`${actor}:${JSON.stringify(action)}`); + } + return { steps: maxSteps, finished: false, trace: trace.join(';'), final: s }; +} + +const PRESETS: Partial[] = [ + {}, + { characterDraft: 'choose2', autoBarrel: false }, + { autoDefend: false, drawBeforeDeath: true }, + { beerAtFullHealth: true, selfTargetCatBalou: true, deputyCount7: true }, + { characterDraft: 'choose2', autoBarrel: false, autoDefend: false, reactionSeconds: 8 }, +]; + +describe('무작위 대국 (시드 1,000개, 4~7명)', () => { + test('1,000 games finish; cards conserved, life in bounds, no leaks, onTimeout always legal, deterministic', () => { + let finished = 0; + const wins = { law: 0, outlaws: 0, renegade: 0 }; + for (let i = 1; i <= 1000; i++) { + const n = 4 + (i % 4); + const players = Array.from({ length: n }, (_, k) => `P${k}`); + const options = bang.optionsSchema.parse(PRESETS[i % PRESETS.length]!); + const a = fastPlayout(players, options, seedN(i), 5000); + if (!a.finished) throw new Error(`seed ${i} (${n} players) did not finish in 5,000 steps`); + finished++; + wins[a.final.winner!]++; + const res = bang.result(a.final)!; + expect(res.ranking.flat().sort()).toEqual([...players].sort()); + if (i % 25 === 0) { + const b = fastPlayout(players, options, seedN(i), 5000); + expect(b.trace).toBe(a.trace); + expect(JSON.stringify(b.final)).toBe(JSON.stringify(a.final)); + } + } + expect(finished).toBe(1000); + // Every team wins sometimes. + expect(wins.law).toBeGreaterThan(0); + expect(wins.outlaws).toBeGreaterThan(0); + expect(wins.renegade).toBeGreaterThan(0); + }, 60_000); + + test.each([ + [4, {}], + [5, { characterDraft: 'choose2' }], + [6, { autoBarrel: false }], + [7, { drawBeforeDeath: true }], + ] as const)('randomPlayout with the engine per-step leak / round-trip checks: %i players', (n, opts) => { + const players = Array.from({ length: n }, (_, k) => `P${k}`); + const options = bang.optionsSchema.parse(opts); + for (let i = 1; i <= 3; i++) { + const a = randomPlayout(bang, { players, options, seed: seedN(2000 + i), maxSteps: 5000, invariant: (st) => invariants(st as BangState), maxApplyMs: 30 }); + expect(a.finished).toBe(true); + if (i === 1) { + const b = randomPlayout(bang, { players, options, seed: seedN(2000 + i), maxSteps: 5000, maxApplyMs: 30 }); + expect(JSON.stringify(b.trace)).toBe(JSON.stringify(a.trace)); + } + } + }, 60_000); + + test('같은 시드 + 같은 행동 기록을 다시 적용하면 같은 최종 상태', () => { + const players = ['A', 'B', 'C', 'D', 'E', 'F']; + const options = bang.optionsSchema.parse({ characterDraft: 'choose2' }); + const rep = randomPlayout(bang, { players, options, seed: seedN(77), maxSteps: 5000, maxApplyMs: 30 }); + const replayRng = SeededRng.fromSeed(seedN(77)); + let now = 1_700_000_000_000; + let s = bang.setup({ players, options, rng: replayRng, now }); + for (const { actor, action } of rep.trace) s = bang.apply(s, actor, action as BangAction, { rng: replayRng, now: (now += 1000) }).state; + expect(JSON.stringify(s)).toBe(JSON.stringify(rep.finalState)); + }); +}); diff --git a/packages/games/src/bang/cards.ts b/packages/games/src/bang/cards.ts new file mode 100644 index 0000000..a7d7ed0 --- /dev/null +++ b/packages/games/src/bang/cards.ts @@ -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; + +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 = { + 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> = { 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 = { 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(dep).fill('deputy'), ...Array(out).fill('outlaw'), ...Array(ren).fill('renegade')]; +} diff --git a/packages/games/src/bang/distance.ts b/packages/games/src/bang/distance.ts new file mode 100644 index 0000000..94d3356 --- /dev/null +++ b/packages/games/src/bang/distance.ts @@ -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); +} diff --git a/packages/games/src/bang/effects.ts b/packages/games/src/bang/effects.ts new file mode 100644 index 0000000..51d8203 --- /dev/null +++ b/packages/games/src/bang/effects.ts @@ -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 : 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['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): 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 = { 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 = { + 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): 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): 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 = { 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): 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, 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; + 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; + 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); +} diff --git a/packages/games/src/bang/index.ts b/packages/games/src/bang/index.ts new file mode 100644 index 0000000..1eedced --- /dev/null +++ b/packages/games/src/bang/index.ts @@ -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; + +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 }; diff --git a/packages/games/src/bang/rules.ts b/packages/games/src/bang/rules.ts new file mode 100644 index 0000000..743fd0b --- /dev/null +++ b/packages/games/src/bang/rules.ts @@ -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; +} diff --git a/packages/games/src/bang/types.ts b/packages/games/src/bang/types.ts new file mode 100644 index 0000000..36991c6 --- /dev/null +++ b/packages/games/src/bang/types.ts @@ -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 = (...vals: T[]) => + z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral, z.ZodLiteral, ...z.ZodLiteral[]]); + +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; + +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 | { type: 'timeout' } | { type: 'leave' }; +export type PlayAction = Extract; + +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; +} diff --git a/packages/games/src/index.ts b/packages/games/src/index.ts index a4d2a12..c697b2d 100644 --- a/packages/games/src/index.ts +++ b/packages/games/src/index.ts @@ -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 = { [omok.id]: omok, @@ -25,4 +26,5 @@ export const GAMES: Record = { [oneCard.id]: oneCard, [rummikub.id]: rummikub, [davinci.id]: davinci, + [bang.id]: bang, }; diff --git a/packages/shared/src/catalog.ts b/packages/shared/src/catalog.ts index b3bd701..d0b573f 100644 --- a/packages/shared/src/catalog.ts +++ b/packages/shared/src/catalog.ts @@ -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 }, ];