Merge branch 'm8/catchmind' into codex/owner/grp_discord_joke-app
# Conflicts: # apps/web/src/games/registry.ts # apps/web/src/lib/catalog-ui.ts # e2e/games.e2e.ts # packages/games/src/index.ts # packages/shared/src/catalog.ts
This commit is contained in:
141
e2e/games.e2e.ts
141
e2e/games.e2e.ts
@@ -671,6 +671,119 @@ async function dobbleTap(p: Page, wrong: boolean): Promise<string | null> {
|
||||
return bubble;
|
||||
}
|
||||
|
||||
/** 그림 맞히기: non-white pixels on a page's drawing canvas (proves streamed strokes were rendered). */
|
||||
async function cmInk(p: Page): Promise<number> {
|
||||
return p.evaluate(() => {
|
||||
const c = document.querySelector<HTMLCanvasElement>('.cm-paper canvas');
|
||||
if (!c || !c.width) return 0;
|
||||
const data = c.getContext('2d')!.getImageData(0, 0, c.width, c.height).data;
|
||||
let n = 0;
|
||||
for (let i = 0; i < data.length; i += 16) if (data[i]! < 200 || data[i + 1]! < 200 || data[i + 2]! < 200) n++;
|
||||
return n;
|
||||
});
|
||||
}
|
||||
|
||||
/** 그림 맞히기: one full turn — the drawer picks a word and draws with the mouse, the other sees it, guesses wrong, close, then right. */
|
||||
async function catchmindTurn(pages: Page[], turn: number, shots: boolean): Promise<string> {
|
||||
const tag = turn === 1 ? '' : `${turn}`;
|
||||
const status = (p: Page) => p.locator('.turn-status').innerText();
|
||||
let drawer: Page | null = null;
|
||||
for (let t = 0; t < 150 && !drawer; t++) {
|
||||
for (const p of pages) if ((await status(p)).includes('그릴 단어를 골라 주세요')) drawer = p;
|
||||
if (!drawer) await pages[0]!.waitForTimeout(100);
|
||||
}
|
||||
if (!drawer) throw new Error(`turn ${turn}: nobody is choosing a word`);
|
||||
const guesser = pages.find((p) => p !== drawer)!;
|
||||
await guesser.getByText(/단어를 고르고 있어요/).first().waitFor();
|
||||
const pick = drawer.locator('.cm-choices button').nth(1);
|
||||
const picked = ((await pick.getAttribute('aria-label')) ?? '').split(' (')[0]!;
|
||||
await pick.click();
|
||||
// The plan reads the answer from the drawer's screen.
|
||||
const answer = drawer.locator('.cm-hint .answer');
|
||||
await answer.waitFor();
|
||||
const word = (await answer.innerText()).replace(/^정답:\s*/, '').trim();
|
||||
if (word !== picked) throw new Error(`picked ${picked} but the drawer sees ${word}`);
|
||||
const hidden = await guesser.locator('.cm-hint').innerText();
|
||||
if (hidden.includes(word)) throw new Error('the guesser sees the answer');
|
||||
// Real mouse strokes on the canvas: a red zig-zag, a blue circle-ish, a thick black line.
|
||||
const canvas = drawer.locator('.cm-paper canvas');
|
||||
const stroke = async (pts: (readonly [number, number])[]) => {
|
||||
// Tool buttons may have scrolled the page (phone): measure the canvas right before each stroke.
|
||||
await canvas.scrollIntoViewIfNeeded();
|
||||
const box = (await canvas.boundingBox())!;
|
||||
const at = (fx: number, fy: number) => [box.x + box.width * fx, box.y + box.height * fy] as const;
|
||||
await drawer!.mouse.move(...at(...pts[0]!));
|
||||
await drawer!.mouse.down();
|
||||
for (const pt of pts.slice(1)) await drawer!.mouse.move(...at(...pt), { steps: 6 });
|
||||
await drawer!.mouse.up();
|
||||
};
|
||||
await drawer.getByRole('button', { name: '빨강' }).click();
|
||||
await stroke([[0.2, 0.2], [0.35, 0.4], [0.5, 0.2], [0.65, 0.4], [0.8, 0.2]]);
|
||||
await drawer.getByRole('button', { name: '파랑' }).click();
|
||||
await stroke(Array.from({ length: 13 }, (_, i) => [0.5 + 0.2 * Math.cos((i / 12) * Math.PI * 2), 0.6 + 0.2 * Math.sin((i / 12) * Math.PI * 2)] as const));
|
||||
await drawer.getByRole('button', { name: '검정' }).click();
|
||||
await drawer.getByRole('button', { name: '굵기 아주 굵게' }).click();
|
||||
await stroke([[0.15, 0.9], [0.85, 0.9]]);
|
||||
// The guesser's canvas shows the same strokes (stroke count + ink).
|
||||
await guesser.waitForFunction(() => Number(document.querySelector('.cm-paper canvas')?.getAttribute('data-strokes')) >= 3, null, { timeout: 8000 });
|
||||
const inkD = await cmInk(drawer);
|
||||
let inkG = 0;
|
||||
for (let t = 0; t < 30 && inkG < 300; t++) {
|
||||
inkG = await cmInk(guesser);
|
||||
if (inkG < 300) await guesser.waitForTimeout(100);
|
||||
}
|
||||
if (inkD < 300 || inkG < 300) throw new Error(`turn ${turn}: no ink (drawer ${inkD}, guesser ${inkG})`);
|
||||
if (shots) {
|
||||
await drawer.waitForTimeout(300);
|
||||
for (const [scheme, suffix] of (turn === 1 ? [['light', ''], ['dark', '-dark']] : [['light', '']]) as [string, string][]) {
|
||||
await drawer.emulateMedia({ colorScheme: scheme as 'light' | 'dark' });
|
||||
await guesser.emulateMedia({ colorScheme: scheme as 'light' | 'dark' });
|
||||
await drawer.waitForTimeout(250);
|
||||
const [pc, phone] = pages[0] === drawer ? [drawer, guesser] : [guesser, drawer];
|
||||
await pc.screenshot({ path: join(SHOTS, `catchmind-draw${tag}-pc${suffix}.png`) });
|
||||
await phone.screenshot({ path: join(SHOTS, `catchmind-draw${tag}-phone${suffix}.png`) });
|
||||
await phone.screenshot({ path: join(SHOTS, `catchmind-draw${tag}-phone-full${suffix}.png`), fullPage: true });
|
||||
}
|
||||
await drawer.emulateMedia({ colorScheme: 'light' });
|
||||
await guesser.emulateMedia({ colorScheme: 'light' });
|
||||
}
|
||||
const input = guesser.locator('.cm-form input');
|
||||
await guesser.getByLabel('정답', { exact: true }).waitFor();
|
||||
const send = async (text: string) => {
|
||||
await input.fill(text);
|
||||
await input.press('Enter');
|
||||
};
|
||||
// Wrong guess: visible to the drawer too.
|
||||
await send('모르겠어요');
|
||||
await drawer.locator('.cm-chat-list').getByText('모르겠어요').first().waitFor();
|
||||
// Close guess (last syllable's final consonant changed): "아까워요" for the guesser only.
|
||||
const close = await guesser.evaluate((w) => {
|
||||
const c = w.charCodeAt(w.length - 1) - 0xac00;
|
||||
if (c < 0 || c > 11171) return null;
|
||||
const jong = c % 28;
|
||||
if ([3, 5, 6, 9, 10, 11, 12, 13, 14, 15, 18].includes(jong)) return null; // double final: distance 2
|
||||
return w.slice(0, -1) + String.fromCharCode(0xac00 + (jong === 0 ? c + 4 : c - jong));
|
||||
}, word);
|
||||
if (close) {
|
||||
await guesser.waitForTimeout(400); // per-player limit: 3 guesses a second
|
||||
await send(close);
|
||||
await guesser.locator('.cm-close').waitFor({ timeout: 5000 });
|
||||
if (await drawer.locator('.cm-close').count()) throw new Error('drawer sees the close hint');
|
||||
}
|
||||
await guesser.waitForTimeout(400);
|
||||
await send(`${word}!`);
|
||||
await guesser.locator('.cm-pop').waitFor({ timeout: 5000 });
|
||||
if ((await input.inputValue()) !== '') throw new Error('the answer stayed in the input box');
|
||||
await drawer.locator('.cm-over .word', { hasText: word }).waitFor();
|
||||
await guesser.locator('.cm-over .word', { hasText: word }).waitFor();
|
||||
if (shots) {
|
||||
const [pc, phone] = pages[0] === drawer ? [drawer, guesser] : [guesser, drawer];
|
||||
await pc.screenshot({ path: join(SHOTS, `catchmind-reveal${tag}-pc.png`) });
|
||||
await phone.screenshot({ path: join(SHOTS, `catchmind-reveal${tag}-phone.png`) });
|
||||
}
|
||||
return word;
|
||||
}
|
||||
|
||||
const PLANS: Record<string, Plan> = {
|
||||
rummikub: {
|
||||
// PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI.
|
||||
@@ -1697,6 +1810,34 @@ const PLANS: Record<string, Plan> = {
|
||||
console.log(` dobble: finished after ${st.ok} correct taps, ${st.wrong} wrong, ${st.late} late, ${st.other} other`);
|
||||
},
|
||||
},
|
||||
catchmind: {
|
||||
// PC host + phone guest, 1 round of 60 s: each player draws once with real mouse strokes; the other sees the strokes
|
||||
// (stroke count + pixels), guesses wrong, gets "아까워요" for a near miss, then the right answer; scores update.
|
||||
lobby: async (host) => {
|
||||
await host.getByRole('group', { name: '라운드 수' }).getByRole('button', { name: '1라운드' }).click();
|
||||
await host.getByRole('group', { name: '그리기 시간' }).getByRole('button', { name: '60초' }).click();
|
||||
await host.getByRole('button', { name: '설정 저장' }).click();
|
||||
await host.getByText(/^여러 명 맞히기 · 1라운드 · 60초/).first().waitFor();
|
||||
},
|
||||
drive: async (pages) => {
|
||||
const [host, guest] = pages as [Page, Page];
|
||||
const words: string[] = [];
|
||||
for (let turn = 1; turn <= 2; turn++) {
|
||||
words.push(await catchmindTurn(pages, turn, true));
|
||||
// Multi mode: 1st solver 10 points, drawer 2 per solver.
|
||||
const scores = await host.locator('.cm-score').innerText();
|
||||
if (turn === 1 && !(/10점/.test(scores) && /2점/.test(scores))) throw new Error(`scores not updated: ${scores.replace(/\n/g, ' ')}`);
|
||||
}
|
||||
await host.getByRole('dialog', { name: '게임 결과' }).waitFor({ timeout: 20_000 });
|
||||
await guest.getByRole('dialog', { name: '게임 결과' }).waitFor();
|
||||
await host.waitForTimeout(500);
|
||||
await host.screenshot({ path: join(SHOTS, 'catchmind-result-pc.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'catchmind-result-phone.png') });
|
||||
const final = await host.locator('.cm-score').innerText();
|
||||
if ((final.match(/12점/g) ?? []).length !== 2) throw new Error(`unexpected final scores: ${final.replace(/\n/g, ' ')}`);
|
||||
console.log(` catchmind: drew and guessed ${words.join(', ')}; both 12 points (10 + 2)`);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS);
|
||||
|
||||
Reference in New Issue
Block a user