M9: 게임 26종 30초 요약 카드·[?] 지금 할 수 있는 것 내용

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-06 13:35:21 +09:00
parent 32e3e89d8d
commit 68edd9b434
102 changed files with 6555 additions and 1 deletions

View File

@@ -0,0 +1,134 @@
/**
* 뱅! [?] "지금 할 수 있는 것" (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, PublicUser>): 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<string, { r: ReactionView; who: string[] }>();
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}`;
}