M8: 그림 맞히기 규칙·화면·테스트

- packages/games/src/catchmind: 규칙 모듈(출제 순서·단어 풀 RNG, 고르기/그리기/공개 단계,
  정답 정규화(NFKC·제로폭·끝말·초성 차단·정답 포함 차단·자모 거리 "아까워요"), multi/firstOnly 점수,
  힌트 50%/75%, 신고 과반, 출제자 연결 끊김 10초, 자리 비움 건너뛰기와 "돌아왔어요", 결과 순위),
  단어 740개(words.ko.json, 10개 분야·쉬움/보통, 어린이 안전), 테스트 34개(§10 전부 + 무작위 1,000판)
- 그림 스트리밍: draw 액션은 canvasOp 이벤트만 'stream' 메시지로 중계하고 view에는 개수만,
  (재)접속·sync 때만 syncView로 전체 그림 기록 전송. 스트림 액션 로그는 묶어서(최대 1초) 저장
- 엔진/서버 최소 확장(하위 호환): validate(..., ctx?: {now}), isStreamAction, syncView,
  disconnectGraceMs 선택 훅, ServerMessage 'stream', RoomStore.save 로그 배열 허용
- apps/web/src/games/catchmind: 캔버스 그리기(포인터·coalesced 이벤트, 색 12·굵기 4·지우개·되돌리기·
  전체 지우기), 게이트웨이 한도 아래로 맞춘 배치 전송, 중간점 곡선 렌더링, 힌트 줄·시간 막대·점수판,
  정답/채팅 입력과 "아까워요"·"정답!" 표시, 단어 고르기, 그림 PNG 저장, 옵션·규칙
- 등록(GAMES, 카탈로그, 화면 레지스트리, 아이콘), 방 단위 테스트 4개, E2E 계획(PC+폰 실제 그리기·맞히기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-06 00:38:30 +09:00
parent bc199de10b
commit 16d3c86486
17 changed files with 4313 additions and 18 deletions

View File

@@ -641,6 +641,119 @@ async function ninetyNineStep(p: Page, stats: Record<string, number>): Promise<b
return true;
}
/** 그림 맞히기: 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.
@@ -1606,6 +1719,34 @@ const PLANS: Record<string, Plan> = {
console.log(` ninety-nine: finished after ${turns} UI turns ${JSON.stringify(stats)}, ${outs} eliminations in the log`);
},
},
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);