/** * 뱅! [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. * Roles stay secret: only the sheriff (public to everyone) is ever named; my own role is not spelled * out either, because the screen only shows it on the hold-to-see card. */ import type { BangView, ReactionView } from '@bg/games/bang'; import type { PublicUser } from '@bg/shared/lite'; /** The two card names the reaction buttons use (display names otherwise live in index.tsx). */ const NAME = { bang: '뱅!', missed: '빗나감!' } as const; export function helpNow(v: BangView, me: string | null, players: Record): string { const n = (id: string | null | undefined) => `${(id && players[id]?.nickname) || '다른 사람'}님`; if (v.result || v.winner) { return `게임이 끝났어요. ${v.result?.summary ?? ''}`.trim() + '\n이제 모든 사람의 역할이 공개돼요. 결과 창에서 [한 판 더]를 누르면 다시 할 수 있어요.'; } const my = v.my; const meP = my ? v.players.find((p) => p.id === me) : undefined; const sheriff = v.players.find((p) => p.role === 'sheriff'); const tip = `⭐ 보안관${sheriff ? `(${n(sheriff.id)})` : ''}만 역할이 공개돼요. 다른 사람의 역할은 쓰러질 때 공개돼요.`; // Who the game is waiting for, in neutral words (spectators, dead players, everyone waiting). const waitingText = (rs: ReactionView[]): string => { const groups = new Map(); for (const r of rs) { const key = `${r.kind}-${r.source ?? ''}-${r.via ?? ''}`; const g = groups.get(key) ?? { r, who: [] }; if (!g.who.includes(r.who)) g.who.push(r.who); groups.set(key, g); } return [...groups.values()] .map(({ r, who }) => { const w = who.map(n).join(', '); switch (r.kind) { case 'bang': return `${w}이 ${n(r.source)}의 ${r.via === 'gatling' ? '개틀링' : '뱅!'}을 빗나감!으로 막을지 고르는 중이에요.`; case 'indians': return `${w}이 인디언!에 뱅!을 버릴지 고르는 중이에요.`; case 'duel': return `${w}이 결투에서 뱅!을 낼 차례예요.`; case 'store': return `${w}이 잡화점에서 카드를 고르는 중이에요.`; case 'dying': return `${w}이 쓰러지기 직전이에요. 맥주가 있으면 살아날 수 있어요.`; case 'kitKeep': return `${w}이 받을 카드를 고르는 중이에요.`; case 'choose2': return `${w}이 캐릭터를 고르는 중이에요.`; } return ''; }) .join('\n'); }; const situation = (): string => { if (v.phase === 'draft') return '모두 캐릭터를 고르는 중이에요.'; if (v.reactions.length) return `${n(v.current)} 차례예요.\n${waitingText(v.reactions)}`; return `${n(v.current)} 차례예요. 카드를 내며 공격하거나 준비하고 있어요.`; }; if (!my) return `${situation()}\n${tip}`; if (!meP?.alive) return `나는 쓰러졌어요. 남은 게임을 지켜봐요.\n${situation()}`; const life = `지금 내 생명: ${meP.life}/${meP.maxLife}.`; const sid = my.canSidHeal ? '\n시드 케첨 능력으로 카드 2장을 버리고 생명 1을 회복할 수도 있어요.' : ''; const p = my.prompt; if (p) { switch (p.kind) { case 'choose2': return '캐릭터 2명 중 1명을 눌러 골라요. 이름 아래에 능력이 적혀 있어요.\n고른 캐릭터에 따라 생명 수와 특별한 능력이 달라요.'; case 'bang': { const what = p.via === 'gatling' ? `${n(p.source)}의 개틀링` : `${n(p.source)}이 쏜 뱅!`; const hand = p.options.map((id) => my.hand.find((c) => c.id === id)!).filter(Boolean); hand.sort((a, b) => Number(a.kind !== 'missed') - Number(b.kind !== 'missed')); const use = hand.slice(0, p.needed); const need = p.needed > 1 ? ` 이 뱅!은 빗나감! ${p.needed}장이 있어야 막을 수 있어요.` : ''; const lines = [`${what}이 날아왔어요!${need}`]; if (use.length >= p.needed) { const label = use.map((c) => NAME[c.kind as 'bang' | 'missed'] ?? '빗나감!').join(' + '); lines.push(`[${label} 내기]를 누르면 막아요. 막지 않으려면 [맞기 (생명 −1)]를 눌러요.`); if (v.options.autoDefend) lines.push('시간 안에 고르지 않으면 막을 카드를 자동으로 내요.'); } else lines.push(`막을 카드가 ${p.options.length > 0 ? '모자라요' : '없어요'}. [맞기 (생명 −1)]를 눌러요.`); if (p.canBarrel) lines.push('[술통 판정하기]를 누르면 카드를 한 장 뒤집어 ♥가 나올 때 피해요.'); lines.push(life); return lines.join('\n') + sid; } case 'indians': case 'duel': { const card = p.options.map((id) => my.hand.find((c) => c.id === id)!).filter(Boolean).sort((a, b) => Number(a.kind !== 'bang') - Number(b.kind !== 'bang'))[0]; const give = card ? `[${NAME[card.kind as 'bang' | 'missed'] ?? '뱅!'} 내기]` : ''; const hit = p.kind === 'duel' ? '[포기하고 맞기 (생명 −1)]' : '[맞기 (생명 −1)]'; const head = p.kind === 'indians' ? `${n(p.source)}의 인디언!이에요. 뱅! 1장을 버리거나 생명 1을 잃어요.` : `${n(p.opponent)}과 결투 중이에요. 번갈아 뱅!을 내고, 먼저 못 내는 사람이 생명 1을 잃어요.`; const how = card ? `${give}를 누르거나 ${hit}를 눌러요.` : `낼 뱅!이 없어요. ${hit}를 눌러요.`; return `${head}\n${how}\n${life}${sid}`; } case 'dying': { if (!p.beerAllowed) return `생명이 다 떨어졌어요. 두 명만 남아서 맥주로는 살아날 수 없어요. [쓰러지기]를 눌러요.${sid}`; if (p.beers.length >= p.needed) return `생명이 다 떨어졌어요! [맥주 ${p.needed}장 마시기]를 누르면 살아나요. 아니면 [쓰러지기]예요.${sid}`; return `생명이 다 떨어졌어요. 살아나려면 맥주 ${p.needed}장이 필요한데 모자라요. [쓰러지기]를 눌러요.${sid}`; } case 'store': return '잡화점이에요. 펼쳐진 카드 중 1장을 눌러 가져와요. 순서대로 한 사람씩 1장씩 가져가요.'; case 'kitKeep': return '카드 3장 중 받을 2장을 눌러 고른 뒤 [이 2장 받기]를 눌러요. 남은 1장은 카드 더미 맨 위로 돌아가요.'; case 'draw': { const alt = [p.pedro ? '버린 카드 맨 위 1장' : '', p.jesseTargets.length ? '다른 사람 손패에서 무작위 1장' : ''].filter(Boolean).join(' 또는 '); return `내 차례예요. 이번 턴 카드 2장을 받아요.\n[카드 더미에서 2장 받기]를 누르세요.${alt ? ` 캐릭터 능력으로 첫 1장은 ${alt}으로 받을 수도 있어요.` : ''}`; } case 'discard': return `턴을 마치려면 손패를 생명 수(${meP.life}장)까지 줄여야 해요.\n버릴 카드 ${p.count}장을 눌러 고르고 [${p.count}장 버리기]를 눌러요.${sid}`; } } if (v.phase === 'draft') return `${situation()} 다 고르면 보안관부터 시작해요.`; const mine = v.current === me; if (mine && v.reactions.length) return `내 차례예요. 다른 사람의 반응을 기다리는 중이에요.\n${waitingText(v.reactions)}`; if (mine && v.phase === 'play') { const usable = my.plays.filter((o) => o.ok).length; const lines = [ usable > 0 ? '내 차례예요. 초록 테두리 카드를 누른 뒤 아래 버튼으로 내요. 대상이 필요한 카드는 초록 테두리 사람을 먼저 눌러요.' : '내 차례예요. 지금 낼 수 있는 카드가 없어요. [턴 마치기]를 눌러요.', ]; if (usable > 0) lines.push('흐린 카드를 누르면 지금 쓸 수 없는 이유를 알려 줘요. 다 냈으면 [턴 마치기]를 눌러요.'); const bang = my.unlimitedBang ? '뱅!은 이번 턴에 몇 번이든 쓸 수 있어요.' : v.bangsPlayed > 0 ? '이번 턴에는 뱅!을 이미 썼어요(한 턴에 1번).' : '뱅!은 한 턴에 1번만 쓸 수 있어요.'; lines.push(`내 사거리(${my.range})보다 거리가 먼 사람은 뱅!으로 쏠 수 없어요. ${bang}`); lines.push(`${life} 턴이 끝날 때 손패는 생명 수까지만 남길 수 있어요.${sid}`); return lines.join('\n'); } if (mine) return `내 차례예요. 잠시만 기다려 주세요.\n${life}`; return `${situation()}\n기다리는 동안 공격을 받으면 막을지 묻는 창이 떠요. ${life}`; }