From 6321ea8c0d8e336e2795e3bf82844ac800f34587 Mon Sep 17 00:00:00 2001 From: EJClaw Date: Mon, 5 Oct 2026 04:48:19 +0900 Subject: [PATCH] =?UTF-8?q?M4:=20=EA=B0=80=EC=A1=B1=20=EA=B2=8C=EC=9E=84?= =?UTF-8?q?=205=EC=A2=85=20=ED=95=A9=EC=B9=98=EA=B8=B0,=20E2E=20=EA=B3=84?= =?UTF-8?q?=ED=9A=8D=20=EC=A0=95=EB=A6=AC,=20=EC=84=AF=EB=8B=A4=20E2E=20?= =?UTF-8?q?=EC=9E=AC=EA=B2=BD=EA=B8=B0=20=EB=8C=80=EC=9D=91,=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=A0=9C=ED=95=9C=2060=EC=B4=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 윷놀이·원카드·숫자 타일·다빈치 코드·요트 다이스 브랜치 병합(등록 목록·E2E 계획 충돌 정리) - 섯다 E2E: 동점·구사 재경기가 나오면 결과가 나올 때까지 계속 체크 - bun run test: 무작위 1,000판 테스트가 부하 때 5초 기본 제한을 넘던 문제 → --timeout 60000 - 로드맵 M4 결과, README 확인 명령 갱신 Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- apps/web/src/lib/catalog-ui.ts | 14 +- docs/12-roadmap.md | 4 + e2e/games.e2e.ts | 319 +++++++++++++++++---------------- package.json | 2 +- 5 files changed, 171 insertions(+), 172 deletions(-) diff --git a/README.md b/README.md index 3c7e16d..4949b68 100644 --- a/README.md +++ b/README.md @@ -14,11 +14,11 @@ bun run --filter '@bg/web' dev # 웹 :5173 (API/WS는 :3000으로 프록 ## 확인 ```bash bun run typecheck -bun test # 규칙·서버 통합·방 단위 테스트 +bun run test # 규칙·서버 통합·방 단위 테스트 (무작위 대국이 길어 제한 60초) (cd apps/web && bunx vite build) # 웹 빌드 bun e2e/omok.e2e.ts # 브라우저 두 개로 오목 한 판 (Chrome 필요) bun e2e/admin.e2e.ts # 관리자: 슈퍼어드민 로그인 → 규칙 변경·잠금 → 새 방 반영 -bun e2e/games.e2e.ts # 오셀로·체스·바둑·장기: 방 만들기 → 친구 입장 → 첫 수 +bun e2e/games.e2e.ts [게임id...] # 게임별: 방 만들기 → 친구 입장 → 실제 화면 조작으로 여러 턴(일부는 끝까지) bun e2e/a11y.e2e.ts # 접근성(axe) 검사 bun scripts/loadtest.ts --help # 부하·장애 주입 (docs/reports 참고) ``` diff --git a/apps/web/src/lib/catalog-ui.ts b/apps/web/src/lib/catalog-ui.ts index 86f11d2..0124b59 100644 --- a/apps/web/src/lib/catalog-ui.ts +++ b/apps/web/src/lib/catalog-ui.ts @@ -9,21 +9,9 @@ export const GAME_ICON: Record = { yut: '🪵', othello: '◐', yacht: '🎲', -}; - -/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */ -export const UPCOMING = ['마작', '고스톱', '뱅', '할리갈리']; 'one-card': '♣', -}; - -/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */ -export const UPCOMING = ['마작', '고스톱', '뱅', '할리갈리']; rummikub: '🔢', -}; - -/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */ -export const UPCOMING = ['마작', '고스톱', '뱅', '할리갈리']; - 'davinci-code': '🔢', + 'davinci-code': '🔐', }; /** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */ diff --git a/docs/12-roadmap.md b/docs/12-roadmap.md index a3d40ae..97a95ee 100644 --- a/docs/12-roadmap.md +++ b/docs/12-roadmap.md @@ -38,6 +38,10 @@ ## M4. 가족 게임 - 윷놀이, 원카드(+색깔 카드 모드), 숫자 타일(루미큐브 방식), 다빈치 코드, 요트 다이스. +- **진행 결과(2026-10-05)**: 다섯 게임 규칙 + 화면 완료. 규칙 테스트 윷놀이 33·원카드 53·숫자 타일 56·다빈치 코드 31·요트 36, + 게임마다 무작위 플레이 1,000판(말·카드·타일 보존, 비공개 정보 유출 없음, 모든 상태에서 시간 초과 행동 합법). + 윷놀이 팀전(짝수 인원) 검사를 위해 엔진에 `playerCountError(options, count)`를 추가하고 방 시작 시 확인. + E2E: 윷놀이·요트 한 판 끝까지, 원카드 3인 60턴, 숫자 타일·다빈치 코드 여러 턴(PC 방장 + 휴대폰). ## M5. 마작·고스톱 - 리치 마작(4인, 3인), 점수 계산기 테스트(역·부 계산 사례 100개 이상), 초보 모드. diff --git a/e2e/games.e2e.ts b/e2e/games.e2e.ts index 8837032..8a6fd27 100644 --- a/e2e/games.e2e.ts +++ b/e2e/games.e2e.ts @@ -27,8 +27,6 @@ interface Plan { moves?: string[][]; /** Card games: custom driver over all pages (host first). */ drive?: (pages: Page[]) => Promise; - /** Host changes game options in the lobby before starting. */ - lobby?: (host: Page) => Promise; } /** Card games: whoever has the turn presses the first available safe action (check/call/…) until `rounds` actions were taken. */ @@ -57,71 +55,6 @@ async function finishHand(pages: Page[]) { for (const p of pages) await p.getByText('이번 판 결과').first().waitFor({ timeout: 10_000 }); } -/** - * 윷놀이: one UI step for whoever can act on `p` — press the throw button, or pick a result chip → - * a glowing piece (or the waiting-piece tray) → "이동하기" (the two-step move confirmation). - */ -async function yutStep(p: Page): Promise { - const throwBtn = p.getByRole('button', { name: /윷 던지기|한 번 더 던지기/ }); - if ((await throwBtn.count()) && (await throwBtn.isEnabled().catch(() => false))) { - return throwBtn.click({ timeout: 1500 }).then(() => true, () => false); - } - const chip = p.locator('.yut-chip:not([disabled])').first(); - if (!(await chip.count())) return false; - try { - await chip.click({ timeout: 1500 }); - const src = p.locator('g.yut-hit[tabindex="0"], .yut-tray-btn.can').first(); - await src.waitFor({ timeout: 2000 }); - // Keyboard: Enter on a piece shows where it lands, Enter again on the same piece moves it. - const h = await src.elementHandle({ timeout: 1000 }); - await h!.focus(); - await p.keyboard.press('Enter'); - await p.getByRole('button', { name: /^이동하기/ }).waitFor({ timeout: 1500 }); - await h!.focus(); - await p.keyboard.press('Enter'); - return true; - } catch { - return false; - } -} - -const PLANS: Record = { - 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`); /** 숫자 타일: whoever has the turn tries "도와줘" → "새 묶음" → "확정"; otherwise takes one tile. */ async function rummikubTurns(pages: Page[], turns: number) { const stats = { plays: 0, draws: 0, undo: false }; @@ -189,6 +122,34 @@ async function rummikubTurns(pages: Page[], turns: number) { return stats; } +/** + * 윷놀이: one UI step for whoever can act on `p` — press the throw button, or pick a result chip → + * a glowing piece (or the waiting-piece tray) → "이동하기" (the two-step move confirmation). + */ +async function yutStep(p: Page): Promise { + const throwBtn = p.getByRole('button', { name: /윷 던지기|한 번 더 던지기/ }); + if ((await throwBtn.count()) && (await throwBtn.isEnabled().catch(() => false))) { + return throwBtn.click({ timeout: 1500 }).then(() => true, () => false); + } + const chip = p.locator('.yut-chip:not([disabled])').first(); + if (!(await chip.count())) return false; + try { + await chip.click({ timeout: 1500 }); + const src = p.locator('g.yut-hit[tabindex="0"], .yut-tray-btn.can').first(); + await src.waitFor({ timeout: 2000 }); + // Keyboard: Enter on a piece shows where it lands, Enter again on the same piece moves it. + const h = await src.elementHandle({ timeout: 1000 }); + await h!.focus(); + await p.keyboard.press('Enter'); + await p.getByRole('button', { name: /^이동하기/ }).waitFor({ timeout: 1500 }); + await h!.focus(); + await p.keyboard.press('Enter'); + return true; + } catch { + return false; + } +} + const PLANS: Record = { rummikub: { // PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI. @@ -202,27 +163,126 @@ const PLANS: Record = { if (stats.plays + stats.draws < 10) throw new Error('not enough turns'); }, }, - seotda: { - // 3 players, default 2장 섯다 (1장씩 두 번): everyone checks both betting rounds → showdown. - players: 3, + 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) => { - await pressActions(pages, /^(체크|콜)/, 6); - await finishHand(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`); }, }, - poker: { - // 3 players, default 7포커: 초이스 (discard 1st card, open 2nd), then everyone checks 4 streets → showdown. + '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) => { - 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(); + 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); } - await pressActions(pages, /^체크/, 12); - await finishHand(pages); + 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: { @@ -273,86 +333,33 @@ const PLANS: Record = { 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'); - '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. + }, + }, + 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) => { - 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); + await pressActions(pages, /^(체크|콜)/, 6); + for (let extra = 0; extra < 30 && !(await pages[0]!.getByText('이번 판 결과').count()); extra++) { + await pressActions(pages, /^(체크|콜)/, 1).catch(() => {}); } - 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 "여기서 멈추기". + 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) { - await p.getByRole('button', { name: '검정 2개 · 흰색 2개' }).click(); - await p.getByRole('button', { name: '이렇게 받기' }).click(); + 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(); } - 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(); + await pressActions(pages, /^체크/, 12); + await finishHand(pages); }, }, othello: { diff --git a/package.json b/package.json index 649eb60..d0919ad 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "dev": "bun run --filter '@bg/server' dev & bun run --filter '@bg/web' dev", "build": "bun run --filter '@bg/web' build", "typecheck": "tsc -p packages/engine && tsc -p packages/shared && tsc -p packages/games && tsc -p apps/server && tsc -p apps/web", - "test": "bun test" + "test": "bun test --timeout 60000" }, "devDependencies": { "@types/bun": "1.4.2",