M8: 마피아 규칙·화면·테스트
- 규칙 모듈 packages/games/src/mafia: 4~12명, 자동/수동 마피아 수, 추가 직업(군인·정치인·영매·연인),
서버 타이머가 phaseTimeout(시스템 전용)으로 페이즈 진행, 인사 낮/토론(±15초, 토론 끝내기)/지목 투표/
최후의 반론/찬반 투표/밤(마피아 공용 대상, 경찰·영매 결과는 밤 마감 시 전달, 의사·군인 순서),
채널(전체/마피아/죽은 사람/연인) 권한을 view에서 필터, 레이트 리밋·고운 말 필터, 자리 비움(2일) 탈락,
나가기 즉시 탈락, 승리 판정·결과(MVP 포함)
- 테스트: 문서 §10 항목 전부, 시드 1,000판 무작위 대국(불변식·누출·onTimeout 합법·결정성),
엔진 randomPlayout, 기록 재생, 실제 Room 통과 테스트(방 채팅 닫힘, 연결별 채팅 필터)
- 화면 apps/web/src/games/mafia: 낮/밤 배너와 큰 남은 시간, 채널 탭 채팅, 꾹 눌러 보는 내 직업,
그리드에서 투표·밤 대상 고르기, 아침 발표·득표 막대, 최종 공개
- 공용 코드(하위 호환): GameDefinition.ownChat, validate의 선택 ctx {now}; Room이 ownChat 게임 진행 중
방 채팅·반응을 거절하고 validate에 서버 시각 전달; 웹 GameUI.ownChat이면 진행 중 방 채팅 패널 숨김
- 등록(GAMES, 카탈로그, 화면 레지스트리, 아이콘)과 E2E 계획(PC 방장 + 휴대폰 3명)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
70
apps/server/src/rooms/mafia.room.test.ts
Normal file
70
apps/server/src/rooms/mafia.room.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -536,6 +536,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);
|
||||||
@@ -553,8 +554,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);
|
||||||
@@ -671,7 +677,7 @@ export class Room {
|
|||||||
let next: { state: unknown; events: unknown[] };
|
let next: { state: unknown; events: unknown[] };
|
||||||
const rng = new SeededRng(g.rng);
|
const rng = new SeededRng(g.rng);
|
||||||
try {
|
try {
|
||||||
const v = def.validate(g.state, userId, action);
|
const v = def.validate(g.state, userId, action, { now: this.now() });
|
||||||
if (!v.ok) return v.reason;
|
if (!v.ok) return v.reason;
|
||||||
next = def.apply(g.state, userId, action, { rng, now: this.now() });
|
next = def.apply(g.state, userId, action, { rng, now: this.now() });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
1030
apps/web/src/games/mafia/index.tsx
Normal file
1030
apps/web/src/games/mafia/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
|
|||||||
'las-vegas': () => import('./las-vegas'),
|
'las-vegas': () => import('./las-vegas'),
|
||||||
'cockroach-poker': () => import('./cockroach-poker'),
|
'cockroach-poker': () => import('./cockroach-poker'),
|
||||||
'ninety-nine': () => import('./ninety-nine'),
|
'ninety-nine': () => import('./ninety-nine'),
|
||||||
|
mafia: () => import('./mafia'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const cache = new Map<string, GameUI>();
|
const cache = new Map<string, GameUI>();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const GAME_ICON: Record<string, string> = {
|
|||||||
'las-vegas': '⚅',
|
'las-vegas': '⚅',
|
||||||
'cockroach-poker': '🐞',
|
'cockroach-poker': '🐞',
|
||||||
'ninety-nine': '💯',
|
'ninety-nine': '💯',
|
||||||
|
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). */
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
146
e2e/games.e2e.ts
146
e2e/games.e2e.ts
@@ -641,6 +641,51 @@ async function ninetyNineStep(p: Page, stats: Record<string, number>): Promise<b
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 마피아: 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.
|
||||||
@@ -1127,7 +1172,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();
|
||||||
},
|
},
|
||||||
@@ -1606,6 +1651,105 @@ const PLANS: Record<string, Plan> = {
|
|||||||
console.log(` ninety-nine: finished after ${turns} UI turns ${JSON.stringify(stats)}, ${outs} eliminations in the log`);
|
console.log(` ninety-nine: finished after ${turns} UI turns ${JSON.stringify(stats)}, ${outs} eliminations in the log`);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
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);
|
||||||
|
|||||||
@@ -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,10 +52,12 @@ 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;
|
||||||
validate(state: S, actor: PlayerId, action: A): 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;
|
||||||
activePlayers(state: S): PlayerId[];
|
activePlayers(state: S): PlayerId[];
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { quoridor } from './quoridor';
|
|||||||
import { lasVegas } from './las-vegas';
|
import { lasVegas } from './las-vegas';
|
||||||
import { cockroachPoker } from './cockroach-poker';
|
import { cockroachPoker } from './cockroach-poker';
|
||||||
import { ninetyNine } from './ninety-nine';
|
import { ninetyNine } from './ninety-nine';
|
||||||
|
import { mafia } from './mafia';
|
||||||
|
|
||||||
export const GAMES: Record<string, AnyGameDefinition> = {
|
export const GAMES: Record<string, AnyGameDefinition> = {
|
||||||
[omok.id]: omok,
|
[omok.id]: omok,
|
||||||
@@ -47,4 +48,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
|
|||||||
[lasVegas.id]: lasVegas,
|
[lasVegas.id]: lasVegas,
|
||||||
[cockroachPoker.id]: cockroachPoker,
|
[cockroachPoker.id]: cockroachPoker,
|
||||||
[ninetyNine.id]: ninetyNine,
|
[ninetyNine.id]: ninetyNine,
|
||||||
|
[mafia.id]: mafia,
|
||||||
};
|
};
|
||||||
|
|||||||
784
packages/games/src/mafia/index.ts
Normal file
784
packages/games/src/mafia/index.ts
Normal 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 };
|
||||||
883
packages/games/src/mafia/mafia.test.ts
Normal file
883
packages/games/src/mafia/mafia.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
287
packages/games/src/mafia/types.ts
Normal file
287
packages/games/src/mafia/types.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ export const CATALOG: CatalogEntry[] = [
|
|||||||
{ id: 'las-vegas', nameKo: '라스베가스', minPlayers: 2, maxPlayers: 5, minutes: '20~30', level: '쉬움', blurb: '주사위를 같은 숫자 카지노에 몽땅! 개수가 같으면 둘 다 꽝 · 놀이용 지폐', available: true },
|
{ id: 'las-vegas', nameKo: '라스베가스', minPlayers: 2, maxPlayers: 5, minutes: '20~30', level: '쉬움', blurb: '주사위를 같은 숫자 카지노에 몽땅! 개수가 같으면 둘 다 꽝 · 놀이용 지폐', available: true },
|
||||||
{ id: 'cockroach-poker', nameKo: '바퀴벌레 포커', minPlayers: 2, maxPlayers: 6, minutes: '15~20', level: '쉬움', blurb: '벌레 카드를 건네며 "이건 쥐야!" 진짜일까 거짓말일까?', available: true },
|
{ id: 'cockroach-poker', nameKo: '바퀴벌레 포커', minPlayers: 2, maxPlayers: 6, minutes: '15~20', level: '쉬움', blurb: '벌레 카드를 건네며 "이건 쥐야!" 진짜일까 거짓말일까?', available: true },
|
||||||
{ 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: '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);
|
||||||
|
|||||||
Reference in New Issue
Block a user