M4: 요트 다이스 규칙·화면·테스트

- 규칙 모듈(packages/games/src/yacht): 12칸(기본)·13칸 변형, 풀 하우스 요트 인정,
  추가 요트 보너스와 강제/자유 조커, 상단 보너스, 굴림 3회·고정 검증, 기권,
  시간 초과 시 (안 굴렸으면 굴린 뒤) 최고 점수 칸 자동 기록·희생 순서, 1~8명(혼자 가능)
- 점수 계산은 scoreFor 순수 함수로 분리, view에 빈 칸 미리보기·허용 칸 포함
- 테스트: 문서 10절 시나리오 전부, 시드 1,000개 무작위 대국(점수·합계 일관성,
  onTimeout 합법, 결정성), 기권·시간 초과 섞은 200개 대국
- 화면: SVG 주사위(눌러서 고정, 굴림 애니메이션·움직임 줄이기 존중), 남은 굴림 표시,
  전원 점수표(내 열 강조, 미리보기, 0점 확인), 상단 보너스 진행 막대, 기권
- 등록: GAMES, 카탈로그, 웹 레지스트리, 아이콘(준비 중 목록에서 제거)
- 결과 화면: 1인 게임은 무승부/승리로 표시하지 않음
- E2E: PC 방장 + 휴대폰 친구가 12라운드 전체를 화면으로 진행

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-05 04:17:15 +09:00
parent 5dd8e6ea07
commit 41ab2cf187
10 changed files with 1991 additions and 4 deletions

View File

@@ -77,6 +77,56 @@ const PLANS: Record<string, Plan> = {
await finishHand(pages);
},
},
yacht: {
// PC host + phone guest play a whole 12-round game through the UI: roll, sometimes hold + re-roll, then
// write the best open category (phone uses touch taps, so the confirm sheet path is exercised too).
drive: async (pages) => {
const myTurn = (p: Page) => p.evaluate(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'));
const shot = new Set<string>();
for (let turn = 0; turn < 24; turn++) {
let mover: Page | null = null;
for (let t = 0; t < 150 && !mover; t++) {
for (const p of pages) if (!mover && (await myTurn(p))) mover = p;
if (!mover) await pages[0]!.waitForTimeout(100);
}
if (!mover) throw new Error(`nobody has the turn (turn ${turn + 1})`);
const phone = mover !== pages[0];
const press = (l: ReturnType<Page['locator']>) => (phone ? l.tap() : l.click());
await press(mover.getByRole('button', { name: /^🎲 굴리기/ }));
await mover.locator('.yt-die[aria-pressed]').first().waitFor();
if (turn % 3 !== 2) {
// Hold the first die, check it shows as held, then roll the rest again.
await press(mover.locator('.yt-die').first());
await mover.locator('.yt-die').first().and(mover.locator('[aria-pressed="true"]')).waitFor();
await press(mover.getByRole('button', { name: /^🎲 굴리기 \(남은 2번\)/ }));
await mover.getByRole('button', { name: /^🎲 굴리기 \(남은 1번\)/ }).waitFor();
await mover.waitForTimeout(700); // roll animation
}
const cells = mover.locator('button.yt-cell:not([disabled])');
await cells.first().waitFor();
// Mid-game screenshots of a turn in progress (held die + preview cells), light and dark.
const tag = phone ? 'phone' : 'pc';
if (turn >= 8 && !shot.has(tag)) {
shot.add(tag);
await mover.screenshot({ path: join(SHOTS, `yacht-mid-${tag}.png`), fullPage: phone });
await mover.emulateMedia({ colorScheme: 'dark' });
await mover.waitForTimeout(200);
await mover.screenshot({ path: join(SHOTS, `yacht-mid-${tag}-dark.png`), fullPage: phone });
await mover.emulateMedia({ colorScheme: 'light' });
}
const values = await cells.allInnerTexts();
let best = 0;
values.forEach((v, i) => Number(v) > Number(values[best]) && (best = i));
await press(cells.nth(best));
const confirm = mover.getByRole('button', { name: '기록하기', exact: true });
if (await confirm.isVisible({ timeout: 600 }).catch(() => false)) await confirm.click();
await mover.waitForFunction(() => !document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'), null, { timeout: 5000 });
}
for (const p of pages) await p.getByRole('dialog', { name: '게임 결과' }).waitFor({ timeout: 10_000 });
const sheet = await pages[0]!.locator('.yt-table tr.yt-total').innerText();
if (!/\d/.test(sheet)) throw new Error('no totals on the score sheet');
},
},
othello: {
// cursor starts at d4 (3,3): black d3 = up; then white c3 = up, left
moves: [['ArrowUp', 'Enter'], ['ArrowUp', 'ArrowLeft', 'Enter']],