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:
EJClaw
2026-10-06 00:21:32 +09:00
parent bc199de10b
commit c682725968
14 changed files with 3231 additions and 9 deletions

View File

@@ -22,6 +22,7 @@ import { quoridor } from './quoridor';
import { lasVegas } from './las-vegas';
import { cockroachPoker } from './cockroach-poker';
import { ninetyNine } from './ninety-nine';
import { mafia } from './mafia';
export const GAMES: Record<string, AnyGameDefinition> = {
[omok.id]: omok,
@@ -47,4 +48,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
[lasVegas.id]: lasVegas,
[cockroachPoker.id]: cockroachPoker,
[ninetyNine.id]: ninetyNine,
[mafia.id]: mafia,
};

View File

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

View File

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

View File

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