Merge branch 'm8/mafia' into codex/owner/grp_discord_joke-app
# Conflicts: # apps/server/src/rooms/room.ts # apps/web/src/games/registry.ts # apps/web/src/lib/catalog-ui.ts # e2e/games.e2e.ts # packages/engine/src/types.ts # packages/games/src/index.ts # packages/shared/src/catalog.ts
This commit is contained in:
@@ -24,6 +24,7 @@ import { cockroachPoker } from './cockroach-poker';
|
||||
import { ninetyNine } from './ninety-nine';
|
||||
import { dobble } from './dobble';
|
||||
import { catchmind } from './catchmind';
|
||||
import { mafia } from './mafia';
|
||||
|
||||
export const GAMES: Record<string, AnyGameDefinition> = {
|
||||
[omok.id]: omok,
|
||||
@@ -51,4 +52,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
|
||||
[ninetyNine.id]: ninetyNine,
|
||||
[dobble.id]: dobble,
|
||||
[catchmind.id]: catchmind,
|
||||
[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;
|
||||
}
|
||||
Reference in New Issue
Block a user