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

132 lines
8.7 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.
* Only what this viewer may know: the own role is named only where the screen already shows
* role-specific things (night actions, after death, after the game).
*/
import type { MafiaView } from '@bg/games/mafia';
import type { PublicUser } from '@bg/shared/lite';
const PHASE_KO: Record<MafiaView['phase']['kind'], string> = {
roleReveal: '직업 확인',
greeting: '인사 시간',
discussion: '낮 토론',
vote: '지목 투표',
defense: '최후의 반론',
confirm: '찬반 투표',
night: '밤',
ended: '게임 끝',
};
/** "~예요" after a vowel, "~이에요" after a final consonant. */
function yeyo(word: string): string {
const c = word.charCodeAt(word.length - 1) - 0xac00;
return c >= 0 && c < 11172 && c % 28 !== 0 ? `${word}이에요` : `${word}예요`;
}
const WIN = '시민 팀은 마피아를 모두 찾아내면, 마피아 팀은 살아 있는 마피아가 나머지 사람만큼 많아지면 이겨요.';
export function helpNow(v: MafiaView, me: string | null, players: Record<string, PublicUser>): string {
const n = (id: string) => `${players[id]?.nickname ?? '다른 사람'}님`;
const ph = v.phase;
const head = `${v.day}일차 ${yeyo(PHASE_KO[ph.kind])}.`;
if (ph.kind === 'ended') {
const chats = v.options.revealChatsAtEnd ? ' 모든 채팅방 기록도 이제 볼 수 있어요.' : '';
return `게임이 끝났어요. ${v.final?.summary ?? ''}`.trim() + `\n최종 공개 표에서 모든 사람의 직업을 볼 수 있어요.${chats}`;
}
const my = v.my;
// Spectators see the day chat and announcements only.
if (!my) {
const what: Record<string, string> = {
roleReveal: '모두 자기 직업을 확인하고 있어요.',
greeting: '살아 있는 사람들이 전체 채팅으로 인사하고 있어요. 오늘은 투표가 없어요.',
discussion: '살아 있는 사람들이 누가 마피아인지 이야기하고 있어요.',
vote: '살아 있는 사람들이 마피아로 의심되는 사람을 지목하고 있어요.',
defense: ph.accused ? `${n(ph.accused)}이 지목돼서 마지막으로 변론하고 있어요.` : '지목된 사람이 변론하고 있어요.',
confirm: ph.accused ? `${n(ph.accused)}을 탈락시킬지 찬반 투표를 하고 있어요.` : '찬반 투표를 하고 있어요.',
night: '각자 직업에 따라 몰래 행동하는 시간이에요. 아침에 발표가 있어요.',
};
return `${head} ${what[ph.kind] ?? ''}\n관전 중에는 전체 채팅과 발표만 볼 수 있어요.`;
}
const idle = my.idleWarning ? '\n오늘도 아무것도 하지 않으면 자리 비움으로 탈락해요.' : '';
if (!my.alive) {
const see = v.options.deadSeeAll ? ' 이 방에서는 탈락하면 모든 직업과 마피아 채팅도 볼 수 있어요.' : '';
return `${head} 나는 탈락했어요. [죽은 사람] 채팅에서만 이야기할 수 있어요.${see}\n살아 있는 사람에게 정보를 알려 주지 말아 주세요(디스코드 같은 다른 대화방도요).`;
}
switch (ph.kind) {
case 'roleReveal':
return `직업을 확인하는 시간이에요. 위의 [내 직업] 카드를 꾹 누르고 있는 동안만 직업이 보여요(옆 사람에게 보이지 않게).\n곧 ${v.options.firstPhase === 'day' ? '인사 시간' : '첫 밤'}이 시작돼요. ${WIN}`;
case 'greeting':
return `${head} [전체] 채팅으로 인사해요. 오늘은 투표가 없고, 끝나면 밤이 와요.\n다 됐으면 [인사 끝내기]를 눌러요. 살아 있는 사람이 모두 누르면 바로 넘어가요(${ph.readyCount}/${ph.readyNeeded}).${idle}`;
case 'discussion': {
const adj = my.canAdjust ? '\n[+15초]·[−15초]로 토론 시간을 하루 한 번 바꿀 수 있어요.' : '';
return `${head} [전체] 채팅으로 누가 마피아 같은지 이야기해요. 직업을 밝힐지는 내 선택이에요.${adj}\n다 이야기했으면 [토론 끝내기]를 눌러요. 모두 누르면 바로 지목 투표로 넘어가요(${ph.readyCount}/${ph.readyNeeded}).${idle}`;
}
case 'vote': {
const now = my.vote === null ? '아직 고르지 않았어요(안 고르면 기권).' : my.vote === 'abstain' ? '지금은 기권으로 되어 있어요.' : `지금 ${n(my.vote)}을 골랐어요.`;
return `${head} '사람들' 목록에서 초록 테두리 사람을 눌러 마피아로 의심되는 사람을 골라요. 시간 안에는 바꿀 수 있고, [기권]도 돼요.\n${now} 표가 가장 많은 한 명이 지목돼요. 같은 표가 나오면 아무도 지목되지 않아요.${idle}`;
}
case 'defense':
if (ph.accused === me) return `${head} 내가 지목됐어요! [전체] 채팅으로 내가 왜 마피아가 아닌지 마지막으로 말해요. 지금은 나만 말할 수 있어요.`;
return `${head} ${ph.accused ? n(ph.accused) : '지목된 사람'}의 마지막 변론을 들어요. 지금은 지목된 사람만 채팅할 수 있어요. 곧 찬반 투표를 해요.`;
case 'confirm': {
const who = ph.accused ? n(ph.accused) : '지목된 사람';
if (!my.canConfirm) return `${head} 다른 사람들이 나를 탈락시킬지 찬반 투표를 하고 있어요. 결과를 기다려 주세요.`;
const now = my.ballot ? `지금 ${my.ballot === 'yes' ? '찬성' : '반대'}했어요. 시간 안에는 바꿀 수 있어요.` : '안 누르면 집계에서 빠져요.';
return `${head} ${who}을 탈락시킬지 정해요. [찬성 (탈락)] 또는 [반대 (살리기)]를 눌러요.\n찬성이 반대보다 많아야 탈락해요. ${now}${idle}`;
}
case 'night':
return nightHelp(v, me, n) + idle;
}
return head;
}
function nightHelp(v: MafiaView, me: string | null, n: (id: string) => string): string {
const my = v.my!;
const nv = my.night;
const pick = "'사람들' 목록에서 초록 테두리 사람을 눌러 골라요. 밤이 끝날 때 고른 사람으로 정해지고, 그 전엔 바꿀 수 있어요.";
const lines: string[] = [];
const isAlive = (id: string) => v.players.some((p) => p.id === id && p.alive);
const mates = my.mates.filter(isAlive);
switch (nv?.action ?? null) {
case null: {
lines.push('밤이에요. 지금은 할 일이 없어요. 아침 발표를 기다려 주세요.');
if (my.role === 'lover' && mates.length) lines.push(`밤에는 [연인] 채팅으로 ${mates.map(n).join(', ')}과 둘이서만 이야기할 수 있어요.`);
break;
}
case 'mafiaTarget': {
lines.push(`밤이에요. 나는 마피아예요. 오늘 밤 노릴 사람을 ${pick}`);
if (mates.length) lines.push(`마피아끼리 한 명을 함께 정해요. 누가 고르든 마지막에 고른 사람이 대상이에요. 살아 있는 동료 마피아는 ${mates.map(n).join(', ')}이에요. [마피아] 채팅으로 의논해요.`);
const none = v.options.mafiaMustKill === 'random' ? '아무도 안 고르면 무작위로 한 명이 탈락해요.' : '아무도 안 고르면 오늘 밤은 아무도 탈락하지 않아요.';
lines.push(nv!.choice ? `지금 대상: ${n(nv!.choice)}${nv!.pickedBy && nv!.pickedBy !== me ? ` (${n(nv!.pickedBy)}이 골랐어요)` : ''}. [선택 취소]로 지울 수 있어요.` : none);
break;
}
case 'investigate': {
const what = v.options.policeRevealRole ? '그 사람의 직업이' : '마피아인지 아닌지가';
lines.push(`밤이에요. 나는 경찰이에요. 조사할 사람을 ${pick}`);
lines.push(`밤이 끝나면 ${what} '조사 결과'에 나만 보이게 나와요.${nv!.choice ? ` 지금 고른 사람: ${n(nv!.choice)}.` : ''}`);
break;
}
case 'heal': {
const self = v.options.doctorSelfHeal ? '나 자신도 고를 수 있어요.' : '나 자신은 고를 수 없어요.';
const rep = v.options.doctorRepeat ? '' : ' 어젯밤 지킨 사람은 오늘 다시 고를 수 없어요.';
lines.push(`밤이에요. 나는 의사예요. 지켜 줄 사람을 ${pick}`);
lines.push(`마피아가 그 사람을 노리면 살아나요. ${self}${rep}${nv!.choice ? ` 지금 고른 사람: ${n(nv!.choice)}.` : ''}`);
break;
}
case 'mediumPeek': {
if (nv!.targets.length === 0) lines.push('밤이에요. 나는 영매예요. 아직 탈락한 사람이 없어서 알아볼 사람이 없어요.');
else {
lines.push(`밤이에요. 나는 영매예요. 직업을 알아볼 탈락한 사람을 ${pick}`);
lines.push(`결과는 밤이 끝나면 '영매 결과'에 나만 보이게 나와요.${nv!.choice ? ` 지금 고른 사람: ${n(nv!.choice)}.` : ''}`);
}
lines.push('밤에는 [죽은 사람] 채팅을 읽고 쓸 수 있어요.');
break;
}
}
return lines.join('\n');
}