M4: 윷놀이 규칙·화면·테스트
- 규칙(packages/games/yut): 29칸 윷판 그래프(모서리·방은 멈췄다 출발할 때만 꺾음, 방에서는 항상 참먹이 쪽), 가락 단위 확률(realistic 0.6 / fair 0.5, 빽도 표시 가락, 낙), 윷·모·잡기 추가 던지기, 모아 둔 결과 자유 순서, 쓸 수 없는 빽도 자동 버림, 빽도는 왔던 길(trail)로, 도 자리 빽도(참먹이/바로 남), pass/reach 날 판정, 업기·잡기(지나가면 안 잡음), 개인전 2~4명(1등 종료/꼴찌까지), 팀전 4·6·8명(청·홍 번갈아, 팀원 순환), 기권(팀은 전원 기권 시 패배), 시간 초과 자동 행동(잡기 > 내기 > 업기 > 뒤처진 말 > 올리기), 3연속 초과 시 자리 비움(5초) - 테스트: 문서 10절 시나리오 + 확률 10만 회(±0.5%p), 무작위 1,000판(불변식·onTimeout 합법·결정성·리플레이), 자동 진행·무작위 기권 60판, apply p99 < 5ms. 서버 방 테스트 2개(팀전 인원 검사, 연결 끊김 자동 진행·재접속 View) - 화면(apps/web/games/yut): SVG 윷판·말(편마다 색+모양), 윷가락 던지기 애니메이션, 결과 칩 → 말 → 한 번 더 눌러 확정, 경로 미리보기와 잡기!/날!/업기 배지, 자동 이동, 칸마다 통통 이동 애니메이션, 진행 기록, 옵션·규칙 설명, 키보드 조작 - 엔진/서버(하위 호환): GameDefinition.playerCountError(옵션별 인원 규칙) 추가, 방 시작 시 검사. 방 인원이 게임 최대였으면 옵션 변경으로 범위가 바뀔 때 새 최대를 따라감(팀전 8자리) - 등록: GAMES, 카탈로그(쉬움·15~25분), 화면 레지스트리, 홈의 "준비 중" 목록에서 윷놀이 제거 - E2E: PC 방장 + 휴대폰 친구, 말 2개로 실제 화면 조작(키보드 2단계 확정)으로 끝까지, 다크 모드 화면 저장 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,8 @@ interface Plan {
|
||||
moves?: string[][];
|
||||
/** Card games: custom driver over all pages (host first). */
|
||||
drive?: (pages: Page[]) => Promise<void>;
|
||||
/** Host changes game options in the lobby before starting. */
|
||||
lobby?: (host: Page) => Promise<void>;
|
||||
}
|
||||
|
||||
/** Card games: whoever has the turn presses the first available safe action (check/call/…) until `rounds` actions were taken. */
|
||||
@@ -53,7 +55,73 @@ async function finishHand(pages: Page[]) {
|
||||
for (const p of pages) await p.getByText('이번 판 결과').first().waitFor({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* 윷놀이: one UI step for whoever can act on `p` — press the throw button, or pick a result chip →
|
||||
* a glowing piece (or the waiting-piece tray) → "이동하기" (the two-step move confirmation).
|
||||
*/
|
||||
async function yutStep(p: Page): Promise<boolean> {
|
||||
const throwBtn = p.getByRole('button', { name: /윷 던지기|한 번 더 던지기/ });
|
||||
if ((await throwBtn.count()) && (await throwBtn.isEnabled().catch(() => false))) {
|
||||
return throwBtn.click({ timeout: 1500 }).then(() => true, () => false);
|
||||
}
|
||||
const chip = p.locator('.yut-chip:not([disabled])').first();
|
||||
if (!(await chip.count())) return false;
|
||||
try {
|
||||
await chip.click({ timeout: 1500 });
|
||||
const src = p.locator('g.yut-hit[tabindex="0"], .yut-tray-btn.can').first();
|
||||
await src.waitFor({ timeout: 2000 });
|
||||
// Keyboard: Enter on a piece shows where it lands, Enter again on the same piece moves it.
|
||||
const h = await src.elementHandle({ timeout: 1000 });
|
||||
await h!.focus();
|
||||
await p.keyboard.press('Enter');
|
||||
await p.getByRole('button', { name: /^이동하기/ }).waitFor({ timeout: 1500 });
|
||||
await h!.focus();
|
||||
await p.keyboard.press('Enter');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const PLANS: Record<string, Plan> = {
|
||||
yut: {
|
||||
// 말 2개로 줄여서 PC 방장 + 휴대폰 친구가 실제 화면 조작으로 끝까지 둔다(중간 화면도 저장).
|
||||
lobby: async (host) => {
|
||||
await host.getByRole('button', { name: '2개', exact: true }).click();
|
||||
await host.getByRole('button', { name: '설정 저장' }).click();
|
||||
await host.getByText(/말 2개/).first().waitFor();
|
||||
},
|
||||
drive: async (pages) => {
|
||||
const [host, guest] = pages as [Page, Page];
|
||||
let actions = 0;
|
||||
let shot = false;
|
||||
const until = Date.now() + 300_000;
|
||||
const over = () => host.getByText(/이겼어요|아쉽게 졌어요/).count();
|
||||
while (Date.now() < until && !(await over())) {
|
||||
let acted = false;
|
||||
for (const p of pages) {
|
||||
if (await yutStep(p)) {
|
||||
acted = true;
|
||||
actions++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (actions >= 16 && !shot) {
|
||||
shot = true;
|
||||
await host.waitForTimeout(400);
|
||||
await host.screenshot({ path: join(SHOTS, 'yut-mid-pc.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'yut-mid-phone.png'), fullPage: true });
|
||||
await guest.emulateMedia({ colorScheme: 'dark' });
|
||||
await guest.screenshot({ path: join(SHOTS, 'yut-mid-phone-dark.png') });
|
||||
await guest.emulateMedia({ colorScheme: 'light' });
|
||||
}
|
||||
if (!acted) await host.waitForTimeout(120);
|
||||
}
|
||||
if (!(await over())) throw new Error(`game did not finish (${actions} UI actions)`);
|
||||
await guest.getByText(/이겼어요|아쉽게 졌어요/).waitFor();
|
||||
console.log(` yut: finished after ${actions} UI actions`);
|
||||
},
|
||||
},
|
||||
seotda: {
|
||||
// 3 players, default 2장 섯다 (1장씩 두 번): everyone checks both betting rounds → showdown.
|
||||
players: 3,
|
||||
@@ -161,6 +229,7 @@ for (const id of games) {
|
||||
await g.getByRole('button', { name: '이 이름으로 입장하기' }).click();
|
||||
await host.getByLabel('자리').getByText(nick, { exact: true }).waitFor();
|
||||
}
|
||||
if (plan.lobby) await plan.lobby(host);
|
||||
await host.screenshot({ path: join(SHOTS, `${id}-0-lobby.png`) });
|
||||
await host.getByRole('button', { name: '▶ 게임 시작' }).click();
|
||||
await host.getByRole('button', { name: '시작하기' }).click();
|
||||
|
||||
Reference in New Issue
Block a user