M7: 바퀴벌레 포커 규칙·화면·테스트

- 규칙 모듈(packages/games/src/cockroach-poker): 64장(8종×8) 셔플·좌석순 분배, 2인 10장 비공개 제거,
  offer/call/peek/pass 흐름, seen 규칙, 2인 판정 전용(twoPlayerPass 하우스 룰은 1회 되돌려 주기),
  같은 종류 loseCount장(자동: 2인 5, 3~6인 4)·손패 0장 시작자·eightKindsRule 패배,
  시간 초과 자동 행동(§8.5)과 3연속 시간 초과 시 자리 비움(5초), 나가면 그 사람 패배,
  빠른 말(say, 라운드당 3번), view는 남의 손패·미확인자에게 건네는 카드·제거 카드를 숨김
- 테스트: §10 시나리오 전부, randomPlayout 200판 + 빠른 루프 800판(카드 보존·유출·onTimeout 합법·재생 결정성)
- 화면(apps/web/src/games/cockroach-poker): 자체 SVG 벌레 8종, 앞마당 종류별 배지(경고/위험),
  건네기 3단계(카드→사람→주장), 판정 시트(진짜!/거짓말!/몰래 보고 넘기기), 넘긴 순서·본 사람 표시
- 등록: GAMES, CATALOG, 웹 registry, GAME_ICON / E2E 플랜(PC 방장 + 휴대폰 2명, 끝까지 진행)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-05 14:46:30 +09:00
parent 559fb8507c
commit 3e414e62ca
8 changed files with 2422 additions and 0 deletions

View File

@@ -260,6 +260,55 @@ async function marbleStep(p: Page, stats: Record<string, number>): Promise<strin
return null;
}
/**
* 바퀴벌레 포커: one UI step for whoever must act on `p` — offer (card → person → claim → confirm), pass after
* peeking (person → claim → confirm) or answer an offer (진짜!/거짓말!, sometimes 몰래 보고 넘기기).
*/
async function cockroachStep(p: Page, stats: Record<string, number>): Promise<string | null> {
const status = await p.locator('.turn-status').innerText().catch(() => '');
if (!status.includes('내 차례예요')) return null;
const panel = p.locator('.cp-panel').first();
const offering = (await panel.getByText('카드를 건네 주세요', { exact: true }).count()) > 0;
if (offering || (await panel.getByText('본 카드를 다른 사람에게 넘겨 주세요', { exact: true }).count())) {
const give = panel.getByRole('button', { name: /(건네기|넘기기)$/ });
const n = (stats.offer ?? 0) + (stats.pass ?? 0);
if (offering) {
const cards = panel.locator('.cp-hand button.cp-pick');
await cards.nth(n % (await cards.count())).click();
}
const targets = panel.locator('button.cp-target:not([disabled])');
await targets.nth(n % (await targets.count())).click();
await panel.locator('button.cp-claim').nth((n * 3) % 8).click();
if (!stats[`shot-give-${tag(p)}`]) {
stats[`shot-give-${tag(p)}`] = 1;
await p.screenshot({ path: join(SHOTS, `cockroach-poker-give-${tag(p)}.png`), fullPage: tag(p) === 'phone' });
}
await give.click({ timeout: 2000 });
stats[offering ? 'offer' : 'pass'] = (stats[offering ? 'offer' : 'pass'] ?? 0) + 1;
return offering ? 'offer' : 'pass';
}
const yes = panel.getByRole('button', { name: /^진짜!/ });
if (await yes.count()) {
if (!stats[`shot-respond-${tag(p)}`]) {
stats[`shot-respond-${tag(p)}`] = 1;
await p.screenshot({ path: join(SHOTS, `cockroach-poker-respond-${tag(p)}.png`), fullPage: tag(p) === 'phone' });
}
const peek = panel.getByRole('button', { name: '👁 몰래 보고 넘기기' });
const calls = (stats.true ?? 0) + (stats.false ?? 0);
if ((await peek.isEnabled().catch(() => false)) && calls % 3 === 1) {
await peek.click();
await panel.getByText('본 카드를 다른 사람에게 넘겨 주세요').waitFor({ timeout: 3000 });
stats.peek = (stats.peek ?? 0) + 1;
return 'peek';
}
const which = calls % 2 === 0 ? 'true' : 'false';
await panel.getByRole('button', { name: which === 'true' ? /^진짜!/ : /^거짓말!/ }).click();
stats[which] = (stats[which] ?? 0) + 1;
return which;
}
return null;
}
const PLANS: Record<string, Plan> = {
rummikub: {
// PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI.
@@ -879,6 +928,61 @@ const PLANS: Record<string, Plan> = {
console.log(` marble: finished after ${actions} UI actions ${JSON.stringify(stats)}`);
},
},
'cockroach-poker': {
// PC host + 2 phones, "같은 벌레 3장" for a shorter game. Real turns through the UI until someone loses:
// offer a face-down card with a claim, the target calls 진짜!/거짓말! or peeks and passes; the card goes face-up.
players: 3,
lobby: async (host) => {
await host.getByRole('group', { name: '같은 벌레 몇 장이면 패배' }).getByRole('button', { name: '3장(짧게)' }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/같은 벌레 3장이면 패배/).first().waitFor();
},
drive: async (pages) => {
const [host, guest] = pages as [Page, Page, Page];
const stats: Record<string, number> = {};
let actions = 0;
let shot = false;
const until = Date.now() + 300_000;
const over = () => host.getByRole('dialog', { name: '게임 결과' }).count();
while (Date.now() < until && !(await over())) {
let acted = false;
for (const p of pages) {
const did = await cockroachStep(p, stats);
if (did) {
acted = true;
actions++;
// Wait for the server to move on before looking again.
await p.waitForFunction(() => !document.querySelector('.turn-status')?.textContent?.includes('내 차례예요') || !!document.querySelector('.cp-panel button.cp-target'), null, { timeout: 4000 }).catch(() => {});
await p.waitForTimeout(120);
break;
}
}
if (!shot && (stats.true ?? 0) + (stats.false ?? 0) >= 4 && (await host.locator('.cp-chip').count()) >= 3) {
shot = true;
await host.waitForTimeout(800);
await host.screenshot({ path: join(SHOTS, 'cockroach-poker-mid-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'cockroach-poker-mid-phone.png') });
await guest.screenshot({ path: join(SHOTS, 'cockroach-poker-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, 'cockroach-poker-mid-pc-dark.png') });
await guest.screenshot({ path: join(SHOTS, 'cockroach-poker-mid-phone-dark.png'), fullPage: true });
await host.emulateMedia({ colorScheme: 'light' });
await guest.emulateMedia({ colorScheme: 'light' });
}
if (!acted) await host.waitForTimeout(120);
}
if (!(await over())) throw new Error(`game did not finish (${actions} UI actions: ${JSON.stringify(stats)})`);
for (const p of pages) await p.getByRole('dialog', { name: '게임 결과' }).waitFor();
await host.waitForTimeout(600);
await host.screenshot({ path: join(SHOTS, 'cockroach-poker-result-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'cockroach-poker-result-phone.png') });
const calls = (stats.true ?? 0) + (stats.false ?? 0);
if (!stats.offer || calls < 3) throw new Error(`too few real turns: ${JSON.stringify(stats)}`);
console.log(` cockroach-poker: finished after ${actions} UI actions ${JSON.stringify(Object.fromEntries(Object.entries(stats).filter(([k]) => !k.startsWith('shot'))))}`);
},
},
};
const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS);