From 4de9ca467c31d92b6355b47980fd26e0c1ba0559 Mon Sep 17 00:00:00 2001 From: EJClaw Date: Mon, 5 Oct 2026 14:20:18 +0900 Subject: [PATCH] =?UTF-8?q?M5=C2=B7M6:=20=EB=A7=88=EC=9E=91=C2=B7=EA=B3=A0?= =?UTF-8?q?=EC=8A=A4=ED=86=B1=C2=B7=EB=B1=85=C2=B7=ED=95=A0=EB=A6=AC?= =?UTF-8?q?=EA=B0=88=EB=A6=AC=C2=B7=EC=A3=BC=EC=82=AC=EC=9C=84=20=EB=95=85?= =?UTF-8?q?=EB=94=B0=EB=A8=B9=EA=B8=B0=20=ED=95=A9=EC=B9=98=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 다섯 게임 브랜치 병합, 등록 목록·E2E 계획 충돌 정리(E2E 계획은 기존 게임 계획 그대로 + 새 계획 5개) - 홈 '곧 나와요' 목록을 M7·M8 게임으로 갱신 - 로드맵 M5·M6 진행 결과 기록 Co-Authored-By: Claude Opus 5.5 --- apps/web/src/lib/catalog-ui.ts | 10 +- docs/12-roadmap.md | 10 + e2e/games.e2e.ts | 758 +++++++++++++++++---------------- 3 files changed, 392 insertions(+), 386 deletions(-) diff --git a/apps/web/src/lib/catalog-ui.ts b/apps/web/src/lib/catalog-ui.ts index 4189089..a88513f 100644 --- a/apps/web/src/lib/catalog-ui.ts +++ b/apps/web/src/lib/catalog-ui.ts @@ -14,17 +14,9 @@ export const GAME_ICON: Record = { 'davinci-code': '🔐', gostop: '🌸', marble: '🏙️', -}; - -/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */ -export const UPCOMING = ['뱅', '할리갈리']; bang: '🤠', -}; - -/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */ -export const UPCOMING = ['마작', '고스톱', '할리갈리']; 'halli-galli': '🔔', }; /** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */ -export const UPCOMING = ['마작', '고스톱', '뱅']; +export const UPCOMING = ['달무티', '보석 상인', '쿼리도', '라스베가스', '바퀴벌레 포커', '구십구', '도블', '그림 맞히기', '마피아']; diff --git a/docs/12-roadmap.md b/docs/12-roadmap.md index 97a95ee..1d2f5d2 100644 --- a/docs/12-roadmap.md +++ b/docs/12-roadmap.md @@ -46,9 +46,19 @@ ## M5. 마작·고스톱 - 리치 마작(4인, 3인), 점수 계산기 테스트(역·부 계산 사례 100개 이상), 초보 모드. - 고스톱/맞고. +- **진행 결과(2026-10-05)**: 마작(4인 동풍전·반장전, 3인 산마, 초보 모드)·고스톱(맞고·3인·광팔기) 규칙 + 화면 완료. + 마작 점수 계산 테스트 161(1~4판 × 20~110부 표 전체, 만관~더블 역만, 손패 계산 사례 약 60) + 규칙 77, + 고스톱 규칙 58. 게임마다 무작위 플레이 1,000판(패·점수 보존, 비공개 정보 유출 없음, 시간 초과 행동 합법). + 마작 울기 창은 남은 시간을 공개하지 않음(누가 울 수 있는지 드러나지 않게). 3인 최종 점수는 합 0 보정. + E2E: 마작 4인(PC 방장 + 휴대폰 3명) 첫 국 끝까지 → 다음 국 시작, 맞고 한 판(고·스톱 포함). ## M6. 뱅 + 실시간 반응 + 땅따먹기 - 뱅(반응 창 처리), 할리갈리(서버 도착 순서 판정), 주사위 땅따먹기. +- **진행 결과(2026-10-05)**: 세 게임 + 할리갈리 규칙 + 화면 완료. 규칙 테스트 뱅 62·할리갈리 46(+방 단위 서버 테스트)·땅따먹기 80, + 게임마다 무작위 플레이 1,000판. 할리갈리는 같은 밀리초에 두 사람이 쳐도 서버에 먼저 도착한 사람만 인정(서버 테스트로 확인). + 엔진에 선택 훅 `timeoutNotBefore` 추가(접속 끊긴 사람 차례에 타이머가 0ms로 반복 발동하던 문제 방지, 기존 게임 영향 없음). + 뱅은 내 역할을 꾹 눌러야 보이게(옆 사람 훔쳐보기 방지). 땅따먹기 금액은 놀이용 포인트(P) 표기. + E2E: 뱅 4인 10턴, 할리갈리 3인 24번 뒤집기(맞는 종·틀린 종), 땅따먹기 15라운드 한 판 끝까지. ## M7. 보드게임카페 인기 게임 - 달무티, 보석 상인, 쿼리도, 라스베가스, 바퀴벌레 포커, 구십구. diff --git a/e2e/games.e2e.ts b/e2e/games.e2e.ts index 6111699..d1d9937 100644 --- a/e2e/games.e2e.ts +++ b/e2e/games.e2e.ts @@ -203,6 +203,19 @@ async function gostopStep(p: Page, state: { go: number; stop: number; picks: num if (await lifted.count()) await lifted.click({ timeout: 1500 }); state.plays++; return 'play'; +} + +/** 할리갈리: fruit totals of the face-up tops, read from the seat labels ("펼친 카드 딸기 3개"). */ +async function halliTotals(p: Page): Promise> { + const labels = await p.locator('.hg-seat').evaluateAll((els) => els.map((e) => e.getAttribute('aria-label') ?? '')); + const t: Record = { 딸기: 0, 바나나: 0, 라임: 0, 자두: 0 }; + for (const l of labels) { + const m = /펼친 카드 (딸기|바나나|라임|자두) (\d)개/.exec(l); + if (m) t[m[1]!]! += Number(m[2]); + } + return t; +} + /** * 주사위 땅따먹기: one UI step for whoever can act on `p` — answer a trade, sell, buy/build (or skip), * pick a space-travel square on the board, escape the island or roll. Returns the label pressed. @@ -248,151 +261,6 @@ async function marbleStep(p: Page, stats: Record): Promise = { - 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}`); -/** 할리갈리: fruit totals of the face-up tops, read from the seat labels ("펼친 카드 딸기 3개"). */ -async function halliTotals(p: Page): Promise> { - const labels = await p.locator('.hg-seat').evaluateAll((els) => els.map((e) => e.getAttribute('aria-label') ?? '')); - const t: Record = { 딸기: 0, 바나나: 0, 라임: 0, 자두: 0 }; - for (const l of labels) { - const m = /펼친 카드 (딸기|바나나|라임|자두) (\d)개/.exec(l); - if (m) t[m[1]!]! += Number(m[2]); - } - return t; -} - -const PLANS: Record = { - 'halli-galli': { - // PC host + 2 phones. Real flips (Enter on PC, taps on phones); whenever some fruit totals exactly five a player - // rings the bell (PC: Space, phone: tap), plus at least one deliberate wrong bell. 24+ flips through the UI. - players: 3, - lobby: async (host) => { - await host.getByRole('group', { name: '뒤집기 제한 시간' }).getByRole('button', { name: '5초' }).click(); - await host.getByRole('button', { name: '설정 저장' }).click(); - await host.getByText(/뒤집기 5초/).first().waitFor(); - }, - drive: async (pages) => { - const [host, guest] = pages as [Page, Page, Page]; - const myTurn = (p: Page) => p.evaluate(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요')); - const ended = () => host.getByRole('dialog', { name: '게임 결과' }).count(); - await host.locator('.hg-seat').first().waitFor(); - // 3-second countdown first. - await host.waitForFunction(() => !document.querySelector('.hg-count'), null, { timeout: 8000 }); - let flips = 0; - let rights = 0; - let wrongs = 0; - let lates = 0; - let shots = 0; - let ringer = 0; - const until = Date.now() + 150_000; - const bubble = (p: Page) => p.locator('.hg-bubble').first().innerText({ timeout: 2500 }).catch(() => ''); - while (Date.now() < until && !(await ended()) && (flips < 24 || rights < 1 || wrongs < 1)) { - const totals = await halliTotals(host); - const five = Object.values(totals).includes(5); - const wantWrong = !five && wrongs === 0 && flips >= 4 && Object.values(totals).some((n) => n > 0); - if (five || wantWrong) { - const p = pages[ringer++ % pages.length]!; - const bell = p.locator('.hg-bell'); - if (await bell.isEnabled().catch(() => false)) { - if (p === host) { - await host.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); - await host.keyboard.press('Space'); - } else await bell.tap(); - const text = await bubble(p); - if (text.includes('땡')) rights++; - else if (text.includes('삐')) wrongs++; - else if (text.includes('늦었어요')) lates++; - if (shots === 0 && text) { - shots++; - await host.screenshot({ path: join(SHOTS, 'halli-galli-bell-pc.png') }); - await guest.screenshot({ path: join(SHOTS, 'halli-galli-bell-phone.png') }); - } - await host.waitForTimeout(400); - continue; - } - } - let flipped = false; - for (const p of pages) { - if (!(await myTurn(p))) continue; - const btn = p.getByRole('button', { name: '뒤집기', exact: true }); - if (!(await btn.isEnabled().catch(() => false))) break; - const before = await p.locator('.hg-seat[data-me="true"] .hg-meta').innerText(); - if (p === host) { - await host.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); - await host.keyboard.press('Enter'); - } else await btn.tap(); - await p.waitForFunction((b) => document.querySelector('.hg-seat[data-me="true"] .hg-meta')?.textContent !== b, before, { timeout: 3000 }).catch(() => {}); - flips++; - flipped = true; - break; - } - if (flips === 12 && flipped && shots < 2) { - shots = 2; - await host.waitForTimeout(300); - await host.screenshot({ path: join(SHOTS, 'halli-galli-mid-pc.png') }); - await guest.screenshot({ path: join(SHOTS, 'halli-galli-mid-phone.png') }); - await guest.emulateMedia({ colorScheme: 'dark' }); - await guest.waitForTimeout(400); // let the theme transition finish - await guest.screenshot({ path: join(SHOTS, 'halli-galli-mid-phone-dark.png') }); - await guest.emulateMedia({ colorScheme: 'light' }); - await host.emulateMedia({ colorScheme: 'dark' }); - await host.waitForTimeout(400); - await host.screenshot({ path: join(SHOTS, 'halli-galli-mid-pc-dark.png') }); - await host.emulateMedia({ colorScheme: 'light' }); - } - await host.waitForTimeout(flipped ? 650 : 120); - } - console.log(` halli-galli: ${flips} UI flips, ${rights} correct bells, ${wrongs} wrong bells, ${lates} late`); - if (!(await ended()) && (flips < 20 || wrongs < 1)) throw new Error('not enough flips / bells'); - if (rights < 1 && !(await ended())) throw new Error('no correct bell happened'); - }, - }, rummikub: { // PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI. lobby: async (host) => { @@ -436,7 +304,6 @@ const PLANS: Record = { await guest.screenshot({ path: join(SHOTS, 'yut-mid-phone-dark.png') }); 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)`); @@ -444,59 +311,6 @@ const PLANS: Record = { console.log(` yut: finished after ${actions} UI actions`); }, }, - marble: { - // PC host + phone guest, 15 rounds (shortest limit) played through the UI to the result: roll, buy or - // build (skip when short), pay tolls, sell when short, space travel via a board square. Mid-game shots. - lobby: async (host) => { - await host.getByRole('group', { name: '라운드 제한' }).getByRole('button', { name: '15라운드' }).click(); - await host.getByRole('group', { name: '시작 포인트' }).getByRole('button', { name: '200P' }).click(); - await host.getByRole('button', { name: '설정 저장' }).click(); - await host.getByText(/^15라운드 · 시작 200P/).first().waitFor(); - }, - drive: async (pages) => { - const [host, guest] = pages as [Page, Page]; - const stats: Record = {}; - let actions = 0; - let shot = false; - const until = Date.now() + 420_000; - const over = () => host.getByRole('dialog', { name: '게임 결과' }).count(); - while (Date.now() < until && !(await over())) { - let acted = false; - for (const p of pages) { - if (await marbleStep(p, stats)) { - acted = true; - actions++; - await p.waitForTimeout(80); - break; - } - } - if (actions >= 40 && !shot && (stats['사기'] ?? 0) >= 3) { - shot = true; - await host.waitForTimeout(1300); - await host.screenshot({ path: join(SHOTS, 'marble-mid-pc.png') }); - await guest.screenshot({ path: join(SHOTS, 'marble-mid-phone.png') }); - await guest.screenshot({ path: join(SHOTS, 'marble-mid-phone-full.png'), fullPage: true }); - await host.emulateMedia({ colorScheme: 'dark' }); - await guest.emulateMedia({ colorScheme: 'dark' }); - await host.waitForTimeout(400); // let the button colour transitions settle - await host.screenshot({ path: join(SHOTS, 'marble-mid-pc-dark.png') }); - await guest.screenshot({ path: join(SHOTS, 'marble-mid-phone-dark.png') }); - await host.emulateMedia({ colorScheme: 'light' }); - await guest.emulateMedia({ colorScheme: 'light' }); - } - if (acted && !stats.toll && (await host.locator('.mb-log li', { hasText: /→.*통행료/ }).count())) stats.toll = 1; - if (!acted) await host.waitForTimeout(120); - } - if (!(await over())) throw new Error(`game did not finish (${actions} UI actions: ${JSON.stringify(stats)})`); - await guest.getByRole('dialog', { name: '게임 결과' }).waitFor(); - await host.waitForTimeout(600); // result dialog fade-in - await host.screenshot({ path: join(SHOTS, 'marble-result-pc.png') }); - await guest.screenshot({ path: join(SHOTS, 'marble-result-phone.png') }); - const rolls = (stats['🎲 주사위 굴리기'] ?? 0) + (stats['🎲 한 번 더 굴리기'] ?? 0) + (stats['🎲 탈출 굴리기'] ?? 0); - if (rolls < 15 || !stats['사기']) throw new Error(`too few real turns: ${JSON.stringify(stats)}`); - console.log(` marble: finished after ${actions} UI actions ${JSON.stringify(stats)}`); - }, - }, '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. @@ -545,184 +359,6 @@ const PLANS: Record = { console.log(` one-card: ${turns} turns (${plays} plays, ${draws} draws)${finished ? ', game finished' : ''}`); }, }, - mahjong: { - // 4인 리치 마작 (PC host + 3 phones): whoever has the turn taps the right-most legal tile (lift) and presses - // the "… 버리기" button; claim windows are answered with 패스 (론/쯔모 when offered). Plays until the first hand - // ends (win or exhaustive draw, ~70 discards), checks the result screen on every device and confirms it. - players: 4, - drive: async (pages) => { - const statusOf = (p: Page) => p.locator('.turn-status').innerText().catch(() => ''); - let discards = 0; - let passes = 0; - let wins = 0; - let results = 0; - const shotClaim = new Set(); - for (let t = 0; t < 4000 && discards < 100 && results < 1; t++) { - let acted = false; - for (const [pi, p] of pages.entries()) { - const status = await statusOf(p); - const win = p.getByRole('button', { name: /^(쯔모|론)!$/ }); - if ((await win.count()) && (await win.first().isEnabled().catch(() => false))) { - await win.first().click({ timeout: 1000 }).catch(() => {}); - wins++; - acted = true; - break; - } - const pass = p.getByRole('group', { name: '울기·론 선택' }).getByRole('button', { name: '패스' }); - if (await pass.count()) { - const dev = pi === 0 ? 'pc' : 'phone'; - if (!shotClaim.has(dev)) { - await p.screenshot({ path: join(SHOTS, `mahjong-3-claim-${dev}.png`) }); - shotClaim.add(dev); - } - await pass.click({ timeout: 1000 }).catch(() => {}); - passes++; - acted = true; - break; - } - const next = p.getByRole('button', { name: /^(다음 국으로|최종 결과 보기)$/ }); - if ((await next.count()) && (await next.isEnabled().catch(() => false))) { - // The hand is over: every device shows the result, then everyone confirms → the next hand starts. - await pages[0]!.screenshot({ path: join(SHOTS, 'mahjong-4-result-pc.png') }); - await pages[1]!.screenshot({ path: join(SHOTS, 'mahjong-4-result-phone.png'), fullPage: true }); - for (const q of pages) { - await q.getByRole('region', { name: /결과$/ }).waitFor({ timeout: 5000 }); - const b = q.getByRole('button', { name: /^(다음 국으로|최종 결과 보기)$/ }); - if (await b.isEnabled().catch(() => false)) await b.click({ timeout: 1000 }).catch(() => {}); - } - await pages[0]!.getByText(/^동1국|^동2국/).first().waitFor({ timeout: 10_000 }); - results++; - acted = true; - break; - } - if (!status.includes('내 차례예요')) continue; - const tiles = p.locator('button.mj-htile[data-legal="true"]'); - const n = await tiles.count(); - if (!n) continue; - const tile = tiles.nth(n - 1); - await tile.click({ timeout: 1000 }).catch(() => {}); - if (discards === 4 && pi > 0) await p.screenshot({ path: join(SHOTS, 'mahjong-2-selected-phone.png') }); - const send = p.getByRole('button', { name: /버리기$/ }); - if ((await send.count()) && (await send.isEnabled().catch(() => false))) await send.click({ timeout: 1000 }).catch(() => {}); - await p.waitForFunction((s) => document.querySelector('.turn-status')?.textContent?.trim() !== s, status.trim(), { timeout: 5000 }).catch(() => {}); - discards++; - acted = true; - break; - } - if (!acted) await pages[0]!.waitForTimeout(120); - } - if (results === 0) throw new Error(`no hand finished after ${discards} discards`); - // The next hand has started: the table and my hand are back on every screen. - for (const q of pages) await q.locator('.mj-table').waitFor({ timeout: 10_000 }); - console.log(` mahjong: ${discards} discards, ${passes} passes, ${wins} wins, ${results} hand finished → next hand started`); - bang: { - // PC host + 3 phone guests, reaction time 25s: real turns through the UI — draw, play usable cards - // (뱅! at a highlighted target → confirm, other untargeted cards by double tap), answer reaction - // sheets (빗나감!/맥주/결투/인디언!/잡화점), discard down to the hand limit, end the turn. - players: 4, - lobby: async (host) => { - await host.getByRole('group', { name: '반응 제한 시간' }).getByRole('button', { name: '25초' }).click(); - await host.getByRole('button', { name: '설정 저장' }).click(); - await host.getByText(/반응 25초/).first().waitFor(); - }, - drive: async (pages) => { - const stats = { turns: 0, bangs: 0, plays: 0, reactions: 0, discards: 0 }; - let finished = false; - let shot = false; - let midShot = false; - const tryClick = (l: ReturnType) => l.click({ timeout: 1500 }).then(() => true, () => false); - /** One UI step on this page; true when something was pressed. */ - const step = async (p: Page): Promise => { - const modal = p.locator('.bg-modal'); - if (await modal.count()) { - const store = modal.locator('button.bg-card'); - const title = (await modal.locator('h2').innerText().catch(() => '')).trim(); - if (title.startsWith('3장 중')) { - await store.nth(0).click(); - await store.nth(1).click(); - await modal.getByRole('button', { name: '이 2장 받기' }).click(); - } else if (title.startsWith('잡화점')) await store.first().click(); - else { - const use = modal.getByRole('button', { name: /(내기|마시기)$/ }); - if (!(await use.count()) || !(await tryClick(use.first()))) { - if (!(await tryClick(modal.getByRole('button', { name: /^(포기하고 맞기|맞기|쓰러지기)/ }).first()))) return false; - } - } - stats.reactions++; - return true; - } - const draw = p.getByRole('button', { name: '카드 더미에서 2장 받기' }); - if (await draw.count()) return tryClick(draw); - const discardBtn = p.getByRole('button', { name: /장 버리기$/ }); - if (await discardBtn.count()) { - const need = Number((await p.locator('.bg-actions').innerText()).match(/버릴 카드 (\d+)장/)?.[1] ?? 0); - const cards = p.locator('.bg-me .bg-hand button.bg-card'); - for (let i = 0; i < need; i++) await cards.nth(i).click(); - await discardBtn.click(); - stats.discards++; - return true; - } - const end = p.getByRole('button', { name: '턴 마치기' }); - if (!(await end.count()) || !(await end.isEnabled())) return false; - // Shoot first (once per turn is enough), then other untargeted cards, then end the turn. - const bang = p.locator('.bg-me button.bg-card[data-kind="bang"][data-ok="true"]').first(); - if (await bang.count()) { - await bang.click(); - const target = p.locator('button.bg-seat[data-target="ok"]').first(); - await target.waitFor({ timeout: 2000 }); - await target.click(); - const fire = p.getByRole('button', { name: /에게 뱅!$/ }); - await fire.click(); - stats.bangs++; - if (!shot) { - shot = true; - await p.waitForTimeout(400); - for (const [i, q] of pages.entries()) await q.screenshot({ path: join(SHOTS, `bang-3-shot-${i === 0 ? 'pc' : `phone${i}`}.png`) }); - } - return true; - } - const simple = ['beer', 'stagecoach', 'wellsFargo', 'saloon', 'barrel', 'mustang', 'scope', 'schofield', 'remington', 'carabine', 'winchester', 'volcanic', 'indians', 'gatling', 'generalStore']; - for (const k of simple) { - const c = p.locator(`.bg-me button.bg-card[data-kind="${k}"][data-ok="true"]`).first(); - if (!(await c.count())) continue; - await c.click(); - await c.click(); - stats.plays++; - return true; - } - await end.click(); - stats.turns++; - return true; - }; - const until = Date.now() + 240_000; - while (Date.now() < until && stats.turns < 10) { - if (await pages[0]!.getByRole('dialog', { name: '게임 결과' }).count()) { - finished = true; - break; - } - let acted = false; - for (const p of pages) { - if (await step(p)) { - acted = true; - await p.waitForTimeout(250); - break; - } - } - if (stats.turns >= 4 && !midShot) { - midShot = true; - await pages[0]!.screenshot({ path: join(SHOTS, 'bang-4-mid-pc.png') }); - await pages[1]!.screenshot({ path: join(SHOTS, 'bang-4-mid-phone.png'), fullPage: true }); - await pages[1]!.emulateMedia({ colorScheme: 'dark' }); - await pages[1]!.screenshot({ path: join(SHOTS, 'bang-4-mid-phone-dark.png') }); - await pages[1]!.emulateMedia({ colorScheme: 'light' }); - } - if (!acted) await pages[0]!.waitForTimeout(150); - } - console.log(` bang: ${stats.turns} turns, ${stats.bangs} 뱅!, ${stats.plays} other cards, ${stats.reactions} reactions, ${stats.discards} discards${finished ? ', game finished' : ''}`); - if (!finished && stats.turns < 8) throw new Error(`only ${stats.turns} turns were played`); - if (stats.bangs < 1) throw new Error('no 뱅! was played'); - }, - }, '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 "여기서 멈추기". @@ -875,6 +511,374 @@ const PLANS: Record = { ['ArrowUp', 'ArrowUp', 'Enter', 'ArrowUp', 'Enter'], ], }, + mahjong: { + // 4인 리치 마작 (PC host + 3 phones): whoever has the turn taps the right-most legal tile (lift) and presses + // the "… 버리기" button; claim windows are answered with 패스 (론/쯔모 when offered). Plays until the first hand + // ends (win or exhaustive draw, ~70 discards), checks the result screen on every device and confirms it. + players: 4, + drive: async (pages) => { + const statusOf = (p: Page) => p.locator('.turn-status').innerText().catch(() => ''); + let discards = 0; + let passes = 0; + let wins = 0; + let results = 0; + const shotClaim = new Set(); + for (let t = 0; t < 4000 && discards < 100 && results < 1; t++) { + let acted = false; + for (const [pi, p] of pages.entries()) { + const status = await statusOf(p); + const win = p.getByRole('button', { name: /^(쯔모|론)!$/ }); + if ((await win.count()) && (await win.first().isEnabled().catch(() => false))) { + await win.first().click({ timeout: 1000 }).catch(() => {}); + wins++; + acted = true; + break; + } + const pass = p.getByRole('group', { name: '울기·론 선택' }).getByRole('button', { name: '패스' }); + if (await pass.count()) { + const dev = pi === 0 ? 'pc' : 'phone'; + if (!shotClaim.has(dev)) { + await p.screenshot({ path: join(SHOTS, `mahjong-3-claim-${dev}.png`) }); + shotClaim.add(dev); + } + await pass.click({ timeout: 1000 }).catch(() => {}); + passes++; + acted = true; + break; + } + const next = p.getByRole('button', { name: /^(다음 국으로|최종 결과 보기)$/ }); + if ((await next.count()) && (await next.isEnabled().catch(() => false))) { + // The hand is over: every device shows the result, then everyone confirms → the next hand starts. + await pages[0]!.screenshot({ path: join(SHOTS, 'mahjong-4-result-pc.png') }); + await pages[1]!.screenshot({ path: join(SHOTS, 'mahjong-4-result-phone.png'), fullPage: true }); + for (const q of pages) { + await q.getByRole('region', { name: /결과$/ }).waitFor({ timeout: 5000 }); + const b = q.getByRole('button', { name: /^(다음 국으로|최종 결과 보기)$/ }); + if (await b.isEnabled().catch(() => false)) await b.click({ timeout: 1000 }).catch(() => {}); + } + await pages[0]!.getByText(/^동1국|^동2국/).first().waitFor({ timeout: 10_000 }); + results++; + acted = true; + break; + } + if (!status.includes('내 차례예요')) continue; + const tiles = p.locator('button.mj-htile[data-legal="true"]'); + const n = await tiles.count(); + if (!n) continue; + const tile = tiles.nth(n - 1); + await tile.click({ timeout: 1000 }).catch(() => {}); + if (discards === 4 && pi > 0) await p.screenshot({ path: join(SHOTS, 'mahjong-2-selected-phone.png') }); + const send = p.getByRole('button', { name: /버리기$/ }); + if ((await send.count()) && (await send.isEnabled().catch(() => false))) await send.click({ timeout: 1000 }).catch(() => {}); + await p.waitForFunction((s) => document.querySelector('.turn-status')?.textContent?.trim() !== s, status.trim(), { timeout: 5000 }).catch(() => {}); + discards++; + acted = true; + break; + } + if (!acted) await pages[0]!.waitForTimeout(120); + } + if (results === 0) throw new Error(`no hand finished after ${discards} discards`); + // The next hand has started: the table and my hand are back on every screen. + for (const q of pages) await q.locator('.mj-table').waitFor({ timeout: 10_000 }); + console.log(` mahjong: ${discards} discards, ${passes} passes, ${wins} wins, ${results} hand finished → next hand started`); + }, + }, + gostop: { + // 맞고: PC host + phone guest, 1판. Real turns through the UI — hand cards (tap twice on the phone), floor-card choices, + // 흔들기/폭탄 sheets, one 고 and then 스톱 — until the hand ends (스톱 or 나가리) and the game result shows. + lobby: async (host) => { + await host.getByRole('group', { name: '판 수' }).getByRole('button', { name: '1판', exact: true }).click(); + await host.getByRole('button', { name: '설정 저장' }).click(); + await host.getByText(/^1판 · /).first().waitFor(); + }, + drive: async (pages) => { + const [host, guest] = pages as [Page, Page]; + const state = { go: 0, stop: 0, picks: 0, plays: 0, sheets: 0 }; + let shot = false; + const until = Date.now() + 240_000; + const over = async () => (await host.getByRole('dialog', { name: '게임 결과' }).count()) > 0; + while (Date.now() < until && !(await over())) { + let acted = false; + for (const p of pages) { + const did = await gostopStep(p, state).catch(() => null); + if (did) { + acted = true; + await p.waitForTimeout(250); + break; + } + } + if (state.plays >= 8 && !shot) { + shot = true; + await host.waitForTimeout(1500); + await host.screenshot({ path: join(SHOTS, 'gostop-mid-pc.png') }); + await guest.screenshot({ path: join(SHOTS, 'gostop-mid-phone.png'), fullPage: true }); + await guest.emulateMedia({ colorScheme: 'dark' }); + await host.emulateMedia({ colorScheme: 'dark' }); + await guest.screenshot({ path: join(SHOTS, 'gostop-mid-phone-dark.png'), fullPage: true }); + await host.screenshot({ path: join(SHOTS, 'gostop-mid-pc-dark.png') }); + await guest.emulateMedia({ colorScheme: 'light' }); + await host.emulateMedia({ colorScheme: 'light' }); + } + if (!acted) await host.waitForTimeout(150); + } + if (!(await over())) throw new Error(`hand did not end (${JSON.stringify(state)})`); + await guest.getByRole('dialog', { name: '게임 결과' }).waitFor(); + await host.screenshot({ path: join(SHOTS, 'gostop-result-pc.png') }); + await guest.screenshot({ path: join(SHOTS, 'gostop-result-phone.png') }); + // Close the result sheet to see the round breakdown underneath. + await host.keyboard.press('Escape'); + await host.getByText(/1판 결과/).first().waitFor(); + await host.screenshot({ path: join(SHOTS, 'gostop-breakdown-pc.png') }); + if (state.plays < 10) throw new Error(`only ${state.plays} cards were played`); + console.log(` gostop: ${state.plays} plays, ${state.sheets} choice sheets (${state.picks} card picks), 고 ${state.go}, 스톱 ${state.stop}`); + }, + }, + bang: { + // PC host + 3 phone guests, reaction time 25s: real turns through the UI — draw, play usable cards + // (뱅! at a highlighted target → confirm, other untargeted cards by double tap), answer reaction + // sheets (빗나감!/맥주/결투/인디언!/잡화점), discard down to the hand limit, end the turn. + players: 4, + lobby: async (host) => { + await host.getByRole('group', { name: '반응 제한 시간' }).getByRole('button', { name: '25초' }).click(); + await host.getByRole('button', { name: '설정 저장' }).click(); + await host.getByText(/반응 25초/).first().waitFor(); + }, + drive: async (pages) => { + const stats = { turns: 0, bangs: 0, plays: 0, reactions: 0, discards: 0 }; + let finished = false; + let shot = false; + let midShot = false; + const tryClick = (l: ReturnType) => l.click({ timeout: 1500 }).then(() => true, () => false); + /** One UI step on this page; true when something was pressed. */ + const step = async (p: Page): Promise => { + const modal = p.locator('.bg-modal'); + if (await modal.count()) { + const store = modal.locator('button.bg-card'); + const title = (await modal.locator('h2').innerText().catch(() => '')).trim(); + if (title.startsWith('3장 중')) { + await store.nth(0).click(); + await store.nth(1).click(); + await modal.getByRole('button', { name: '이 2장 받기' }).click(); + } else if (title.startsWith('잡화점')) await store.first().click(); + else { + const use = modal.getByRole('button', { name: /(내기|마시기)$/ }); + if (!(await use.count()) || !(await tryClick(use.first()))) { + if (!(await tryClick(modal.getByRole('button', { name: /^(포기하고 맞기|맞기|쓰러지기)/ }).first()))) return false; + } + } + stats.reactions++; + return true; + } + const draw = p.getByRole('button', { name: '카드 더미에서 2장 받기' }); + if (await draw.count()) return tryClick(draw); + const discardBtn = p.getByRole('button', { name: /장 버리기$/ }); + if (await discardBtn.count()) { + const need = Number((await p.locator('.bg-actions').innerText()).match(/버릴 카드 (\d+)장/)?.[1] ?? 0); + const cards = p.locator('.bg-me .bg-hand button.bg-card'); + for (let i = 0; i < need; i++) await cards.nth(i).click(); + await discardBtn.click(); + stats.discards++; + return true; + } + const end = p.getByRole('button', { name: '턴 마치기' }); + if (!(await end.count()) || !(await end.isEnabled())) return false; + // Shoot first (once per turn is enough), then other untargeted cards, then end the turn. + const bang = p.locator('.bg-me button.bg-card[data-kind="bang"][data-ok="true"]').first(); + if (await bang.count()) { + await bang.click(); + const target = p.locator('button.bg-seat[data-target="ok"]').first(); + await target.waitFor({ timeout: 2000 }); + await target.click(); + const fire = p.getByRole('button', { name: /에게 뱅!$/ }); + await fire.click(); + stats.bangs++; + if (!shot) { + shot = true; + await p.waitForTimeout(400); + for (const [i, q] of pages.entries()) await q.screenshot({ path: join(SHOTS, `bang-3-shot-${i === 0 ? 'pc' : `phone${i}`}.png`) }); + } + return true; + } + const simple = ['beer', 'stagecoach', 'wellsFargo', 'saloon', 'barrel', 'mustang', 'scope', 'schofield', 'remington', 'carabine', 'winchester', 'volcanic', 'indians', 'gatling', 'generalStore']; + for (const k of simple) { + const c = p.locator(`.bg-me button.bg-card[data-kind="${k}"][data-ok="true"]`).first(); + if (!(await c.count())) continue; + await c.click(); + await c.click(); + stats.plays++; + return true; + } + await end.click(); + stats.turns++; + return true; + }; + const until = Date.now() + 240_000; + while (Date.now() < until && stats.turns < 10) { + if (await pages[0]!.getByRole('dialog', { name: '게임 결과' }).count()) { + finished = true; + break; + } + let acted = false; + for (const p of pages) { + if (await step(p)) { + acted = true; + await p.waitForTimeout(250); + break; + } + } + if (stats.turns >= 4 && !midShot) { + midShot = true; + await pages[0]!.screenshot({ path: join(SHOTS, 'bang-4-mid-pc.png') }); + await pages[1]!.screenshot({ path: join(SHOTS, 'bang-4-mid-phone.png'), fullPage: true }); + await pages[1]!.emulateMedia({ colorScheme: 'dark' }); + await pages[1]!.screenshot({ path: join(SHOTS, 'bang-4-mid-phone-dark.png') }); + await pages[1]!.emulateMedia({ colorScheme: 'light' }); + } + if (!acted) await pages[0]!.waitForTimeout(150); + } + console.log(` bang: ${stats.turns} turns, ${stats.bangs} 뱅!, ${stats.plays} other cards, ${stats.reactions} reactions, ${stats.discards} discards${finished ? ', game finished' : ''}`); + if (!finished && stats.turns < 8) throw new Error(`only ${stats.turns} turns were played`); + if (stats.bangs < 1) throw new Error('no 뱅! was played'); + }, + }, + 'halli-galli': { + // PC host + 2 phones. Real flips (Enter on PC, taps on phones); whenever some fruit totals exactly five a player + // rings the bell (PC: Space, phone: tap), plus at least one deliberate wrong bell. 24+ flips through the UI. + players: 3, + lobby: async (host) => { + await host.getByRole('group', { name: '뒤집기 제한 시간' }).getByRole('button', { name: '5초' }).click(); + await host.getByRole('button', { name: '설정 저장' }).click(); + await host.getByText(/뒤집기 5초/).first().waitFor(); + }, + drive: async (pages) => { + const [host, guest] = pages as [Page, Page, Page]; + const myTurn = (p: Page) => p.evaluate(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요')); + const ended = () => host.getByRole('dialog', { name: '게임 결과' }).count(); + await host.locator('.hg-seat').first().waitFor(); + // 3-second countdown first. + await host.waitForFunction(() => !document.querySelector('.hg-count'), null, { timeout: 8000 }); + let flips = 0; + let rights = 0; + let wrongs = 0; + let lates = 0; + let shots = 0; + let ringer = 0; + const until = Date.now() + 150_000; + const bubble = (p: Page) => p.locator('.hg-bubble').first().innerText({ timeout: 2500 }).catch(() => ''); + while (Date.now() < until && !(await ended()) && (flips < 24 || rights < 1 || wrongs < 1)) { + const totals = await halliTotals(host); + const five = Object.values(totals).includes(5); + const wantWrong = !five && wrongs === 0 && flips >= 4 && Object.values(totals).some((n) => n > 0); + if (five || wantWrong) { + const p = pages[ringer++ % pages.length]!; + const bell = p.locator('.hg-bell'); + if (await bell.isEnabled().catch(() => false)) { + if (p === host) { + await host.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); + await host.keyboard.press('Space'); + } else await bell.tap(); + const text = await bubble(p); + if (text.includes('땡')) rights++; + else if (text.includes('삐')) wrongs++; + else if (text.includes('늦었어요')) lates++; + if (shots === 0 && text) { + shots++; + await host.screenshot({ path: join(SHOTS, 'halli-galli-bell-pc.png') }); + await guest.screenshot({ path: join(SHOTS, 'halli-galli-bell-phone.png') }); + } + await host.waitForTimeout(400); + continue; + } + } + let flipped = false; + for (const p of pages) { + if (!(await myTurn(p))) continue; + const btn = p.getByRole('button', { name: '뒤집기', exact: true }); + if (!(await btn.isEnabled().catch(() => false))) break; + const before = await p.locator('.hg-seat[data-me="true"] .hg-meta').innerText(); + if (p === host) { + await host.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); + await host.keyboard.press('Enter'); + } else await btn.tap(); + await p.waitForFunction((b) => document.querySelector('.hg-seat[data-me="true"] .hg-meta')?.textContent !== b, before, { timeout: 3000 }).catch(() => {}); + flips++; + flipped = true; + break; + } + if (flips === 12 && flipped && shots < 2) { + shots = 2; + await host.waitForTimeout(300); + await host.screenshot({ path: join(SHOTS, 'halli-galli-mid-pc.png') }); + await guest.screenshot({ path: join(SHOTS, 'halli-galli-mid-phone.png') }); + await guest.emulateMedia({ colorScheme: 'dark' }); + await guest.waitForTimeout(400); // let the theme transition finish + await guest.screenshot({ path: join(SHOTS, 'halli-galli-mid-phone-dark.png') }); + await guest.emulateMedia({ colorScheme: 'light' }); + await host.emulateMedia({ colorScheme: 'dark' }); + await host.waitForTimeout(400); + await host.screenshot({ path: join(SHOTS, 'halli-galli-mid-pc-dark.png') }); + await host.emulateMedia({ colorScheme: 'light' }); + } + await host.waitForTimeout(flipped ? 650 : 120); + } + console.log(` halli-galli: ${flips} UI flips, ${rights} correct bells, ${wrongs} wrong bells, ${lates} late`); + if (!(await ended()) && (flips < 20 || wrongs < 1)) throw new Error('not enough flips / bells'); + if (rights < 1 && !(await ended())) throw new Error('no correct bell happened'); + }, + }, + marble: { + // PC host + phone guest, 15 rounds (shortest limit) played through the UI to the result: roll, buy or + // build (skip when short), pay tolls, sell when short, space travel via a board square. Mid-game shots. + lobby: async (host) => { + await host.getByRole('group', { name: '라운드 제한' }).getByRole('button', { name: '15라운드' }).click(); + await host.getByRole('group', { name: '시작 포인트' }).getByRole('button', { name: '200P' }).click(); + await host.getByRole('button', { name: '설정 저장' }).click(); + await host.getByText(/^15라운드 · 시작 200P/).first().waitFor(); + }, + drive: async (pages) => { + const [host, guest] = pages as [Page, Page]; + const stats: Record = {}; + let actions = 0; + let shot = false; + const until = Date.now() + 420_000; + const over = () => host.getByRole('dialog', { name: '게임 결과' }).count(); + while (Date.now() < until && !(await over())) { + let acted = false; + for (const p of pages) { + if (await marbleStep(p, stats)) { + acted = true; + actions++; + await p.waitForTimeout(80); + break; + } + } + if (actions >= 40 && !shot && (stats['사기'] ?? 0) >= 3) { + shot = true; + await host.waitForTimeout(1300); + await host.screenshot({ path: join(SHOTS, 'marble-mid-pc.png') }); + await guest.screenshot({ path: join(SHOTS, 'marble-mid-phone.png') }); + await guest.screenshot({ path: join(SHOTS, 'marble-mid-phone-full.png'), fullPage: true }); + await host.emulateMedia({ colorScheme: 'dark' }); + await guest.emulateMedia({ colorScheme: 'dark' }); + await host.waitForTimeout(400); // let the button colour transitions settle + await host.screenshot({ path: join(SHOTS, 'marble-mid-pc-dark.png') }); + await guest.screenshot({ path: join(SHOTS, 'marble-mid-phone-dark.png') }); + await host.emulateMedia({ colorScheme: 'light' }); + await guest.emulateMedia({ colorScheme: 'light' }); + } + if (acted && !stats.toll && (await host.locator('.mb-log li', { hasText: /→.*통행료/ }).count())) stats.toll = 1; + if (!acted) await host.waitForTimeout(120); + } + if (!(await over())) throw new Error(`game did not finish (${actions} UI actions: ${JSON.stringify(stats)})`); + await guest.getByRole('dialog', { name: '게임 결과' }).waitFor(); + await host.waitForTimeout(600); // result dialog fade-in + await host.screenshot({ path: join(SHOTS, 'marble-result-pc.png') }); + await guest.screenshot({ path: join(SHOTS, 'marble-result-phone.png') }); + const rolls = (stats['🎲 주사위 굴리기'] ?? 0) + (stats['🎲 한 번 더 굴리기'] ?? 0) + (stats['🎲 탈출 굴리기'] ?? 0); + if (rolls < 15 || !stats['사기']) throw new Error(`too few real turns: ${JSON.stringify(stats)}`); + console.log(` marble: finished after ${actions} UI actions ${JSON.stringify(stats)}`); + }, + }, }; const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS);