Files
joke-app/apps/web/src/games/bang/help.ts
2026-10-06 13:35:21 +09:00

135 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 뱅! [?] "지금 할 수 있는 것" (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}`;
}