M7: 쿼리도(벽 쌓기 길찾기) 규칙·화면·테스트
- 규칙 모듈 packages/games/src/quoridor (id 'quoridor', 표시 이름 '벽 쌓기 길찾기'): 2인/4인(3인 거부, playerCountError), 직선·대각 점프(wallEdgePawn/wallOnly), 벽 겹침·판 밖·남은 벽·BFS 길 막기 검사, 도착 즉시 승리, 4인 순위(남은 거리, 공동 순위), continueForRanking, 기권·시간패(4인은 말 제거·남은 벽 소멸), 자동 패스, 무르기(2인 최대 2개 / 4인 다음 사람 전, 전원 동의)·무승부, perMove/fischer/none 시계, autoMove(최단 거리 칸, 동률 RNG, 연속 한도 넘으면 시간패). view는 차례인 사람에게만 legalPawnMoves·legalWalls(거리 변화량 포함)·myShortestPath. - 테스트: 문서 §10 시나리오 전부, 1,000판 빠른 무작위 대국(불변식·정보 누출·onTimeout 합법), 결정성·기록 재생, 엔진 randomPlayout 24판, 합법 벽 목록 성능(약 1.2ms). - 화면 apps/web/src/games/quoridor: 내 출발 쪽이 아래로 오게 판 회전, 홈 교차점 스냅 → 미리보기(불가하면 빨간색+이유) → 확인, 가로/세로 전환, 키보드(방향키·R·W·Enter), 이동 가능 칸·최단 길 점선·목표 줄 색 띠·남은 벽 막대, 무르기/무승부/기권, 규칙 그림. - 등록: GAMES, CATALOG, 웹 registry, GAME_ICON. E2E quoridor 플랜(PC 방장+휴대폰 친구). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
114
e2e/games.e2e.ts
114
e2e/games.e2e.ts
@@ -260,6 +260,56 @@ async function marbleStep(p: Page, stats: Record<string, number>): Promise<strin
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 벽 쌓기 길찾기: 판의 data 속성(모델 좌표 a1~i9)을 읽어 PC는 클릭, 휴대폰은 탭으로 조작한다. */
|
||||
const qdSvg = (p: Page) => p.locator('svg[data-my-seat]');
|
||||
async function qdTap(p: Page, target: ReturnType<Page['locator']>) {
|
||||
if (tag(p) === 'phone') await target.tap();
|
||||
else await target.click();
|
||||
}
|
||||
async function qdWaitTurn(p: Page) {
|
||||
await p.waitForFunction(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'), null, { timeout: 15_000 });
|
||||
}
|
||||
/** 벽 세우기 모드에서 앵커를 고르고 방향을 맞춘 뒤 미리보기 값("v:d8" / "h:e7:bad")을 돌려준다. */
|
||||
async function qdPreviewWall(p: Page, anchor: string, o: 'h' | 'v'): Promise<string> {
|
||||
await qdWaitTurn(p);
|
||||
if ((await qdSvg(p).getAttribute('data-mode')) !== 'wall') await qdTap(p, p.getByRole('button', { name: /^🧱 벽 세우기/ }));
|
||||
await qdTap(p, p.locator(`[data-anchor="${anchor}"]`));
|
||||
for (let k = 0; k < 3; k++) {
|
||||
const pv = (await qdSvg(p).getAttribute('data-preview')) ?? '';
|
||||
if (pv.startsWith(`${o}:${anchor}`)) return pv;
|
||||
await qdTap(p, p.getByRole('button', { name: /벽 방향 바꾸기/ }));
|
||||
}
|
||||
throw new Error(`wall preview did not become ${o}:${anchor}`);
|
||||
}
|
||||
async function qdConfirmWall(p: Page) {
|
||||
const before = await p.locator('[data-wall]').count();
|
||||
await qdTap(p, p.getByRole('button', { name: '벽 세우기 확인' }));
|
||||
await p.waitForFunction((n) => document.querySelectorAll('[data-wall]').length > n, before, { timeout: 5000 });
|
||||
}
|
||||
/** 내 최단 길(data-path)에서 가장 멀리 가는 이동 가능 칸으로 한 수. 점프였으면 true. */
|
||||
async function qdStep(p: Page, keyboard: boolean): Promise<boolean> {
|
||||
await qdWaitTurn(p);
|
||||
if ((await qdSvg(p).getAttribute('data-mode')) !== 'move') await qdTap(p, p.getByRole('button', { name: /말 움직이기/ }));
|
||||
const seat = await qdSvg(p).getAttribute('data-my-seat');
|
||||
const from = (await p.locator(`[data-pawn="${seat}"]`).getAttribute('data-at'))!;
|
||||
const legal = await p.locator('[data-legal]').evaluateAll((els) => els.map((e) => e.getAttribute('data-cell')!));
|
||||
const path = ((await qdSvg(p).getAttribute('data-path')) ?? '').split(' ');
|
||||
let to = legal[0]!;
|
||||
let best = -1;
|
||||
for (const c of legal) if (path.indexOf(c) > best) [best, to] = [path.indexOf(c), c];
|
||||
if (keyboard && to === path[0]) {
|
||||
// 커서는 내 말에서 시작하고 화면 위쪽이 항상 내 목표 방향.
|
||||
await keys(p, ['ArrowUp', 'Enter']);
|
||||
} else {
|
||||
await qdTap(p, p.locator(`[data-cell="${to}"]`));
|
||||
const confirm = p.getByRole('button', { name: /으로 이동$/ });
|
||||
if (await confirm.count()) await qdTap(p, confirm);
|
||||
}
|
||||
await p.waitForFunction((t) => document.querySelector(`[data-pawn] [data-at="${t}"], [data-at="${t}"]`) !== null, to, { timeout: 5000 });
|
||||
const d = Math.abs(from.charCodeAt(0) - to.charCodeAt(0)) + Math.abs(Number(from.slice(1)) - Number(to.slice(1)));
|
||||
return d === 2;
|
||||
}
|
||||
|
||||
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 +929,70 @@ const PLANS: Record<string, Plan> = {
|
||||
console.log(` marble: finished after ${actions} UI actions ${JSON.stringify(stats)}`);
|
||||
},
|
||||
},
|
||||
quoridor: {
|
||||
// PC 방장 + 휴대폰 친구(2인, 자리는 무작위). 남: 세로벽 d8 → 북: 세로벽 e8(북이 e8·e9 상자 안) → 남: 가로벽 e7은
|
||||
// 북의 길을 완전히 막으므로 빨간 미리보기·이유와 함께 거부 → 다른 가로벽 a3. 그 뒤 둘 다 e열 최단 길로 달려
|
||||
// 마주치면 뛰어넘고, 먼저 끝줄에 닿은 쪽이 이긴다(첫 이동은 키보드 ↑+Enter).
|
||||
drive: async (pages) => {
|
||||
const [host, guest] = pages as [Page, Page];
|
||||
await qdSvg(host).waitFor();
|
||||
await qdSvg(guest).waitFor();
|
||||
const south = (await qdSvg(host).getAttribute('data-my-seat')) === 'S' ? host : guest;
|
||||
const north = south === host ? guest : host;
|
||||
if ((await qdSvg(north).getAttribute('data-my-seat')) !== 'N') throw new Error('seats are not S/N');
|
||||
await qdPreviewWall(south, 'd8', 'v');
|
||||
await qdConfirmWall(south);
|
||||
await qdPreviewWall(north, 'e8', 'v');
|
||||
await north.waitForTimeout(200);
|
||||
await north.screenshot({ path: join(SHOTS, `quoridor-wall-preview-${tag(north)}.png`) });
|
||||
await qdConfirmWall(north);
|
||||
const bad = await qdPreviewWall(south, 'e7', 'h');
|
||||
if (!bad.endsWith(':bad')) throw new Error(`blocking wall was not refused (${bad})`);
|
||||
await south.getByText('누군가의 길을 완전히 막을 수 없어요').first().waitFor();
|
||||
if (await south.getByRole('button', { name: '벽 세우기 확인' }).isEnabled()) throw new Error('confirm enabled for an illegal wall');
|
||||
await south.waitForTimeout(300);
|
||||
await south.screenshot({ path: join(SHOTS, `quoridor-illegal-wall-${tag(south)}.png`) });
|
||||
if ((await south.locator('[data-wall]').count()) !== 2) throw new Error('illegal wall was placed');
|
||||
await qdPreviewWall(south, 'a3', 'h');
|
||||
await qdConfirmWall(south);
|
||||
let jumps = 0;
|
||||
let steps = 0;
|
||||
let shot = false;
|
||||
const over = () => host.getByRole('dialog', { name: '게임 결과' }).count();
|
||||
const first = new Set<Page>();
|
||||
for (let turn = 0; turn < 40 && !(await over()); turn++) {
|
||||
const mover = turn % 2 === 0 ? north : south;
|
||||
const useKeys = mover === host && !first.has(mover);
|
||||
first.add(mover);
|
||||
if (await qdStep(mover, useKeys)) jumps++;
|
||||
steps++;
|
||||
if (steps === 6 && !shot) {
|
||||
shot = true;
|
||||
await host.waitForTimeout(300);
|
||||
await host.screenshot({ path: join(SHOTS, 'quoridor-mid-pc.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'quoridor-mid-phone.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'quoridor-mid-phone-full.png'), fullPage: true });
|
||||
await host.emulateMedia({ colorScheme: 'dark' });
|
||||
await guest.emulateMedia({ colorScheme: 'dark' });
|
||||
await host.waitForTimeout(300);
|
||||
await host.screenshot({ path: join(SHOTS, 'quoridor-mid-pc-dark.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'quoridor-mid-phone-dark.png') });
|
||||
await host.emulateMedia({ colorScheme: 'light' });
|
||||
await guest.emulateMedia({ colorScheme: 'light' });
|
||||
}
|
||||
await host.waitForTimeout(150);
|
||||
}
|
||||
await host.getByRole('dialog', { name: '게임 결과' }).waitFor({ timeout: 10_000 });
|
||||
await guest.getByRole('dialog', { name: '게임 결과' }).waitFor({ timeout: 10_000 });
|
||||
await host.waitForTimeout(600);
|
||||
await host.screenshot({ path: join(SHOTS, 'quoridor-result-pc.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'quoridor-result-phone.png') });
|
||||
const summary = await host.getByRole('dialog', { name: '게임 결과' }).innerText();
|
||||
if (!/승리 — \d+수 만에 도착/.test(summary)) throw new Error(`unexpected result: ${summary.slice(0, 80)}`);
|
||||
if (jumps < 1) throw new Error('no jump happened');
|
||||
console.log(` quoridor: 3 walls + 1 refused wall, ${steps} pawn moves (${jumps} jump), ${summary.split('\n').find((l) => l.includes('승리'))}`);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS);
|
||||
|
||||
Reference in New Issue
Block a user