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);