135 lines
8.6 KiB
TypeScript
135 lines
8.6 KiB
TypeScript
/**
|
||
* 뱅! [?] "지금 할 수 있는 것" (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}`;
|
||
}
|