- 규칙 모듈 packages/games/src/mafia: 4~12명, 자동/수동 마피아 수, 추가 직업(군인·정치인·영매·연인),
서버 타이머가 phaseTimeout(시스템 전용)으로 페이즈 진행, 인사 낮/토론(±15초, 토론 끝내기)/지목 투표/
최후의 반론/찬반 투표/밤(마피아 공용 대상, 경찰·영매 결과는 밤 마감 시 전달, 의사·군인 순서),
채널(전체/마피아/죽은 사람/연인) 권한을 view에서 필터, 레이트 리밋·고운 말 필터, 자리 비움(2일) 탈락,
나가기 즉시 탈락, 승리 판정·결과(MVP 포함)
- 테스트: 문서 §10 항목 전부, 시드 1,000판 무작위 대국(불변식·누출·onTimeout 합법·결정성),
엔진 randomPlayout, 기록 재생, 실제 Room 통과 테스트(방 채팅 닫힘, 연결별 채팅 필터)
- 화면 apps/web/src/games/mafia: 낮/밤 배너와 큰 남은 시간, 채널 탭 채팅, 꾹 눌러 보는 내 직업,
그리드에서 투표·밤 대상 고르기, 아침 발표·득표 막대, 최종 공개
- 공용 코드(하위 호환): GameDefinition.ownChat, validate의 선택 ctx {now}; Room이 ownChat 게임 진행 중
방 채팅·반응을 거절하고 validate에 서버 시각 전달; 웹 GameUI.ownChat이면 진행 중 방 채팅 패널 숨김
- 등록(GAMES, 카탈로그, 화면 레지스트리, 아이콘)과 E2E 계획(PC 방장 + 휴대폰 3명)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1839 lines
98 KiB
TypeScript
1839 lines
98 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|
||
|
||
/** 마피아: nicknames of the E2E pages (host first). */
|
||
const MAFIA_NICKS = ['방장', '친구', '친구2', '친구3'];
|
||
|
||
/** 마피아: the night prompt tells each page its role without holding the role card. */
|
||
async function mafiaRoleOf(p: Page): Promise<'mafia' | 'police' | 'doctor' | 'citizen'> {
|
||
const text = await p.locator('.mf-actions .mf-prompt').first().innerText();
|
||
if (text.includes('마피아가 노릴')) return 'mafia';
|
||
if (text.includes('조사할')) return 'police';
|
||
if (text.includes('지켜 줄')) return 'doctor';
|
||
if (text.includes('기다려')) return 'citizen';
|
||
throw new Error(`unknown night prompt: ${text}`);
|
||
}
|
||
|
||
/** 마피아: picks the highlighted player tile of `nick` (vote / night action). */
|
||
async function mafiaPick(p: Page, nick: string) {
|
||
const tile = p.locator('button.mf-tile[data-target="ok"]', { has: p.locator('.nm', { hasText: new RegExp(`^${nick}( \\(나\\))?$`) }) });
|
||
await tile.click();
|
||
await p.locator('button.mf-tile[aria-pressed="true"]', { has: p.locator('.nm', { hasText: new RegExp(`^${nick}( \\(나\\))?$`) }) }).waitFor({ timeout: 5000 });
|
||
}
|
||
|
||
/** 마피아: writes in the selected channel tab and waits until the line shows up. */
|
||
async function mafiaSay(p: Page, channel: '전체' | '마피아' | '죽은 사람', text: string) {
|
||
await p.getByRole('tab', { name: new RegExp(`^${channel} 채팅`) }).click();
|
||
await p.getByLabel(`${channel} 채팅 메시지`).fill(text);
|
||
await p.getByLabel(`${channel} 채팅 메시지`).press('Enter');
|
||
await p.locator('.mf-msgs .mf-msg', { hasText: text }).waitFor({ timeout: 5000 });
|
||
}
|
||
|
||
async function mafiaBanner(p: Page, phase: string, timeout = 40_000) {
|
||
await p.locator('.mf-banner .mf-ph strong', { hasText: phase }).waitFor({ timeout });
|
||
}
|
||
|
||
async function mafiaShots(pages: Page[], name: string) {
|
||
const [host, phone] = pages as [Page, Page];
|
||
await host.screenshot({ path: join(SHOTS, `mafia-${name}-pc.png`) });
|
||
await phone.screenshot({ path: join(SHOTS, `mafia-${name}-phone.png`) });
|
||
await host.emulateMedia({ colorScheme: 'dark' });
|
||
await phone.emulateMedia({ colorScheme: 'dark' });
|
||
await host.waitForTimeout(250);
|
||
await host.screenshot({ path: join(SHOTS, `mafia-${name}-pc-dark.png`) });
|
||
await phone.screenshot({ path: join(SHOTS, `mafia-${name}-phone-dark.png`), fullPage: true });
|
||
await host.emulateMedia({ colorScheme: 'light' });
|
||
await phone.emulateMedia({ colorScheme: 'light' });
|
||
}
|
||
|
||
const PLANS: Record<string, Plan> = {
|
||
rummikub: {
|
||
// PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI.
|
||
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초', 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<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`);
|
||
},
|
||
},
|
||
mafia: {
|
||
// PC host + 3 phone guests (마피아·경찰·의사·시민), short phases via the lobby. Real flow through the UI: role card,
|
||
// greeting chat in 전체, night (mafia chat + target, police investigates the mafia, doctor guards themself), the
|
||
// morning news, dead chat, discussion → vote → last defense → 찬반 → the mafia is voted out and the town wins.
|
||
// Channel checks: mafia and dead messages never reach other screens; the room chat is closed during the game.
|
||
players: 4,
|
||
lobby: async (host) => {
|
||
await host.getByRole('group', { name: '밤 시간' }).getByRole('button', { name: '15초' }).click();
|
||
await host.getByRole('button', { name: '▼ 자세한 설정' }).click();
|
||
await host.getByRole('group', { name: '지목 투표 시간' }).getByRole('button', { name: '10초', exact: true }).click();
|
||
await host.getByRole('group', { name: '최후의 반론 시간' }).getByRole('button', { name: '10초', exact: true }).click();
|
||
await host.getByRole('group', { name: '찬반 투표 시간' }).getByRole('button', { name: '5초', exact: true }).click();
|
||
await host.getByRole('button', { name: '설정 저장' }).click();
|
||
await host.getByText(/밤 15초 · 투표 10초/).first().waitFor();
|
||
},
|
||
drive: async (pages) => {
|
||
const [host, phone] = pages as [Page, Page, Page, Page];
|
||
for (const p of pages) await p.locator('.mf-banner').waitFor();
|
||
// Hold-to-see role card (keyboard: Enter shows it for a moment).
|
||
const card = host.locator('.mf-rolecard');
|
||
if (!(await card.getAttribute('aria-label'))!.startsWith('내 직업 보기')) throw new Error('role card shows the role before it is held');
|
||
await card.focus();
|
||
await host.keyboard.press('Enter');
|
||
if (!(await card.getAttribute('aria-label'))!.startsWith('내 직업:')) throw new Error('role card did not reveal on Enter');
|
||
await host.screenshot({ path: join(SHOTS, 'mafia-1-role-pc.png') });
|
||
// Room chat is closed while the game runs (the board has its own chat).
|
||
if (await host.locator('.chat-panel').count()) throw new Error('room chat panel is visible during the game');
|
||
if (await phone.getByRole('button', { name: /^채팅/ }).count()) throw new Error('room chat button is visible on the phone during the game');
|
||
|
||
await mafiaBanner(host, '인사 시간');
|
||
await mafiaSay(phone, '전체', '안녕하세요 반가워요');
|
||
for (const p of pages) await p.locator('.mf-msgs .mf-msg', { hasText: '안녕하세요 반가워요' }).waitFor({ timeout: 5000 });
|
||
for (const p of pages) await p.getByRole('button', { name: /^인사 끝내기/ }).click();
|
||
|
||
for (const p of pages) await mafiaBanner(p, '밤');
|
||
const roles = await Promise.all(pages.map(mafiaRoleOf));
|
||
const at = (r: string) => roles.indexOf(r as (typeof roles)[number]);
|
||
const [M, P, D, C] = [at('mafia'), at('police'), at('doctor'), at('citizen')];
|
||
if ([M, P, D, C].some((i) => i < 0)) throw new Error(`unexpected roles ${roles.join(',')}`);
|
||
console.log(` mafia: roles ${roles.map((r, i) => `${MAFIA_NICKS[i]}=${r}`).join(' ')}`);
|
||
// Same night layout for everyone; only the mafia has the 마피아 tab.
|
||
for (const [i, p] of pages.entries()) {
|
||
if ((await p.getByRole('tab', { name: /^마피아 채팅/ }).count()) !== (i === M ? 1 : 0)) throw new Error(`${MAFIA_NICKS[i]}: wrong 마피아 tab`);
|
||
if (!(await p.locator('.mf-actions').count())) throw new Error('night panel missing');
|
||
}
|
||
if (await pages[C]!.getByLabel('전체 채팅 메시지').count()) throw new Error('day chat open at night');
|
||
await mafiaSay(pages[M]!, '마피아', 'MAFIA-SECRET 오늘은 조용히');
|
||
await mafiaPick(pages[M]!, MAFIA_NICKS[C]!);
|
||
await mafiaPick(pages[P]!, MAFIA_NICKS[M]!);
|
||
await mafiaPick(pages[D]!, MAFIA_NICKS[D]!);
|
||
await pages[0]!.waitForTimeout(300);
|
||
for (const [i, p] of pages.entries()) {
|
||
if (i !== M && (await p.content()).includes('MAFIA-SECRET')) throw new Error(`mafia chat leaked to ${MAFIA_NICKS[i]}`);
|
||
}
|
||
await mafiaShots(pages, '2-night');
|
||
|
||
for (const p of pages) await mafiaBanner(p, '낮 토론', 30_000);
|
||
for (const p of pages) await p.locator('.mf-news', { hasText: '아침 발표' }).waitFor();
|
||
await pages[C]!.locator('.mf-news', { hasText: '탈락했어요' }).waitFor();
|
||
await pages[P]!.locator('.mf-results', { hasText: '마피아예요!' }).waitFor();
|
||
for (const [i, p] of pages.entries()) if (i !== P && (await p.locator('.mf-results').count())) throw new Error('police result shown to someone else');
|
||
// The dead citizen: no day chat, writes in 죽은 사람 only — nobody alive sees it.
|
||
await pages[C]!.getByRole('tab', { name: /^전체 채팅/ }).click();
|
||
if (await pages[C]!.getByLabel('전체 채팅 메시지').count()) throw new Error('dead player can write in day chat');
|
||
await mafiaSay(pages[C]!, '죽은 사람', 'DEAD-SECRET 저 시민이었어요');
|
||
await mafiaSay(pages[P]!, '전체', `${MAFIA_NICKS[M]}님이 마피아예요`);
|
||
await pages[0]!.waitForTimeout(300);
|
||
for (const [i, p] of pages.entries()) {
|
||
if (i !== C && (await p.content()).includes('DEAD-SECRET')) throw new Error(`dead chat leaked to ${MAFIA_NICKS[i]}`);
|
||
if (i !== M && (await p.content()).includes('MAFIA-SECRET')) throw new Error(`mafia chat leaked to ${MAFIA_NICKS[i]} by day`);
|
||
}
|
||
await mafiaShots(pages, '3-day');
|
||
for (const i of [M, P, D]) await pages[i]!.getByRole('button', { name: /^토론 끝내기/ }).click();
|
||
|
||
for (const i of [M, P, D]) await mafiaBanner(pages[i]!, '지목 투표', 15_000);
|
||
await mafiaPick(pages[P]!, MAFIA_NICKS[M]!);
|
||
await mafiaPick(pages[D]!, MAFIA_NICKS[M]!);
|
||
await mafiaPick(pages[M]!, MAFIA_NICKS[P]!);
|
||
await mafiaBanner(host, '최후의 반론', 20_000);
|
||
await pages[M]!.locator('.mf-news', { hasText: '지목 투표 결과' }).waitFor();
|
||
if (await pages[P]!.getByLabel('전체 채팅 메시지').count()) throw new Error('others can talk during the last defense');
|
||
await mafiaSay(pages[M]!, '전체', '저는 진짜 시민이에요');
|
||
await pages[P]!.locator('.mf-msgs .mf-msg', { hasText: '저는 진짜 시민이에요' }).waitFor({ timeout: 5000 });
|
||
await mafiaShots(pages, '4-defense');
|
||
await mafiaBanner(pages[P]!, '찬반 투표', 20_000);
|
||
await pages[P]!.getByRole('button', { name: '찬성 (탈락)' }).click();
|
||
await pages[D]!.getByRole('button', { name: '찬성 (탈락)' }).click();
|
||
await pages[P]!.getByRole('button', { name: '찬성 (탈락)', pressed: true }).waitFor();
|
||
|
||
for (const p of pages) await p.getByRole('dialog', { name: '게임 결과' }).waitFor({ timeout: 20_000 });
|
||
for (const p of pages) await p.locator('.mf-final', { hasText: '시민 팀 승리' }).waitFor();
|
||
// After the end every channel is revealed (revealChatsAtEnd).
|
||
await pages[C]!.getByRole('tab', { name: /^마피아 채팅/ }).waitFor();
|
||
await host.waitForTimeout(500);
|
||
await host.screenshot({ path: join(SHOTS, 'mafia-5-result-pc.png') });
|
||
await phone.screenshot({ path: join(SHOTS, 'mafia-5-result-phone.png') });
|
||
console.log(' mafia: night kill, police result, dead chat, vote → defense → 찬반 → town won');
|
||
},
|
||
},
|
||
};
|
||
|
||
const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS);
|
||
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);
|