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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user