M8: 도블 규칙·화면·테스트

- packages/games/src/dobble: 7차 사영평면 덱(57장·57그림, 55장 모드), 카드별 레이아웃 6템플릿,
  미니게임 4종(탑 쌓기·우물·뜨거운 감자·독이 든 선물), 서버 도착 순서 판정과 "늦었어요",
  틀림 잠금(serverTs 기준), 지연 보정 light(60ms 창·보정 상한 80ms), 45초 정체 힌트,
  자리 비움 3회 → 우물·뜨거운 감자 기권, 봇 의심(방장에게만), 순위 점수 3·2·1
- 테스트: §10 체크리스트 전부, 시드 1,000판 + 엔진 randomPlayout(카드 보존·비공개 누출·결정성·onTimeout 합법),
  방 단위 테스트(같은 ms 두 정답, 시스템 행동 거절, 지연 보정 창, 끊긴 사람 기권)
- 화면 apps/web/src/games/dobble: 자체 제작 SVG 그림 57종, 두 원형 카드(PC 좌우·휴대폰 위아래),
  누르는 즉시 판정 표시(찾았어요·틀렸어요 흔들림+잠금 원·늦었어요), 카드가 막 바뀐 직후 탭은 늦음 처리,
  상대 고르기(뜨거운 감자·독이 든 선물), 핑 표시, 키보드 조작, 라이트/다크, 움직임 줄이기
- 등록: GAMES, CATALOG(도블), 화면 레지스트리, 아이콘
- E2E: PC 방장 + 휴대폰 친구, 탑 쌓기 55장을 실제 탭으로 끝까지(오답 1회 포함)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-06 00:33:29 +09:00
parent bc199de10b
commit 953ae90546
13 changed files with 3674 additions and 0 deletions

View File

@@ -641,6 +641,36 @@ async function ninetyNineStep(p: Page, stats: Record<string, number>): Promise<b
return true;
}
/**
* 도블: one real tap on `p`'s own screen. Reads the pictures of the two compared cards from the DOM (data-art), taps the shared one
* (or, when `wrong`, a picture of my card that is not on the other). PC: focus + Enter, phone: tap. Returns the judgement shown.
*/
async function dobbleTap(p: Page, wrong: boolean): Promise<string | null> {
const arts = (side: string) => p.locator(`[data-card="${side}"] .db-sym`).evaluateAll((els) => els.map((e) => Number(e.getAttribute('data-art'))));
const mine = p.locator('[data-card="mine"] .db-sym:not([disabled])');
if ((await mine.count()) === 0) return null;
const [a, b] = await Promise.all([arts('center'), arts('mine')]);
const shared = b.filter((x) => a.includes(x));
if (shared.length !== 1) return null; // the cards changed between the two reads
const art = wrong ? b.find((x) => x !== shared[0])! : shared[0]!;
const key = await p.locator('[data-card="center"]').getAttribute('data-key');
const old = (await p.locator('.db-bubble').getAttribute('data-bubble', { timeout: 100 }).catch(() => null)) ?? '';
const btn = p.locator(`[data-card="mine"] .db-sym[data-art="${art}"]`);
if (tag(p) === 'pc') {
await btn.focus();
await p.keyboard.press('Enter');
} else await btn.tap();
// My own judgement is a new bubble (another one may still be showing).
const bubble = await p
.locator(`.db-bubble:not([data-bubble="${old}"])`)
.first()
.innerText({ timeout: 1500 })
.then((t) => t.trim())
.catch(() => '');
if (!wrong) await p.waitForFunction((k) => document.querySelector('[data-card="center"]')?.getAttribute('data-key') !== k, key, { timeout: 1500 }).catch(() => {});
return bubble;
}
const PLANS: Record<string, Plan> = {
rummikub: {
// PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI.
@@ -1606,6 +1636,67 @@ const PLANS: Record<string, Plan> = {
console.log(` ninety-nine: finished after ${turns} UI turns ${JSON.stringify(stats)}, ${outs} eliminations in the log`);
},
},
dobble: {
// PC host + phone guest, 탑 쌓기 with the 55-card deck and a 1-second lock. Both players find the shared picture of their own card and the centre
// card on their own screen (PC: keyboard focus + Enter, phone: tap), one deliberate wrong tap (shake + lock), until the draw pile runs out.
lobby: async (host) => {
await host.getByRole('button', { name: '▼ 자세한 설정' }).click();
await host.getByRole('group', { name: '카드 장수' }).getByRole('button', { name: '55장' }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/^탑 쌓기 · 55장 · 틀리면 1초 잠금/).first().waitFor();
},
drive: async (pages) => {
const [host, guest] = pages as [Page, Page];
const over = () => host.getByRole('dialog', { name: '게임 결과' }).count();
await host.locator('[data-card="mine"] .db-sym:not([disabled])').first().waitFor({ timeout: 10_000 });
const st = { ok: 0, wrong: 0, late: 0, other: 0 };
let n = 0;
let shots = 0;
const until = Date.now() + 180_000;
while (Date.now() < until && !(await over())) {
const p = pages[n++ % pages.length]!;
const wantWrong = st.wrong === 0 && st.ok >= 3 && p === guest;
const text = await dobbleTap(p, wantWrong);
if (text === null) {
await host.waitForTimeout(150);
continue;
}
if (text.startsWith('찾았어요')) st.ok++;
else if (text.includes('틀렸어요')) {
st.wrong++;
await guest.screenshot({ path: join(SHOTS, 'dobble-wrong-phone.png') });
if (!(await guest.locator('.db-lock').count())) throw new Error('no lock after a wrong tap');
} else if (text.includes('늦었어요')) st.late++;
else {
st.other++;
if (st.other <= 3) console.log(` dobble: other judgement ${JSON.stringify(text)}`);
}
// Human pace: a tap right after the cards changed counts as aimed at the old card (the screen drops it as late).
await host.waitForTimeout(300);
if (shots === 0 && st.ok >= 6) {
shots = 1;
await host.waitForTimeout(500);
await host.screenshot({ path: join(SHOTS, 'dobble-mid-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'dobble-mid-phone.png') });
await guest.screenshot({ path: join(SHOTS, 'dobble-mid-phone-full.png'), fullPage: true });
await host.emulateMedia({ colorScheme: 'dark' });
await guest.emulateMedia({ colorScheme: 'dark' });
await host.waitForTimeout(400);
await host.screenshot({ path: join(SHOTS, 'dobble-mid-pc-dark.png') });
await guest.screenshot({ path: join(SHOTS, 'dobble-mid-phone-dark.png') });
await host.emulateMedia({ colorScheme: 'light' });
await guest.emulateMedia({ colorScheme: 'light' });
}
}
if (!(await over())) throw new Error(`game did not finish ${JSON.stringify(st)}`);
await guest.getByRole('dialog', { name: '게임 결과' }).waitFor();
await host.waitForTimeout(600);
await host.screenshot({ path: join(SHOTS, 'dobble-result-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'dobble-result-phone.png') });
if (st.ok < 20 || st.wrong < 1) throw new Error(`too few real finds / wrong taps: ${JSON.stringify(st)}`);
console.log(` dobble: finished after ${st.ok} correct taps, ${st.wrong} wrong, ${st.late} late, ${st.other} other`);
},
},
};
const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS);