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:
@@ -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']],
|
||||
|
||||
Reference in New Issue
Block a user