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

# Conflicts:
#	apps/web/src/games/registry.ts
#	apps/web/src/lib/catalog-ui.ts
#	e2e/games.e2e.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
10 changed files with 2776 additions and 0 deletions

View File

@@ -19,6 +19,8 @@ mkdirSync(SHOTS, { recursive: true });
interface Plan {
/** Total players including the host (default 2). Extra players join on phones. */
players?: number;
/** Host changes room options before starting (inline settings form on PC). */
lobby?: (host: Page) => Promise<void>;
/** Steps run before normal moves (e.g. janggi formation choice). */
setup?: (host: Page, guest: Page) => Promise<void>;
/** Board games: key sequences on the grid, in turn order. */
@@ -120,6 +122,84 @@ const PLANS: Record<string, Plan> = {
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`);
/** 숫자 타일: whoever has the turn tries "도와줘" → "새 묶음" → "확정"; otherwise takes one tile. */
async function rummikubTurns(pages: Page[], turns: number) {
const stats = { plays: 0, draws: 0, undo: false };
const myTurn = (p: Page) => p.evaluate(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'));
const bar = (p: Page) => p.getByRole('group', { name: '내 차례 행동' });
/** Waits until a bar button is enabled / disabled (server answered). */
const barButton = (p: Page, name: string, enabled: boolean) =>
p.waitForFunction(
([n, e]) => [...document.querySelectorAll('.rk-bar button')].some((b) => b.textContent === n && b.hasAttribute('disabled') !== e),
[name, enabled] as const,
{ timeout: 5000 },
);
for (let n = 0; n < turns; n++) {
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 ${n + 1})`);
await bar(mover).waitFor();
let played = false;
await bar(mover).getByRole('button', { name: '도와줘' }).click();
const picked = await mover.locator('.rk-rack button[aria-pressed="true"]').count();
if (picked > 0) {
// Tap one picked tile twice (deselect, select again) — the tap-to-select path.
const first = mover.locator('.rk-rack button[aria-pressed="true"]').first();
const label = await first.getAttribute('aria-label');
await first.click();
await mover.locator(`.rk-rack button[aria-label="${label}"]`).first().click();
await bar(mover).getByRole('button', { name: '새 묶음' }).click();
await mover.locator('.rk-set').last().waitFor();
if (!stats.undo) {
// Undo once (back to the turn start), then place again.
await barButton(mover, '되돌리기', true);
await bar(mover).getByRole('button', { name: '되돌리기' }).click();
await barButton(mover, '처음으로', false);
stats.undo = true;
await bar(mover).getByRole('button', { name: '도와줘' }).click();
await bar(mover).getByRole('button', { name: '새 묶음' }).click();
}
const commit = bar(mover).getByRole('button', { name: '확정' });
await mover
.waitForFunction(() => [...document.querySelectorAll('.rk-bar button')].some((b) => b.textContent === '확정' && b.classList.contains('btn-primary') && !b.hasAttribute('disabled')), null, { timeout: 5000 })
.then(
() => (played = true),
() => {},
);
if (played) {
await commit.click();
stats.plays++;
} else {
await bar(mover).getByRole('button', { name: '처음으로' }).click();
await mover.waitForTimeout(300);
}
}
if (!played) {
await bar(mover).getByRole('button', { name: /^(한 장 가져오기|넘기기)$/ }).click();
const sheet = mover.getByRole('button', { name: /^되돌리고/ });
if (await sheet.count()) await sheet.click();
stats.draws++;
}
await mover.waitForFunction(() => !document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'), null, { timeout: 5000 });
}
console.log(` rummikub: ${stats.plays} plays, ${stats.draws} draws, undo ${stats.undo ? 'used' : 'not used'}`);
return stats;
}
const PLANS: Record<string, Plan> = {
rummikub: {
// PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI.
lobby: async (host) => {
await host.getByRole('group', { name: '첫 등록 최소 점수' }).getByRole('button', { name: '없음' }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText('첫 등록 점수 없음').first().waitFor();
},
drive: async (pages) => {
const stats = await rummikubTurns(pages, 10);
if (stats.plays + stats.draws < 10) throw new Error('not enough turns');
},
},
seotda: {