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:
EJClaw
2026-10-06 00:39:17 +09:00
17 changed files with 4313 additions and 18 deletions

View File

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