Merge branch 'm8/mafia' into codex/owner/grp_discord_joke-app

# Conflicts:
#	apps/server/src/rooms/room.ts
#	apps/web/src/games/registry.ts
#	apps/web/src/lib/catalog-ui.ts
#	e2e/games.e2e.ts
#	packages/engine/src/types.ts
#	packages/games/src/index.ts
#	packages/shared/src/catalog.ts
This commit is contained in:
EJClaw
2026-10-06 00:39:35 +09:00
16 changed files with 3238 additions and 14 deletions

View File

@@ -0,0 +1,70 @@
/** 마피아 through a real Room: room chat closed while playing, filtered game chat per connection, server-run phases. */
import { describe, expect, test } from 'bun:test';
import type { ServerMessage } from '@bg/shared';
import { GAMES } from '@bg/games';
import type { MafiaState, MafiaView } from '@bg/games/mafia';
import { openDb } from '../db';
import { Room, type Conn, type RoomConfig } from './room';
import { RoomStore } from './store';
function harness(players: string[]) {
const db = openDb(':memory:');
const store = new RoomStore(db);
let now = 1_000_000;
const clock = { now: () => now, advance: (ms: number) => (now += ms) };
const games = { mafia: GAMES.mafia! };
const deps = { store, games, users: { publicUser: (id: string) => ({ id, nickname: id, avatar: null, kind: 'guest' as const }) }, now: clock.now };
const config: RoomConfig = {
gameId: 'mafia',
options: { ...games.mafia.defaultOptions, firstPhase: 'night' },
maxPlayers: players.length,
visibility: 'private',
allowSpectators: true,
chatEnabled: true,
chatFilter: true,
graceSec: 60,
};
const room = new Room('room1', '482913', config, players[0]!, deps);
store.insertRoom({ id: 'room1', code: '482913', hostId: players[0]!, gameId: 'mafia', status: 'lobby', visibility: 'private', config, stateJson: '{}', now });
const conns: Record<string, Conn & { msgs: ServerMessage[] }> = {};
for (const p of players) {
const msgs: ServerMessage[] = [];
conns[p] = { userId: p, msgs, send: (m) => msgs.push(m), close: () => {} };
room.join(conns[p], 'player');
}
const state = () => room.game!.state as MafiaState;
const lastView = (p: string) => (conns[p]!.msgs.filter((m) => m.t === 'state').at(-1) as Extract<ServerMessage, { t: 'state' }>).view as MafiaView;
return { room, clock, conns, state, lastView };
}
describe('마피아 room flow', () => {
test('게임 중 방 채팅·반응은 닫히고, 게임 채팅은 연결마다 채널 권한대로만 간다', () => {
const { room, clock, conns, state, lastView } = harness(['A', 'B', 'C', 'D']);
expect(room.chat('A', '대기실 인사')).toBeNull();
expect(room.start('A')).toBeNull();
expect(room.chat('A', '몰래 말하기')).toBe('게임 중에는 게임 화면의 채팅을 써 주세요.');
// Role reveal → night on the server clock.
clock.advance(8_000);
room.onTimer();
expect(state().phase.kind).toBe('night');
const mafia = state().players.find((p) => p.role === 'mafia')!.id;
const town = state().players.filter((p) => p.role !== 'mafia').map((p) => p.id);
room.act(mafia, 1, { type: 'chat', channel: 'mafia', text: 'ROOM-MAFIA-SECRET' });
expect(lastView(mafia).chat.map((c) => c.text)).toContain('ROOM-MAFIA-SECRET');
for (const t of town) {
const all = JSON.stringify(conns[t]!.msgs);
expect(all).not.toContain('ROOM-MAFIA-SECRET');
expect(all).not.toContain(`"role":"mafia"`);
}
// Rate limit (validate gets the server clock), and the system-only phase timer cannot be sent by clients.
room.act(mafia, 2, { type: 'chat', channel: 'mafia', text: '또' });
expect(conns[mafia]!.msgs.at(-1)).toEqual({ t: 'reject', cs: 2, reason: '조금 천천히 보내 주세요.' });
room.act(town[0]!, 3, { type: 'phaseTimeout' });
expect(conns[town[0]!]!.msgs.at(-1)).toEqual({ t: 'reject', cs: 3, reason: '잘못된 요청이에요.' });
expect(state().phase.kind).toBe('night');
clock.advance(30_000);
room.onTimer();
expect(state().phase.kind).toBe('discussion');
expect(state().day).toBe(2);
});
});

View File

@@ -564,6 +564,7 @@ export class Room {
chat(userId: string, text: string): string | null { chat(userId: string, text: string): string | null {
if (!this.config.chatEnabled) return '이 방은 채팅을 쓰지 않아요.'; if (!this.config.chatEnabled) return '이 방은 채팅을 쓰지 않아요.';
if (!this.isParticipant(userId)) return '방에 들어와야 채팅할 수 있어요.'; if (!this.isParticipant(userId)) return '방에 들어와야 채팅할 수 있어요.';
if (this.gameChatOnly()) return '게임 중에는 게임 화면의 채팅을 써 주세요.';
const now = this.now(); const now = this.now();
if (now - (this.lastChatAt.get(userId) ?? 0) < 500) return '조금 천천히 보내 주세요.'; if (now - (this.lastChatAt.get(userId) ?? 0) < 500) return '조금 천천히 보내 주세요.';
this.lastChatAt.set(userId, now); this.lastChatAt.set(userId, now);
@@ -581,8 +582,13 @@ export class Room {
return null; return null;
} }
/** Games with their own chat channels (마피아) close the room chat while playing, so nothing leaks around them. */
private gameChatOnly(): boolean {
return (this.status === 'playing' || this.status === 'paused') && !!this.def.ownChat;
}
emote(userId: string, id: string): void { emote(userId: string, id: string): void {
if (!this.isParticipant(userId)) return; if (!this.isParticipant(userId) || this.gameChatOnly()) return;
const now = this.now(); const now = this.now();
if (now - (this.lastChatAt.get(`e:${userId}`) ?? 0) < 500) return; if (now - (this.lastChatAt.get(`e:${userId}`) ?? 0) < 500) return;
this.lastChatAt.set(`e:${userId}`, now); this.lastChatAt.set(`e:${userId}`, now);

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
'ninety-nine': () => import('./ninety-nine'), 'ninety-nine': () => import('./ninety-nine'),
dobble: () => import('./dobble'), dobble: () => import('./dobble'),
catchmind: () => import('./catchmind'), catchmind: () => import('./catchmind'),
mafia: () => import('./mafia'),
}; };
const cache = new Map<string, GameUI>(); const cache = new Map<string, GameUI>();

View File

@@ -27,4 +27,6 @@ export interface GameUI<V = any, O = any> {
Options: ComponentType<OptionsProps<O>>; Options: ComponentType<OptionsProps<O>>;
optionsSummary(options: O): string; optionsSummary(options: O): string;
Rules: ComponentType<{ options?: O }>; Rules: ComponentType<{ options?: O }>;
/** The board has its own chat channels; the room chat panel is hidden while the game runs (마피아). */
ownChat?: boolean;
} }

View File

@@ -24,7 +24,8 @@ export const GAME_ICON: Record<string, string> = {
'ninety-nine': '💯', 'ninety-nine': '💯',
dobble: '👀', dobble: '👀',
catchmind: '🖍️', catchmind: '🖍️',
mafia: '🌙',
}; };
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */ /** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
export const UPCOMING = ['도블', '그림 맞히기', '마피아']; export const UPCOMING: string[] = [];

View File

@@ -9,10 +9,12 @@ export default function GamesPage() {
<main className="page"> <main className="page">
<h1>게임 목록</h1> <h1>게임 목록</h1>
<GameGrid games={catalog.filter((g) => g.available)} /> <GameGrid games={catalog.filter((g) => g.available)} />
<section className="stack"> {UPCOMING.length > 0 && (
<h2>곧 나와요</h2> <section className="stack">
<p className="muted">{UPCOMING.join(' · ')}</p> <h2>곧 나와요</h2>
</section> <p className="muted">{UPCOMING.join(' · ')}</p>
</section>
)}
</main> </main>
); );
} }

View File

@@ -86,7 +86,7 @@ export function Home() {
<section className="stack" style={{ gap: 16 }}> <section className="stack" style={{ gap: 16 }}>
<h2>게임 고르기</h2> <h2>게임 고르기</h2>
<GameGrid games={available} /> <GameGrid games={available} />
<p className="muted small">곧 나와요: {UPCOMING.join(' · ')}</p> {UPCOMING.length > 0 && <p className="muted small">곧 나와요: {UPCOMING.join(' · ')}</p>}
</section> </section>
<aside className="stack" style={{ gap: 12 }}> <aside className="stack" style={{ gap: 12 }}>
<h2>지금 열린 방</h2> <h2>지금 열린 방</h2>

View File

@@ -136,6 +136,8 @@ function Room({ code, me }: { code: string; me: PublicUser }) {
const g = { nameKo: gameName(room.gameId) }; const g = { nameKo: gameName(room.gameId) };
const inGame = room.status === 'playing' || room.status === 'paused' || (room.status === 'finished' && !!game); const inGame = room.status === 'playing' || room.status === 'paused' || (room.status === 'finished' && !!game);
// Games with their own chat channels (마피아) close the room chat while playing (the server refuses it too).
const roomChatClosed = !!ui.ownChat && (room.status === 'playing' || room.status === 'paused');
const isHost = room.hostId === me.id; const isHost = room.hostId === me.id;
const doLeave = () => { const doLeave = () => {
socket.leave(); socket.leave();
@@ -157,9 +159,11 @@ function Room({ code, me }: { code: string; me: PublicUser }) {
</strong> </strong>
<CopyButton text={`${location.origin}/r/${code}`} label={<>방 <CodeLabel code={code} /> 🔗</>} done="링크 복사됨 ✓" className="btn btn-ghost btn-sm" /> <CopyButton text={`${location.origin}/r/${code}`} label={<>방 <CodeLabel code={code} /> 🔗</>} done="링크 복사됨 ✓" className="btn btn-ghost btn-sm" />
<span style={{ flex: 1 }} /> <span style={{ flex: 1 }} />
<span className="phone-only"> {!roomChatClosed && (
<ChatButton onOpen={() => setChatOpen(true)} /> <span className="phone-only">
</span> <ChatButton onOpen={() => setChatOpen(true)} />
</span>
)}
<button className="btn btn-ghost btn-sm wide-only" onClick={() => nav(`/rules/${room.gameId}`)}> <button className="btn btn-ghost btn-sm wide-only" onClick={() => nav(`/rules/${room.gameId}`)}>
📖 규칙 📖 규칙
</button> </button>
@@ -183,7 +187,7 @@ function Room({ code, me }: { code: string; me: PublicUser }) {
)} )}
{room.status === 'finished' && result && game && <ResultPanel room={room} me={me} players={players} onLeave={doLeave} />} {room.status === 'finished' && result && game && <ResultPanel room={room} me={me} players={players} onLeave={doLeave} />}
<EmoteBubbles room={room} /> <EmoteBubbles room={room} />
{chatOpen && ( {chatOpen && !roomChatClosed && (
<Sheet onClose={() => setChatOpen(false)} label="채팅"> <Sheet onClose={() => setChatOpen(false)} label="채팅">
<ChatPanel room={room} me={me} /> <ChatPanel room={room} me={me} />
<button className="btn btn-secondary btn-block" onClick={() => setChatOpen(false)}> <button className="btn btn-secondary btn-block" onClick={() => setChatOpen(false)}>
@@ -549,7 +553,7 @@ function GameView({ room, ui, me, players, pending, slow }: { room: RoomView; ui
<aside className="game-side" aria-label="게임 정보"> <aside className="game-side" aria-label="게임 정보">
{wide && status} {wide && status}
<ui.Side {...props} /> <ui.Side {...props} />
{wide && <ChatPanel room={room} me={me} title={false} />} {wide && !(ui.ownChat && (room.status === 'playing' || room.status === 'paused')) && <ChatPanel room={room} me={me} title={false} />}
</aside> </aside>
</div> </div>
); );

View File

@@ -784,6 +784,51 @@ async function catchmindTurn(pages: Page[], turn: number, shots: boolean): Promi
return word; return word;
} }
/** 마피아: nicknames of the E2E pages (host first). */
const MAFIA_NICKS = ['방장', '친구', '친구2', '친구3'];
/** 마피아: the night prompt tells each page its role without holding the role card. */
async function mafiaRoleOf(p: Page): Promise<'mafia' | 'police' | 'doctor' | 'citizen'> {
const text = await p.locator('.mf-actions .mf-prompt').first().innerText();
if (text.includes('마피아가 노릴')) return 'mafia';
if (text.includes('조사할')) return 'police';
if (text.includes('지켜 줄')) return 'doctor';
if (text.includes('기다려')) return 'citizen';
throw new Error(`unknown night prompt: ${text}`);
}
/** 마피아: picks the highlighted player tile of `nick` (vote / night action). */
async function mafiaPick(p: Page, nick: string) {
const tile = p.locator('button.mf-tile[data-target="ok"]', { has: p.locator('.nm', { hasText: new RegExp(`^${nick}( \\(나\\))?$`) }) });
await tile.click();
await p.locator('button.mf-tile[aria-pressed="true"]', { has: p.locator('.nm', { hasText: new RegExp(`^${nick}( \\(나\\))?$`) }) }).waitFor({ timeout: 5000 });
}
/** 마피아: writes in the selected channel tab and waits until the line shows up. */
async function mafiaSay(p: Page, channel: '전체' | '마피아' | '죽은 사람', text: string) {
await p.getByRole('tab', { name: new RegExp(`^${channel} 채팅`) }).click();
await p.getByLabel(`${channel} 채팅 메시지`).fill(text);
await p.getByLabel(`${channel} 채팅 메시지`).press('Enter');
await p.locator('.mf-msgs .mf-msg', { hasText: text }).waitFor({ timeout: 5000 });
}
async function mafiaBanner(p: Page, phase: string, timeout = 40_000) {
await p.locator('.mf-banner .mf-ph strong', { hasText: phase }).waitFor({ timeout });
}
async function mafiaShots(pages: Page[], name: string) {
const [host, phone] = pages as [Page, Page];
await host.screenshot({ path: join(SHOTS, `mafia-${name}-pc.png`) });
await phone.screenshot({ path: join(SHOTS, `mafia-${name}-phone.png`) });
await host.emulateMedia({ colorScheme: 'dark' });
await phone.emulateMedia({ colorScheme: 'dark' });
await host.waitForTimeout(250);
await host.screenshot({ path: join(SHOTS, `mafia-${name}-pc-dark.png`) });
await phone.screenshot({ path: join(SHOTS, `mafia-${name}-phone-dark.png`), fullPage: true });
await host.emulateMedia({ colorScheme: 'light' });
await phone.emulateMedia({ colorScheme: 'light' });
}
const PLANS: Record<string, Plan> = { const PLANS: Record<string, Plan> = {
rummikub: { rummikub: {
// PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI. // PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI.
@@ -1270,7 +1315,7 @@ const PLANS: Record<string, Plan> = {
// rings the bell (PC: Space, phone: tap), plus at least one deliberate wrong bell. 24+ flips through the UI. // rings the bell (PC: Space, phone: tap), plus at least one deliberate wrong bell. 24+ flips through the UI.
players: 3, players: 3,
lobby: async (host) => { lobby: async (host) => {
await host.getByRole('group', { name: '뒤집기 제한 시간' }).getByRole('button', { name: '5초' }).click(); await host.getByRole('group', { name: '뒤집기 제한 시간' }).getByRole('button', { name: '5초', exact: true }).click();
await host.getByRole('button', { name: '설정 저장' }).click(); await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/뒤집기 5초/).first().waitFor(); await host.getByText(/뒤집기 5초/).first().waitFor();
}, },
@@ -1838,6 +1883,105 @@ const PLANS: Record<string, Plan> = {
console.log(` catchmind: drew and guessed ${words.join(', ')}; both 12 points (10 + 2)`); console.log(` catchmind: drew and guessed ${words.join(', ')}; both 12 points (10 + 2)`);
}, },
}, },
mafia: {
// PC host + 3 phone guests (마피아·경찰·의사·시민), short phases via the lobby. Real flow through the UI: role card,
// greeting chat in 전체, night (mafia chat + target, police investigates the mafia, doctor guards themself), the
// morning news, dead chat, discussion → vote → last defense → 찬반 → the mafia is voted out and the town wins.
// Channel checks: mafia and dead messages never reach other screens; the room chat is closed during the game.
players: 4,
lobby: async (host) => {
await host.getByRole('group', { name: '밤 시간' }).getByRole('button', { name: '15초' }).click();
await host.getByRole('button', { name: '▼ 자세한 설정' }).click();
await host.getByRole('group', { name: '지목 투표 시간' }).getByRole('button', { name: '10초', exact: true }).click();
await host.getByRole('group', { name: '최후의 반론 시간' }).getByRole('button', { name: '10초', exact: true }).click();
await host.getByRole('group', { name: '찬반 투표 시간' }).getByRole('button', { name: '5초', exact: true }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/밤 15초 · 투표 10초/).first().waitFor();
},
drive: async (pages) => {
const [host, phone] = pages as [Page, Page, Page, Page];
for (const p of pages) await p.locator('.mf-banner').waitFor();
// Hold-to-see role card (keyboard: Enter shows it for a moment).
const card = host.locator('.mf-rolecard');
if (!(await card.getAttribute('aria-label'))!.startsWith('내 직업 보기')) throw new Error('role card shows the role before it is held');
await card.focus();
await host.keyboard.press('Enter');
if (!(await card.getAttribute('aria-label'))!.startsWith('내 직업:')) throw new Error('role card did not reveal on Enter');
await host.screenshot({ path: join(SHOTS, 'mafia-1-role-pc.png') });
// Room chat is closed while the game runs (the board has its own chat).
if (await host.locator('.chat-panel').count()) throw new Error('room chat panel is visible during the game');
if (await phone.getByRole('button', { name: /^채팅/ }).count()) throw new Error('room chat button is visible on the phone during the game');
await mafiaBanner(host, '인사 시간');
await mafiaSay(phone, '전체', '안녕하세요 반가워요');
for (const p of pages) await p.locator('.mf-msgs .mf-msg', { hasText: '안녕하세요 반가워요' }).waitFor({ timeout: 5000 });
for (const p of pages) await p.getByRole('button', { name: /^인사 끝내기/ }).click();
for (const p of pages) await mafiaBanner(p, '밤');
const roles = await Promise.all(pages.map(mafiaRoleOf));
const at = (r: string) => roles.indexOf(r as (typeof roles)[number]);
const [M, P, D, C] = [at('mafia'), at('police'), at('doctor'), at('citizen')];
if ([M, P, D, C].some((i) => i < 0)) throw new Error(`unexpected roles ${roles.join(',')}`);
console.log(` mafia: roles ${roles.map((r, i) => `${MAFIA_NICKS[i]}=${r}`).join(' ')}`);
// Same night layout for everyone; only the mafia has the 마피아 tab.
for (const [i, p] of pages.entries()) {
if ((await p.getByRole('tab', { name: /^마피아 채팅/ }).count()) !== (i === M ? 1 : 0)) throw new Error(`${MAFIA_NICKS[i]}: wrong 마피아 tab`);
if (!(await p.locator('.mf-actions').count())) throw new Error('night panel missing');
}
if (await pages[C]!.getByLabel('전체 채팅 메시지').count()) throw new Error('day chat open at night');
await mafiaSay(pages[M]!, '마피아', 'MAFIA-SECRET 오늘은 조용히');
await mafiaPick(pages[M]!, MAFIA_NICKS[C]!);
await mafiaPick(pages[P]!, MAFIA_NICKS[M]!);
await mafiaPick(pages[D]!, MAFIA_NICKS[D]!);
await pages[0]!.waitForTimeout(300);
for (const [i, p] of pages.entries()) {
if (i !== M && (await p.content()).includes('MAFIA-SECRET')) throw new Error(`mafia chat leaked to ${MAFIA_NICKS[i]}`);
}
await mafiaShots(pages, '2-night');
for (const p of pages) await mafiaBanner(p, '낮 토론', 30_000);
for (const p of pages) await p.locator('.mf-news', { hasText: '아침 발표' }).waitFor();
await pages[C]!.locator('.mf-news', { hasText: '탈락했어요' }).waitFor();
await pages[P]!.locator('.mf-results', { hasText: '마피아예요!' }).waitFor();
for (const [i, p] of pages.entries()) if (i !== P && (await p.locator('.mf-results').count())) throw new Error('police result shown to someone else');
// The dead citizen: no day chat, writes in 죽은 사람 only — nobody alive sees it.
await pages[C]!.getByRole('tab', { name: /^전체 채팅/ }).click();
if (await pages[C]!.getByLabel('전체 채팅 메시지').count()) throw new Error('dead player can write in day chat');
await mafiaSay(pages[C]!, '죽은 사람', 'DEAD-SECRET 저 시민이었어요');
await mafiaSay(pages[P]!, '전체', `${MAFIA_NICKS[M]}님이 마피아예요`);
await pages[0]!.waitForTimeout(300);
for (const [i, p] of pages.entries()) {
if (i !== C && (await p.content()).includes('DEAD-SECRET')) throw new Error(`dead chat leaked to ${MAFIA_NICKS[i]}`);
if (i !== M && (await p.content()).includes('MAFIA-SECRET')) throw new Error(`mafia chat leaked to ${MAFIA_NICKS[i]} by day`);
}
await mafiaShots(pages, '3-day');
for (const i of [M, P, D]) await pages[i]!.getByRole('button', { name: /^토론 끝내기/ }).click();
for (const i of [M, P, D]) await mafiaBanner(pages[i]!, '지목 투표', 15_000);
await mafiaPick(pages[P]!, MAFIA_NICKS[M]!);
await mafiaPick(pages[D]!, MAFIA_NICKS[M]!);
await mafiaPick(pages[M]!, MAFIA_NICKS[P]!);
await mafiaBanner(host, '최후의 반론', 20_000);
await pages[M]!.locator('.mf-news', { hasText: '지목 투표 결과' }).waitFor();
if (await pages[P]!.getByLabel('전체 채팅 메시지').count()) throw new Error('others can talk during the last defense');
await mafiaSay(pages[M]!, '전체', '저는 진짜 시민이에요');
await pages[P]!.locator('.mf-msgs .mf-msg', { hasText: '저는 진짜 시민이에요' }).waitFor({ timeout: 5000 });
await mafiaShots(pages, '4-defense');
await mafiaBanner(pages[P]!, '찬반 투표', 20_000);
await pages[P]!.getByRole('button', { name: '찬성 (탈락)' }).click();
await pages[D]!.getByRole('button', { name: '찬성 (탈락)' }).click();
await pages[P]!.getByRole('button', { name: '찬성 (탈락)', pressed: true }).waitFor();
for (const p of pages) await p.getByRole('dialog', { name: '게임 결과' }).waitFor({ timeout: 20_000 });
for (const p of pages) await p.locator('.mf-final', { hasText: '시민 팀 승리' }).waitFor();
// After the end every channel is revealed (revealChatsAtEnd).
await pages[C]!.getByRole('tab', { name: /^마피아 채팅/ }).waitFor();
await host.waitForTimeout(500);
await host.screenshot({ path: join(SHOTS, 'mafia-5-result-pc.png') });
await phone.screenshot({ path: join(SHOTS, 'mafia-5-result-phone.png') });
console.log(' mafia: night kill, police result, dead chat, vote → defense → 찬반 → town won');
},
},
}; };
const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS); const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS);

View File

@@ -31,6 +31,11 @@ export interface SetupContext<O> {
gameNo?: number; gameNo?: number;
} }
/** Optional server context for `validate` (time-based checks such as chat rate limits). Absent in pure tests. */
export interface ValidateContext {
now: number;
}
export interface ApplyContext { export interface ApplyContext {
rng: Rng; rng: Rng;
now: number; now: number;
@@ -47,11 +52,13 @@ export interface GameDefinition<S, A, V, O> {
optionsSchema: ZodType<O>; optionsSchema: ZodType<O>;
actionSchema: ZodType<A>; actionSchema: ZodType<A>;
playersFor?(options: O): { min: number; max: number }; playersFor?(options: O): { min: number; max: number };
/** The game runs its own (filtered) chat channels: the room chat and quick reactions are closed while it is played. */
ownChat?: boolean;
/** Extra player-count rule `playersFor` cannot express (e.g. even counts only); Korean reason or null. */ /** Extra player-count rule `playersFor` cannot express (e.g. even counts only); Korean reason or null. */
playerCountError?(options: O, count: number): string | null; playerCountError?(options: O, count: number): string | null;
setup(ctx: SetupContext<O>): S; setup(ctx: SetupContext<O>): S;
/** `ctx.now` is passed by the server (real-time games may rate-limit with it); tests may omit it. */ /** `ctx.now` is passed by the server (real-time games may rate-limit with it); tests may omit it. */
validate(state: S, actor: PlayerId, action: A, ctx?: { now: number }): Validation; validate(state: S, actor: PlayerId, action: A, ctx?: ValidateContext): Validation;
apply(state: S, actor: PlayerId, action: A, ctx: ApplyContext): { state: S; events: GameEvent[] }; apply(state: S, actor: PlayerId, action: A, ctx: ApplyContext): { state: S; events: GameEvent[] };
view(state: S, viewer: PlayerId | null): V; view(state: S, viewer: PlayerId | null): V;
/** /**

View File

@@ -24,6 +24,7 @@ import { cockroachPoker } from './cockroach-poker';
import { ninetyNine } from './ninety-nine'; import { ninetyNine } from './ninety-nine';
import { dobble } from './dobble'; import { dobble } from './dobble';
import { catchmind } from './catchmind'; import { catchmind } from './catchmind';
import { mafia } from './mafia';
export const GAMES: Record<string, AnyGameDefinition> = { export const GAMES: Record<string, AnyGameDefinition> = {
[omok.id]: omok, [omok.id]: omok,
@@ -51,4 +52,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
[ninetyNine.id]: ninetyNine, [ninetyNine.id]: ninetyNine,
[dobble.id]: dobble, [dobble.id]: dobble,
[catchmind.id]: catchmind, [catchmind.id]: catchmind,
[mafia.id]: mafia,
}; };

View File

@@ -0,0 +1,784 @@
/**
* 마피아 (docs/games/mafia.md). Pure and deterministic; state is JSON.
*
* The server runs the phases: every phase has `endsAt`; the room timer applies the system-only
* `phaseTimeout` there (one "driver" player, `timeoutNotBefore` keeps away players from firing it
* early). Chat lives in the state and the view filters it by channel permission (§5.6, §8.3).
*/
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng, type Validation } from '@bg/engine';
import {
CHANNELS,
EXTRA_ROLES,
mafiaActionSchema,
mafiaOptionsSchema,
type Channel,
type ChatView,
type DiedBy,
type ExtraRole,
type MafiaAction,
type MafiaFinal,
type MafiaOptions,
type MafiaState,
type MafiaView,
type MyView,
type NightActionType,
type NightView,
type Phase,
type PlayerState,
type PrivateResult,
type PublicEvent,
type Role,
type Team,
type WinCause,
} from './types';
export * from './types';
export const ROLE_REVEAL_MS = 8_000;
export const GREETING_MS = 60_000;
export const ADJUST_MS = 15_000;
/** `-15초` never leaves less than this (§5.2). */
export const ADJUST_FLOOR_MS = 5_000;
export const CHAT_MAX = 200;
export const CHAT_GAP_MS = 700;
/** Voting days in a row without any action → 자리 비움 탈락 (§8.5). */
export const AWAY_DAYS = 2;
const CHAT_KEEP = 400;
const CHAT_VIEW = 150;
const EVENTS_VIEW = 120;
export const ROLE_KO: Record<Role, string> = {
mafia: '마피아',
police: '경찰',
doctor: '의사',
citizen: '시민',
soldier: '군인',
politician: '정치인',
medium: '영매',
lover: '연인',
};
export const teamOf = (r: Role): Team => (r === 'mafia' ? 'mafia' : 'town');
/** §4 table: mafia count for `auto`. */
const AUTO_MAFIA: Record<number, number> = { 4: 1, 5: 1, 6: 2, 7: 2, 8: 2, 9: 3, 10: 3, 11: 3, 12: 3 };
const EXTRA_SLOTS: Record<ExtraRole, number> = { soldier: 1, politician: 1, medium: 1, lovers: 2 };
const EXTRA_OBJ: Record<ExtraRole, string> = { soldier: '군인을', politician: '정치인을', medium: '영매를', lovers: '연인을' };
export function mafiaCountFor(o: MafiaOptions, n: number): number {
return o.mafiaCount === 'auto' ? (AUTO_MAFIA[n] ?? Math.max(1, Math.floor(n / 4))) : o.mafiaCount;
}
/** Why this composition cannot start (lobby refusal, §4.2), or null. */
export function compositionError(o: MafiaOptions, n: number): string | null {
if (n < 4 || n > 12) return '마피아는 4~12명이 할 수 있어요.';
const m = mafiaCountFor(o, n);
if (m >= n / 2) return `${n}명일 때 마피아는 ${Math.ceil(n / 2) - 1}명까지 정할 수 있어요.`;
let citizens = n - m - 2;
for (const r of EXTRA_ROLES) {
if (!o.extraRoles[r]) continue;
if (citizens < EXTRA_SLOTS[r]) return `인원이 부족해서 ${EXTRA_OBJ[r]} 넣을 수 없어요(시민 자리 ${EXTRA_SLOTS[r]}개 필요).`;
citizens -= EXTRA_SLOTS[r];
}
return null;
}
/** Roles for `n` players before shuffling (§4). */
export function rolesFor(o: MafiaOptions, n: number): Role[] {
const err = compositionError(o, n);
if (err) throw new Error(`mafia: ${err}`);
const m = mafiaCountFor(o, n);
const out: Role[] = [...Array<Role>(m).fill('mafia'), 'police', 'doctor'];
if (o.extraRoles.soldier) out.push('soldier');
if (o.extraRoles.politician) out.push('politician');
if (o.extraRoles.medium) out.push('medium');
if (o.extraRoles.lovers) out.push('lover', 'lover');
while (out.length < n) out.push('citizen');
return out;
}
// ---------------------------------------------------------------- chat filter (kidSafeChat)
const BANNED = ['시발', '씨발', '병신', '좆', '개새끼', '느금', '니애미', 'fuck', 'shit', 'bitch'];
const LINK = /(https?:\/\/|www\.|\b[a-z0-9-]+\.(com|net|org|kr|io|gg|me|ly|co|xyz|app|dev|site|link)\b)/i;
const PHONE = /(01[016789]|0\d{1,2})[-\s.]?\d{3,4}[-\s.]?\d{4}/;
export function cleanChat(text: string): string {
return text.replace(/[\u0000-\u001f\u007f]/g, ' ').trim();
}
/** Korean reason a message is refused, or null. */
export function chatProblem(text: string, kidSafe: boolean): string | null {
const len = [...text].length;
if (len === 0) return '메시지를 입력해 주세요.';
if (len > CHAT_MAX) return `메시지는 ${CHAT_MAX}자까지 보낼 수 있어요.`;
if (!kidSafe) return null;
const squashed = text.normalize('NFC').toLowerCase().replace(/[\s_.\-*0-9]/g, '');
if (BANNED.some((w) => squashed.includes(w))) return '고운 말을 써 주세요.';
if (LINK.test(text)) return '링크는 보낼 수 없어요.';
if (PHONE.test(text)) return '전화번호 같은 개인 정보는 보낼 수 없어요.';
return null;
}
// ---------------------------------------------------------------- helpers
const P = (s: MafiaState, id: PlayerId): PlayerState | undefined => s.players.find((p) => p.id === id);
const alive = (s: MafiaState) => s.players.filter((p) => p.alive);
const isAlive = (s: MafiaState, id: PlayerId | null) => !!id && !!P(s, id)?.alive;
type NightPhase = Extract<Phase, { kind: 'night' }>;
function discussionMs(s: MafiaState): number {
const o = s.options;
const lo = o.discussionMin;
const hi = Math.max(o.discussionMin, o.discussionMax);
return Math.min(hi, Math.max(lo, alive(s).length * o.discussionPerAlive)) * 1000;
}
function pushEvent(s: MafiaState, now: number, e: DistributiveOmit<PublicEvent, 'n' | 'at' | 'day'>, day = s.day): void {
s.events.push({ ...e, n: s.events.length + 1, at: now, day } as PublicEvent);
}
type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : never;
function startPhase(s: MafiaState, now: number, kind: 'greeting' | 'discussion' | 'vote' | 'night'): void {
switch (kind) {
case 'greeting':
s.phase = { kind, startedAt: now, endsAt: now + GREETING_MS, ready: [] };
break;
case 'discussion':
s.phase = { kind, startedAt: now, endsAt: now + discussionMs(s), adjustedBy: [], ready: [] };
break;
case 'vote':
s.phase = { kind, startedAt: now, endsAt: now + s.options.voteSeconds * 1000, votes: {} };
break;
case 'night':
s.phase = { kind, startedAt: now, endsAt: now + s.options.nightSeconds * 1000, mafiaTarget: null, mafiaPickedBy: null, investigate: null, heal: null, mediumPeek: null };
break;
}
pushEvent(s, now, { t: 'phase', kind });
}
function eliminate(s: MafiaState, now: number, p: PlayerState, by: DiedBy): void {
p.alive = false;
p.diedDay = s.day;
p.diedBy = by;
const reveal = s.options.deathReveal;
pushEvent(s, now, { t: 'death', p: p.id, by, team: reveal === 'none' ? null : teamOf(p.role), role: reveal === 'role' ? p.role : null });
}
function summaryOf(winner: Team, cause: WinCause, day: number): string {
if (winner === 'town') {
if (cause === 'vote') return `시민 팀 승리! ${day}일차 낮 투표로 마지막 마피아를 찾아냈어요.`;
if (cause === 'disconnect') return `시민 팀 승리! ${day}일차에 마지막 마피아가 자리를 비워 탈락했어요.`;
return '시민 팀 승리! 마피아가 모두 탈락했어요.';
}
if (cause === 'vote') return `마피아 팀 승리! ${day}일차 낮 투표 뒤 마피아 수가 시민 수와 같아졌어요.`;
if (cause === 'night') return `마피아 팀 승리! ${day}일차 아침, 마피아 수가 시민 수와 같아졌어요.`;
return `마피아 팀 승리! ${day}일차에 자리 비움 탈락으로 마피아 수가 시민 수와 같아졌어요.`;
}
/** §6: checked after an execution, after the morning news and after an away/leave elimination. */
function checkWin(s: MafiaState, now: number, cause: WinCause): boolean {
const living = alive(s);
const m = living.filter((p) => p.role === 'mafia').length;
const winner: Team | null = m === 0 ? 'town' : m >= living.length - m ? 'mafia' : null;
if (!winner) return false;
s.winner = winner;
s.phase = { kind: 'ended' };
pushEvent(s, now, { t: 'end', winner });
const players = s.players.map((p) => ({
id: p.id,
role: p.role,
team: teamOf(p.role),
alive: p.alive,
diedDay: p.diedDay,
diedBy: p.diedBy,
won: teamOf(p.role) === winner,
}));
const order = (won: boolean) => [...players.filter((p) => p.won === won && p.alive), ...players.filter((p) => p.won === won && !p.alive)].map((p) => p.id);
let mvp: PlayerId | null = null;
for (const p of s.players) if (teamOf(p.role) === 'town' && (s.mvpVotes[p.id] ?? 0) > (mvp ? s.mvpVotes[mvp]! : 0)) mvp = p.id;
s.final = { winner, days: s.day, cause, players, ranking: [...order(true), ...order(false)], mvp, summary: summaryOf(winner, cause, s.day) };
return true;
}
/** End of a voting day: away detection (§8.5), then win check. Returns true when the game ended. */
function endDay(s: MafiaState, now: number): boolean {
const away: PlayerState[] = [];
for (const p of alive(s)) {
p.idleDays = p.activeToday ? 0 : p.idleDays + 1;
p.activeToday = false;
if (p.idleDays >= AWAY_DAYS) away.push(p);
}
for (const p of away) eliminate(s, now, p, 'disconnect');
return away.length > 0 && checkWin(s, now, 'disconnect');
}
function resolveVote(s: MafiaState, now: number): PlayerId | null {
const ph = s.phase as Extract<Phase, { kind: 'vote' }>;
const tally: Record<PlayerId, number> = {};
const votes: Record<PlayerId, PlayerId | 'abstain'> = {};
let abstain = 0;
for (const p of alive(s)) {
const w = p.role === 'politician' ? 2 : 1;
const v = ph.votes[p.id];
if (!v || v === 'abstain' || !isAlive(s, v)) {
abstain += w;
votes[p.id] = 'abstain';
} else {
tally[v] = (tally[v] ?? 0) + w;
votes[p.id] = v;
}
}
const max = Math.max(0, ...Object.values(tally));
const tops = Object.keys(tally).filter((k) => tally[k] === max);
const accused = max >= 1 && tops.length === 1 ? tops[0]! : null;
s.trialVoters = accused ? Object.keys(votes).filter((k) => votes[k] === accused) : [];
pushEvent(s, now, { t: 'vote', tally, abstain, votes: s.options.openVote ? votes : null, accused, tie: tops.length > 1 });
return accused;
}
/** Returns true when the game ended. */
function resolveConfirm(s: MafiaState, now: number): boolean {
const ph = s.phase as Extract<Phase, { kind: 'confirm' }>;
let yes = 0;
let no = 0;
for (const p of alive(s)) {
const b = ph.ballots[p.id];
if (!b || p.id === ph.accused) continue;
const w = p.role === 'politician' ? 2 : 1;
if (b === 'yes') yes += w;
else no += w;
}
const acc = P(s, ph.accused)!;
if (yes <= no || !acc.alive) {
pushEvent(s, now, { t: 'confirm', accused: acc.id, yes, no, outcome: 'spared' });
return false;
}
if (acc.role === 'politician') {
acc.publicRole = true;
pushEvent(s, now, { t: 'confirm', accused: acc.id, yes, no, outcome: 'politician' });
return false;
}
pushEvent(s, now, { t: 'confirm', accused: acc.id, yes, no, outcome: 'executed' });
eliminate(s, now, acc, 'vote');
if (acc.role === 'mafia') {
for (const v of s.trialVoters) if (teamOf(P(s, v)!.role) === 'town') s.mvpVotes[v] = (s.mvpVotes[v] ?? 0) + 1;
}
return checkWin(s, now, 'vote');
}
/** Night resolution in the §5.5 order. Returns true when the game ended. */
function resolveNight(s: MafiaState, now: number, rng: Rng): boolean {
const ph = s.phase as NightPhase;
let target = isAlive(s, ph.mafiaTarget) ? ph.mafiaTarget : null;
const mafiaAlive = alive(s).some((p) => p.role === 'mafia');
if (!target && s.options.mafiaMustKill === 'random' && mafiaAlive) {
const pool = alive(s).filter((p) => p.role !== 'mafia');
if (pool.length) target = pool[rng.int(pool.length)]!.id;
}
if (!target) pushEvent(s, now, { t: 'quiet' });
else if (target === ph.heal) pushEvent(s, now, { t: 'healed' });
else {
const t = P(s, target)!;
if (t.role === 'soldier' && !t.soldierUsed) {
t.soldierUsed = true;
t.publicRole = true;
pushEvent(s, now, { t: 'soldier', p: t.id });
} else eliminate(s, now, t, 'mafia');
}
s.lastHealTarget = ph.heal;
// Private results, delivered at the end of the night (§5.5.5) even if the police/medium died tonight.
const police = s.players.find((p) => p.role === 'police');
if (police && ph.investigate) {
const r = P(s, ph.investigate)!.role;
addResult(s, police.id, 'investigate', ph.investigate, s.options.policeRevealRole ? r : r === 'mafia' ? 'mafia' : 'notMafia');
}
const medium = s.players.find((p) => p.role === 'medium');
if (medium && ph.mediumPeek) addResult(s, medium.id, 'peek', ph.mediumPeek, P(s, ph.mediumPeek)!.role);
s.day += 1;
return checkWin(s, now, 'night');
}
function addResult(s: MafiaState, to: PlayerId, kind: PrivateResult['kind'], target: PlayerId, result: PrivateResult['result']): void {
s.privateResults.push({ id: `res-${s.day}-${kind}-${s.privateResults.length + 1}`, to, day: s.day, kind, target, result });
}
/** The phase timer ran out (or everyone pressed 토론 끝내기). */
function advance(s: MafiaState, now: number, rng: Rng): void {
const ph = s.phase;
switch (ph.kind) {
case 'roleReveal':
startPhase(s, now, s.options.firstPhase === 'day' ? 'greeting' : 'night');
return;
case 'greeting':
startPhase(s, now, 'night');
return;
case 'discussion':
startPhase(s, now, 'vote');
return;
case 'vote': {
const accused = resolveVote(s, now);
if (accused) {
s.phase = { kind: 'defense', startedAt: now, endsAt: now + s.options.defenseSeconds * 1000, accused };
pushEvent(s, now, { t: 'phase', kind: 'defense' });
return;
}
if (!endDay(s, now)) startPhase(s, now, 'night');
return;
}
case 'defense':
s.phase = { kind: 'confirm', startedAt: now, endsAt: now + s.options.confirmSeconds * 1000, accused: ph.accused, ballots: {} };
pushEvent(s, now, { t: 'phase', kind: 'confirm' });
return;
case 'confirm':
if (resolveConfirm(s, now)) return;
if (!endDay(s, now)) startPhase(s, now, 'night');
return;
case 'night':
if (!resolveNight(s, now, rng)) startPhase(s, now, 'discussion');
return;
case 'ended':
throw new Error('mafia: advance after the end');
}
}
/** A player leaves mid-game: eliminated at once (like 자리 비움), then the phase is repaired. */
function leave(s: MafiaState, now: number, rng: Rng, p: PlayerState): void {
eliminate(s, now, p, 'disconnect');
if (checkWin(s, now, 'disconnect')) return;
const ph = s.phase;
switch (ph.kind) {
case 'greeting':
case 'discussion':
ph.ready = ph.ready.filter((x) => x !== p.id);
if (ph.ready.length > 0 && alive(s).every((x) => ph.ready.includes(x.id))) advance(s, now, rng);
return;
case 'vote':
delete ph.votes[p.id];
return;
case 'defense':
case 'confirm':
if (ph.kind === 'confirm') delete ph.ballots[p.id];
if (ph.accused === p.id && !endDay(s, now)) startPhase(s, now, 'night');
return;
case 'night':
if (ph.mafiaTarget === p.id) {
ph.mafiaTarget = null;
ph.mafiaPickedBy = null;
}
if (ph.investigate === p.id) ph.investigate = null;
if (ph.heal === p.id) ph.heal = null;
return;
}
}
// ---------------------------------------------------------------- permissions
function nightActionOf(r: Role): NightActionType | null {
return r === 'mafia' ? 'mafiaTarget' : r === 'police' ? 'investigate' : r === 'doctor' ? 'heal' : r === 'medium' ? 'mediumPeek' : null;
}
/** Legal targets for a night action. */
function nightTargets(s: MafiaState, me: PlayerState, action: NightActionType): PlayerId[] {
switch (action) {
case 'mafiaTarget':
return alive(s).filter((p) => p.role !== 'mafia').map((p) => p.id);
case 'investigate':
return alive(s).filter((p) => p.id !== me.id).map((p) => p.id);
case 'heal':
return alive(s)
.filter((p) => (p.id !== me.id || s.options.doctorSelfHeal) && (s.options.doctorRepeat || p.id !== s.lastHealTarget))
.map((p) => p.id);
case 'mediumPeek':
return s.players.filter((p) => !p.alive && p.id !== me.id).map((p) => p.id);
}
}
const NIGHT_PROMPT: Record<NightActionType, string> = {
mafiaTarget: '오늘 밤 할 일: 마피아가 노릴 사람을 골라 주세요',
investigate: '오늘 밤 할 일: 조사할 사람을 골라 주세요',
heal: '오늘 밤 할 일: 지켜 줄 사람을 골라 주세요',
mediumPeek: '오늘 밤 할 일: 직업을 알아볼 탈락자를 골라 주세요',
};
function canRead(s: MafiaState, viewer: PlayerState | null, ch: Channel): boolean {
if (ch === 'day') return true;
if (s.winner && s.options.revealChatsAtEnd) return true;
if (!viewer) return false;
switch (ch) {
case 'mafia':
return viewer.role === 'mafia' || (!viewer.alive && s.options.deadSeeAll);
case 'dead':
return !viewer.alive || (viewer.role === 'medium' && s.phase.kind === 'night');
case 'lovers':
return viewer.role === 'lover';
}
}
/** Why `p` cannot write in `ch` now (Korean), or null (§5.6). */
function writeBlock(s: MafiaState, p: PlayerState, ch: Channel): string | null {
const k = s.phase.kind;
if (k === 'ended') return '게임이 끝났어요.';
switch (ch) {
case 'day':
if (!p.alive) return '탈락한 사람은 낮 채팅을 쓸 수 없어요. 죽은 사람 채팅을 써 주세요.';
if (k === 'roleReveal') return '직업을 확인하는 중이에요. 잠시 기다려 주세요.';
if (k === 'night') return '밤에는 낮 채팅을 쓸 수 없어요.';
if (k === 'defense' && s.phase.accused !== p.id) return '최후의 반론 중에는 지목된 사람만 말할 수 있어요.';
return null;
case 'mafia':
if (p.role !== 'mafia') return '마피아만 쓸 수 있어요.';
if (!p.alive) return '탈락한 마피아는 읽기만 할 수 있어요.';
return k === 'night' ? null : '마피아 채팅은 밤에만 쓸 수 있어요.';
case 'dead':
if (!p.alive) return null;
if (p.role !== 'medium') return '탈락한 사람만 쓸 수 있어요.';
return k === 'night' ? null : '영매는 밤에만 죽은 사람과 이야기할 수 있어요.';
case 'lovers':
if (p.role !== 'lover') return '연인만 쓸 수 있어요.';
if (!p.alive) return '탈락하면 연인 채팅은 읽기만 할 수 있어요.';
return k === 'night' ? null : '연인 채팅은 밤에만 쓸 수 있어요.';
}
}
// ---------------------------------------------------------------- validate
function validate(s: MafiaState, actor: PlayerId, a: MafiaAction, ctx?: { now: number }): Validation {
const p = P(s, actor);
if (!p) return fail('이 게임의 플레이어가 아니에요.');
if (s.winner) return fail('게임이 이미 끝났어요.');
const ph = s.phase;
if (ph.kind === 'ended') return fail('게임이 이미 끝났어요.');
switch (a.type) {
case 'phaseTimeout':
return ctx && ctx.now < ph.endsAt ? fail('아직 시간이 남았어요.') : ok;
case 'leave':
return p.alive ? ok : fail('이미 탈락했어요.');
case 'chat': {
if (!(CHANNELS as readonly string[]).includes(a.channel)) return fail('채팅방을 골라 주세요.');
const block = writeBlock(s, p, a.channel);
if (block) return fail(block);
const why = chatProblem(cleanChat(a.text), s.options.kidSafeChat);
if (why) return fail(why);
if (ctx && ctx.now - (s.lastChatAt[actor] ?? -Infinity) < CHAT_GAP_MS) return fail('조금 천천히 보내 주세요.');
return ok;
}
default:
break;
}
if (!p.alive) return fail('탈락한 사람은 할 수 없어요.');
switch (a.type) {
case 'adjustTime':
if (ph.kind !== 'discussion') return fail('토론 시간에만 시간을 바꿀 수 있어요.');
if (!s.options.timeAdjust) return fail('이 방은 토론 시간을 바꿀 수 없어요.');
if (ph.adjustedBy.includes(actor)) return fail('시간 바꾸기는 하루에 한 번만 할 수 있어요.');
return a.delta === 15 || a.delta === -15 ? ok : fail('15초씩만 바꿀 수 있어요.');
case 'readyEarly':
if (ph.kind !== 'discussion' && ph.kind !== 'greeting') return fail('지금은 토론 시간이 아니에요.');
return ph.ready.includes(actor) ? fail('이미 토론 끝내기를 눌렀어요.') : ok;
case 'vote':
if (ph.kind !== 'vote') return fail('지금은 지목 투표 시간이 아니에요.');
if (a.target === 'abstain') return ok;
if (a.target === actor) return fail('나 자신은 지목할 수 없어요.');
return isAlive(s, a.target) ? ok : fail('살아 있는 사람만 지목할 수 있어요.');
case 'confirm':
if (ph.kind !== 'confirm') return fail('지금은 찬반 투표 시간이 아니에요.');
if (ph.accused === actor) return fail('지목된 사람은 찬반 투표를 할 수 없어요.');
return a.choice === 'yes' || a.choice === 'no' ? ok : fail('찬성이나 반대를 골라 주세요.');
case 'mafiaTarget':
case 'investigate':
case 'heal':
case 'mediumPeek': {
if (ph.kind !== 'night') return fail('밤에만 할 수 있어요.');
if (nightActionOf(p.role) !== a.type) return fail('내 직업으로는 할 수 없는 행동이에요.');
if (a.type === 'mafiaTarget' && a.target === null) return ok;
const t = a.target === null ? undefined : P(s, a.target);
if (!t) return fail('이 게임의 플레이어가 아니에요.');
if (nightTargets(s, p, a.type).includes(t.id)) return ok;
if (a.type === 'mediumPeek') return fail(t.alive ? '탈락한 사람만 고를 수 있어요.' : '나 자신은 고를 수 없어요.');
if (!t.alive) return fail('살아 있는 사람만 고를 수 있어요.');
if (a.type === 'mafiaTarget') return fail('마피아끼리는 고를 수 없어요.');
if (a.type === 'heal') return t.id === actor ? fail('이 방은 의사가 자기 자신을 지킬 수 없어요.') : fail('같은 사람을 이틀 연속 지킬 수 없어요.');
return fail('나 자신은 조사할 수 없어요.');
}
}
}
// ---------------------------------------------------------------- view
function roleKnown(s: MafiaState, p: PlayerState, viewer: PlayerState | null): boolean {
if (s.winner) return true;
if (p.publicRole || (!p.alive && s.options.deathReveal === 'role')) return true;
if (!viewer) return false;
if (viewer.id === p.id) return true;
if (viewer.role === 'mafia' && p.role === 'mafia') return true;
if (viewer.role === 'lover' && p.role === 'lover') return true;
return !viewer.alive && s.options.deadSeeAll;
}
function nightView(s: MafiaState, me: PlayerState): NightView {
const ph = s.phase as NightPhase;
const action = nightActionOf(me.role);
if (!action) return { action: null, prompt: '밤이에요. 잠시 기다려 주세요', targets: [], choice: null, pickedBy: null };
const choice = action === 'mafiaTarget' ? ph.mafiaTarget : action === 'investigate' ? ph.investigate : action === 'heal' ? ph.heal : ph.mediumPeek;
const targets = nightTargets(s, me, action);
return {
action,
prompt: action === 'mediumPeek' && targets.length === 0 ? '오늘 밤 할 일: 아직 탈락한 사람이 없어요. 잠시 기다려 주세요' : NIGHT_PROMPT[action],
targets,
choice,
pickedBy: action === 'mafiaTarget' ? ph.mafiaPickedBy : null,
};
}
function myView(s: MafiaState, me: PlayerState): MyView {
const ph = s.phase;
const mates = me.role === 'mafia' || me.role === 'lover' ? s.players.filter((p) => p.role === me.role && p.id !== me.id).map((p) => p.id) : [];
const day = ph.kind === 'greeting' || ph.kind === 'discussion';
return {
role: me.role,
team: teamOf(me.role),
alive: me.alive,
mates,
vote: ph.kind === 'vote' && me.alive ? (ph.votes[me.id] ?? null) : null,
voteTargets: ph.kind === 'vote' && me.alive ? alive(s).filter((p) => p.id !== me.id).map((p) => p.id) : [],
ballot: ph.kind === 'confirm' && me.alive ? (ph.ballots[me.id] ?? null) : null,
canConfirm: ph.kind === 'confirm' && me.alive && ph.accused !== me.id,
canAdjust: ph.kind === 'discussion' && me.alive && s.options.timeAdjust && !ph.adjustedBy.includes(me.id),
canReady: day && me.alive && !ph.ready.includes(me.id),
ready: day && ph.ready.includes(me.id),
night: ph.kind === 'night' && me.alive ? nightView(s, me) : null,
results: s.privateResults.filter((r) => r.to === me.id),
idleWarning: me.alive && !s.winner && me.idleDays >= AWAY_DAYS - 1 && !me.activeToday,
};
}
function view(s: MafiaState, viewer: PlayerId | null): MafiaView {
const me = viewer ? (P(s, viewer) ?? null) : null;
const ph = s.phase;
const readable = CHANNELS.filter((ch) => canRead(s, me, ch));
const chat: ChatView[] = s.chat
.filter((m) => readable.includes(m.channel))
.slice(-CHAT_VIEW)
.map((m) => ({ key: `${m.channel}:${m.id}`, channel: m.channel, from: m.from, text: m.text, at: m.at, day: m.day }));
const setup: Partial<Record<Role, number>> = {};
for (const p of s.players) setup[p.role] = (setup[p.role] ?? 0) + 1;
const day = ph.kind === 'greeting' || ph.kind === 'discussion';
return {
me: me?.id ?? null,
options: s.options,
day: s.day,
phase: {
kind: ph.kind,
startedAt: ph.kind === 'ended' ? 0 : ph.startedAt,
endsAt: ph.kind === 'ended' ? null : ph.endsAt,
accused: ph.kind === 'defense' || ph.kind === 'confirm' ? ph.accused : null,
adjustedBy: ph.kind === 'discussion' ? [...ph.adjustedBy] : [],
readyCount: day ? ph.ready.length : 0,
readyNeeded: day ? alive(s).length : 0,
},
players: s.players.map((p) => {
const known = roleKnown(s, p, me);
const teamKnown = known || (!p.alive && s.options.deathReveal === 'team');
return { id: p.id, alive: p.alive, diedDay: p.diedDay, diedBy: p.diedBy, role: known ? p.role : null, team: teamKnown ? teamOf(p.role) : null };
}),
setup,
aliveCount: alive(s).length,
events: s.events.slice(-EVENTS_VIEW),
chat,
channels: me ? readable.map((ch) => ({ channel: ch, canWrite: !writeBlock(s, me, ch), why: writeBlock(s, me, ch) })) : [],
my: me ? myView(s, me) : null,
winner: s.winner,
final: s.final,
};
}
// ---------------------------------------------------------------- definition
function activePlayers(s: MafiaState): PlayerId[] {
const ph = s.phase;
if (ph.kind === 'ended') return [];
if (ph.kind === 'defense') return [ph.accused];
if (ph.kind === 'confirm') return alive(s).filter((p) => p.id !== ph.accused).map((p) => p.id);
// Night: every living player, so the list never reveals who has a night role (§8.3).
return alive(s).map((p) => p.id);
}
type Def = GameDefinition<MafiaState, MafiaAction, MafiaView, MafiaOptions>;
export const mafia: Def = {
id: 'mafia',
nameKo: '마피아',
minPlayers: 4,
maxPlayers: 12,
stateVersion: 1,
defaultOptions: mafiaOptionsSchema.parse({}),
optionsSchema: mafiaOptionsSchema as unknown as Def['optionsSchema'],
actionSchema: mafiaActionSchema as unknown as Def['actionSchema'],
ownChat: true,
playerCountError: compositionError,
setup({ players, options, rng, now }) {
const roles = rng.shuffle(rolesFor(options, players.length));
const ps: PlayerState[] = players.map((id, i) => ({
id,
role: roles[i]!,
alive: true,
soldierUsed: false,
publicRole: false,
diedDay: null,
diedBy: null,
activeToday: false,
idleDays: 0,
}));
return {
options,
players: ps,
day: 1,
phase: { kind: 'roleReveal', startedAt: now, endsAt: now + ROLE_REVEAL_MS },
lastHealTarget: null,
privateResults: [],
chat: [],
chatCount: { day: 0, mafia: 0, dead: 0, lovers: 0 },
lastChatAt: {},
events: [],
trialVoters: [],
mvpVotes: {},
winner: null,
seq: 0,
final: null,
};
},
validate,
apply(prev, actor, a, { rng, now }) {
const s = structuredClone(prev);
const before = s.events.length;
s.seq += 1;
const p = P(s, actor)!;
const ph = s.phase;
if (a.type !== 'phaseTimeout' && a.type !== 'leave') p.activeToday = true;
switch (a.type) {
case 'phaseTimeout':
advance(s, now, rng);
break;
case 'leave':
leave(s, now, rng, p);
break;
case 'chat': {
s.chatCount[a.channel] += 1;
s.chat.push({ id: s.chatCount[a.channel], channel: a.channel, from: actor, text: cleanChat(a.text), at: now, day: s.day });
if (s.chat.length > CHAT_KEEP) s.chat.splice(0, s.chat.length - CHAT_KEEP);
s.lastChatAt[actor] = now;
break;
}
case 'adjustTime':
if (ph.kind !== 'discussion') throw new Error('mafia: adjustTime outside discussion');
ph.adjustedBy.push(actor);
ph.endsAt = a.delta > 0 ? ph.endsAt + ADJUST_MS : Math.max(ph.endsAt - ADJUST_MS, Math.min(ph.endsAt, now + ADJUST_FLOOR_MS));
pushEvent(s, now, { t: 'adjust', p: actor, delta: a.delta });
break;
case 'readyEarly':
if (ph.kind !== 'discussion' && ph.kind !== 'greeting') throw new Error('mafia: readyEarly outside discussion');
ph.ready.push(actor);
if (alive(s).every((x) => ph.ready.includes(x.id))) advance(s, now, rng);
break;
case 'vote':
if (ph.kind !== 'vote') throw new Error('mafia: vote outside the vote phase');
ph.votes[actor] = a.target;
break;
case 'confirm':
if (ph.kind !== 'confirm') throw new Error('mafia: confirm outside the confirm phase');
ph.ballots[actor] = a.choice;
break;
case 'mafiaTarget':
if (ph.kind !== 'night') throw new Error('mafia: night action by day');
ph.mafiaTarget = a.target;
ph.mafiaPickedBy = a.target === null ? null : actor;
break;
case 'investigate':
case 'heal':
case 'mediumPeek':
if (ph.kind !== 'night') throw new Error('mafia: night action by day');
ph[a.type] = a.target;
break;
}
// Only public announcements become events (chat and night choices stay in the filtered view).
const events: GameEvent[] = s.events.slice(before).map((e) => ({ ...e, type: e.t }));
return { state: s, events };
},
view,
activePlayers,
deadline(s) {
return s.phase.kind === 'ended' ? null : s.phase.endsAt;
},
/** One player drives the phase clock (the deadline is shared by everybody). */
timeoutPlayers(s) {
return s.phase.kind === 'ended' ? [] : [s.players[0]!.id];
},
/** An away driver must not end a phase early. */
timeoutNotBefore(s) {
return s.phase.kind === 'ended' ? null : s.phase.endsAt;
},
/** Phases end on the clock; a missing vote is an abstention and a missing night action is "no action" (§8.5). */
onTimeout() {
return { type: 'phaseTimeout' };
},
onLeave(s, player) {
const p = P(s, player);
if (!p || !p.alive || s.winner) return null;
return { type: 'leave' };
},
result(s): GameResult | null {
const f = s.final;
if (!f) return null;
const won = (id: PlayerId) => f.players.find((p) => p.id === id)!.won;
return {
ranking: [f.ranking.filter(won), f.ranking.filter((id) => !won(id))],
scores: Object.fromEntries(f.players.map((p) => [p.id, p.won ? 1 : 0])),
summary: f.summary,
reason: 'normal',
};
},
/** Test helper: every legal move plus the phase clock and one short message per writable channel. */
legalActions(s, player) {
const p = P(s, player);
if (!p || s.winner || s.phase.kind === 'ended') return [];
const out: MafiaAction[] = [{ type: 'phaseTimeout' }];
for (const ch of CHANNELS) if (!writeBlock(s, p, ch)) out.push({ type: 'chat', channel: ch, text: `m${s.seq}-${player}-${ch}` });
if (!p.alive) return out;
const ph = s.phase;
const me = myView(s, p);
if (me.canReady) out.push({ type: 'readyEarly' });
if (me.canAdjust) out.push({ type: 'adjustTime', delta: 15 }, { type: 'adjustTime', delta: -15 });
if (ph.kind === 'vote') {
out.push({ type: 'vote', target: 'abstain' });
for (const t of me.voteTargets) out.push({ type: 'vote', target: t });
}
if (me.canConfirm) out.push({ type: 'confirm', choice: 'yes' }, { type: 'confirm', choice: 'no' });
if (me.night?.action) {
const type = me.night.action;
for (const t of me.night.targets) out.push({ type, target: t } as MafiaAction);
if (type === 'mafiaTarget') out.push({ type, target: null });
}
return out.filter((a) => validate(s, player, a).ok);
},
/** Private result tokens: only their owner may ever see them. */
secretsOf(s, player) {
if (s.winner) return [];
return s.privateResults.filter((r) => r.to === player).map((r) => r.id);
},
};
export const _internal = { canRead, writeBlock, roleKnown, nightTargets, discussionMs };

View File

@@ -0,0 +1,883 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type PlayerId, type RngState } from '@bg/engine';
import {
AWAY_DAYS,
CHANNELS,
GREETING_MS,
ROLE_REVEAL_MS,
compositionError,
mafia,
rolesFor,
teamOf,
type Channel,
type MafiaAction,
type MafiaOptions,
type MafiaState,
type MafiaView,
type PublicEvent,
type Role,
} from './index';
// ---------------------------------------------------------------- helpers
const T0 = 1_000_000;
const opts = (o: Record<string, unknown> = {}): MafiaOptions => mafia.optionsSchema.parse(o);
const ids = (n: number) => Array.from({ length: n }, (_, i) => `P${i}`);
/** Game with fixed roles (P0, P1, … in order), started at `T0`, still in the role-reveal phase. */
function rig(roles: Role[], o: Record<string, unknown> = {}): MafiaState {
const s = mafia.setup({ players: ids(roles.length), options: opts(o), rng: SeededRng.fromSeed(seedN(1)), now: T0 });
s.players.forEach((p, i) => (p.role = roles[i]!));
return s;
}
const rng = SeededRng.fromSeed([9, 9, 9, 9]);
function act(s: MafiaState, actor: PlayerId, a: MafiaAction, now?: number): MafiaState {
const at = now ?? (s.phase.kind === 'ended' ? T0 : Math.max(T0, s.phase.startedAt + 1));
const v = mafia.validate(s, actor, a, { now: at });
if (!v.ok) throw new Error(`${actor} ${JSON.stringify(a)} rejected: ${v.reason}`);
return mafia.apply(s, actor, a, { rng, now: at }).state;
}
function reject(s: MafiaState, actor: PlayerId, a: MafiaAction, now?: number): string {
const v = mafia.validate(s, actor, a, now === undefined ? undefined : { now });
if (v.ok) throw new Error(`${actor} ${JSON.stringify(a)} was accepted`);
return v.reason;
}
/** The phase clock runs out. */
function tick(s: MafiaState): MafiaState {
if (s.phase.kind === 'ended') throw new Error('tick after the end');
return act(s, s.players[0]!.id, { type: 'phaseTimeout' }, s.phase.endsAt);
}
/** Ticks until `kind` (stops early if the game ends). */
function until(s: MafiaState, kind: MafiaState['phase']['kind']): MafiaState {
for (let i = 0; i < 20 && s.phase.kind !== kind && s.phase.kind !== 'ended'; i++) s = tick(s);
if (s.phase.kind !== kind) throw new Error(`never reached ${kind} (now ${s.phase.kind})`);
return s;
}
/** Marks everyone active so the away rule does not interfere. */
function busy(s: MafiaState): MafiaState {
for (const p of s.players) p.activeToday = true;
return s;
}
const pl = (s: MafiaState, id: string) => s.players.find((p) => p.id === id)!;
const evs = (s: MafiaState, t: PublicEvent['t']) => s.events.filter((e) => e.t === t);
const lastEv = <K extends PublicEvent['t']>(s: MafiaState, t: K) => evs(s, t).at(-1) as Extract<PublicEvent, { t: K }> | undefined;
const say = (s: MafiaState, p: PlayerId, channel: Channel, text: string, now?: number) => act(s, p, { type: 'chat', channel, text }, now);
/** Plays a vote day: every alive player votes per `votes` (others abstain); returns the state after the vote phase. */
function voteDay(s: MafiaState, votes: Record<string, string>): MafiaState {
s = until(busy(s), 'vote');
for (const [p, t] of Object.entries(votes)) s = act(s, p, { type: 'vote', target: t });
return tick(s);
}
// ---------------------------------------------------------------- setup (§4)
describe('직업 배분 (§4)', () => {
const TABLE: Record<number, [number, number]> = { 4: [1, 1], 5: [1, 2], 6: [2, 2], 7: [2, 3], 8: [2, 4], 9: [3, 4], 10: [3, 5], 11: [3, 6], 12: [3, 7] };
test('4~12인 자동 배분이 표와 일치', () => {
for (let n = 4; n <= 12; n++) {
const roles = rolesFor(opts(), n);
const count = (r: Role) => roles.filter((x) => x === r).length;
expect([count('mafia'), count('citizen')]).toEqual(TABLE[n]!);
expect(count('police')).toBe(1);
expect(count('doctor')).toBe(1);
const s = mafia.setup({ players: ids(n), options: opts(), rng: SeededRng.fromSeed(seedN(n)), now: T0 });
expect(s.players.map((p) => p.role).sort()).toEqual([...roles].sort());
}
});
test('추가 직업은 시민 자리만 대신하고, 모자라면 대기실에서 거부', () => {
const all = { extraRoles: { soldier: true, politician: true, medium: true, lovers: true } };
const r10 = rolesFor(opts(all), 10);
expect(r10.filter((r) => r === 'mafia')).toHaveLength(3);
expect(r10.filter((r) => r === 'citizen')).toHaveLength(0);
expect(r10.filter((r) => r === 'lover')).toHaveLength(2);
expect(compositionError(opts(all), 9)).toContain('연인을');
expect(rolesFor(opts(all), 12).filter((r) => r === 'citizen')).toHaveLength(2);
// 4명: 시민 1자리 → 연인(2자리)은 안 돼요.
expect(compositionError(opts({ extraRoles: { lovers: true } }), 4)).toBe('인원이 부족해서 연인을 넣을 수 없어요(시민 자리 2개 필요).');
expect(compositionError(opts({ extraRoles: { soldier: true } }), 4)).toBeNull();
expect(compositionError(opts({ extraRoles: { soldier: true, medium: true } }), 4)).toContain('영매를 넣을 수 없어요');
expect(mafia.playerCountError!(opts(all), 8)).toContain('연인을');
expect(() => mafia.setup({ players: ids(4), options: opts({ extraRoles: { lovers: true } }), rng: SeededRng.fromSeed(seedN(1)), now: 0 })).toThrow();
});
test('수동 마피아 수는 전체의 절반 미만', () => {
expect(compositionError(opts({ mafiaCount: 2 }), 4)).toBe('4명일 때 마피아는 1명까지 정할 수 있어요.');
expect(compositionError(opts({ mafiaCount: 2 }), 5)).toBeNull();
expect(compositionError(opts({ mafiaCount: 3 }), 6)).toContain('2명까지');
expect(compositionError(opts({ mafiaCount: 4 }), 9)).toBeNull();
expect(mafia.playerCountError!(opts(), 3)).toBeTruthy();
});
test('셔플은 시드로 결정; 마피아는 동료를, 연인은 상대를 안다; 8초 후 첫 페이즈', () => {
const o = opts({ extraRoles: { lovers: true } });
const a = mafia.setup({ players: ids(8), options: o, rng: SeededRng.fromSeed(seedN(3)), now: T0 });
const b = mafia.setup({ players: ids(8), options: o, rng: SeededRng.fromSeed(seedN(3)), now: T0 });
expect(JSON.stringify(a)).toBe(JSON.stringify(b));
const orders = new Set<string>();
for (let i = 1; i <= 20; i++) orders.add(mafia.setup({ players: ids(8), options: o, rng: SeededRng.fromSeed(seedN(i)), now: T0 }).players.map((p) => p.role).join());
expect(orders.size).toBeGreaterThan(10);
const maf = a.players.filter((p) => p.role === 'mafia').map((p) => p.id);
const v = mafia.view(a, maf[0]!);
expect(v.my!.mates).toEqual([maf[1]!]);
expect(v.players.filter((p) => p.role === 'mafia').map((p) => p.id)).toEqual(maf);
const lovers = a.players.filter((p) => p.role === 'lover').map((p) => p.id);
expect(mafia.view(a, lovers[0]!).my!.mates).toEqual([lovers[1]!]);
expect(a.phase).toMatchObject({ kind: 'roleReveal', endsAt: T0 + ROLE_REVEAL_MS });
expect(a.day).toBe(1);
const g = tick(a);
expect(g.phase).toMatchObject({ kind: 'greeting', endsAt: T0 + ROLE_REVEAL_MS + GREETING_MS });
expect(tick(g).phase.kind).toBe('night');
expect(tick(rig(['mafia', 'police', 'doctor', 'citizen'], { firstPhase: 'night' })).phase.kind).toBe('night');
});
test('토론 시간 = clamp(생존자 × 값, 최소, 최대)', () => {
const s = until(busy(until(rig(['mafia', 'police', 'doctor', 'citizen', 'citizen']), 'night')), 'discussion');
expect(s.phase.kind === 'discussion' && s.phase.endsAt - s.phase.startedAt).toBe(75_000); // 5 × 15
let t = until(rig(['mafia', 'police', 'doctor', 'citizen'], { discussionPerAlive: 10, discussionMin: 60 }), 'night');
t = tick(t);
expect(t.phase.kind === 'discussion' && t.phase.endsAt - t.phase.startedAt).toBe(60_000);
let u = until(rig(['mafia', 'mafia', 'mafia', 'police', 'doctor', 'citizen', 'citizen', 'citizen', 'citizen', 'citizen', 'citizen', 'citizen'], { discussionPerAlive: 20, discussionMax: 120 }), 'night');
u = tick(u);
expect(u.phase.kind === 'discussion' && u.phase.endsAt - u.phase.startedAt).toBe(120_000);
});
});
// ---------------------------------------------------------------- votes (§5.3, §5.4)
describe('지목 투표와 찬반 투표 (§5.3, §5.4)', () => {
// P0 마피아, P1 경찰, P2 의사, P3~P5 시민 (2마피아 없이 6명: 마피아 1명이라 오래 버팀)
const six = (): Role[] => ['mafia', 'police', 'doctor', 'citizen', 'citizen', 'citizen'];
const day2 = (roles = six(), o: Record<string, unknown> = {}) => until(busy(until(rig(roles, o), 'night')), 'discussion');
test('동률이면 처형 없이 밤으로, 전원 기권도 처형 없음', () => {
let s = voteDay(day2(), { P0: 'P1', P1: 'P0', P2: 'P3', P3: 'P2' });
expect(lastEv(s, 'vote')).toMatchObject({ accused: null, tie: true });
expect(s.phase.kind).toBe('night');
s = voteDay(day2(), { P0: 'abstain', P1: 'abstain' });
expect(lastEv(s, 'vote')).toMatchObject({ accused: null, tie: false, abstain: 6 });
expect(s.phase.kind).toBe('night');
expect(s.players.every((p) => p.alive)).toBe(true);
});
test('정치인 2표로 동률이 깨짐; 투표 변경은 마지막 선택이 유효', () => {
const roles: Role[] = ['mafia', 'police', 'doctor', 'politician', 'citizen', 'citizen'];
let s = until(day2(roles), 'vote');
s = act(s, 'P1', { type: 'vote', target: 'P4' });
s = act(s, 'P1', { type: 'vote', target: 'P0' });
s = act(s, 'P3', { type: 'vote', target: 'P0' });
s = act(s, 'P0', { type: 'vote', target: 'P5' });
s = act(s, 'P2', { type: 'vote', target: 'P5' });
s = tick(s);
const v = lastEv(s, 'vote')!;
expect(v.tally).toEqual({ P0: 3, P5: 2 });
expect(v.accused).toBe('P0');
expect(v.votes).toMatchObject({ P1: 'P0', P3: 'P0', P4: 'abstain' });
expect(s.phase).toMatchObject({ kind: 'defense', accused: 'P0' });
});
test('openVote 끄면 득표수만 공개; 자기 자신·죽은 사람 지목 불가', () => {
let s = until(day2(six(), { openVote: false }), 'vote');
expect(reject(s, 'P1', { type: 'vote', target: 'P1' })).toBe('나 자신은 지목할 수 없어요.');
s = act(s, 'P1', { type: 'vote', target: 'P0' });
s = tick(s);
expect(lastEv(s, 'vote')).toMatchObject({ votes: null, tally: { P0: 1 }, accused: 'P0' });
});
test('찬반 동률이면 생존; 찬성이 많으면 처형(공개는 deathReveal)', () => {
let s = voteDay(day2(), { P1: 'P3' });
s = tick(s); // defense → confirm
expect(s.phase).toMatchObject({ kind: 'confirm', accused: 'P3' });
expect(reject(s, 'P3', { type: 'confirm', choice: 'no' })).toBe('지목된 사람은 찬반 투표를 할 수 없어요.');
s = act(s, 'P0', { type: 'confirm', choice: 'yes' });
s = act(s, 'P1', { type: 'confirm', choice: 'no' });
s = tick(s);
expect(lastEv(s, 'confirm')).toMatchObject({ accused: 'P3', yes: 1, no: 1, outcome: 'spared' });
expect(pl(s, 'P3').alive).toBe(true);
expect(s.phase.kind).toBe('night');
let t = voteDay(day2(), { P1: 'P3' });
t = tick(t);
t = act(t, 'P0', { type: 'confirm', choice: 'yes' });
t = act(t, 'P1', { type: 'confirm', choice: 'yes' });
t = act(t, 'P2', { type: 'confirm', choice: 'no' });
t = tick(t);
expect(lastEv(t, 'confirm')).toMatchObject({ yes: 2, no: 1, outcome: 'executed' });
expect(lastEv(t, 'death')).toEqual(expect.objectContaining({ p: 'P3', by: 'vote', team: 'town', role: null }));
expect(pl(t, 'P3')).toMatchObject({ alive: false, diedBy: 'vote', diedDay: 2 });
});
test('정치인 피고가 찬반을 통과하면 정치인임이 공개되고 생존', () => {
const roles: Role[] = ['mafia', 'police', 'doctor', 'politician', 'citizen', 'citizen'];
let s = voteDay(day2(roles), { P1: 'P3', P0: 'P3' });
s = tick(s);
for (const p of ['P0', 'P1', 'P2']) s = act(s, p, { type: 'confirm', choice: 'yes' });
s = tick(s);
expect(lastEv(s, 'confirm')).toMatchObject({ accused: 'P3', outcome: 'politician' });
expect(pl(s, 'P3').alive).toBe(true);
for (const viewer of ['P4', null]) expect(mafia.view(s, viewer).players.find((p) => p.id === 'P3')!.role).toBe('politician');
});
test('최후의 반론 중에는 피고만 낮 채팅', () => {
let s = voteDay(day2(), { P1: 'P3' });
expect(s.phase.kind).toBe('defense');
expect(reject(s, 'P1', { type: 'chat', channel: 'day', text: '안녕' })).toBe('최후의 반론 중에는 지목된 사람만 말할 수 있어요.');
s = say(s, 'P3', 'day', '저는 시민이에요!');
expect(s.chat.at(-1)).toMatchObject({ from: 'P3', channel: 'day', text: '저는 시민이에요!' });
});
});
// ---------------------------------------------------------------- night (§5.5)
describe('밤 (§5.5)', () => {
const night1 = (roles: Role[], o: Record<string, unknown> = {}) => until(rig(roles, o), 'night');
const five = (): Role[] => ['mafia', 'police', 'doctor', 'citizen', 'citizen'];
test('의사가 마피아 대상을 치료하면 사망 없음, 발표에 이름 없음', () => {
let s = night1(five());
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P3' });
s = act(s, 'P2', { type: 'heal', target: 'P3' });
s = tick(s);
expect(s.players.every((p) => p.alive)).toBe(true);
const healed = lastEv(s, 'healed')!;
expect(healed).toBeDefined();
expect(JSON.stringify(healed)).not.toContain('P3');
expect(s.phase.kind).toBe('discussion');
expect(s.day).toBe(2);
expect(s.lastHealTarget).toBe('P3');
});
test('doctorSelfHeal / doctorRepeat 끄면 검증 거부', () => {
let s = night1(five(), { doctorSelfHeal: false, doctorRepeat: false });
expect(reject(s, 'P2', { type: 'heal', target: 'P2' })).toBe('이 방은 의사가 자기 자신을 지킬 수 없어요.');
s = act(s, 'P2', { type: 'heal', target: 'P4' });
s = until(busy(tick(s)), 'night');
expect(reject(s, 'P2', { type: 'heal', target: 'P4' })).toBe('같은 사람을 이틀 연속 지킬 수 없어요.');
expect(mafia.view(s, 'P2').my!.night!.targets).not.toContain('P4');
s = act(s, 'P2', { type: 'heal', target: 'P3' });
// 기본값(켜짐)이면 자기 자신·연속 모두 허용
let t = night1(five());
t = act(t, 'P2', { type: 'heal', target: 'P2' });
t = until(busy(tick(t)), 'night');
t = act(t, 'P2', { type: 'heal', target: 'P2' });
expect(t.phase.kind === 'night' && t.phase.heal).toBe('P2');
});
test('군인: 첫 공격은 버티고 공개, 두 번째 공격에 사망; 의사 치료가 겹치면 의사 우선', () => {
const roles: Role[] = ['mafia', 'police', 'doctor', 'soldier', 'citizen', 'citizen'];
let s = night1(roles);
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P3' });
s = act(s, 'P2', { type: 'heal', target: 'P3' });
s = tick(s);
expect(pl(s, 'P3')).toMatchObject({ alive: true, soldierUsed: false, publicRole: false });
expect(lastEv(s, 'healed')).toBeDefined();
s = until(busy(s), 'night');
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P3' });
s = tick(s);
expect(pl(s, 'P3')).toMatchObject({ alive: true, soldierUsed: true, publicRole: true });
expect(lastEv(s, 'soldier')).toMatchObject({ p: 'P3' });
expect(mafia.view(s, null).players.find((p) => p.id === 'P3')!.role).toBe('soldier');
s = until(busy(s), 'night');
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P3' });
s = tick(s);
expect(pl(s, 'P3')).toMatchObject({ alive: false, diedBy: 'mafia', diedDay: 3 });
});
test('마피아 2명이 다른 대상을 고르면 마지막 선택만 유효; 미선택 + noKill이면 사망 없음', () => {
const roles: Role[] = ['mafia', 'mafia', 'police', 'doctor', 'citizen', 'citizen', 'citizen'];
let s = night1(roles);
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P4' });
s = act(s, 'P1', { type: 'mafiaTarget', target: 'P5' });
expect(mafia.view(s, 'P0').my!.night).toMatchObject({ choice: 'P5', pickedBy: 'P1' });
expect(reject(s, 'P0', { type: 'mafiaTarget', target: 'P1' })).toBe('마피아끼리는 고를 수 없어요.');
s = tick(s);
expect(pl(s, 'P4').alive).toBe(true);
expect(pl(s, 'P5')).toMatchObject({ alive: false, diedBy: 'mafia' });
let t = night1(roles);
t = act(t, 'P0', { type: 'mafiaTarget', target: 'P4' });
t = act(t, 'P1', { type: 'mafiaTarget', target: null });
t = tick(t);
expect(t.players.every((p) => p.alive)).toBe(true);
expect(lastEv(t, 'quiet')).toBeDefined();
});
test('mafiaMustKill=random이면 대상 미선택 시 RNG로 비마피아 1명', () => {
const s = tick(night1(['mafia', 'police', 'doctor', 'citizen', 'citizen'], { mafiaMustKill: 'random' }));
const dead = s.players.filter((p) => !p.alive);
expect(dead).toHaveLength(1);
expect(dead[0]!.role).not.toBe('mafia');
});
test('경찰이 대상을 3번 바꾸면 마감 때 마지막 1명 결과만 받는다', () => {
let s = night1(five());
for (const t of ['P3', 'P0', 'P4']) s = act(s, 'P1', { type: 'investigate', target: t });
expect(s.privateResults).toHaveLength(0);
expect(mafia.view(s, 'P1').my!.results).toEqual([]);
s = tick(s);
expect(s.privateResults).toHaveLength(1);
expect(s.privateResults[0]).toMatchObject({ to: 'P1', target: 'P4', result: 'notMafia', kind: 'investigate', day: 1 });
s = until(busy(s), 'night');
s = act(s, 'P1', { type: 'investigate', target: 'P0' });
s = tick(s);
expect(mafia.view(s, 'P1').my!.results.map((r) => r.result)).toEqual(['notMafia', 'mafia']);
expect(reject(until(busy(s), 'night'), 'P1', { type: 'investigate', target: 'P1' })).toBe('나 자신은 조사할 수 없어요.');
// policeRevealRole
let r = night1(five(), { policeRevealRole: true });
r = act(r, 'P1', { type: 'investigate', target: 'P2' });
expect(tick(r).privateResults[0]!.result).toBe('doctor');
});
test('경찰이 그 밤에 죽어도 결과는 전달; 영매는 탈락자 직업 확인', () => {
const roles: Role[] = ['mafia', 'police', 'doctor', 'medium', 'citizen', 'citizen'];
let s = night1(roles);
expect(mafia.view(s, 'P3').my!.night).toMatchObject({ action: 'mediumPeek', targets: [] });
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P1' });
s = act(s, 'P1', { type: 'investigate', target: 'P0' });
s = tick(s);
expect(pl(s, 'P1').alive).toBe(false);
expect(mafia.view(s, 'P1').my!.results[0]).toMatchObject({ target: 'P0', result: 'mafia' });
s = until(busy(s), 'night');
expect(reject(s, 'P3', { type: 'mediumPeek', target: 'P4' })).toBe('탈락한 사람만 고를 수 있어요.');
s = act(s, 'P3', { type: 'mediumPeek', target: 'P1' });
s = tick(s);
expect(mafia.view(s, 'P3').my!.results[0]).toMatchObject({ kind: 'peek', target: 'P1', result: 'police' });
});
test('밤 행동은 자기 직업만, 밤에만', () => {
let s = night1(five());
expect(reject(s, 'P3', { type: 'investigate', target: 'P0' })).toBe('내 직업으로는 할 수 없는 행동이에요.');
expect(reject(s, 'P1', { type: 'heal', target: 'P0' })).toBe('내 직업으로는 할 수 없는 행동이에요.');
s = until(busy(s), 'discussion');
expect(reject(s, 'P1', { type: 'investigate', target: 'P0' })).toBe('밤에만 할 수 있어요.');
});
});
// ---------------------------------------------------------------- win (§6)
describe('승리 판정 (§6)', () => {
test('처형 직후 마피아 = 비마피아면 즉시 마피아 승리(밤으로 넘어가지 않음)', () => {
// 마피아 2, 시민 4 → 밤에 1명 → 2 대 3 → 시민 1명 처형 → 2 대 2
let s = rig(['mafia', 'mafia', 'police', 'doctor', 'citizen', 'citizen']);
s = until(s, 'night');
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P5' });
s = tick(s);
expect(pl(s, 'P5').alive).toBe(false);
s = voteDay(s, { P0: 'P4', P1: 'P4', P2: 'P4' });
s = tick(s);
for (const p of ['P0', 'P1', 'P2']) s = act(s, p, { type: 'confirm', choice: 'yes' });
s = tick(s);
expect(s.winner).toBe('mafia');
expect(s.phase.kind).toBe('ended');
expect(lastEv(s, 'phase')!.kind).not.toBe('night');
const res = mafia.result(s)!;
expect(res.ranking).toEqual([['P0', 'P1'], ['P2', 'P3', 'P4', 'P5']]);
expect(res.scores).toMatchObject({ P0: 1, P1: 1, P2: 0 });
expect(res.summary).toBe('마피아 팀 승리! 2일차 낮 투표 뒤 마피아 수가 시민 수와 같아졌어요.');
expect(mafia.activePlayers(s)).toEqual([]);
expect(mafia.deadline!(s)).toBeNull();
});
test('4인: 첫 밤 시민 사망 → 계속, 다음 낮 오처형 → 마피아 승리; 마피아 처형 → 시민 승리', () => {
let s = until(rig(['mafia', 'police', 'doctor', 'citizen']), 'night');
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P3' });
s = tick(s);
expect(s.winner).toBeNull();
expect(lastEv(s, 'death')).toMatchObject({ p: 'P3', by: 'mafia', team: 'town' });
let a = voteDay(s, { P0: 'P1', P2: 'P1' });
a = tick(a);
a = act(a, 'P0', { type: 'confirm', choice: 'yes' });
a = tick(a);
expect(a.winner).toBe('mafia');
let b = voteDay(s, { P1: 'P0', P2: 'P0' });
b = tick(b);
b = act(b, 'P1', { type: 'confirm', choice: 'yes' });
b = act(b, 'P2', { type: 'confirm', choice: 'yes' });
b = tick(b);
expect(b.winner).toBe('town');
expect(b.final!.summary).toBe('시민 팀 승리! 2일차 낮 투표로 마지막 마피아를 찾아냈어요.');
expect(b.final!.mvp).toBe('P1');
expect(mafia.result(b)!.ranking).toEqual([['P1', 'P2', 'P3'], ['P0']]);
});
test('아침 발표 직후 판정', () => {
let s = until(rig(['mafia', 'mafia', 'police', 'doctor', 'citizen', 'citizen']), 'night');
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P4' });
s = tick(s); // 2 vs 3
s = voteDay(s, {});
s = act(s, 'P1', { type: 'mafiaTarget', target: 'P5' });
s = tick(s); // 2 vs 2
expect(s.winner).toBe('mafia');
expect(s.final!.summary).toBe('마피아 팀 승리! 3일차 아침, 마피아 수가 시민 수와 같아졌어요.');
});
});
// ---------------------------------------------------------------- chat (§5.6)
describe('채팅 권한 (§5.6)', () => {
const roles: Role[] = ['mafia', 'mafia', 'police', 'doctor', 'medium', 'lover', 'lover', 'citizen', 'citizen'];
const o = { extraRoles: { medium: true, lovers: true } };
test('밤에 시민 낮 채팅 거부, 죽은 사람 낮 채팅 거부, 레이트 리밋 초과 거부', () => {
let s = until(rig(roles, o), 'greeting');
const g0 = (s.phase as Extract<MafiaState['phase'], { kind: 'greeting' }>).startedAt;
s = say(s, 'P7', 'day', '안녕하세요');
expect(reject(s, 'P7', { type: 'chat', channel: 'day', text: '또 안녕' }, g0 + 200)).toBe('조금 천천히 보내 주세요.');
expect(mafia.validate(s, 'P7', { type: 'chat', channel: 'day', text: '또 안녕' }, { now: g0 + 1 + 700 }).ok).toBe(true);
s = tick(s);
expect(reject(s, 'P7', { type: 'chat', channel: 'day', text: '밤인데' })).toBe('밤에는 낮 채팅을 쓸 수 없어요.');
expect(reject(s, 'P7', { type: 'chat', channel: 'mafia', text: '...' })).toBe('마피아만 쓸 수 있어요.');
expect(reject(s, 'P7', { type: 'chat', channel: 'dead', text: '...' })).toBe('탈락한 사람만 쓸 수 있어요.');
s = say(s, 'P0', 'mafia', '누구 노릴까');
s = say(s, 'P5', 'lovers', '자기야');
s = say(s, 'P4', 'dead', '영매예요');
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P7' });
s = tick(s);
expect(pl(s, 'P7').alive).toBe(false);
expect(reject(s, 'P7', { type: 'chat', channel: 'day', text: '억울해' })).toBe('탈락한 사람은 낮 채팅을 쓸 수 없어요. 죽은 사람 채팅을 써 주세요.');
s = say(s, 'P7', 'dead', '억울해요');
expect(reject(s, 'P0', { type: 'chat', channel: 'mafia', text: '낮' })).toBe('마피아 채팅은 밤에만 쓸 수 있어요.');
expect(reject(s, 'P4', { type: 'chat', channel: 'dead', text: '낮' })).toBe('영매는 밤에만 죽은 사람과 이야기할 수 있어요.');
expect(reject(s, 'P5', { type: 'chat', channel: 'lovers', text: '낮' })).toBe('연인 채팅은 밤에만 쓸 수 있어요.');
});
test('고운 말 필터·링크·전화번호·길이', () => {
const s = until(rig(roles, o), 'greeting');
expect(reject(s, 'P7', { type: 'chat', channel: 'day', text: '시 발' })).toBe('고운 말을 써 주세요.');
expect(reject(s, 'P7', { type: 'chat', channel: 'day', text: '여기 봐 https://a.b' })).toBe('링크는 보낼 수 없어요.');
expect(reject(s, 'P7', { type: 'chat', channel: 'day', text: 'example.com 들어와' })).toBe('링크는 보낼 수 없어요.');
expect(reject(s, 'P7', { type: 'chat', channel: 'day', text: '010-1234-5678로 연락' })).toBe('전화번호 같은 개인 정보는 보낼 수 없어요.');
expect(reject(s, 'P7', { type: 'chat', channel: 'day', text: '가'.repeat(201) })).toBe('메시지는 200자까지 보낼 수 있어요.');
expect(reject(s, 'P7', { type: 'chat', channel: 'day', text: ' ' })).toBe('메시지를 입력해 주세요.');
const off = until(rig(roles, { ...o, kidSafeChat: false }), 'greeting');
expect(mafia.validate(off, 'P7', { type: 'chat', channel: 'day', text: 'example.com' }).ok).toBe(true);
expect(say(s, 'P7', 'day', '가'.repeat(200)).chat.at(-1)!.text).toHaveLength(200);
});
});
// ---------------------------------------------------------------- views (§8.3)
/** Independent re-statement of who may know whose role (§8.3). */
function mayKnowRole(s: MafiaState, owner: PlayerId, viewer: PlayerId | null): boolean {
const p = pl(s, owner);
if (s.winner || p.publicRole || (!p.alive && s.options.deathReveal === 'role')) return true;
if (!viewer) return false;
const v = pl(s, viewer);
if (viewer === owner) return true;
if (v.role === 'mafia' && p.role === 'mafia') return true;
if (v.role === 'lover' && p.role === 'lover') return true;
return !v.alive && s.options.deadSeeAll;
}
function mayRead(s: MafiaState, ch: Channel, viewer: PlayerId | null): boolean {
if (ch === 'day' || (s.winner && s.options.revealChatsAtEnd)) return true;
if (!viewer) return false;
const v = pl(s, viewer);
if (ch === 'mafia') return v.role === 'mafia' || (!v.alive && s.options.deadSeeAll);
if (ch === 'lovers') return v.role === 'lover';
return !v.alive || (v.role === 'medium' && s.phase.kind === 'night');
}
/** Structural + string leak check of one viewer's serialized view. */
function checkView(s: MafiaState, viewer: PlayerId | null): MafiaView {
const v = mafia.view(s, viewer);
const json = JSON.stringify(v);
for (const p of v.players) {
if (p.role !== null && !mayKnowRole(s, p.id, viewer)) throw new Error(`role of ${p.id} leaked to ${viewer}`);
if (p.role !== null && p.role !== pl(s, p.id).role) throw new Error('wrong role shown');
if (p.team !== null && p.role === null && !(!pl(s, p.id).alive && s.options.deathReveal === 'team')) throw new Error(`team of ${p.id} leaked to ${viewer}`);
}
for (const m of s.chat) {
const visible = v.chat.some((c) => c.key === `${m.channel}:${m.id}`);
if (!mayRead(s, m.channel, viewer)) {
if (visible || json.includes(JSON.stringify(m.text))) throw new Error(`${m.channel} message "${m.text}" leaked to ${viewer}`);
}
}
for (const r of s.privateResults) if (r.to !== viewer && json.includes(`"${r.id}"`)) throw new Error(`private result ${r.id} leaked to ${viewer}`);
if (v.my && viewer) {
const me = pl(s, viewer);
const n = v.my.night;
if (n && s.phase.kind === 'night') {
const ph = s.phase;
const own = me.role === 'mafia' ? ph.mafiaTarget : me.role === 'police' ? ph.investigate : me.role === 'doctor' ? ph.heal : me.role === 'medium' ? ph.mediumPeek : null;
if (n.choice !== own) throw new Error(`night choice of someone else shown to ${viewer}`);
if (me.role !== 'mafia' && n.pickedBy !== null) throw new Error('mafia picker leaked');
}
if (v.my.mates.some((m) => pl(s, m).role !== me.role)) throw new Error('wrong mates');
}
if (viewer === null && v.my !== null) throw new Error('spectator has my');
return v;
}
function keyShape(x: unknown): unknown {
if (Array.isArray(x)) return 'array';
if (x && typeof x === 'object') return Object.fromEntries(Object.keys(x).sort().map((k) => [k, keyShape((x as Record<string, unknown>)[k])]));
return x === null ? 'null|value' : 'null|value';
}
describe('화면 정보와 누출 (§8.3)', () => {
const roles: Role[] = ['mafia', 'mafia', 'police', 'doctor', 'medium', 'lover', 'lover', 'citizen', 'citizen'];
const o = { extraRoles: { medium: true, lovers: true } };
function busyGame(extra: Record<string, unknown> = {}): MafiaState {
let s = until(rig(roles, { ...o, ...extra }), 'night');
s = say(s, 'P0', 'mafia', 'SECRET-MAFIA-1');
s = say(s, 'P5', 'lovers', 'SECRET-LOVE-1');
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P7' });
s = act(s, 'P2', { type: 'investigate', target: 'P0' });
s = act(s, 'P3', { type: 'heal', target: 'P8' });
s = tick(s);
s = say(s, 'P7', 'dead', 'SECRET-DEAD-1');
return s;
}
test('생존 시민·관전자 view에 남의 직업, 비공개 채널, 밤 대상, 결과가 없다', () => {
const s = busyGame();
for (const viewer of [...s.players.map((p) => p.id), null]) checkView(s, viewer);
const citizen = JSON.stringify(mafia.view(s, 'P8'));
for (const secret of ['SECRET-MAFIA-1', 'SECRET-LOVE-1', 'SECRET-DEAD-1', s.privateResults[0]!.id]) expect(citizen).not.toContain(secret);
const spec = mafia.view(s, null);
expect(spec.players.every((p) => p.role === null)).toBe(true);
expect(spec.channels).toEqual([]);
expect(spec.chat.every((c) => c.channel === 'day')).toBe(true);
// 마피아는 마피아 채팅(언제든), 연인은 연인 채팅, 죽은 사람은 죽은 사람 채팅
expect(mafia.view(s, 'P1').chat.map((c) => c.text)).toContain('SECRET-MAFIA-1');
expect(mafia.view(s, 'P6').chat.map((c) => c.text)).toContain('SECRET-LOVE-1');
expect(mafia.view(s, 'P7').chat.map((c) => c.text)).toContain('SECRET-DEAD-1');
expect(mafia.view(s, 'P7').chat.map((c) => c.text)).not.toContain('SECRET-MAFIA-1');
expect(mafia.view(s, 'P2').my!.results).toHaveLength(1);
});
test('영매는 낮에는 dead 메시지를 받지 않고 밤에만 본다', () => {
let s = busyGame();
expect(s.phase.kind).toBe('discussion');
expect(JSON.stringify(mafia.view(s, 'P4'))).not.toContain('SECRET-DEAD-1');
expect(mafia.view(s, 'P4').channels.map((c) => c.channel)).toEqual(['day']);
s = until(busy(s), 'night');
const v = mafia.view(s, 'P4');
expect(v.chat.map((c) => c.text)).toContain('SECRET-DEAD-1');
expect(v.channels.find((c) => c.channel === 'dead')).toMatchObject({ canWrite: true });
s = say(s, 'P4', 'dead', '영매가 왔어요');
expect(mafia.view(s, 'P7').chat.map((c) => c.text)).toContain('영매가 왔어요');
expect(JSON.stringify(mafia.view(s, 'P8'))).not.toContain('영매가 왔어요');
});
test('deadSeeAll: 죽은 사람은 모든 직업과 마피아 채팅; 게임 끝나면 모두 공개', () => {
const s = busyGame({ deadSeeAll: true });
const v = checkView(s, 'P7');
expect(v.players.every((p) => p.role !== null)).toBe(true);
expect(v.chat.map((c) => c.text)).toContain('SECRET-MAFIA-1');
const end = structuredClone(s);
end.winner = 'town';
end.phase = { kind: 'ended' };
const sv = mafia.view(end, null);
expect(sv.players.every((p) => p.role !== null)).toBe(true);
expect(sv.chat.map((c) => c.text)).toEqual(expect.arrayContaining(['SECRET-MAFIA-1', 'SECRET-LOVE-1', 'SECRET-DEAD-1']));
end.options = { ...end.options, revealChatsAtEnd: false };
expect(mafia.view(end, null).chat.map((c) => c.text)).not.toContain('SECRET-MAFIA-1');
});
test('밤 화면 view 형태는 시민·경찰·의사가 같다(행동 정보는 my.night 값으로만)', () => {
let s = until(rig(['mafia', 'police', 'doctor', 'citizen', 'citizen']), 'night');
s = act(s, 'P1', { type: 'investigate', target: 'P0' });
s = act(s, 'P2', { type: 'heal', target: 'P3' });
const shape = (p: string) => JSON.stringify(keyShape(mafia.view(s, p)));
expect(shape('P1')).toBe(shape('P3'));
expect(shape('P2')).toBe(shape('P3'));
expect(mafia.view(s, 'P3').my!.night).toEqual({ action: null, prompt: '밤이에요. 잠시 기다려 주세요', targets: [], choice: null, pickedBy: null });
expect(mafia.view(s, 'P1').my!.night!.prompt).toBe('오늘 밤 할 일: 조사할 사람을 골라 주세요');
// 밤에 기다리는 사람 목록도 직업을 드러내지 않는다
expect(mafia.activePlayers(s)).toEqual(['P0', 'P1', 'P2', 'P3', 'P4']);
// 밤 행동은 이벤트로 나가지 않는다
const out = mafia.apply(s, 'P1', { type: 'investigate', target: 'P4' }, { rng, now: s.phase.kind === 'night' ? s.phase.startedAt + 5 : 0 });
expect(out.events).toEqual([]);
});
test('채널 탭: 읽을 수 있는 탭만, 쓸 수 없으면 이유', () => {
const s = until(rig(roles, o), 'night');
expect(mafia.view(s, 'P0').channels.map((c) => c.channel)).toEqual(['day', 'mafia']);
expect(mafia.view(s, 'P5').channels.map((c) => c.channel)).toEqual(['day', 'lovers']);
expect(mafia.view(s, 'P4').channels.map((c) => c.channel)).toEqual(['day', 'dead']);
expect(mafia.view(s, 'P8').channels).toEqual([{ channel: 'day', canWrite: false, why: '밤에는 낮 채팅을 쓸 수 없어요.' }]);
});
});
// ---------------------------------------------------------------- time, away, leave (§5.2, §8.5)
describe('시간 조절·자리 비움·나가기 (§5.2, §8.5)', () => {
const day2 = () => until(busy(until(rig(['mafia', 'police', 'doctor', 'citizen', 'citizen']), 'night')), 'discussion');
test('timeAdjust: 1인 1일 1회, -15초로 5초 미만 내려가지 않음', () => {
let s = day2();
const ph0 = s.phase as Extract<MafiaState['phase'], { kind: 'discussion' }>;
const end0 = ph0.endsAt;
s = act(s, 'P1', { type: 'adjustTime', delta: 15 });
expect((s.phase as typeof ph0).endsAt).toBe(end0 + 15_000);
expect(reject(s, 'P1', { type: 'adjustTime', delta: -15 })).toBe('시간 바꾸기는 하루에 한 번만 할 수 있어요.');
expect(lastEv(s, 'adjust')).toMatchObject({ p: 'P1', delta: 15 });
// 끝나기 8초 전에 -15 → 5초 남김
s = act(s, 'P2', { type: 'adjustTime', delta: -15 }, (s.phase as typeof ph0).endsAt - 8_000);
const now = end0 + 15_000 - 8_000;
expect((s.phase as typeof ph0).endsAt).toBe(now + 5_000);
// 이미 5초 미만이면 그대로
s = act(s, 'P3', { type: 'adjustTime', delta: -15 }, (s.phase as typeof ph0).endsAt - 3_000);
expect((s.phase as typeof ph0).endsAt).toBe(now + 5_000);
expect(mafia.view(s, null).phase.adjustedBy).toEqual(['P1', 'P2', 'P3']);
// 다음 날에는 다시 가능
s = until(until(busy(s), 'night'), 'discussion');
expect(mafia.validate(s, 'P1', { type: 'adjustTime', delta: 15 }).ok).toBe(true);
const off = until(busy(until(rig(['mafia', 'police', 'doctor', 'citizen'], { timeAdjust: false }), 'night')), 'discussion');
expect(reject(off, 'P1', { type: 'adjustTime', delta: 15 })).toBe('이 방은 토론 시간을 바꿀 수 없어요.');
});
test('토론 끝내기: 생존자 전원이 누르면 즉시 지목 투표', () => {
let s = day2();
for (const p of ['P0', 'P1', 'P2', 'P3']) s = act(s, p, { type: 'readyEarly' });
expect(s.phase.kind).toBe('discussion');
expect(mafia.view(s, 'P4').phase).toMatchObject({ readyCount: 4, readyNeeded: 5 });
expect(reject(s, 'P0', { type: 'readyEarly' })).toBe('이미 토론 끝내기를 눌렀어요.');
s = act(s, 'P4', { type: 'readyEarly' });
expect(s.phase.kind).toBe('vote');
});
test('2번의 낮 동안 아무것도 안 하면 자리 비움 탈락, 그 결과로 승리 조건이면 종료', () => {
let s = until(rig(['mafia', 'police', 'doctor', 'citizen', 'citizen']), 'night');
const keep = (x: MafiaState) => {
for (const p of x.players) if (p.id !== 'P0') p.activeToday = true;
return x;
};
s = until(keep(tick(s)), 'night'); // 2일차: P0 idle 1
expect(pl(s, 'P0').idleDays).toBe(1);
expect(mafia.view(tick(s), 'P0').my!.idleWarning).toBe(true);
s = until(keep(tick(s)), 'ended'); // 3일차 끝: P0(마피아) 자리 비움 → 시민 승리
expect(pl(s, 'P0')).toMatchObject({ alive: false, diedBy: 'disconnect', idleDays: AWAY_DAYS });
expect(s.winner).toBe('town');
expect(s.final!.cause).toBe('disconnect');
// 한 번이라도 행동하면 다시 0
let t = until(rig(['mafia', 'police', 'doctor', 'citizen', 'citizen']), 'night');
t = until(keep(tick(t)), 'vote');
t = act(t, 'P0', { type: 'vote', target: 'abstain' });
t = until(keep(t), 'night');
expect(pl(t, 'P0').idleDays).toBe(0);
});
test('나가기: 즉시 탈락(공개 규칙 적용), 피고가 나가면 밤으로, 승리 판정', () => {
let s = until(rig(['mafia', 'police', 'doctor', 'citizen', 'citizen', 'citizen'], { deathReveal: 'role' }), 'night');
s = act(s, 'P0', { type: 'mafiaTarget', target: 'P4' });
s = act(s, 'P4', { type: 'leave' });
expect(lastEv(s, 'death')).toMatchObject({ p: 'P4', by: 'disconnect', role: 'citizen' });
expect(s.phase.kind === 'night' && s.phase.mafiaTarget).toBeNull();
s = voteDay(tick(s), { P1: 'P5' });
expect(s.phase.kind).toBe('defense');
s = act(s, 'P5', { type: 'leave' });
expect(s.phase.kind).toBe('night');
expect(mafia.onLeave!(s, 'P5')).toBeNull();
expect(mafia.onLeave!(s, 'P0')).toEqual({ type: 'leave' });
s = act(s, 'P0', { type: 'leave' });
expect(s.winner).toBe('town');
expect(mafia.onLeave!(s, 'P1')).toBeNull();
});
test('phaseTimeout은 시스템 전용이고 마감 전에는 거부', () => {
const s = rig(['mafia', 'police', 'doctor', 'citizen']);
expect(mafia.actionSchema.safeParse({ type: 'phaseTimeout' }).success).toBe(false);
expect(mafia.actionSchema.safeParse({ type: 'leave' }).success).toBe(false);
expect(reject(s, 'P0', { type: 'phaseTimeout' }, T0 + 100)).toBe('아직 시간이 남았어요.');
expect(mafia.validate(s, 'P0', { type: 'phaseTimeout' }, { now: T0 + ROLE_REVEAL_MS }).ok).toBe(true);
expect(mafia.timeoutPlayers!(s)).toEqual(['P0']);
expect(mafia.timeoutNotBefore!(s)).toBe(T0 + ROLE_REVEAL_MS);
expect(mafia.deadline!(s)).toBe(T0 + ROLE_REVEAL_MS);
expect(reject(s, 'X', { type: 'readyEarly' })).toBe('이 게임의 플레이어가 아니에요.');
});
});
describe('스키마', () => {
test('옵션에 한국어 메타, 기본값', () => {
const shape = (mafia.optionsSchema as unknown as { shape: Record<string, { meta(): { title?: string } | undefined }> }).shape;
for (const [k, v] of Object.entries(shape)) expect(v.meta()?.title, k).toBeTruthy();
const extra = (shape.extraRoles as unknown as { unwrap(): { shape: Record<string, { meta(): { title?: string } | undefined }> } }).unwrap().shape;
for (const [k, v] of Object.entries(extra)) expect(v.meta()?.title, k).toBeTruthy();
expect(mafia.defaultOptions).toMatchObject({ mafiaCount: 'auto', firstPhase: 'day', discussionPerAlive: 15, discussionMin: 60, discussionMax: 180, voteSeconds: 20, defenseSeconds: 20, confirmSeconds: 10, nightSeconds: 30, deathReveal: 'team', openVote: true, kidSafeChat: true });
expect(mafia.defaultOptions.extraRoles).toEqual({ soldier: false, politician: false, medium: false, lovers: false });
expect(mafia.ownChat).toBe(true);
expect(mafia.actionSchema.safeParse({ type: 'chat', channel: 'system', text: 'x' }).success).toBe(false);
});
});
// ---------------------------------------------------------------- properties
const PRESETS: Record<string, unknown>[] = [
{},
{ extraRoles: { soldier: true, medium: true }, deathReveal: 'role', mafiaMustKill: 'random' },
{ extraRoles: { lovers: true, politician: true }, openVote: false, firstPhase: 'night', deadSeeAll: true },
{ deathReveal: 'none', doctorSelfHeal: false, doctorRepeat: false, policeRevealRole: true, timeAdjust: false },
{ extraRoles: { soldier: true, politician: true, medium: true, lovers: true }, mafiaMustKill: 'random', revealChatsAtEnd: false },
];
/** Fits the preset's extra roles to the player count (drops the ones that do not fit). */
function presetFor(i: number, n: number): MafiaOptions {
const o = opts(PRESETS[i % PRESETS.length]!);
for (const r of ['lovers', 'politician', 'medium', 'soldier'] as const) if (compositionError(o, n)) o.extraRoles = { ...o.extraRoles, [r]: false };
return o;
}
function invariants(s: MafiaState, initialRoles: string): void {
if (s.players.map((p) => p.role).sort().join() !== initialRoles) throw new Error('roles changed');
for (const p of s.players) {
if (p.alive !== (p.diedBy === null) || p.alive !== (p.diedDay === null)) throw new Error(`${p.id} death fields out of sync`);
if (p.soldierUsed && p.role !== 'soldier') throw new Error('soldierUsed on non-soldier');
}
const living = s.players.filter((p) => p.alive);
const m = living.filter((p) => p.role === 'mafia').length;
if (!s.winner) {
if (m === 0 || m >= living.length - m) throw new Error('win condition met but game goes on');
if (s.phase.kind === 'ended') throw new Error('ended without winner');
if (mafia.activePlayers(s).length === 0) throw new Error('nobody to act');
} else {
if (s.phase.kind !== 'ended' || !s.final) throw new Error('winner without end');
if ((s.winner === 'town') !== (m === 0)) throw new Error('wrong winner');
}
for (const ch of CHANNELS) {
const msgs = s.chat.filter((c) => c.channel === ch);
if (msgs.some((c, i) => i > 0 && c.id !== msgs[i - 1]!.id + 1)) throw new Error('chat ids not consecutive');
if (msgs.length && msgs.at(-1)!.id !== s.chatCount[ch]) throw new Error('chat count out of sync');
}
if (s.events.some((e, i) => e.n !== i + 1)) throw new Error('event numbers out of order');
}
interface FastReport {
steps: number;
finished: boolean;
trace: string;
final: MafiaState;
}
/** Random play with cheaper scheduled checks; dead players chat too and players sometimes leave. */
function fastPlayout(players: PlayerId[], options: MafiaOptions, seed: RngState, maxSteps: number): FastReport {
const r = SeededRng.fromSeed(seed);
const chooser = SeededRng.fromSeed([seed[3], seed[2], seed[1], seed[0] ^ 0x9e3779b9]);
let now = 1_700_000_000_000;
let s = mafia.setup({ players, options, rng: r, now });
const roles = s.players.map((p) => p.role).sort().join();
const trace: string[] = [];
for (let step = 0; step < maxSteps; step++) {
invariants(s, roles);
if (step % 7 === 0) for (const v of [null, players[(step / 7) % players.length]!]) checkView(s, v);
if (step % 50 === 0 && JSON.stringify(JSON.parse(JSON.stringify(s))) !== JSON.stringify(s)) throw new Error('not JSON round-trippable');
for (const p of mafia.timeoutPlayers!(s)) {
const v = mafia.validate(s, p, mafia.onTimeout(s, p));
if (!v.ok) throw new Error(`onTimeout rejected: ${v.reason}`);
}
if (mafia.result(s)) {
for (const v of [...players, null]) checkView(s, v);
return { steps: step, finished: true, trace: trace.join(';'), final: s };
}
const pick = chooser.int(10);
let actor: PlayerId;
let action: MafiaAction;
if (pick === 0 && chooser.int(30) === 0) {
const living = s.players.filter((p) => p.alive);
actor = living[chooser.int(living.length)]!.id;
action = { type: 'leave' };
} else {
const pool = pick < 2 ? players : mafia.activePlayers(s);
actor = pool[chooser.int(pool.length)]!;
const legal = mafia.legalActions!(s, actor);
action = legal.length ? legal[chooser.int(legal.length)]! : mafia.onTimeout(s, actor);
}
const v = mafia.validate(s, actor, action);
if (!v.ok) throw new Error(`generated action rejected: ${v.reason} ${JSON.stringify(action)}`);
now += 1000;
const out = mafia.apply(s, actor, action, { rng: r, now });
// Events are broadcast to everyone: never chat text, night choices or private results.
if (out.events.length) {
const ej = JSON.stringify(out.events);
for (const m of out.state.chat) if (m.channel !== 'day' && ej.includes(JSON.stringify(m.text))) throw new Error('event leaks chat');
for (const pr of out.state.privateResults) if (ej.includes(pr.id)) throw new Error('event leaks a private result');
}
s = out.state;
trace.push(`${actor}:${JSON.stringify(action)}`);
}
return { steps: maxSteps, finished: false, trace: trace.join(';'), final: s };
}
describe('무작위 대국 (시드 1,000개, 4~12명)', () => {
test('1,000 games finish; roles conserved, no leaks, onTimeout always legal, deterministic', () => {
const wins = { town: 0, mafia: 0 };
const causes: Record<string, number> = {};
for (let i = 1; i <= 1000; i++) {
const n = 4 + (i % 9);
const players = ids(n);
const options = presetFor(i, n);
const a = fastPlayout(players, options, seedN(i), 4000);
if (!a.finished) throw new Error(`seed ${i} (${n} players) did not finish`);
wins[a.final.winner!]++;
causes[a.final.final!.cause] = (causes[a.final.final!.cause] ?? 0) + 1;
const res = mafia.result(a.final)!;
expect(res.ranking.flat().sort()).toEqual([...players].sort());
if (i % 50 === 0) {
const b = fastPlayout(players, options, seedN(i), 4000);
expect(b.trace).toBe(a.trace);
expect(JSON.stringify(b.final)).toBe(JSON.stringify(a.final));
}
}
expect(wins.town).toBeGreaterThan(50);
expect(wins.mafia).toBeGreaterThan(50);
expect(Object.keys(causes).sort()).toEqual(['disconnect', 'night', 'vote']);
}, 60_000);
test.each([4, 6, 9, 12])('randomPlayout with the engine per-step leak / round-trip checks: %i players', (n) => {
const players = ids(n);
for (let i = 1; i <= 3; i++) {
const options = presetFor(i + n, n);
const roles = (st: MafiaState) => st.players.map((p) => p.role).sort().join();
let initial: string | null = null;
const a = randomPlayout(mafia, {
players,
options,
seed: seedN(3000 + i * n),
maxSteps: 5000,
maxApplyMs: 30,
invariant: (st) => invariants(st as MafiaState, (initial ??= roles(st as MafiaState))),
});
expect(a.finished).toBe(true);
if (i === 1) {
const b = randomPlayout(mafia, { players, options, seed: seedN(3000 + i * n), maxSteps: 5000, maxApplyMs: 30 });
expect(JSON.stringify(b.trace)).toBe(JSON.stringify(a.trace));
}
}
}, 60_000);
test('onTimeout은 모든 상태에서 합법 행동', () => {
for (let seed = 1; seed <= 30; seed++) {
const n = 4 + (seed % 9);
let s = mafia.setup({ players: ids(n), options: presetFor(seed, n), rng: SeededRng.fromSeed(seedN(seed)), now: T0 });
const r = SeededRng.fromSeed(seedN(seed + 500));
while (!s.winner) {
for (const p of [...s.players.map((x) => x.id)]) expect(mafia.validate(s, p, mafia.onTimeout(s, p)).ok).toBe(true);
const ph = s.phase as Exclude<MafiaState['phase'], { kind: 'ended' }>;
expect(mafia.validate(s, 'P0', mafia.onTimeout(s, 'P0'), { now: ph.endsAt }).ok).toBe(true);
s = mafia.apply(s, 'P0', mafia.onTimeout(s, 'P0'), { rng: r, now: ph.endsAt }).state;
}
// Only timeouts: nobody acts, so away eliminations end the game.
expect(s.final!.cause === 'disconnect' || s.final!.cause === 'night').toBe(true);
}
});
test('같은 시드 + 같은 행동(채팅 포함) 기록을 다시 적용하면 같은 최종 상태', () => {
const players = ids(8);
const options = presetFor(2, 8);
const rep = randomPlayout(mafia, { players, options, seed: seedN(77), maxSteps: 5000, maxApplyMs: 30 });
expect(rep.trace.some((t) => (t.action as MafiaAction).type === 'chat')).toBe(true);
const replayRng = SeededRng.fromSeed(seedN(77));
let now = 1_700_000_000_000;
let s = mafia.setup({ players, options, rng: replayRng, now });
for (const { actor, action } of rep.trace) s = mafia.apply(s, actor, action as MafiaAction, { rng: replayRng, now: (now += 1000) }).state;
expect(JSON.stringify(s)).toBe(JSON.stringify(rep.finalState));
});
test('팀 판정 도우미', () => {
expect(teamOf('mafia')).toBe('mafia');
for (const r of ['police', 'doctor', 'citizen', 'soldier', 'politician', 'medium', 'lover'] as Role[]) expect(teamOf(r)).toBe('town');
});
});

View File

@@ -0,0 +1,287 @@
/** 마피아 options, actions, state and view types (docs/games/mafia.md §2, §8). */
import { z } from 'zod';
import type { PlayerId } from '@bg/engine';
const lit = <T extends number>(...vals: T[]) =>
z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral<T>, z.ZodLiteral<T>, ...z.ZodLiteral<T>[]]);
const secLabels = (...vals: number[]) => Object.fromEntries(vals.map((v) => [v, `${v}초`]));
export const ROLES = ['mafia', 'police', 'doctor', 'citizen', 'soldier', 'politician', 'medium', 'lover'] as const;
export type Role = (typeof ROLES)[number];
export type Team = 'town' | 'mafia';
export const EXTRA_ROLES = ['soldier', 'politician', 'medium', 'lovers'] as const;
export type ExtraRole = (typeof EXTRA_ROLES)[number];
export const CHANNELS = ['day', 'mafia', 'dead', 'lovers'] as const;
/** Channels players write in; `system` lines are the public announcements (`events`). */
export type Channel = (typeof CHANNELS)[number];
export const mafiaOptionsSchema = z.object({
mafiaCount: z
.union([z.literal('auto'), z.literal(1), z.literal(2), z.literal(3), z.literal(4)])
.default('auto')
.meta({ title: '마피아 수', labels: { auto: '자동(인원에 맞게)', 1: '1명', 2: '2명', 3: '3명', 4: '4명' } }),
extraRoles: z
.object({
soldier: z.boolean().default(false).meta({ title: '군인(마피아 공격을 한 번 버텨요)' }),
politician: z.boolean().default(false).meta({ title: '정치인(표가 2표, 투표로 탈락하지 않아요)' }),
medium: z.boolean().default(false).meta({ title: '영매(밤에 죽은 사람과 대화해요)' }),
lovers: z.boolean().default(false).meta({ title: '연인(2명, 밤에 둘만 대화해요)' }),
})
.default({ soldier: false, politician: false, medium: false, lovers: false })
.meta({ title: '추가 직업(시민 자리를 대신해요)' }),
firstPhase: z
.enum(['day', 'night'])
.default('day')
.meta({ title: '시작 페이즈', labels: { day: '인사하는 낮부터(투표 없음)', night: '밤부터' } }),
discussionPerAlive: lit(10, 15, 20)
.default(15)
.meta({ title: '토론 시간(살아 있는 사람 1명당)', labels: secLabels(10, 15, 20) }),
discussionMin: lit(30, 45, 60, 90, 120)
.default(60)
.meta({ title: '토론 시간 최소', labels: secLabels(30, 45, 60, 90, 120) }),
discussionMax: lit(60, 120, 180, 240, 300)
.default(180)
.meta({ title: '토론 시간 최대', labels: secLabels(60, 120, 180, 240, 300) }),
timeAdjust: z.boolean().default(true).meta({ title: '하루 한 번 토론 시간 늘리기·줄이기(±15초)' }),
voteSeconds: lit(10, 15, 20, 30, 45, 60)
.default(20)
.meta({ title: '지목 투표 시간', labels: secLabels(10, 15, 20, 30, 45, 60) }),
defenseSeconds: lit(10, 15, 20, 30, 45, 60)
.default(20)
.meta({ title: '최후의 반론 시간', labels: secLabels(10, 15, 20, 30, 45, 60) }),
confirmSeconds: lit(5, 10, 15, 20, 30)
.default(10)
.meta({ title: '찬반 투표 시간', labels: secLabels(5, 10, 15, 20, 30) }),
nightSeconds: lit(15, 20, 30, 45, 60, 90)
.default(30)
.meta({ title: '밤 시간', labels: secLabels(15, 20, 30, 45, 60, 90) }),
deathReveal: z
.enum(['none', 'team', 'role'])
.default('team')
.meta({ title: '탈락한 사람 공개', labels: { none: '공개 안 함', team: '마피아인지만', role: '직업까지' } }),
openVote: z.boolean().default(true).meta({ title: '지목 투표에서 누가 누구를 골랐는지 공개' }),
doctorSelfHeal: z.boolean().default(true).meta({ title: '의사가 자기 자신 지키기' }),
doctorRepeat: z.boolean().default(true).meta({ title: '의사가 같은 사람을 이틀 연속 지키기' }),
mafiaMustKill: z
.enum(['noKill', 'random'])
.default('noKill')
.meta({ title: '마피아가 아무도 안 골랐을 때', labels: { noKill: '아무도 탈락하지 않음', random: '무작위로 1명 탈락' } }),
policeRevealRole: z.boolean().default(false).meta({ title: '(하우스 룰) 경찰이 마피아 여부 대신 직업을 알아요' }),
deadSeeAll: z.boolean().default(false).meta({ title: '탈락한 사람이 모든 직업과 마피아 채팅 보기' }),
kidSafeChat: z.boolean().default(true).meta({ title: '고운 말 필터(욕설·링크·전화번호 막기)' }),
revealChatsAtEnd: z.boolean().default(true).meta({ title: '게임이 끝나면 모든 채팅 공개' }),
});
export type MafiaOptions = z.infer<typeof mafiaOptionsSchema>;
const pid = z.string().min(1).max(64);
export const mafiaActionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('chat'), channel: z.enum(CHANNELS), text: z.string().max(400) }),
z.object({ type: z.literal('adjustTime'), delta: z.union([z.literal(15), z.literal(-15)]) }),
z.object({ type: z.literal('readyEarly') }),
z.object({ type: z.literal('vote'), target: pid }),
z.object({ type: z.literal('confirm'), choice: z.enum(['yes', 'no']) }),
z.object({ type: z.literal('mafiaTarget'), target: pid.nullable() }),
z.object({ type: z.literal('investigate'), target: pid }),
z.object({ type: z.literal('heal'), target: pid }),
z.object({ type: z.literal('mediumPeek'), target: pid }),
]);
export type MafiaClientAction = z.infer<typeof mafiaActionSchema>;
/** Client actions plus the system-only ones (phase timer, leaving) that are not in the schema. */
export type MafiaAction = MafiaClientAction | { type: 'phaseTimeout' } | { type: 'leave' };
export type NightActionType = 'mafiaTarget' | 'investigate' | 'heal' | 'mediumPeek';
// ---------------------------------------------------------------- state
export type DiedBy = 'vote' | 'mafia' | 'disconnect';
export interface PlayerState {
id: PlayerId;
role: Role;
alive: boolean;
soldierUsed: boolean;
/** Soldier who survived an attack / politician saved from a vote: role is public. */
publicRole: boolean;
diedDay: number | null;
diedBy: DiedBy | null;
/** Did anything since the last day ended (away detection, §8.5). */
activeToday: boolean;
/** Voting days in a row without any action. */
idleDays: number;
}
export type Phase =
| { kind: 'roleReveal'; startedAt: number; endsAt: number }
| { kind: 'greeting'; startedAt: number; endsAt: number; ready: PlayerId[] }
| { kind: 'discussion'; startedAt: number; endsAt: number; adjustedBy: PlayerId[]; ready: PlayerId[] }
| { kind: 'vote'; startedAt: number; endsAt: number; votes: Record<PlayerId, PlayerId | 'abstain'> }
| { kind: 'defense'; startedAt: number; endsAt: number; accused: PlayerId }
| { kind: 'confirm'; startedAt: number; endsAt: number; accused: PlayerId; ballots: Record<PlayerId, 'yes' | 'no'> }
| {
kind: 'night';
startedAt: number;
endsAt: number;
mafiaTarget: PlayerId | null;
mafiaPickedBy: PlayerId | null;
investigate: PlayerId | null;
heal: PlayerId | null;
mediumPeek: PlayerId | null;
}
| { kind: 'ended' };
export type PhaseKind = Phase['kind'];
export interface ChatMessage {
/** Running number inside its channel (a global counter would let others count hidden messages). */
id: number;
channel: Channel;
from: PlayerId;
text: string;
at: number;
day: number;
}
/** Public announcement (the `system` channel). */
export type PublicEvent = { n: number; at: number; day: number } & (
| { t: 'phase'; kind: Exclude<PhaseKind, 'ended'> }
| { t: 'adjust'; p: PlayerId; delta: 15 | -15 }
| { t: 'vote'; tally: Record<PlayerId, number>; abstain: number; votes: Record<PlayerId, PlayerId | 'abstain'> | null; accused: PlayerId | null; tie: boolean }
| { t: 'confirm'; accused: PlayerId; yes: number; no: number; outcome: 'executed' | 'spared' | 'politician' }
| { t: 'death'; p: PlayerId; by: DiedBy; team: Team | null; role: Role | null }
| { t: 'healed' }
| { t: 'soldier'; p: PlayerId }
| { t: 'quiet' }
| { t: 'end'; winner: Team }
);
export type PublicEventKind = PublicEvent['t'];
export interface PrivateResult {
/** Unique token (also the leak-test secret). */
id: string;
to: PlayerId;
day: number;
kind: 'investigate' | 'peek';
target: PlayerId;
/** Police: 'mafia' / 'notMafia' (or the role with `policeRevealRole`); medium: the role. */
result: Role | 'notMafia';
}
export type WinCause = 'vote' | 'night' | 'disconnect';
export interface MafiaFinal {
winner: Team;
days: number;
cause: WinCause;
players: { id: PlayerId; role: Role; team: Team; alive: boolean; diedDay: number | null; diedBy: DiedBy | null; won: boolean }[];
/** Winning team (alive first) → losing team (alive first). */
ranking: PlayerId[];
/** Town player whose day votes sent the most mafia out (optional MVP, §6). */
mvp: PlayerId | null;
summary: string;
}
export interface MafiaState {
options: MafiaOptions;
players: PlayerState[];
day: number;
phase: Phase;
lastHealTarget: PlayerId | null;
privateResults: PrivateResult[];
/** Every message of every channel; the view filters by channel permission. */
chat: ChatMessage[];
chatCount: Record<Channel, number>;
lastChatAt: Record<PlayerId, number>;
events: PublicEvent[];
/** Voters (vote phase) for the current accused — for the MVP count. */
trialVoters: PlayerId[];
mvpVotes: Record<PlayerId, number>;
winner: Team | null;
/** Applied actions. */
seq: number;
final: MafiaFinal | null;
}
// ---------------------------------------------------------------- view
export interface PlayerView {
id: PlayerId;
alive: boolean;
diedDay: number | null;
diedBy: DiedBy | null;
/** Only when the viewer may know it (own role, team mates, public reveals, after the game). */
role: Role | null;
team: Team | null;
}
export interface ChannelView {
channel: Channel;
/** Writable right now. */
canWrite: boolean;
/** Korean reason when not writable. */
why: string | null;
}
export interface ChatView {
key: string;
channel: Channel;
from: PlayerId;
text: string;
at: number;
day: number;
}
export interface NightView {
/** null = no night action (same fields for every role, so the screen does not give the role away). */
action: NightActionType | null;
prompt: string;
targets: PlayerId[];
choice: PlayerId | null;
/** Mafia: who picked the shared target. */
pickedBy: PlayerId | null;
}
export interface MyView {
role: Role;
team: Team;
alive: boolean;
/** Mafia: the other mafia; lovers: the partner. */
mates: PlayerId[];
vote: PlayerId | 'abstain' | null;
voteTargets: PlayerId[];
ballot: 'yes' | 'no' | null;
canConfirm: boolean;
canAdjust: boolean;
canReady: boolean;
ready: boolean;
/** Alive at night: always an object with the same keys. */
night: NightView | null;
results: PrivateResult[];
/** One more idle voting day eliminates me (§8.5). */
idleWarning: boolean;
}
export interface MafiaView {
me: PlayerId | null;
options: MafiaOptions;
day: number;
phase: {
kind: PhaseKind;
startedAt: number;
endsAt: number | null;
accused: PlayerId | null;
/** Discussion: who used ±15초 today (public). */
adjustedBy: PlayerId[];
readyCount: number;
readyNeeded: number;
};
players: PlayerView[];
/** Starting role counts (public composition). */
setup: Partial<Record<Role, number>>;
aliveCount: number;
events: PublicEvent[];
chat: ChatView[];
/** Tabs the viewer may read. */
channels: ChannelView[];
my: MyView | null;
winner: Team | null;
final: MafiaFinal | null;
}

View File

@@ -36,6 +36,7 @@ export const CATALOG: CatalogEntry[] = [
{ id: 'ninety-nine', nameKo: '구십구', minPlayers: 2, maxPlayers: 10, minutes: '5~15', level: '쉬움', blurb: '카드를 내며 숫자를 더해요. 99를 넘기면 탈락!', available: true }, { id: 'ninety-nine', nameKo: '구십구', minPlayers: 2, maxPlayers: 10, minutes: '5~15', level: '쉬움', blurb: '카드를 내며 숫자를 더해요. 99를 넘기면 탈락!', available: true },
{ id: 'dobble', nameKo: '도블', minPlayers: 2, maxPlayers: 8, minutes: '3~10', level: '쉬움', blurb: '어느 두 카드에나 딱 하나 있는 같은 그림을 누구보다 빨리 콕!', available: true }, { id: 'dobble', nameKo: '도블', minPlayers: 2, maxPlayers: 8, minutes: '3~10', level: '쉬움', blurb: '어느 두 카드에나 딱 하나 있는 같은 그림을 누구보다 빨리 콕!', available: true },
{ id: 'catchmind', nameKo: '그림 맞히기', minPlayers: 2, maxPlayers: 10, minutes: '10~20', level: '쉬움', blurb: '한 사람이 그림을 그리면 나머지가 채팅으로 정답을 맞혀요!', available: true }, { id: 'catchmind', nameKo: '그림 맞히기', minPlayers: 2, maxPlayers: 10, minutes: '10~20', level: '쉬움', blurb: '한 사람이 그림을 그리면 나머지가 채팅으로 정답을 맞혀요!', available: true },
{ id: 'mafia', nameKo: '마피아', minPlayers: 4, maxPlayers: 12, minutes: '10~25', level: '보통', blurb: '낮에는 토론하고 밤에는 몰래! 숨은 마피아를 찾아요 · 채팅 추리', available: true },
]; ];
export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id); export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);