Files
joke-app/e2e/games.e2e.ts
EJClaw f99784331f Merge branch 'm8/catchmind' into codex/owner/grp_discord_joke-app
# Conflicts:
#	apps/web/src/games/registry.ts
#	apps/web/src/lib/catalog-ui.ts
#	e2e/games.e2e.ts
#	packages/games/src/index.ts
#	packages/shared/src/catalog.ts
2026-10-06 00:39:17 +09:00

1927 lines
101 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<void>;
/** Steps run before normal moves (e.g. janggi formation choice). */
setup?: (host: Page, guest: Page) => Promise<void>;
/** 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<void>;
}
/** 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<boolean> {
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<string | null> {
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<Record<string, number>> {
const labels = await p.locator('.hg-seat').evaluateAll((els) => els.map((e) => e.getAttribute('aria-label') ?? ''));
const t: Record<string, number> = { 딸기: 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<string, number>): Promise<string | null> {
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<string, number>): Promise<string | null> {
const enabled = async (l: ReturnType<Page['locator']>) => (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<string, number>, turn: number): Promise<string | null> {
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<Page['locator']>) => {
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<Page['locator']>) {
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<string> {
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<boolean> {
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<string> },
): Promise<boolean> {
const press = (l: ReturnType<Page['locator']>) => (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<string, number>): Promise<string | null> {
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<string>();
/** 구십구: one turn for whoever shows "내 차례예요" — joker via the dial, ± via its buttons, 0, else a number card. */
async function ninetyNineStep(p: Page, stats: Record<string, number>): Promise<boolean> {
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<Page['locator']>) => (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<string | null> {
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<number> {
return p.evaluate(() => {
const c = document.querySelector<HTMLCanvasElement>('.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<string> {
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;
}
const PLANS: Record<string, Plan> = {
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<string>();
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<Page['locator']>) => (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<string>();
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<Page['locator']>) => l.click({ timeout: 1500 }).then(() => true, () => false);
/** One UI step on this page; true when something was pressed. */
const step = async (p: Page): Promise<boolean> => {
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초' }).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<string, number> = {};
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<string, number> = { 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<string, number> = {};
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<Page>();
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<string>() };
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<string, number> = {};
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<string, number> = {};
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)`);
},
},
};
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);