Merge branch 'm4/yut' into codex/owner/grp_discord_joke-app

# Conflicts:
#	apps/web/src/games/registry.ts
#	apps/web/src/lib/catalog-ui.ts
#	packages/games/src/index.ts
#	packages/shared/src/catalog.ts
This commit is contained in:
EJClaw
2026-10-05 04:40:49 +09:00
12 changed files with 2706 additions and 1 deletions

View File

@@ -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,
@@ -211,6 +279,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();