Merge branch 'm7/quoridor' into codex/owner/grp_discord_joke-app

This commit is contained in:
EJClaw
2026-10-05 14:56:12 +09:00
9 changed files with 2811 additions and 0 deletions

View File

@@ -416,6 +416,56 @@ async function splendorStep(p: Page, stats: Record<string, number>, turn: number
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.
@@ -1155,6 +1205,70 @@ const PLANS: Record<string, Plan> = {
console.log(` splendor: finished after ${turns} UI turns ${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);