M8: 마피아 규칙·화면·테스트

- 규칙 모듈 packages/games/src/mafia: 4~12명, 자동/수동 마피아 수, 추가 직업(군인·정치인·영매·연인),
  서버 타이머가 phaseTimeout(시스템 전용)으로 페이즈 진행, 인사 낮/토론(±15초, 토론 끝내기)/지목 투표/
  최후의 반론/찬반 투표/밤(마피아 공용 대상, 경찰·영매 결과는 밤 마감 시 전달, 의사·군인 순서),
  채널(전체/마피아/죽은 사람/연인) 권한을 view에서 필터, 레이트 리밋·고운 말 필터, 자리 비움(2일) 탈락,
  나가기 즉시 탈락, 승리 판정·결과(MVP 포함)
- 테스트: 문서 §10 항목 전부, 시드 1,000판 무작위 대국(불변식·누출·onTimeout 합법·결정성),
  엔진 randomPlayout, 기록 재생, 실제 Room 통과 테스트(방 채팅 닫힘, 연결별 채팅 필터)
- 화면 apps/web/src/games/mafia: 낮/밤 배너와 큰 남은 시간, 채널 탭 채팅, 꾹 눌러 보는 내 직업,
  그리드에서 투표·밤 대상 고르기, 아침 발표·득표 막대, 최종 공개
- 공용 코드(하위 호환): GameDefinition.ownChat, validate의 선택 ctx {now}; Room이 ownChat 게임 진행 중
  방 채팅·반응을 거절하고 validate에 서버 시각 전달; 웹 GameUI.ownChat이면 진행 중 방 채팅 패널 숨김
- 등록(GAMES, 카탈로그, 화면 레지스트리, 아이콘)과 E2E 계획(PC 방장 + 휴대폰 3명)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-06 00:21:32 +09:00
parent bc199de10b
commit c682725968
14 changed files with 3231 additions and 9 deletions

View File

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