/** * M2 smoke E2E for every 2-player game (docs/10 §2): PC host creates the room from the game page, * a phone friend joins by link, host starts, both see the board, a few legal moves are played via * the keyboard (role="grid" boards support arrows + Enter) and reach the opponent. * Run: bun e2e/games.e2e.ts [gameId...] (needs apps/web/dist built) */ import { chromium, type Page } from 'playwright-core'; import { mkdirSync, mkdtempSync } from 'node:fs'; import { join } from 'node:path'; import { loadConfig } from '../apps/server/src/config'; import { startServer } from '../apps/server/src/server'; const PORT = Number(process.env.E2E_PORT ?? 4519); const ORIGIN = `http://localhost:${PORT}`; const SHOTS = process.env.E2E_SHOTS ?? join(import.meta.dir, '../test-results'); mkdirSync(SHOTS, { recursive: true }); /** Per game: key sequences on the grid, in turn order (first mover, second mover, ...). */ interface Plan { /** Total players including the host (default 2). Extra players join on phones. */ players?: number; /** Host changes room options before starting (inline settings form on PC). */ lobby?: (host: Page) => Promise; /** Steps run before normal moves (e.g. janggi formation choice). */ setup?: (host: Page, guest: Page) => Promise; /** Board games: key sequences on the grid, in turn order. */ moves?: string[][]; /** Card games: custom driver over all pages (host first). */ drive?: (pages: Page[]) => Promise; } /** Card games: whoever has the turn presses the first available safe action (check/call/…) until `rounds` actions were taken. */ async function pressActions(pages: Page[], labels: RegExp, rounds: number) { for (let n = 0; n < rounds; n++) { let done = false; for (let t = 0; t < 150 && !done; t++) { for (const p of pages) { const btn = p.getByRole('button', { name: labels }).first(); if ((await btn.count()) && (await btn.isEnabled({ timeout: 200 }).catch(() => false))) { if (await btn.click({ timeout: 1000 }).then(() => true, () => false)) { done = true; break; } } } if (!done) await pages[0]!.waitForTimeout(100); } if (!done) throw new Error(`no action button matching ${labels} became available (step ${n + 1})`); await pages[0]!.waitForTimeout(150); } } /** Waits until every page shows the hand result, then presses "다음 판으로" where it is offered. */ async function finishHand(pages: Page[]) { for (const p of pages) await p.getByText('이번 판 결과').first().waitFor({ timeout: 10_000 }); } /** 숫자 타일: whoever has the turn tries "도와줘" → "새 묶음" → "확정"; otherwise takes one tile. */ async function rummikubTurns(pages: Page[], turns: number) { const stats = { plays: 0, draws: 0, undo: false }; const myTurn = (p: Page) => p.evaluate(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요')); const bar = (p: Page) => p.getByRole('group', { name: '내 차례 행동' }); /** Waits until a bar button is enabled / disabled (server answered). */ const barButton = (p: Page, name: string, enabled: boolean) => p.waitForFunction( ([n, e]) => [...document.querySelectorAll('.rk-bar button')].some((b) => b.textContent === n && b.hasAttribute('disabled') !== e), [name, enabled] as const, { timeout: 5000 }, ); for (let n = 0; n < turns; n++) { let mover: Page | null = null; for (let t = 0; t < 150 && !mover; t++) { for (const p of pages) if (!mover && (await myTurn(p))) mover = p; if (!mover) await pages[0]!.waitForTimeout(100); } if (!mover) throw new Error(`nobody has the turn (turn ${n + 1})`); await bar(mover).waitFor(); let played = false; await bar(mover).getByRole('button', { name: '도와줘' }).click(); const picked = await mover.locator('.rk-rack button[aria-pressed="true"]').count(); if (picked > 0) { // Tap one picked tile twice (deselect, select again) — the tap-to-select path. const first = mover.locator('.rk-rack button[aria-pressed="true"]').first(); const label = await first.getAttribute('aria-label'); await first.click(); await mover.locator(`.rk-rack button[aria-label="${label}"]`).first().click(); await bar(mover).getByRole('button', { name: '새 묶음' }).click(); await mover.locator('.rk-set').last().waitFor(); if (!stats.undo) { // Undo once (back to the turn start), then place again. await barButton(mover, '되돌리기', true); await bar(mover).getByRole('button', { name: '되돌리기' }).click(); await barButton(mover, '처음으로', false); stats.undo = true; await bar(mover).getByRole('button', { name: '도와줘' }).click(); await bar(mover).getByRole('button', { name: '새 묶음' }).click(); } const commit = bar(mover).getByRole('button', { name: '확정' }); await mover .waitForFunction(() => [...document.querySelectorAll('.rk-bar button')].some((b) => b.textContent === '확정' && b.classList.contains('btn-primary') && !b.hasAttribute('disabled')), null, { timeout: 5000 }) .then( () => (played = true), () => {}, ); if (played) { await commit.click(); stats.plays++; } else { await bar(mover).getByRole('button', { name: '처음으로' }).click(); await mover.waitForTimeout(300); } } if (!played) { await bar(mover).getByRole('button', { name: /^(한 장 가져오기|넘기기)$/ }).click(); const sheet = mover.getByRole('button', { name: /^되돌리고/ }); if (await sheet.count()) await sheet.click(); stats.draws++; } await mover.waitForFunction(() => !document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'), null, { timeout: 5000 }); } console.log(` rummikub: ${stats.plays} plays, ${stats.draws} draws, undo ${stats.undo ? 'used' : 'not used'}`); return stats; } /** * 윷놀이: one UI step for whoever can act on `p` — press the throw button, or pick a result chip → * a glowing piece (or the waiting-piece tray) → "이동하기" (the two-step move confirmation). */ async function yutStep(p: Page): Promise { const throwBtn = p.getByRole('button', { name: /윷 던지기|한 번 더 던지기/ }); if ((await throwBtn.count()) && (await throwBtn.isEnabled().catch(() => false))) { return throwBtn.click({ timeout: 1500 }).then(() => true, () => false); } const chip = p.locator('.yut-chip:not([disabled])').first(); if (!(await chip.count())) return false; try { await chip.click({ timeout: 1500 }); const src = p.locator('g.yut-hit[tabindex="0"], .yut-tray-btn.can').first(); await src.waitFor({ timeout: 2000 }); // Keyboard: Enter on a piece shows where it lands, Enter again on the same piece moves it. const h = await src.elementHandle({ timeout: 1000 }); await h!.focus(); await p.keyboard.press('Enter'); await p.getByRole('button', { name: /^이동하기/ }).waitFor({ timeout: 1500 }); await h!.focus(); await p.keyboard.press('Enter'); return true; } catch { return false; } } /** * 고스톱: one UI step for `p` if it has something to do — answer an open decision sheet (고/스톱: 고 once, then 스톱; * choose a floor card; 흔들기/폭탄; 총통 → 그냥 치기; 국진) or play a hand card (green-bordered match first; phones * use tap-to-lift, so a lifted card is tapped again). Returns what it did, or null. */ async function gostopStep(p: Page, state: { go: number; stop: number; picks: number; plays: number; sheets: number }): Promise { const dialog = p.getByRole('dialog'); if (await dialog.count()) { const name = (await dialog.first().getAttribute('aria-label')) ?? ''; if (name === '게임 결과') return null; if (name === '고 또는 스톱') { const goFirst = state.go === 0; if (goFirst && state.stop === 0) await p.screenshot({ path: join(SHOTS, 'gostop-gostop-dialog.png') }); await dialog.getByRole('button', { name: goFirst ? /^고/ : /^스톱/ }).click({ timeout: 1500 }); if (goFirst) state.go++; else state.stop++; return goFirst ? 'go' : 'stop'; } if (name === '총통') { await dialog.getByRole('button', { name: /^그냥 치기/ }).click({ timeout: 1500 }); return 'chongtong'; } const pick = dialog.locator('.gs-pick button, .gs-big .btn').first(); if (await pick.count()) { if (state.sheets === 0) await p.screenshot({ path: join(SHOTS, 'gostop-choice-dialog.png') }); await pick.click({ timeout: 1500 }); state.sheets++; if (name.includes('고르기')) state.picks++; return name; } return null; } const status = (await p.locator('.turn-status').innerText().catch(() => '')).trim(); if (!status.includes('내 차례예요')) return null; const card = p.locator('.gs-hcard[data-match="true"]:not([disabled])').first(); const any = p.locator('.gs-hcard:not([disabled])').first(); const target = (await card.count()) ? card : any; if (!(await target.count())) { const dummy = p.getByRole('button', { name: /^빈패 내기/ }); if (await dummy.isEnabled().catch(() => false)) { await dummy.click({ timeout: 1500 }); state.plays++; return 'dummy'; } return null; } const label = await target.getAttribute('aria-label'); await target.click({ timeout: 1500 }); const lifted = p.locator(`.gs-hcard[data-lifted="true"][aria-label="${label}"]`); if (await lifted.count()) await lifted.click({ timeout: 1500 }); state.plays++; return 'play'; } /** 할리갈리: fruit totals of the face-up tops, read from the seat labels ("펼친 카드 딸기 3개"). */ async function halliTotals(p: Page): Promise> { const labels = await p.locator('.hg-seat').evaluateAll((els) => els.map((e) => e.getAttribute('aria-label') ?? '')); const t: Record = { 딸기: 0, 바나나: 0, 라임: 0, 자두: 0 }; for (const l of labels) { const m = /펼친 카드 (딸기|바나나|라임|자두) (\d)개/.exec(l); if (m) t[m[1]!]! += Number(m[2]); } return t; } /** * 주사위 땅따먹기: one UI step for whoever can act on `p` — answer a trade, sell, buy/build (or skip), * pick a space-travel square on the board, escape the island or roll. Returns the label pressed. */ const tag = (p: Page) => ((p.viewportSize()?.width ?? 0) < 600 ? 'phone' : 'pc'); async function marbleStep(p: Page, stats: Record): Promise { const prompt = p.locator('.mb-prompt.mine'); if (!(await prompt.count())) return null; const btn = (name: string | RegExp) => prompt.getByRole('button', { name, exact: typeof name === 'string' }); const press = async (name: string | RegExp) => { const b = btn(name); if (!(await b.count()) || !(await b.first().isEnabled().catch(() => false))) return false; return b.first().click({ timeout: 1500 }).then(() => true, () => false); }; // Space travel: tap a glowing square on the board, check the preview, fly there. if (await btn(/^출발로 가기/).count()) { const cell = p.locator('g.mb-cell.pick').nth(5 + (stats.space ?? 0) * 7); await cell.click({ timeout: 1500 }).catch(() => {}); const go = p.getByRole('button', { name: '🚀 여기로 가기' }); if (await go.waitFor({ timeout: 1500 }).then(() => true, () => false)) { await p.getByText('여기로 가면').waitFor({ timeout: 1000 }); if (!stats.space) await p.screenshot({ path: join(SHOTS, `marble-space-${tag(p)}.png`) }); await go.click(); stats.space = (stats.space ?? 0) + 1; return 'space'; } return (await press(/^출발로 가기/)) ? 'space-start' : null; } // First buy prompt on each screen size, for the visual check. const shotKey = `shot-${tag(p)}`; if (!stats[shotKey] && (await btn('사기').count())) { stats[shotKey] = 1; await p.screenshot({ path: join(SHOTS, `marble-buy-${tag(p)}.png`) }); } for (const name of ['거절', /^가장 싼 것부터 팔기/, '사기', '짓기', '인수하기', '🎲 탈출 굴리기', '🎲 주사위 굴리기', '🎲 한 번 더 굴리기', '안 사기', '안 짓기', '안 하기'] as const) { if (await press(name)) { const key = typeof name === 'string' ? name : 'sell'; stats[key] = (stats[key] ?? 0) + 1; return key; } } return null; } /** * 달무티: one UI step for `p` — confirm the hand, give tax cards back (suggested cards), or on its turn pick a * number group (a "광대 필요" group first; leads mix a jester in via the toggle) and press "내기", passing now * and then. The host uses the keyboard (focus + Enter) for the group. Returns what it did, or null. */ async function dalmutiStep(p: Page, isHost: boolean, st: Record): Promise { const enabled = async (l: ReturnType) => (await l.count()) > 0 && (await l.first().isEnabled().catch(() => false)); const confirm = p.getByRole('button', { name: '손패 확인', exact: true }); if (await enabled(confirm)) { await confirm.click({ timeout: 1500 }); return 'confirm'; } const giveBack = p.getByRole('button', { name: /^돌려주기/ }); if (await enabled(giveBack)) { if (!st[`taxshot-${tag(p)}`] && (await p.getByText('1라운드 결과 → 새 신분').count())) { st[`taxshot-${tag(p)}`] = 1; await p.screenshot({ path: join(SHOTS, `dalmuti-tax-${tag(p)}.png`) }); await p.emulateMedia({ colorScheme: 'dark' }); await p.waitForTimeout(400); await p.screenshot({ path: join(SHOTS, `dalmuti-tax-${tag(p)}-dark.png`) }); await p.emulateMedia({ colorScheme: 'light' }); } await giveBack.click({ timeout: 1500 }); return 'return'; } const status = (await p.locator('.turn-status').innerText().catch(() => '')).trim(); if (!status.includes('내 차례예요')) return null; const pass = p.getByRole('button', { name: '패스', exact: true }); const canPass = await enabled(pass); const legal = p.locator('button.dm-group[data-legal="true"]'); const n = await legal.count(); if (n === 0 || (canPass && st.turns! % 4 === 3)) { if (!canPass) return null; await pass.click({ timeout: 1500 }); st.turns!++; st.passes = (st.passes ?? 0) + 1; return 'pass'; } const needs = p.locator('button.dm-group[data-legal="true"]', { hasText: '광대 필요' }); const lead = status.includes('새로 시작'); const jesterGroup = p.locator('button.dm-group[data-legal="true"][data-n="13"]'); let target = legal.last(); if (await needs.count()) target = needs.last(); else if (lead && (await jesterGroup.count()) && n > 1) target = legal.nth(n - 2); if (isHost) { await target.focus(); await p.keyboard.press('Enter'); st.hostKeys = (st.hostKeys ?? 0) + 1; } else await target.click({ timeout: 1500 }); const toggle = p.locator('.dm-toggle'); if (lead && (await enabled(toggle)) && (await toggle.getAttribute('aria-pressed')) === 'false') await toggle.click({ timeout: 1500 }); const play = p.getByRole('button', { name: /^내기 \(/ }); if (!(await enabled(play))) return null; const summary = await p.locator('.dm-pick .small').innerText().catch(() => ''); if (!st[`pickshot-${tag(p)}`]) { st[`pickshot-${tag(p)}`] = 1; await p.screenshot({ path: join(SHOTS, `dalmuti-pick-${tag(p)}.png`) }); } if (summary.includes('광대')) st.jesterPlays = (st.jesterPlays ?? 0) + 1; await play.click({ timeout: 1500 }); st.turns!++; st.plays = (st.plays ?? 0) + 1; return 'play'; } /** * 보석 상인: one UI action for whoever has the turn on `p` — return tokens / choose a noble / pass when * asked, otherwise buy a highlighted card, reserve now and then, take 2 of a colour, or 3 different gems. * Returns the kind of action or null when it isn't this page's turn. */ async function splendorStep(p: Page, stats: Record, turn: number): Promise { const status = (await p.locator('.turn-status').innerText().catch(() => '')).trim(); if (!status.startsWith('내 차례예요')) return null; const phone = (p.viewportSize()?.width ?? 0) < 600; const press = async (l: ReturnType) => { if (phone) await l.tap({ timeout: 2000 }); else if (turn % 2 === 0) { // Keyboard path on PC every other turn: focus + Enter. await l.focus({ timeout: 2000 }); await p.keyboard.press('Enter'); } else await l.click({ timeout: 2000 }); }; const panel = p.locator('.sp-panel'); if (status.includes('돌려놓아')) { const need = Number(status.match(/(\d+)개를 돌려놓아/)?.[1] ?? 0); for (let i = 0; i < need; i++) await press(panel.locator('.sp-basket').first().locator('button.sp-token').first()); await press(panel.getByRole('button', { name: `돌려놓기 (${need}/${need})` })); return 'discard'; } if (status.includes('귀족 중 한 명')) { await press(panel.getByRole('button', { name: /이 귀족 고르기$/ }).first()); return 'noble'; } if (status.includes('넘겨야')) { await press(panel.getByRole('button', { name: '차례 넘기기' })); const ok = p.getByRole('button', { name: '넘기기', exact: true }); if (await ok.isVisible({ timeout: 500 }).catch(() => false)) await ok.click(); return 'pass'; } const affordable = p.locator('.sp-market button.sp-card.can, .sp-market button.sp-card.gold, .sp-mine button.sp-card.can, .sp-mine button.sp-card.gold'); const reserveTurn = (stats.reserve ?? 0) < 2 && turn >= 2 && turn % 5 === 2; // The phone player hoards gems early on (no buying) until it has had to give some back once. const hoard = phone && !stats.discard && turn < 30; if ((await affordable.count()) && !reserveTurn && !hoard) { const card = affordable.first(); await press(card); if ((await p.locator('.sp-card.gold.sel').count()) && !stats.shotPay) { stats.shotPay = 1; await p.waitForTimeout(250); await p.screenshot({ path: join(SHOTS, `splendor-pay-${phone ? 'phone' : 'pc'}.png`), fullPage: phone }); } await press(panel.getByRole('button', { name: '🛒 사기' })); return 'buy'; } const reserveBtn = panel.getByRole('button', { name: /^📌 예약/ }); if (reserveTurn) { // Tier 1 open card on even turns, the tier 2 deck (blind) otherwise. const target = (stats.reserve ?? 0) === 0 ? p.locator('.sp-market .sp-row').nth(2).locator('button.sp-card').first() : p.locator('.sp-market .sp-row').nth(1).locator('button.sp-deck'); await press(target); if (await reserveBtn.isEnabled({ timeout: 800 }).catch(() => false)) { await press(reserveBtn); return 'reserve'; } await press(panel.getByRole('button', { name: '닫기' })); } const tokens = p.locator('.sp-bank button.sp-token:not([disabled])'); const counts = await tokens.evaluateAll((els) => els.map((e) => Number(e.querySelector('.ct')?.textContent ?? 0))); const four = counts.findIndex((n) => n >= 4); if ((stats.same ?? 0) < 2 && four >= 0 && turn % 3 === 1) { await press(tokens.nth(four)); await press(tokens.nth(four)); await press(panel.getByRole('button', { name: '💎 가져오기 (2개)' })); return 'same'; } const want = Number((await panel.locator('.sp-basket').innerText()).match(/서로 다른 색 (\d)개/)?.[1] ?? 3); let picked = 0; for (let i = 0; i < counts.length && picked < want; i++) { if (counts[i]! > 0) { await press(tokens.nth(i)); picked++; } } const takeBtn = panel.getByRole('button', { name: /^💎 가져오기/ }); if (picked > 0 && (await takeBtn.isEnabled().catch(() => false))) { await press(takeBtn); return 'different'; } // Nothing to take: reserve whatever is first, or buy anything. await press(p.locator('.sp-market button.sp-card').first()); if (await reserveBtn.isEnabled({ timeout: 500 }).catch(() => false)) { await press(reserveBtn); return 'reserve'; } return null; } /** 벽 쌓기 길찾기: 판의 data 속성(모델 좌표 a1~i9)을 읽어 PC는 클릭, 휴대폰은 탭으로 조작한다. */ const qdSvg = (p: Page) => p.locator('svg[data-my-seat]'); async function qdTap(p: Page, target: ReturnType) { if (tag(p) === 'phone') await target.tap(); else await target.click(); } async function qdWaitTurn(p: Page) { await p.waitForFunction(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'), null, { timeout: 15_000 }); } /** 벽 세우기 모드에서 앵커를 고르고 방향을 맞춘 뒤 미리보기 값("v:d8" / "h:e7:bad")을 돌려준다. */ async function qdPreviewWall(p: Page, anchor: string, o: 'h' | 'v'): Promise { await qdWaitTurn(p); if ((await qdSvg(p).getAttribute('data-mode')) !== 'wall') await qdTap(p, p.getByRole('button', { name: /^🧱 벽 세우기/ })); await qdTap(p, p.locator(`[data-anchor="${anchor}"]`)); for (let k = 0; k < 3; k++) { const pv = (await qdSvg(p).getAttribute('data-preview')) ?? ''; if (pv.startsWith(`${o}:${anchor}`)) return pv; await qdTap(p, p.getByRole('button', { name: /벽 방향 바꾸기/ })); } throw new Error(`wall preview did not become ${o}:${anchor}`); } async function qdConfirmWall(p: Page) { const before = await p.locator('[data-wall]').count(); await qdTap(p, p.getByRole('button', { name: '벽 세우기 확인' })); await p.waitForFunction((n) => document.querySelectorAll('[data-wall]').length > n, before, { timeout: 5000 }); } /** 내 최단 길(data-path)에서 가장 멀리 가는 이동 가능 칸으로 한 수. 점프였으면 true. */ async function qdStep(p: Page, keyboard: boolean): Promise { await qdWaitTurn(p); if ((await qdSvg(p).getAttribute('data-mode')) !== 'move') await qdTap(p, p.getByRole('button', { name: /말 움직이기/ })); const seat = await qdSvg(p).getAttribute('data-my-seat'); const from = (await p.locator(`[data-pawn="${seat}"]`).getAttribute('data-at'))!; const legal = await p.locator('[data-legal]').evaluateAll((els) => els.map((e) => e.getAttribute('data-cell')!)); const path = ((await qdSvg(p).getAttribute('data-path')) ?? '').split(' '); let to = legal[0]!; let best = -1; for (const c of legal) if (path.indexOf(c) > best) [best, to] = [path.indexOf(c), c]; if (keyboard && to === path[0]) { // 커서는 내 말에서 시작하고 화면 위쪽이 항상 내 목표 방향. await keys(p, ['ArrowUp', 'Enter']); } else { await qdTap(p, p.locator(`[data-cell="${to}"]`)); const confirm = p.getByRole('button', { name: /으로 이동$/ }); if (await confirm.count()) await qdTap(p, confirm); } await p.waitForFunction((t) => document.querySelector(`[data-pawn] [data-at="${t}"], [data-at="${t}"]`) !== null, to, { timeout: 5000 }); const d = Math.abs(from.charCodeAt(0) - to.charCodeAt(0)) + Math.abs(Number(from.slice(1)) - Number(to.slice(1))); return d === 2; } /** * 라스베가스: one UI step for `p` — press "주사위 굴리기", or pick a number group (every other pick prefers a group * marked "동점 주의" so duplicate-tie cancellations really happen) and confirm "N번 카지노에 M개 놓기". * The PC host's first pick uses the keyboard, its second one taps the casino card instead of the group. */ async function lasVegasStep( p: Page, phone: boolean, st: { rolls: number; places: number; keyboard: boolean; casinoPick: boolean; shots: Set }, ): Promise { const press = (l: ReturnType) => (phone ? l.tap({ timeout: 1500 }) : l.click({ timeout: 1500 })); const roll = p.getByRole('button', { name: /^🎲 주사위 굴리기/ }); if ((await roll.count()) && (await roll.isEnabled().catch(() => false))) { if (!(await press(roll).then(() => true, () => false))) return false; st.rolls++; return true; } const groups = p.locator('.lv-group:not([disabled])'); if (!(await groups.count())) return false; try { const tie = p.locator('.lv-group:not([disabled])[data-standing="tie"]'); const target = st.places % 2 === 1 && (await tie.count()) ? tie.first() : groups.first(); const confirm = p.getByRole('button', { name: /번 카지노에 \d+개 놓기$/ }); if (!phone && !st.keyboard) { // Keyboard only: Enter on a number group, then Enter on the confirm button. await target.focus(); await p.keyboard.press('Enter'); await confirm.waitFor({ timeout: 1500 }); await confirm.focus(); await p.keyboard.press('Enter'); st.keyboard = true; } else if (!phone && !st.casinoPick) { // Choose by tapping the highlighted casino card instead of the group button. await p.locator('button.lv-casino').first().click({ timeout: 1500 }); await confirm.waitFor({ timeout: 1500 }); st.casinoPick = true; await confirm.click({ timeout: 1500 }); } else { await press(target); await confirm.waitFor({ timeout: 1500 }); const tag = phone ? 'phone' : 'pc'; if (st.places >= 6 && !st.shots.has(tag)) { // Mid-game: a number group chosen, casino highlighted with its preview tag, light + dark. st.shots.add(tag); if (phone) await p.evaluate(() => window.scrollTo(0, 0)); await p.screenshot({ path: join(SHOTS, `las-vegas-mid-${tag}.png`) }); if (phone) await p.screenshot({ path: join(SHOTS, `las-vegas-mid-${tag}-full.png`), fullPage: true }); await p.emulateMedia({ colorScheme: 'dark' }); await p.waitForTimeout(250); await p.screenshot({ path: join(SHOTS, `las-vegas-mid-${tag}-dark.png`) }); await p.emulateMedia({ colorScheme: 'light' }); } await press(confirm); } st.places++; await p.waitForFunction(() => !document.querySelector('.lv-group[aria-pressed="true"]'), null, { timeout: 5000 }); return true; } catch { return false; } } /** * 바퀴벌레 포커: one UI step for whoever must act on `p` — offer (card → person → claim → confirm), pass after * peeking (person → claim → confirm) or answer an offer (진짜!/거짓말!, sometimes 몰래 보고 넘기기). */ async function cockroachStep(p: Page, stats: Record): Promise { const status = await p.locator('.turn-status').innerText().catch(() => ''); if (!status.includes('내 차례예요')) return null; const panel = p.locator('.cp-panel').first(); const offering = (await panel.getByText('카드를 건네 주세요', { exact: true }).count()) > 0; if (offering || (await panel.getByText('본 카드를 다른 사람에게 넘겨 주세요', { exact: true }).count())) { const give = panel.getByRole('button', { name: /(건네기|넘기기)$/ }); const n = (stats.offer ?? 0) + (stats.pass ?? 0); if (offering) { const cards = panel.locator('.cp-hand button.cp-pick'); await cards.nth(n % (await cards.count())).click(); } const targets = panel.locator('button.cp-target:not([disabled])'); await targets.nth(n % (await targets.count())).click(); await panel.locator('button.cp-claim').nth((n * 3) % 8).click(); if (!stats[`shot-give-${tag(p)}`]) { stats[`shot-give-${tag(p)}`] = 1; await p.screenshot({ path: join(SHOTS, `cockroach-poker-give-${tag(p)}.png`), fullPage: tag(p) === 'phone' }); } await give.click({ timeout: 2000 }); stats[offering ? 'offer' : 'pass'] = (stats[offering ? 'offer' : 'pass'] ?? 0) + 1; return offering ? 'offer' : 'pass'; } const yes = panel.getByRole('button', { name: /^진짜!/ }); if (await yes.count()) { if (!stats[`shot-respond-${tag(p)}`]) { stats[`shot-respond-${tag(p)}`] = 1; await p.screenshot({ path: join(SHOTS, `cockroach-poker-respond-${tag(p)}.png`), fullPage: tag(p) === 'phone' }); } const peek = panel.getByRole('button', { name: '👁 몰래 보고 넘기기' }); const calls = (stats.true ?? 0) + (stats.false ?? 0); if ((await peek.isEnabled().catch(() => false)) && calls % 3 === 1) { await peek.click(); await panel.getByText('본 카드를 다른 사람에게 넘겨 주세요').waitFor({ timeout: 3000 }); stats.peek = (stats.peek ?? 0) + 1; return 'peek'; } const which = calls % 2 === 0 ? 'true' : 'false'; await panel.getByRole('button', { name: which === 'true' ? /^진짜!/ : /^거짓말!/ }).click(); stats[which] = (stats[which] ?? 0) + 1; return which; } return null; } const nnShots = new Set(); /** 구십구: one turn for whoever shows "내 차례예요" — joker via the dial, ± via its buttons, 0, else a number card. */ async function ninetyNineStep(p: Page, stats: Record): Promise { const status = (await p.locator('.turn-status').innerText().catch(() => '')).trim(); if (!status.includes('내 차례예요')) return false; const total = Number(/합계 (\d+)/.exec(status)?.[1] ?? 0); const phone = tag(p) === 'phone'; const press = async (l: ReturnType) => (phone ? l.tap() : l.click()); const cards = p.locator('button.nn-card[data-legal="true"]'); const n = await cards.count(); if (!n) return false; const info: { i: number; label: string; prev: number }[] = []; for (let i = 0; i < n; i++) { const label = (await cards.nth(i).getAttribute('aria-label')) ?? ''; const prev = Number(/→ (\d+)/.exec(await cards.nth(i).locator('.nn-prev').innerText())?.[1] ?? -1); info.push({ i, label, prev }); } const joker = info.find((c) => c.label.startsWith('조커')); const pm = info.find((c) => c.label.startsWith('±')); const zero = info.find((c) => c.label.startsWith('0 카드')); const nums = info.filter((c) => c.prev >= 0 && !c.label.startsWith('0 카드')).sort((a, b) => a.prev - b.prev); if (joker && (total >= 40 || !nums.length)) { await press(cards.nth(joker.i)); await press(p.getByRole('button', { name: '99', exact: true })); if (!nnShots.has(`joker-${tag(p)}`)) { nnShots.add(`joker-${tag(p)}`); await p.screenshot({ path: join(SHOTS, `ninety-nine-joker-${tag(p)}.png`) }); } await press(p.getByRole('button', { name: '1 작게' })); await press(p.getByRole('button', { name: /^조커 내기 → 98$/ })); stats.joker = (stats.joker ?? 0) + 1; } else if (pm) { await press(cards.nth(pm.i)); if (!nnShots.has(`pm-${tag(p)}`)) { nnShots.add(`pm-${tag(p)}`); await p.screenshot({ path: join(SHOTS, `ninety-nine-plusminus-${tag(p)}.png`) }); } const plus = p.locator('.nn-pick button', { hasText: /^\+/ }); const minus = p.locator('.nn-pick button', { hasText: /^−/ }); const usePlus = (await plus.isEnabled()) && (total < 70 || !(await minus.isEnabled())); await press(usePlus ? plus : minus); stats[usePlus ? 'plus' : 'minus'] = (stats[usePlus ? 'plus' : 'minus'] ?? 0) + 1; } else if (zero && (total >= 60 || !nums.length)) { await press(cards.nth(zero.i)); await press(cards.nth(zero.i)); stats.reverse = (stats.reverse ?? 0) + 1; } else { // Small cards while the total is low (longer game, more cards seen), then the biggest one. const pick = total < 55 ? nums[0]! : nums[nums.length - 1]!; const card = cards.nth(pick.i); if (phone) { await card.tap(); await card.tap(); } else { await card.focus(); await p.keyboard.press('Enter'); await p.keyboard.press('Enter'); } stats.number = (stats.number ?? 0) + 1; } await p.waitForFunction((s) => document.querySelector('.turn-status')?.textContent?.trim() !== s, status, { timeout: 5000 }).catch(() => {}); return true; } /** * 도블: one real tap on `p`'s own screen. Reads the pictures of the two compared cards from the DOM (data-art), taps the shared one * (or, when `wrong`, a picture of my card that is not on the other). PC: focus + Enter, phone: tap. Returns the judgement shown. */ async function dobbleTap(p: Page, wrong: boolean): Promise { const arts = (side: string) => p.locator(`[data-card="${side}"] .db-sym`).evaluateAll((els) => els.map((e) => Number(e.getAttribute('data-art')))); const mine = p.locator('[data-card="mine"] .db-sym:not([disabled])'); if ((await mine.count()) === 0) return null; const [a, b] = await Promise.all([arts('center'), arts('mine')]); const shared = b.filter((x) => a.includes(x)); if (shared.length !== 1) return null; // the cards changed between the two reads const art = wrong ? b.find((x) => x !== shared[0])! : shared[0]!; const key = await p.locator('[data-card="center"]').getAttribute('data-key'); const old = (await p.locator('.db-bubble').getAttribute('data-bubble', { timeout: 100 }).catch(() => null)) ?? ''; const btn = p.locator(`[data-card="mine"] .db-sym[data-art="${art}"]`); if (tag(p) === 'pc') { await btn.focus(); await p.keyboard.press('Enter'); } else await btn.tap(); // My own judgement is a new bubble (another one may still be showing). const bubble = await p .locator(`.db-bubble:not([data-bubble="${old}"])`) .first() .innerText({ timeout: 1500 }) .then((t) => t.trim()) .catch(() => ''); if (!wrong) await p.waitForFunction((k) => document.querySelector('[data-card="center"]')?.getAttribute('data-key') !== k, key, { timeout: 1500 }).catch(() => {}); return bubble; } /** 그림 맞히기: non-white pixels on a page's drawing canvas (proves streamed strokes were rendered). */ async function cmInk(p: Page): Promise { return p.evaluate(() => { const c = document.querySelector('.cm-paper canvas'); if (!c || !c.width) return 0; const data = c.getContext('2d')!.getImageData(0, 0, c.width, c.height).data; let n = 0; for (let i = 0; i < data.length; i += 16) if (data[i]! < 200 || data[i + 1]! < 200 || data[i + 2]! < 200) n++; return n; }); } /** 그림 맞히기: one full turn — the drawer picks a word and draws with the mouse, the other sees it, guesses wrong, close, then right. */ async function catchmindTurn(pages: Page[], turn: number, shots: boolean): Promise { const tag = turn === 1 ? '' : `${turn}`; const status = (p: Page) => p.locator('.turn-status').innerText(); let drawer: Page | null = null; for (let t = 0; t < 150 && !drawer; t++) { for (const p of pages) if ((await status(p)).includes('그릴 단어를 골라 주세요')) drawer = p; if (!drawer) await pages[0]!.waitForTimeout(100); } if (!drawer) throw new Error(`turn ${turn}: nobody is choosing a word`); const guesser = pages.find((p) => p !== drawer)!; await guesser.getByText(/단어를 고르고 있어요/).first().waitFor(); const pick = drawer.locator('.cm-choices button').nth(1); const picked = ((await pick.getAttribute('aria-label')) ?? '').split(' (')[0]!; await pick.click(); // The plan reads the answer from the drawer's screen. const answer = drawer.locator('.cm-hint .answer'); await answer.waitFor(); const word = (await answer.innerText()).replace(/^정답:\s*/, '').trim(); if (word !== picked) throw new Error(`picked ${picked} but the drawer sees ${word}`); const hidden = await guesser.locator('.cm-hint').innerText(); if (hidden.includes(word)) throw new Error('the guesser sees the answer'); // Real mouse strokes on the canvas: a red zig-zag, a blue circle-ish, a thick black line. const canvas = drawer.locator('.cm-paper canvas'); const stroke = async (pts: (readonly [number, number])[]) => { // Tool buttons may have scrolled the page (phone): measure the canvas right before each stroke. await canvas.scrollIntoViewIfNeeded(); const box = (await canvas.boundingBox())!; const at = (fx: number, fy: number) => [box.x + box.width * fx, box.y + box.height * fy] as const; await drawer!.mouse.move(...at(...pts[0]!)); await drawer!.mouse.down(); for (const pt of pts.slice(1)) await drawer!.mouse.move(...at(...pt), { steps: 6 }); await drawer!.mouse.up(); }; await drawer.getByRole('button', { name: '빨강' }).click(); await stroke([[0.2, 0.2], [0.35, 0.4], [0.5, 0.2], [0.65, 0.4], [0.8, 0.2]]); await drawer.getByRole('button', { name: '파랑' }).click(); await stroke(Array.from({ length: 13 }, (_, i) => [0.5 + 0.2 * Math.cos((i / 12) * Math.PI * 2), 0.6 + 0.2 * Math.sin((i / 12) * Math.PI * 2)] as const)); await drawer.getByRole('button', { name: '검정' }).click(); await drawer.getByRole('button', { name: '굵기 아주 굵게' }).click(); await stroke([[0.15, 0.9], [0.85, 0.9]]); // The guesser's canvas shows the same strokes (stroke count + ink). await guesser.waitForFunction(() => Number(document.querySelector('.cm-paper canvas')?.getAttribute('data-strokes')) >= 3, null, { timeout: 8000 }); const inkD = await cmInk(drawer); let inkG = 0; for (let t = 0; t < 30 && inkG < 300; t++) { inkG = await cmInk(guesser); if (inkG < 300) await guesser.waitForTimeout(100); } if (inkD < 300 || inkG < 300) throw new Error(`turn ${turn}: no ink (drawer ${inkD}, guesser ${inkG})`); if (shots) { await drawer.waitForTimeout(300); for (const [scheme, suffix] of (turn === 1 ? [['light', ''], ['dark', '-dark']] : [['light', '']]) as [string, string][]) { await drawer.emulateMedia({ colorScheme: scheme as 'light' | 'dark' }); await guesser.emulateMedia({ colorScheme: scheme as 'light' | 'dark' }); await drawer.waitForTimeout(250); const [pc, phone] = pages[0] === drawer ? [drawer, guesser] : [guesser, drawer]; await pc.screenshot({ path: join(SHOTS, `catchmind-draw${tag}-pc${suffix}.png`) }); await phone.screenshot({ path: join(SHOTS, `catchmind-draw${tag}-phone${suffix}.png`) }); await phone.screenshot({ path: join(SHOTS, `catchmind-draw${tag}-phone-full${suffix}.png`), fullPage: true }); } await drawer.emulateMedia({ colorScheme: 'light' }); await guesser.emulateMedia({ colorScheme: 'light' }); } const input = guesser.locator('.cm-form input'); await guesser.getByLabel('정답', { exact: true }).waitFor(); const send = async (text: string) => { await input.fill(text); await input.press('Enter'); }; // Wrong guess: visible to the drawer too. await send('모르겠어요'); await drawer.locator('.cm-chat-list').getByText('모르겠어요').first().waitFor(); // Close guess (last syllable's final consonant changed): "아까워요" for the guesser only. const close = await guesser.evaluate((w) => { const c = w.charCodeAt(w.length - 1) - 0xac00; if (c < 0 || c > 11171) return null; const jong = c % 28; if ([3, 5, 6, 9, 10, 11, 12, 13, 14, 15, 18].includes(jong)) return null; // double final: distance 2 return w.slice(0, -1) + String.fromCharCode(0xac00 + (jong === 0 ? c + 4 : c - jong)); }, word); if (close) { await guesser.waitForTimeout(400); // per-player limit: 3 guesses a second await send(close); await guesser.locator('.cm-close').waitFor({ timeout: 5000 }); if (await drawer.locator('.cm-close').count()) throw new Error('drawer sees the close hint'); } await guesser.waitForTimeout(400); await send(`${word}!`); await guesser.locator('.cm-pop').waitFor({ timeout: 5000 }); if ((await input.inputValue()) !== '') throw new Error('the answer stayed in the input box'); await drawer.locator('.cm-over .word', { hasText: word }).waitFor(); await guesser.locator('.cm-over .word', { hasText: word }).waitFor(); if (shots) { const [pc, phone] = pages[0] === drawer ? [drawer, guesser] : [guesser, drawer]; await pc.screenshot({ path: join(SHOTS, `catchmind-reveal${tag}-pc.png`) }); await phone.screenshot({ path: join(SHOTS, `catchmind-reveal${tag}-phone.png`) }); } return word; } /** 마피아: nicknames of the E2E pages (host first). */ const MAFIA_NICKS = ['방장', '친구', '친구2', '친구3']; /** 마피아: the night prompt tells each page its role without holding the role card. */ async function mafiaRoleOf(p: Page): Promise<'mafia' | 'police' | 'doctor' | 'citizen'> { const text = await p.locator('.mf-actions .mf-prompt').first().innerText(); if (text.includes('마피아가 노릴')) return 'mafia'; if (text.includes('조사할')) return 'police'; if (text.includes('지켜 줄')) return 'doctor'; if (text.includes('기다려')) return 'citizen'; throw new Error(`unknown night prompt: ${text}`); } /** 마피아: picks the highlighted player tile of `nick` (vote / night action). */ async function mafiaPick(p: Page, nick: string) { const tile = p.locator('button.mf-tile[data-target="ok"]', { has: p.locator('.nm', { hasText: new RegExp(`^${nick}( \\(나\\))?$`) }) }); await tile.click(); await p.locator('button.mf-tile[aria-pressed="true"]', { has: p.locator('.nm', { hasText: new RegExp(`^${nick}( \\(나\\))?$`) }) }).waitFor({ timeout: 15_000 }); } /** 마피아: 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: 15_000 }); } 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 = { rummikub: { // PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI. lobby: async (host) => { await host.getByRole('group', { name: '첫 등록 최소 점수' }).getByRole('button', { name: '없음' }).click(); await host.getByRole('button', { name: '설정 저장' }).click(); await host.getByText('첫 등록 점수 없음').first().waitFor(); }, drive: async (pages) => { const stats = await rummikubTurns(pages, 10); if (stats.plays + stats.draws < 10) throw new Error('not enough turns'); }, }, yut: { // 말 2개로 줄여서 PC 방장 + 휴대폰 친구가 실제 화면 조작으로 끝까지 둔다(중간 화면도 저장). lobby: async (host) => { await host.getByRole('button', { name: '2개', exact: true }).click(); await host.getByRole('button', { name: '설정 저장' }).click(); await host.getByText(/말 2개/).first().waitFor(); }, drive: async (pages) => { const [host, guest] = pages as [Page, Page]; let actions = 0; let shot = false; const until = Date.now() + 300_000; const over = () => host.getByText(/이겼어요|아쉽게 졌어요/).count(); while (Date.now() < until && !(await over())) { let acted = false; for (const p of pages) { if (await yutStep(p)) { acted = true; actions++; break; } } if (actions >= 16 && !shot) { shot = true; await host.waitForTimeout(400); await host.screenshot({ path: join(SHOTS, 'yut-mid-pc.png') }); await guest.screenshot({ path: join(SHOTS, 'yut-mid-phone.png'), fullPage: true }); await guest.emulateMedia({ colorScheme: 'dark' }); await guest.screenshot({ path: join(SHOTS, 'yut-mid-phone-dark.png') }); await guest.emulateMedia({ colorScheme: 'light' }); } if (!acted) await host.waitForTimeout(120); } if (!(await over())) throw new Error(`game did not finish (${actions} UI actions)`); await guest.getByText(/이겼어요|아쉽게 졌어요/).waitFor(); console.log(` yut: finished after ${actions} UI actions`); }, }, 'one-card': { // 3 players, default 원카드: whoever has the turn plays a highlighted card (tap to lift, tap again to play, // pick a suit for 7) or draws; the 원카드 button is pressed whenever it lights up. 60 turns or until the game ends. players: 3, drive: async (pages) => { const statusOf = (p: Page) => p.locator('.turn-status').innerText().catch(() => ''); let turns = 0; let plays = 0; let draws = 0; let finished = false; for (let t = 0; t < 600 && turns < 60; t++) { if (await pages[0]!.getByRole('dialog', { name: '게임 결과' }).count()) { finished = true; break; } let acted = false; for (const p of pages) { const one = p.locator('.oc-onebtn'); if ((await one.count()) && (await one.isEnabled().catch(() => false))) await one.click({ timeout: 1000 }).catch(() => {}); const before = await statusOf(p); if (!before.includes('내 차례예요')) continue; const accept = p.getByRole('button', { name: /^받아들이기/ }); const card = p.locator('button.oc-card[data-legal="true"]').first(); if (await accept.count()) await accept.click(); else if (await card.count()) { await card.click(); await card.click(); const pick = p.locator('.oc-pick button').first(); if (await pick.waitFor({ timeout: 400 }).then(() => true, () => false)) await pick.click(); plays++; } else { const pass = p.getByRole('button', { name: '넘기기' }); if (await pass.count()) await pass.click(); else await p.getByRole('button', { name: /장 먹기$/ }).first().click(); draws++; } await p.waitForFunction((s) => document.querySelector('.turn-status')?.textContent?.trim() !== s, before.trim(), { timeout: 5000 }).catch(() => {}); turns++; acted = true; break; } if (!acted) await pages[0]!.waitForTimeout(150); } if (!finished && turns < 30) throw new Error(`only ${turns} turns were played`); if (finished) await pages[0]!.screenshot({ path: join(SHOTS, 'one-card-3-result-pc.png') }); console.log(` one-card: ${turns} turns (${plays} plays, ${draws} draws)${finished ? ', game finished' : ''}`); }, }, 'davinci-code': { // 2 players, default options (choose colours): both pick 2+2 start tiles, then several real turns: // draw from a pile, tap an opponent's hidden tile, pick a number, confirm; after a hit press "여기서 멈추기". drive: async (pages) => { for (const p of pages) { await p.getByRole('button', { name: '검정 2개 · 흰색 2개' }).click(); await p.getByRole('button', { name: '이렇게 받기' }).click(); } const myTurn = (p: Page) => p.evaluate(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요')); for (let turn = 0; turn < 5; turn++) { let mover: Page | null = null; for (let t = 0; t < 100 && !mover; t++) { for (const p of pages) if (!mover && (await myTurn(p))) mover = p; if (!mover) await pages[0]!.waitForTimeout(100); } if (!mover) throw new Error(`turn ${turn + 1}: nobody has the turn`); const pile = mover.getByRole('button', { name: turn % 2 ? /^흰색 타일 가져오기/ : /^검은색 타일 가져오기/ }); await pile.click(); await mover.getByText('맞힐 상대 타일을 눌러 주세요.').waitFor(); await mover.locator('.dv-opps .dv-pick.can').nth(turn % 3).click(); await mover.getByRole('button', { name: String((turn * 5) % 12), exact: true }).click(); if (turn < 2) await mover.screenshot({ path: join(SHOTS, `davinci-code-3-pad-${mover === pages[0] ? 'pc' : 'phone'}.png`), fullPage: true }); await mover.getByRole('button', { name: /이 숫자!$/ }).click(); // A hit → stop (the drawn tile goes in hidden); a miss ends the turn by itself. for (let t = 0; t < 60; t++) { const stop = mover.getByRole('button', { name: '여기서 멈추기' }); if (await stop.isVisible().catch(() => false)) await stop.click().catch(() => {}); if (!(await myTurn(mover))) break; await mover.waitForTimeout(100); } if (await myTurn(mover)) throw new Error(`turn ${turn + 1}: turn did not pass`); } // Both screens show the guess log with every guess. for (const p of pages) await p.getByText('추리 기록 (5)').waitFor(); }, }, yacht: { // PC host + phone guest play a whole 12-round game through the UI: roll, sometimes hold + re-roll, then // write the best open category (phone uses touch taps, so the confirm sheet path is exercised too). drive: async (pages) => { const myTurn = (p: Page) => p.evaluate(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요')); const shot = new Set(); for (let turn = 0; turn < 24; turn++) { let mover: Page | null = null; for (let t = 0; t < 150 && !mover; t++) { for (const p of pages) if (!mover && (await myTurn(p))) mover = p; if (!mover) await pages[0]!.waitForTimeout(100); } if (!mover) throw new Error(`nobody has the turn (turn ${turn + 1})`); const phone = mover !== pages[0]; const press = (l: ReturnType) => (phone ? l.tap() : l.click()); await press(mover.getByRole('button', { name: /^🎲 굴리기/ })); await mover.locator('.yt-die[aria-pressed]').first().waitFor(); if (turn % 3 !== 2) { // Hold the first die, check it shows as held, then roll the rest again. await press(mover.locator('.yt-die').first()); await mover.locator('.yt-die').first().and(mover.locator('[aria-pressed="true"]')).waitFor(); await press(mover.getByRole('button', { name: /^🎲 굴리기 \(남은 2번\)/ })); await mover.getByRole('button', { name: /^🎲 굴리기 \(남은 1번\)/ }).waitFor(); await mover.waitForTimeout(700); // roll animation } const cells = mover.locator('button.yt-cell:not([disabled])'); await cells.first().waitFor(); // Mid-game screenshots of a turn in progress (held die + preview cells), light and dark. const tag = phone ? 'phone' : 'pc'; if (turn >= 8 && !shot.has(tag)) { shot.add(tag); await mover.screenshot({ path: join(SHOTS, `yacht-mid-${tag}.png`), fullPage: phone }); await mover.emulateMedia({ colorScheme: 'dark' }); await mover.waitForTimeout(200); await mover.screenshot({ path: join(SHOTS, `yacht-mid-${tag}-dark.png`), fullPage: phone }); await mover.emulateMedia({ colorScheme: 'light' }); } const values = await cells.allInnerTexts(); let best = 0; values.forEach((v, i) => Number(v) > Number(values[best]) && (best = i)); await press(cells.nth(best)); const confirm = mover.getByRole('button', { name: '기록하기', exact: true }); if (await confirm.isVisible({ timeout: 600 }).catch(() => false)) await confirm.click(); await mover.waitForFunction(() => !document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'), null, { timeout: 5000 }); } for (const p of pages) await p.getByRole('dialog', { name: '게임 결과' }).waitFor({ timeout: 10_000 }); const sheet = await pages[0]!.locator('.yt-table tr.yt-total').innerText(); if (!/\d/.test(sheet)) throw new Error('no totals on the score sheet'); }, }, seotda: { // 3 players, default 2장 섯다 (1장씩 두 번): everyone checks both betting rounds → showdown. // A tie (or 구사) starts a 재경기 with the same pot, so keep checking until the hand result shows up. players: 3, drive: async (pages) => { await pressActions(pages, /^(체크|콜)/, 6); for (let extra = 0; extra < 30 && !(await pages[0]!.getByText('이번 판 결과').count()); extra++) { await pressActions(pages, /^(체크|콜)/, 1).catch(() => {}); } await finishHand(pages); }, }, poker: { // 3 players, default 7포커: 초이스 (discard 1st card, open 2nd), then everyone checks 4 streets → showdown. players: 3, drive: async (pages) => { for (const p of pages) { const cards = p.locator('.pk-mine button'); await cards.first().waitFor(); await cards.nth(0).click(); await cards.nth(1).click(); await p.getByRole('button', { name: '확인', exact: true }).click(); } await pressActions(pages, /^체크/, 12); await finishHand(pages); }, }, othello: { // cursor starts at d4 (3,3): black d3 = up; then white c3 = up, left moves: [['ArrowUp', 'Enter'], ['ArrowUp', 'ArrowLeft', 'Enter']], }, chess: { // cursor starts on the e-file pawn of whoever is at the bottom: white e2-e4, black d7-d5 (board flipped) moves: [['Enter', 'ArrowUp', 'ArrowUp', 'Enter'], ['Enter', 'ArrowUp', 'ArrowUp', 'Enter']], }, baduk: { // cursor starts at 천원: black plays there, white plays one point to the left moves: [['Enter'], ['ArrowLeft', 'Enter']], }, janggi: { // Both sides pick 안상차림 (order may be han-first), then each pushes the centre 졸 one step. setup: async (host, guest) => { for (let round = 0; round < 20; round++) { let picked = false; for (const p of [host, guest]) { const btn = p.getByRole('button', { name: /^안상차림/ }); // The card list disappears right after choosing, so every probe must be short and tolerant. if ((await btn.count()) && (await btn.isEnabled({ timeout: 500 }).catch(() => false))) { picked = await btn .click({ timeout: 1000 }) .then(() => true) .catch(() => false); } } if (await host.evaluate(() => !document.body.innerText.includes('상차림을 골라'))) { if (await guest.evaluate(() => !document.body.innerText.includes('상차림을 골라'))) return; } if (!picked) await host.waitForTimeout(200); } throw new Error('상차림 단계가 끝나지 않음'); }, moves: [ ['ArrowUp', 'ArrowUp', 'Enter', 'ArrowUp', 'Enter'], ['ArrowUp', 'ArrowUp', 'Enter', 'ArrowUp', 'Enter'], ], }, mahjong: { // 4인 리치 마작 (PC host + 3 phones): whoever has the turn taps the right-most legal tile (lift) and presses // the "… 버리기" button; claim windows are answered with 패스 (론/쯔모 when offered). Plays until the first hand // ends (win or exhaustive draw, ~70 discards), checks the result screen on every device and confirms it. players: 4, drive: async (pages) => { const statusOf = (p: Page) => p.locator('.turn-status').innerText().catch(() => ''); let discards = 0; let passes = 0; let wins = 0; let results = 0; const shotClaim = new Set(); for (let t = 0; t < 4000 && discards < 100 && results < 1; t++) { let acted = false; for (const [pi, p] of pages.entries()) { const status = await statusOf(p); const win = p.getByRole('button', { name: /^(쯔모|론)!$/ }); if ((await win.count()) && (await win.first().isEnabled().catch(() => false))) { await win.first().click({ timeout: 1000 }).catch(() => {}); wins++; acted = true; break; } const pass = p.getByRole('group', { name: '울기·론 선택' }).getByRole('button', { name: '패스' }); if (await pass.count()) { const dev = pi === 0 ? 'pc' : 'phone'; if (!shotClaim.has(dev)) { await p.screenshot({ path: join(SHOTS, `mahjong-3-claim-${dev}.png`) }); shotClaim.add(dev); } await pass.click({ timeout: 1000 }).catch(() => {}); passes++; acted = true; break; } const next = p.getByRole('button', { name: /^(다음 국으로|최종 결과 보기)$/ }); if ((await next.count()) && (await next.isEnabled().catch(() => false))) { // The hand is over: every device shows the result, then everyone confirms → the next hand starts. await pages[0]!.screenshot({ path: join(SHOTS, 'mahjong-4-result-pc.png') }); await pages[1]!.screenshot({ path: join(SHOTS, 'mahjong-4-result-phone.png'), fullPage: true }); for (const q of pages) { await q.getByRole('region', { name: /결과$/ }).waitFor({ timeout: 5000 }); const b = q.getByRole('button', { name: /^(다음 국으로|최종 결과 보기)$/ }); if (await b.isEnabled().catch(() => false)) await b.click({ timeout: 1000 }).catch(() => {}); } await pages[0]!.getByText(/^동1국|^동2국/).first().waitFor({ timeout: 10_000 }); results++; acted = true; break; } if (!status.includes('내 차례예요')) continue; const tiles = p.locator('button.mj-htile[data-legal="true"]'); const n = await tiles.count(); if (!n) continue; const tile = tiles.nth(n - 1); await tile.click({ timeout: 1000 }).catch(() => {}); if (discards === 4 && pi > 0) await p.screenshot({ path: join(SHOTS, 'mahjong-2-selected-phone.png') }); const send = p.getByRole('button', { name: /버리기$/ }); if ((await send.count()) && (await send.isEnabled().catch(() => false))) await send.click({ timeout: 1000 }).catch(() => {}); await p.waitForFunction((s) => document.querySelector('.turn-status')?.textContent?.trim() !== s, status.trim(), { timeout: 5000 }).catch(() => {}); discards++; acted = true; break; } if (!acted) await pages[0]!.waitForTimeout(120); } if (results === 0) throw new Error(`no hand finished after ${discards} discards`); // The next hand has started: the table and my hand are back on every screen. for (const q of pages) await q.locator('.mj-table').waitFor({ timeout: 10_000 }); console.log(` mahjong: ${discards} discards, ${passes} passes, ${wins} wins, ${results} hand finished → next hand started`); }, }, gostop: { // 맞고: PC host + phone guest, 1판. Real turns through the UI — hand cards (tap twice on the phone), floor-card choices, // 흔들기/폭탄 sheets, one 고 and then 스톱 — until the hand ends (스톱 or 나가리) and the game result shows. lobby: async (host) => { await host.getByRole('group', { name: '판 수' }).getByRole('button', { name: '1판', exact: true }).click(); await host.getByRole('button', { name: '설정 저장' }).click(); await host.getByText(/^1판 · /).first().waitFor(); }, drive: async (pages) => { const [host, guest] = pages as [Page, Page]; const state = { go: 0, stop: 0, picks: 0, plays: 0, sheets: 0 }; let shot = false; const until = Date.now() + 240_000; const over = async () => (await host.getByRole('dialog', { name: '게임 결과' }).count()) > 0; while (Date.now() < until && !(await over())) { let acted = false; for (const p of pages) { const did = await gostopStep(p, state).catch(() => null); if (did) { acted = true; await p.waitForTimeout(250); break; } } if (state.plays >= 8 && !shot) { shot = true; await host.waitForTimeout(1500); await host.screenshot({ path: join(SHOTS, 'gostop-mid-pc.png') }); await guest.screenshot({ path: join(SHOTS, 'gostop-mid-phone.png'), fullPage: true }); await guest.emulateMedia({ colorScheme: 'dark' }); await host.emulateMedia({ colorScheme: 'dark' }); await guest.screenshot({ path: join(SHOTS, 'gostop-mid-phone-dark.png'), fullPage: true }); await host.screenshot({ path: join(SHOTS, 'gostop-mid-pc-dark.png') }); await guest.emulateMedia({ colorScheme: 'light' }); await host.emulateMedia({ colorScheme: 'light' }); } if (!acted) await host.waitForTimeout(150); } if (!(await over())) throw new Error(`hand did not end (${JSON.stringify(state)})`); await guest.getByRole('dialog', { name: '게임 결과' }).waitFor(); await host.screenshot({ path: join(SHOTS, 'gostop-result-pc.png') }); await guest.screenshot({ path: join(SHOTS, 'gostop-result-phone.png') }); // Close the result sheet to see the round breakdown underneath. await host.keyboard.press('Escape'); await host.getByText(/1판 결과/).first().waitFor(); await host.screenshot({ path: join(SHOTS, 'gostop-breakdown-pc.png') }); if (state.plays < 10) throw new Error(`only ${state.plays} cards were played`); console.log(` gostop: ${state.plays} plays, ${state.sheets} choice sheets (${state.picks} card picks), 고 ${state.go}, 스톱 ${state.stop}`); }, }, bang: { // PC host + 3 phone guests, reaction time 25s: real turns through the UI — draw, play usable cards // (뱅! at a highlighted target → confirm, other untargeted cards by double tap), answer reaction // sheets (빗나감!/맥주/결투/인디언!/잡화점), discard down to the hand limit, end the turn. players: 4, lobby: async (host) => { await host.getByRole('group', { name: '반응 제한 시간' }).getByRole('button', { name: '25초' }).click(); await host.getByRole('button', { name: '설정 저장' }).click(); await host.getByText(/반응 25초/).first().waitFor(); }, drive: async (pages) => { const stats = { turns: 0, bangs: 0, plays: 0, reactions: 0, discards: 0 }; let finished = false; let shot = false; let midShot = false; const tryClick = (l: ReturnType) => l.click({ timeout: 1500 }).then(() => true, () => false); /** One UI step on this page; true when something was pressed. */ const step = async (p: Page): Promise => { const modal = p.locator('.bg-modal'); if (await modal.count()) { const store = modal.locator('button.bg-card'); const title = (await modal.locator('h2').innerText().catch(() => '')).trim(); if (title.startsWith('3장 중')) { await store.nth(0).click(); await store.nth(1).click(); await modal.getByRole('button', { name: '이 2장 받기' }).click(); } else if (title.startsWith('잡화점')) await store.first().click(); else { const use = modal.getByRole('button', { name: /(내기|마시기)$/ }); if (!(await use.count()) || !(await tryClick(use.first()))) { if (!(await tryClick(modal.getByRole('button', { name: /^(포기하고 맞기|맞기|쓰러지기)/ }).first()))) return false; } } stats.reactions++; return true; } const draw = p.getByRole('button', { name: '카드 더미에서 2장 받기' }); if (await draw.count()) return tryClick(draw); const discardBtn = p.getByRole('button', { name: /장 버리기$/ }); if (await discardBtn.count()) { const need = Number((await p.locator('.bg-actions').innerText()).match(/버릴 카드 (\d+)장/)?.[1] ?? 0); const cards = p.locator('.bg-me .bg-hand button.bg-card'); for (let i = 0; i < need; i++) await cards.nth(i).click(); await discardBtn.click(); stats.discards++; return true; } const end = p.getByRole('button', { name: '턴 마치기' }); if (!(await end.count()) || !(await end.isEnabled())) return false; // Shoot first (once per turn is enough), then other untargeted cards, then end the turn. const bang = p.locator('.bg-me button.bg-card[data-kind="bang"][data-ok="true"]').first(); if (await bang.count()) { await bang.click(); const target = p.locator('button.bg-seat[data-target="ok"]').first(); await target.waitFor({ timeout: 2000 }); await target.click(); const fire = p.getByRole('button', { name: /님에게 뱅!$/ }); await fire.click(); stats.bangs++; if (!shot) { shot = true; await p.waitForTimeout(400); for (const [i, q] of pages.entries()) await q.screenshot({ path: join(SHOTS, `bang-3-shot-${i === 0 ? 'pc' : `phone${i}`}.png`) }); } return true; } const simple = ['beer', 'stagecoach', 'wellsFargo', 'saloon', 'barrel', 'mustang', 'scope', 'schofield', 'remington', 'carabine', 'winchester', 'volcanic', 'indians', 'gatling', 'generalStore']; for (const k of simple) { const c = p.locator(`.bg-me button.bg-card[data-kind="${k}"][data-ok="true"]`).first(); if (!(await c.count())) continue; await c.click(); await c.click(); stats.plays++; return true; } await end.click(); stats.turns++; return true; }; const until = Date.now() + 240_000; while (Date.now() < until && stats.turns < 10) { if (await pages[0]!.getByRole('dialog', { name: '게임 결과' }).count()) { finished = true; break; } let acted = false; for (const p of pages) { if (await step(p)) { acted = true; await p.waitForTimeout(250); break; } } if (stats.turns >= 4 && !midShot) { midShot = true; await pages[0]!.screenshot({ path: join(SHOTS, 'bang-4-mid-pc.png') }); await pages[1]!.screenshot({ path: join(SHOTS, 'bang-4-mid-phone.png'), fullPage: true }); await pages[1]!.emulateMedia({ colorScheme: 'dark' }); await pages[1]!.screenshot({ path: join(SHOTS, 'bang-4-mid-phone-dark.png') }); await pages[1]!.emulateMedia({ colorScheme: 'light' }); } if (!acted) await pages[0]!.waitForTimeout(150); } console.log(` bang: ${stats.turns} turns, ${stats.bangs} 뱅!, ${stats.plays} other cards, ${stats.reactions} reactions, ${stats.discards} discards${finished ? ', game finished' : ''}`); if (!finished && stats.turns < 8) throw new Error(`only ${stats.turns} turns were played`); if (stats.bangs < 1) throw new Error('no 뱅! was played'); }, }, 'halli-galli': { // PC host + 2 phones. Real flips (Enter on PC, taps on phones); whenever some fruit totals exactly five a player // 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초', exact: true }).click(); await host.getByRole('button', { name: '설정 저장' }).click(); await host.getByText(/뒤집기 5초/).first().waitFor(); }, drive: async (pages) => { const [host, guest] = pages as [Page, Page, Page]; const myTurn = (p: Page) => p.evaluate(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요')); const ended = () => host.getByRole('dialog', { name: '게임 결과' }).count(); await host.locator('.hg-seat').first().waitFor(); // 3-second countdown first. await host.waitForFunction(() => !document.querySelector('.hg-count'), null, { timeout: 8000 }); let flips = 0; let rights = 0; let wrongs = 0; let lates = 0; let shots = 0; let ringer = 0; const until = Date.now() + 150_000; const bubble = (p: Page) => p.locator('.hg-bubble').first().innerText({ timeout: 2500 }).catch(() => ''); while (Date.now() < until && !(await ended()) && (flips < 24 || rights < 1 || wrongs < 1)) { const totals = await halliTotals(host); const five = Object.values(totals).includes(5); const wantWrong = !five && wrongs === 0 && flips >= 4 && Object.values(totals).some((n) => n > 0); if (five || wantWrong) { const p = pages[ringer++ % pages.length]!; const bell = p.locator('.hg-bell'); if (await bell.isEnabled().catch(() => false)) { if (p === host) { await host.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); await host.keyboard.press('Space'); } else await bell.tap(); const text = await bubble(p); if (text.includes('땡')) rights++; else if (text.includes('삐')) wrongs++; else if (text.includes('늦었어요')) lates++; if (shots === 0 && text) { shots++; await host.screenshot({ path: join(SHOTS, 'halli-galli-bell-pc.png') }); await guest.screenshot({ path: join(SHOTS, 'halli-galli-bell-phone.png') }); } await host.waitForTimeout(400); continue; } } let flipped = false; for (const p of pages) { if (!(await myTurn(p))) continue; const btn = p.getByRole('button', { name: '뒤집기', exact: true }); if (!(await btn.isEnabled().catch(() => false))) break; const before = await p.locator('.hg-seat[data-me="true"] .hg-meta').innerText(); if (p === host) { await host.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); await host.keyboard.press('Enter'); } else await btn.tap(); await p.waitForFunction((b) => document.querySelector('.hg-seat[data-me="true"] .hg-meta')?.textContent !== b, before, { timeout: 3000 }).catch(() => {}); flips++; flipped = true; break; } if (flips === 12 && flipped && shots < 2) { shots = 2; await host.waitForTimeout(300); await host.screenshot({ path: join(SHOTS, 'halli-galli-mid-pc.png') }); await guest.screenshot({ path: join(SHOTS, 'halli-galli-mid-phone.png') }); await guest.emulateMedia({ colorScheme: 'dark' }); await guest.waitForTimeout(400); // let the theme transition finish await guest.screenshot({ path: join(SHOTS, 'halli-galli-mid-phone-dark.png') }); await guest.emulateMedia({ colorScheme: 'light' }); await host.emulateMedia({ colorScheme: 'dark' }); await host.waitForTimeout(400); await host.screenshot({ path: join(SHOTS, 'halli-galli-mid-pc-dark.png') }); await host.emulateMedia({ colorScheme: 'light' }); } await host.waitForTimeout(flipped ? 650 : 120); } console.log(` halli-galli: ${flips} UI flips, ${rights} correct bells, ${wrongs} wrong bells, ${lates} late`); if (!(await ended()) && (flips < 20 || wrongs < 1)) throw new Error('not enough flips / bells'); if (rights < 1 && !(await ended())) throw new Error('no correct bell happened'); }, }, marble: { // PC host + phone guest, 15 rounds (shortest limit) played through the UI to the result: roll, buy or // build (skip when short), pay tolls, sell when short, space travel via a board square. Mid-game shots. lobby: async (host) => { await host.getByRole('group', { name: '라운드 제한' }).getByRole('button', { name: '15라운드' }).click(); await host.getByRole('group', { name: '시작 포인트' }).getByRole('button', { name: '200P' }).click(); await host.getByRole('button', { name: '설정 저장' }).click(); await host.getByText(/^15라운드 · 시작 200P/).first().waitFor(); }, drive: async (pages) => { const [host, guest] = pages as [Page, Page]; const stats: Record = {}; let actions = 0; let shot = false; const until = Date.now() + 420_000; const over = () => host.getByRole('dialog', { name: '게임 결과' }).count(); while (Date.now() < until && !(await over())) { let acted = false; for (const p of pages) { if (await marbleStep(p, stats)) { acted = true; actions++; await p.waitForTimeout(80); break; } } if (actions >= 40 && !shot && (stats['사기'] ?? 0) >= 3) { shot = true; await host.waitForTimeout(1300); await host.screenshot({ path: join(SHOTS, 'marble-mid-pc.png') }); await guest.screenshot({ path: join(SHOTS, 'marble-mid-phone.png') }); await guest.screenshot({ path: join(SHOTS, 'marble-mid-phone-full.png'), fullPage: true }); await host.emulateMedia({ colorScheme: 'dark' }); await guest.emulateMedia({ colorScheme: 'dark' }); await host.waitForTimeout(400); // let the button colour transitions settle await host.screenshot({ path: join(SHOTS, 'marble-mid-pc-dark.png') }); await guest.screenshot({ path: join(SHOTS, 'marble-mid-phone-dark.png') }); await host.emulateMedia({ colorScheme: 'light' }); await guest.emulateMedia({ colorScheme: 'light' }); } if (acted && !stats.toll && (await host.locator('.mb-log li', { hasText: /→.*통행료/ }).count())) stats.toll = 1; if (!acted) await host.waitForTimeout(120); } if (!(await over())) throw new Error(`game did not finish (${actions} UI actions: ${JSON.stringify(stats)})`); await guest.getByRole('dialog', { name: '게임 결과' }).waitFor(); await host.waitForTimeout(600); // result dialog fade-in await host.screenshot({ path: join(SHOTS, 'marble-result-pc.png') }); await guest.screenshot({ path: join(SHOTS, 'marble-result-phone.png') }); const rolls = (stats['🎲 주사위 굴리기'] ?? 0) + (stats['🎲 한 번 더 굴리기'] ?? 0) + (stats['🎲 탈출 굴리기'] ?? 0); if (rolls < 15 || !stats['사기']) throw new Error(`too few real turns: ${JSON.stringify(stats)}`); console.log(` marble: finished after ${actions} UI actions ${JSON.stringify(stats)}`); }, }, dalmuti: { // PC host + 3 phone guests, 9~12 removed (38 cards) for a shorter round: real turns through the UI (sets with // jesters, passes) until round 1 ends, then the round-2 hand check and the tax exchange (세금 카드 교환) screen. players: 4, lobby: async (host) => { await host.getByRole('button', { name: '▼ 자세한 설정' }).click(); await host.getByRole('group', { name: '9~12 카드 빼기' }).getByRole('button', { name: '켜기' }).click(); await host.getByRole('group', { name: '한 턴 제한 시간' }).getByRole('button', { name: '45초' }).click(); await host.getByRole('button', { name: '설정 저장' }).click(); await host.getByText(/9~12 빼기/).first().waitFor(); }, drive: async (pages) => { const [host, guest] = pages as [Page, Page]; const st: Record = { turns: 0 }; let actions = 0; let midShot = false; let round2 = false; const until = Date.now() + 300_000; const playingAgain = () => host.locator('.dm-table').count(); while (Date.now() < until) { let acted = false; for (const [i, p] of pages.entries()) { if (await dalmutiStep(p, i === 0, st)) { acted = true; actions++; await p.waitForTimeout(120); break; } } if (!midShot && (st.plays ?? 0) >= 6 && (await host.locator('.dm-set.top').count())) { midShot = true; await host.waitForTimeout(400); await host.screenshot({ path: join(SHOTS, 'dalmuti-mid-pc.png') }); await guest.screenshot({ path: join(SHOTS, 'dalmuti-mid-phone.png') }); await guest.screenshot({ path: join(SHOTS, 'dalmuti-mid-phone-full.png'), fullPage: true }); for (const p of [host, guest]) await p.emulateMedia({ colorScheme: 'dark' }); await host.waitForTimeout(400); await host.screenshot({ path: join(SHOTS, 'dalmuti-mid-pc-dark.png') }); await guest.screenshot({ path: join(SHOTS, 'dalmuti-mid-phone-dark.png') }); for (const p of [host, guest]) await p.emulateMedia({ colorScheme: 'light' }); } if (!round2 && (await host.getByText('1라운드 결과 → 새 신분').count())) { round2 = true; await host.waitForTimeout(300); await host.screenshot({ path: join(SHOTS, 'dalmuti-round2-check-pc.png') }); await guest.screenshot({ path: join(SHOTS, 'dalmuti-round2-check-phone.png'), fullPage: true }); } if (round2 && st.taxDone === undefined && (await host.getByRole('heading', { name: '세금 카드 교환' }).count())) { st.taxDone = 0; await host.waitForTimeout(300); await host.screenshot({ path: join(SHOTS, 'dalmuti-round2-tax-pc.png') }); await guest.screenshot({ path: join(SHOTS, 'dalmuti-round2-tax-phone.png'), fullPage: true }); } // Done: round 2's tax exchange happened (or a revolution skipped it) and the table is back, with a jester set seen. if (round2 && (await playingAgain()) && (st.jesterPlays ?? 0) > 0) break; if (!acted) await host.waitForTimeout(120); } if (!round2) throw new Error(`round 1 did not finish (${actions} UI actions: ${JSON.stringify(st)})`); if (!(await playingAgain())) throw new Error(`round 2 did not start playing (${JSON.stringify(st)})`); if (!st.passes || (st.plays ?? 0) < 8 || !st.jesterPlays) throw new Error(`too few real turns / no jester set: ${JSON.stringify(st)}`); await host.screenshot({ path: join(SHOTS, 'dalmuti-round2-pc.png') }); console.log(` dalmuti: ${actions} UI actions ${JSON.stringify(st)}${st.taxDone === 0 ? ', tax screen shown' : ', revolution (no tax)'}`); }, }, splendor: { // PC host + phone guest, short game (10 points) through the UI to the result: 3 different gems, 2 of a // colour, reserve an open card and a blind deck card (gold), buy highlighted cards (with gold too), // return tokens over 10 and choose nobles when it happens. Mid-game shots in light and dark. lobby: async (host) => { await host.getByRole('group', { name: '끝나는 점수' }).getByRole('button', { name: '10점(짧은 판)' }).click(); await host.getByRole('button', { name: '설정 저장' }).click(); await host.getByText(/^10점 · 한 차례 60초/).first().waitFor(); }, drive: async (pages) => { const [host, guest] = pages as [Page, Page]; const stats: Record = {}; let turns = 0; let shot = false; const until = Date.now() + 480_000; const over = () => host.getByRole('dialog', { name: '게임 결과' }).count(); while (Date.now() < until && !(await over())) { let acted: string | null = null; for (const p of pages) { const before = (await p.locator('.turn-status').innerText().catch(() => '')).trim(); acted = await splendorStep(p, stats, turns); if (acted) { stats[acted] = (stats[acted] ?? 0) + 1; turns++; // Wait until the server answered (status or log changed). await p .waitForFunction((b) => (document.querySelector('.turn-status')?.textContent ?? '').trim() !== b || !!document.querySelector('[role="dialog"]'), before, { timeout: 5000 }) .catch(() => {}); break; } } if (turns >= 14 && !shot) { shot = true; await host.waitForTimeout(700); await host.screenshot({ path: join(SHOTS, 'splendor-mid-pc.png') }); await guest.screenshot({ path: join(SHOTS, 'splendor-mid-phone.png') }); await guest.screenshot({ path: join(SHOTS, 'splendor-mid-phone-full.png'), fullPage: true }); await host.emulateMedia({ colorScheme: 'dark' }); await guest.emulateMedia({ colorScheme: 'dark' }); await host.waitForTimeout(400); await host.screenshot({ path: join(SHOTS, 'splendor-mid-pc-dark.png') }); await guest.screenshot({ path: join(SHOTS, 'splendor-mid-phone-dark.png'), fullPage: true }); await host.emulateMedia({ colorScheme: 'light' }); await guest.emulateMedia({ colorScheme: 'light' }); } if (!acted) await host.waitForTimeout(150); } if (!(await over())) throw new Error(`game did not finish (${turns} UI turns: ${JSON.stringify(stats)})`); await guest.getByRole('dialog', { name: '게임 결과' }).waitFor(); await host.waitForTimeout(600); await host.screenshot({ path: join(SHOTS, 'splendor-result-pc.png') }); await guest.screenshot({ path: join(SHOTS, 'splendor-result-phone.png') }); if (turns < 15 || !stats.buy || !stats.reserve || !stats.different || !stats.same || !stats.discard) throw new Error(`too few kinds of turns: ${JSON.stringify(stats)}`); console.log(` splendor: finished after ${turns} UI turns ${JSON.stringify(stats)}`); }, }, quoridor: { // PC 방장 + 휴대폰 친구(2인, 자리는 무작위). 남: 세로벽 d8 → 북: 세로벽 e8(북이 e8·e9 상자 안) → 남: 가로벽 e7은 // 북의 길을 완전히 막으므로 빨간 미리보기·이유와 함께 거부 → 다른 가로벽 a3. 그 뒤 둘 다 e열 최단 길로 달려 // 마주치면 뛰어넘고, 먼저 끝줄에 닿은 쪽이 이긴다(첫 이동은 키보드 ↑+Enter). drive: async (pages) => { const [host, guest] = pages as [Page, Page]; await qdSvg(host).waitFor(); await qdSvg(guest).waitFor(); const south = (await qdSvg(host).getAttribute('data-my-seat')) === 'S' ? host : guest; const north = south === host ? guest : host; if ((await qdSvg(north).getAttribute('data-my-seat')) !== 'N') throw new Error('seats are not S/N'); await qdPreviewWall(south, 'd8', 'v'); await qdConfirmWall(south); await qdPreviewWall(north, 'e8', 'v'); await north.waitForTimeout(200); await north.screenshot({ path: join(SHOTS, `quoridor-wall-preview-${tag(north)}.png`) }); await qdConfirmWall(north); const bad = await qdPreviewWall(south, 'e7', 'h'); if (!bad.endsWith(':bad')) throw new Error(`blocking wall was not refused (${bad})`); await south.getByText('누군가의 길을 완전히 막을 수 없어요').first().waitFor(); if (await south.getByRole('button', { name: '벽 세우기 확인' }).isEnabled()) throw new Error('confirm enabled for an illegal wall'); await south.waitForTimeout(300); await south.screenshot({ path: join(SHOTS, `quoridor-illegal-wall-${tag(south)}.png`) }); if ((await south.locator('[data-wall]').count()) !== 2) throw new Error('illegal wall was placed'); await qdPreviewWall(south, 'a3', 'h'); await qdConfirmWall(south); let jumps = 0; let steps = 0; let shot = false; const over = () => host.getByRole('dialog', { name: '게임 결과' }).count(); const first = new Set(); for (let turn = 0; turn < 40 && !(await over()); turn++) { const mover = turn % 2 === 0 ? north : south; const useKeys = mover === host && !first.has(mover); first.add(mover); if (await qdStep(mover, useKeys)) jumps++; steps++; if (steps === 6 && !shot) { shot = true; await host.waitForTimeout(300); await host.screenshot({ path: join(SHOTS, 'quoridor-mid-pc.png') }); await guest.screenshot({ path: join(SHOTS, 'quoridor-mid-phone.png') }); await guest.screenshot({ path: join(SHOTS, 'quoridor-mid-phone-full.png'), fullPage: true }); await host.emulateMedia({ colorScheme: 'dark' }); await guest.emulateMedia({ colorScheme: 'dark' }); await host.waitForTimeout(300); await host.screenshot({ path: join(SHOTS, 'quoridor-mid-pc-dark.png') }); await guest.screenshot({ path: join(SHOTS, 'quoridor-mid-phone-dark.png') }); await host.emulateMedia({ colorScheme: 'light' }); await guest.emulateMedia({ colorScheme: 'light' }); } await host.waitForTimeout(150); } await host.getByRole('dialog', { name: '게임 결과' }).waitFor({ timeout: 10_000 }); await guest.getByRole('dialog', { name: '게임 결과' }).waitFor({ timeout: 10_000 }); await host.waitForTimeout(600); await host.screenshot({ path: join(SHOTS, 'quoridor-result-pc.png') }); await guest.screenshot({ path: join(SHOTS, 'quoridor-result-phone.png') }); const summary = await host.getByRole('dialog', { name: '게임 결과' }).innerText(); if (!/승리 — \d+수 만에 도착/.test(summary)) throw new Error(`unexpected result: ${summary.slice(0, 80)}`); if (jumps < 1) throw new Error('no jump happened'); console.log(` quoridor: 3 walls + 1 refused wall, ${steps} pawn moves (${jumps} jump), ${summary.split('\n').find((l) => l.includes('승리'))}`); }, }, 'las-vegas': { // PC host + phone guest (2 players → 4 white dice each), 3 rounds via the lobby: real rolls and placements // through the UI until round settlements (with a duplicate-tie cancellation) and the final result appear. lobby: async (host) => { await host.getByRole('group', { name: '라운드 수' }).getByRole('button', { name: '3라운드' }).click(); await host.getByRole('button', { name: '설정 저장' }).click(); await host.getByText(/^3라운드 · 흰 주사위 자동/).first().waitFor(); }, drive: async (pages) => { const [host, guest] = pages as [Page, Page]; const st = { rolls: 0, places: 0, keyboard: false, casinoPick: false, shots: new Set() }; let tieSeen = false; let settleShot = false; const over = () => host.getByRole('dialog', { name: '게임 결과' }).count(); const until = Date.now() + 300_000; while (Date.now() < until && !(await over())) { let acted = false; for (const [i, p] of pages.entries()) { if (await lasVegasStep(p, i > 0, st)) { acted = true; break; } } if (!settleShot && (await host.locator('section[aria-label="1라운드 정산"]').count())) { settleShot = true; await host.waitForTimeout(700); await host.screenshot({ path: join(SHOTS, 'las-vegas-settle-pc.png') }); await guest.screenshot({ path: join(SHOTS, 'las-vegas-settle-phone.png'), fullPage: true }); } if (!tieSeen && (await host.locator('.lv-settle .lv-tieout').count())) tieSeen = true; if (!acted) await host.waitForTimeout(150); } if (!(await over())) throw new Error(`game did not finish (${JSON.stringify({ ...st, shots: [...st.shots] })})`); await guest.getByRole('dialog', { name: '게임 결과' }).waitFor(); if (!tieSeen && (await host.locator('.lv-settle .lv-tieout').count())) tieSeen = true; await host.waitForTimeout(600); await host.screenshot({ path: join(SHOTS, 'las-vegas-result-pc.png') }); await guest.screenshot({ path: join(SHOTS, 'las-vegas-result-phone.png') }); // Close the result sheet to look at the final table on the board. await host.keyboard.press('Escape'); await host.locator('section[aria-label="최종 결과"]').waitFor(); await host.screenshot({ path: join(SHOTS, 'las-vegas-final-pc.png') }); await host.emulateMedia({ colorScheme: 'dark' }); await host.waitForTimeout(250); await host.screenshot({ path: join(SHOTS, 'las-vegas-final-pc-dark.png') }); await host.emulateMedia({ colorScheme: 'light' }); if (!(await host.locator('section[aria-label="3라운드 정산"]').count())) throw new Error('no settlement for round 3'); if (!tieSeen) throw new Error('no duplicate-tie cancellation appeared in any settlement'); if (st.rolls < 10 || st.places < 10 || !st.keyboard || !st.casinoPick) throw new Error(`too few real turns: ${JSON.stringify({ ...st, shots: [...st.shots] })}`); console.log(` las-vegas: ${st.rolls} rolls, ${st.places} placements, tie cancellation seen`); }, }, 'cockroach-poker': { // PC host + 2 phones, "같은 벌레 3장" for a shorter game. Real turns through the UI until someone loses: // offer a face-down card with a claim, the target calls 진짜!/거짓말! or peeks and passes; the card goes face-up. players: 3, lobby: async (host) => { await host.getByRole('group', { name: '같은 벌레 몇 장이면 패배' }).getByRole('button', { name: '3장(짧게)' }).click(); await host.getByRole('button', { name: '설정 저장' }).click(); await host.getByText(/같은 벌레 3장이면 패배/).first().waitFor(); }, drive: async (pages) => { const [host, guest] = pages as [Page, Page, Page]; const stats: Record = {}; let actions = 0; let shot = false; const until = Date.now() + 300_000; const over = () => host.getByRole('dialog', { name: '게임 결과' }).count(); while (Date.now() < until && !(await over())) { let acted = false; for (const p of pages) { const did = await cockroachStep(p, stats); if (did) { acted = true; actions++; // Wait for the server to move on before looking again. await p.waitForFunction(() => !document.querySelector('.turn-status')?.textContent?.includes('내 차례예요') || !!document.querySelector('.cp-panel button.cp-target'), null, { timeout: 4000 }).catch(() => {}); await p.waitForTimeout(120); break; } } if (!shot && (stats.true ?? 0) + (stats.false ?? 0) >= 4 && (await host.locator('.cp-chip').count()) >= 3) { shot = true; await host.waitForTimeout(800); await host.screenshot({ path: join(SHOTS, 'cockroach-poker-mid-pc.png') }); await guest.screenshot({ path: join(SHOTS, 'cockroach-poker-mid-phone.png') }); await guest.screenshot({ path: join(SHOTS, 'cockroach-poker-mid-phone-full.png'), fullPage: true }); await host.emulateMedia({ colorScheme: 'dark' }); await guest.emulateMedia({ colorScheme: 'dark' }); await host.waitForTimeout(400); await host.screenshot({ path: join(SHOTS, 'cockroach-poker-mid-pc-dark.png') }); await guest.screenshot({ path: join(SHOTS, 'cockroach-poker-mid-phone-dark.png'), fullPage: true }); await host.emulateMedia({ colorScheme: 'light' }); await guest.emulateMedia({ colorScheme: 'light' }); } if (!acted) await host.waitForTimeout(120); } if (!(await over())) throw new Error(`game did not finish (${actions} UI actions: ${JSON.stringify(stats)})`); for (const p of pages) await p.getByRole('dialog', { name: '게임 결과' }).waitFor(); await host.waitForTimeout(600); await host.screenshot({ path: join(SHOTS, 'cockroach-poker-result-pc.png') }); await guest.screenshot({ path: join(SHOTS, 'cockroach-poker-result-phone.png') }); const calls = (stats.true ?? 0) + (stats.false ?? 0); if (!stats.offer || calls < 3) throw new Error(`too few real turns: ${JSON.stringify(stats)}`); console.log(` cockroach-poker: finished after ${actions} UI actions ${JSON.stringify(Object.fromEntries(Object.entries(stats).filter(([k]) => !k.startsWith('shot'))))}`); }, }, 'ninety-nine': { // PC host + 2 phones, 전용 카드 with 6 of each special card and 30-second turns. Real turns through the UI until the // game ends: joker via the number dial (quick 99, then −1), ± cards via their +/− buttons, 0 to turn the direction, // otherwise a number card (PC: keyboard Enter twice, phones: tap twice). Players run out of cards and are eliminated. players: 3, lobby: async (host) => { await host.getByRole('group', { name: '한 턴 제한 시간' }).getByRole('button', { name: '30초' }).click(); await host.getByRole('button', { name: '▼ 자세한 설정' }).click(); await host.getByRole('group', { name: '특수 카드 장수' }).getByRole('button', { name: '6장' }).click(); await host.getByRole('button', { name: '설정 저장' }).click(); await host.getByText(/^전용 카드 · 손패 3장 · 목숨 없음 · 30초/).first().waitFor(); }, drive: async (pages) => { const [host, guest] = pages as [Page, Page, Page]; const stats: Record = {}; let turns = 0; let shot = false; const over = () => host.getByRole('dialog', { name: '게임 결과' }).count(); const until = Date.now() + 240_000; while (Date.now() < until && !(await over())) { let acted = false; for (const p of pages) { if (await ninetyNineStep(p, stats)) { acted = true; turns++; break; } } if (!shot && turns >= 5) { shot = true; await host.waitForTimeout(500); await host.screenshot({ path: join(SHOTS, 'ninety-nine-mid-pc.png') }); await guest.screenshot({ path: join(SHOTS, 'ninety-nine-mid-phone.png') }); await guest.screenshot({ path: join(SHOTS, 'ninety-nine-mid-phone-full.png'), fullPage: true }); await host.emulateMedia({ colorScheme: 'dark' }); await guest.emulateMedia({ colorScheme: 'dark' }); await host.waitForTimeout(400); await host.screenshot({ path: join(SHOTS, 'ninety-nine-mid-pc-dark.png') }); await guest.screenshot({ path: join(SHOTS, 'ninety-nine-mid-phone-dark.png') }); await host.emulateMedia({ colorScheme: 'light' }); await guest.emulateMedia({ colorScheme: 'light' }); } if (!acted) await host.waitForTimeout(120); } if (!(await over())) throw new Error(`game did not finish (${turns} turns: ${JSON.stringify(stats)})`); const outs = await host.locator('.nn-log li', { hasText: '탈락했어요' }).count(); await guest.getByRole('dialog', { name: '게임 결과' }).waitFor(); await host.waitForTimeout(600); await host.screenshot({ path: join(SHOTS, 'ninety-nine-result-pc.png') }); await guest.screenshot({ path: join(SHOTS, 'ninety-nine-result-phone.png') }); const specials = (stats.joker ?? 0) + (stats.plus ?? 0) + (stats.minus ?? 0) + (stats.reverse ?? 0); if (turns < 6 || specials < 1 || outs < 1) throw new Error(`too few real turns / special cards / eliminations: ${turns} ${JSON.stringify(stats)} outs=${outs}`); console.log(` ninety-nine: finished after ${turns} UI turns ${JSON.stringify(stats)}, ${outs} eliminations in the log`); }, }, dobble: { // PC host + phone guest, 탑 쌓기 with the 55-card deck and a 1-second lock. Both players find the shared picture of their own card and the centre // card on their own screen (PC: keyboard focus + Enter, phone: tap), one deliberate wrong tap (shake + lock), until the draw pile runs out. lobby: async (host) => { await host.getByRole('button', { name: '▼ 자세한 설정' }).click(); await host.getByRole('group', { name: '카드 장수' }).getByRole('button', { name: '55장' }).click(); await host.getByRole('button', { name: '설정 저장' }).click(); await host.getByText(/^탑 쌓기 · 55장 · 틀리면 1초 잠금/).first().waitFor(); }, drive: async (pages) => { const [host, guest] = pages as [Page, Page]; const over = () => host.getByRole('dialog', { name: '게임 결과' }).count(); await host.locator('[data-card="mine"] .db-sym:not([disabled])').first().waitFor({ timeout: 10_000 }); const st = { ok: 0, wrong: 0, late: 0, other: 0 }; let n = 0; let shots = 0; const until = Date.now() + 180_000; while (Date.now() < until && !(await over())) { const p = pages[n++ % pages.length]!; const wantWrong = st.wrong === 0 && st.ok >= 3 && p === guest; const text = await dobbleTap(p, wantWrong); if (text === null) { await host.waitForTimeout(150); continue; } if (text.startsWith('찾았어요')) st.ok++; else if (text.includes('틀렸어요')) { st.wrong++; await guest.screenshot({ path: join(SHOTS, 'dobble-wrong-phone.png') }); if (!(await guest.locator('.db-lock').count())) throw new Error('no lock after a wrong tap'); } else if (text.includes('늦었어요')) st.late++; else { st.other++; if (st.other <= 3) console.log(` dobble: other judgement ${JSON.stringify(text)}`); } // Human pace: a tap right after the cards changed counts as aimed at the old card (the screen drops it as late). await host.waitForTimeout(300); if (shots === 0 && st.ok >= 6) { shots = 1; await host.waitForTimeout(500); await host.screenshot({ path: join(SHOTS, 'dobble-mid-pc.png') }); await guest.screenshot({ path: join(SHOTS, 'dobble-mid-phone.png') }); await guest.screenshot({ path: join(SHOTS, 'dobble-mid-phone-full.png'), fullPage: true }); await host.emulateMedia({ colorScheme: 'dark' }); await guest.emulateMedia({ colorScheme: 'dark' }); await host.waitForTimeout(400); await host.screenshot({ path: join(SHOTS, 'dobble-mid-pc-dark.png') }); await guest.screenshot({ path: join(SHOTS, 'dobble-mid-phone-dark.png') }); await host.emulateMedia({ colorScheme: 'light' }); await guest.emulateMedia({ colorScheme: 'light' }); } } if (!(await over())) throw new Error(`game did not finish ${JSON.stringify(st)}`); await guest.getByRole('dialog', { name: '게임 결과' }).waitFor(); await host.waitForTimeout(600); await host.screenshot({ path: join(SHOTS, 'dobble-result-pc.png') }); await guest.screenshot({ path: join(SHOTS, 'dobble-result-phone.png') }); if (st.ok < 20 || st.wrong < 1) throw new Error(`too few real finds / wrong taps: ${JSON.stringify(st)}`); console.log(` dobble: finished after ${st.ok} correct taps, ${st.wrong} wrong, ${st.late} late, ${st.other} other`); }, }, catchmind: { // PC host + phone guest, 1 round of 60 s: each player draws once with real mouse strokes; the other sees the strokes // (stroke count + pixels), guesses wrong, gets "아까워요" for a near miss, then the right answer; scores update. lobby: async (host) => { await host.getByRole('group', { name: '라운드 수' }).getByRole('button', { name: '1라운드' }).click(); await host.getByRole('group', { name: '그리기 시간' }).getByRole('button', { name: '60초' }).click(); await host.getByRole('button', { name: '설정 저장' }).click(); await host.getByText(/^여러 명 맞히기 · 1라운드 · 60초/).first().waitFor(); }, drive: async (pages) => { const [host, guest] = pages as [Page, Page]; const words: string[] = []; for (let turn = 1; turn <= 2; turn++) { words.push(await catchmindTurn(pages, turn, true)); // Multi mode: 1st solver 10 points, drawer 2 per solver. const scores = await host.locator('.cm-score').innerText(); if (turn === 1 && !(/10점/.test(scores) && /2점/.test(scores))) throw new Error(`scores not updated: ${scores.replace(/\n/g, ' ')}`); } await host.getByRole('dialog', { name: '게임 결과' }).waitFor({ timeout: 20_000 }); await guest.getByRole('dialog', { name: '게임 결과' }).waitFor(); await host.waitForTimeout(500); await host.screenshot({ path: join(SHOTS, 'catchmind-result-pc.png') }); await guest.screenshot({ path: join(SHOTS, 'catchmind-result-phone.png') }); const final = await host.locator('.cm-score').innerText(); if ((final.match(/12점/g) ?? []).length !== 2) throw new Error(`unexpected final scores: ${final.replace(/\n/g, ' ')}`); console.log(` catchmind: drew and guessed ${words.join(', ')}; both 12 points (10 + 2)`); }, }, mafia: { // PC host + 3 phone guests (마피아·경찰·의사·시민), short phases via the lobby. Real flow through the UI: role card, // greeting chat in 전체, night (mafia chat + target, police investigates the mafia, doctor guards themself), the // morning news, dead chat, discussion → vote → last defense → 찬반 → the mafia is voted out and the town wins. // Channel checks: mafia and dead messages never reach other screens; the room chat is closed during the game. players: 4, lobby: async (host) => { // Night cannot end early (only its clock advances it), so give the night's chat+3 picks ample room under load. await host.getByRole('group', { name: '밤 시간' }).getByRole('button', { name: '45초' }).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(/밤 45초 · 투표 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: 15_000 }); 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, '낮 토론', 60_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: 15_000 }); await mafiaShots(pages, '4-defense'); await mafiaBanner(pages[P]!, '찬반 투표', 20_000); await pages[P]!.getByRole('button', { name: '찬성 (탈락)' }).click(); await pages[D]!.getByRole('button', { name: '찬성 (탈락)' }).click(); await pages[P]!.getByRole('button', { name: '찬성 (탈락)', pressed: true }).waitFor(); for (const p of pages) await p.getByRole('dialog', { name: '게임 결과' }).waitFor({ timeout: 20_000 }); for (const p of pages) await p.locator('.mf-final', { hasText: '시민 팀 승리' }).waitFor(); // After the end every channel is revealed (revealChatsAtEnd). await pages[C]!.getByRole('tab', { name: /^마피아 채팅/ }).waitFor(); await host.waitForTimeout(500); await host.screenshot({ path: join(SHOTS, 'mafia-5-result-pc.png') }); await phone.screenshot({ path: join(SHOTS, 'mafia-5-result-phone.png') }); console.log(' mafia: night kill, police result, dead chat, vote → defense → 찬반 → town won'); }, }, }; const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS); const app = startServer({ config: loadConfig({ PORT: String(PORT), DB_PATH: join(mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'bg-e2e-')), 'app.db'), PUBLIC_ORIGIN: ORIGIN }), log: () => {}, staticDir: join(import.meta.dir, '../apps/web/dist'), }); const browser = await chromium.launch({ executablePath: process.env.CHROME ?? '/usr/bin/google-chrome', headless: true }); let failed = false; async function keys(page: Page, seq: string[]) { await page.locator('[role="grid"]').first().focus(); for (const k of seq) await page.keyboard.press(k); } for (const id of games) { const plan = PLANS[id]; if (!plan) { console.log(`✗ ${id}: no plan`); failed = true; continue; } const errors: string[] = []; const host = await (await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'ko-KR' })).newPage(); const guests: Page[] = []; for (let g = 1; g < (plan.players ?? 2); g++) { guests.push(await (await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, locale: 'ko-KR' })).newPage()); } const guest = guests[0]!; for (const p of [host, ...guests]) p.on('pageerror', (e) => errors.push(`${id}: ${e.message}`)); try { await host.goto(`${ORIGIN}/g/${id}`); await host.getByRole('button', { name: /친구랑 하기/ }).click(); await host.getByLabel('내 이름(닉네임)').fill('방장'); await host.getByRole('button', { name: '이 이름으로 시작하기' }).click(); await host.waitForURL(/\/r\/\d{6}$/); const code = host.url().slice(-6); for (const [gi, g] of guests.entries()) { const nick = gi === 0 ? '친구' : `친구${gi + 1}`; await g.goto(`${ORIGIN}/r/${code}`); await g.getByLabel('내 이름(닉네임)').fill(nick); await g.getByRole('button', { name: '이 이름으로 입장하기' }).click(); await host.getByLabel('자리').getByText(nick, { exact: true }).waitFor(); } if (plan.lobby) await plan.lobby(host); await host.screenshot({ path: join(SHOTS, `${id}-0-lobby.png`) }); await host.getByRole('button', { name: '▶ 게임 시작' }).click(); await host.getByRole('button', { name: '시작하기' }).click(); if (plan.drive) { await host.locator('.turn-status').waitFor(); await plan.drive([host, ...guests]); } else { await host.locator('[role="grid"]').first().waitFor(); await guest.locator('[role="grid"]').first().waitFor(); } if (plan.setup) await plan.setup(host, guest); const status = async (p: Page) => (await p.locator('.turn-status').innerText()).trim(); // Sides may be random: whoever shows "내 차례예요" plays the next sequence. const myTurn = (p: Page) => p.evaluate(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요')); for (const seq of plan.moves ?? []) { let mover: Page | null = null; for (let t = 0; t < 100 && !mover; t++) { if (await myTurn(host)) mover = host; else if (await myTurn(guest)) mover = guest; else await host.waitForTimeout(100); } if (!mover) throw new Error('nobody has the turn'); await keys(mover, seq); await mover.waitForFunction(() => !document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'), null, { timeout: 5000 }); } await host.screenshot({ path: join(SHOTS, `${id}-1-game-pc.png`) }); await guest.screenshot({ path: join(SHOTS, `${id}-2-game-phone.png`) }); if (errors.length) throw new Error(errors.join(' | ')); console.log(`✓ ${id} (host: ${await status(host)})`); } catch (e) { failed = true; console.log(`✗ ${id}: ${(e as Error).message.split('\n')[0]}`); await host.screenshot({ path: join(SHOTS, `${id}-fail-host.png`) }).catch(() => {}); await guest.screenshot({ path: join(SHOTS, `${id}-fail-guest.png`) }).catch(() => {}); } } await browser.close(); await app.stop(); console.log(failed ? 'E2E FAILED' : 'E2E PASSED'); process.exit(failed ? 1 : 0);