Merge branch 'm5/halli-galli' 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 14:07:23 +09:00
13 changed files with 2722 additions and 0 deletions

View File

@@ -254,6 +254,101 @@ const PLANS: Record<string, Plan> = {
await host.screenshot({ path: join(SHOTS, 'gostop-breakdown-pc.png') });
if (state.plays < 10) throw new Error(`only ${state.plays} cards were played`);
console.log(` gostop: ${state.plays} plays, ${state.sheets} choice sheets (${state.picks} card picks), 고 ${state.go}, 스톱 ${state.stop}`);
/** 할리갈리: fruit totals of the face-up tops, read from the seat labels ("펼친 카드 딸기 3개"). */
async function halliTotals(p: Page): Promise<Record<string, number>> {
const labels = await p.locator('.hg-seat').evaluateAll((els) => els.map((e) => e.getAttribute('aria-label') ?? ''));
const t: Record<string, number> = { 딸기: 0, 바나나: 0, 라임: 0, 자두: 0 };
for (const l of labels) {
const m = /펼친 카드 (딸기|바나나|라임|자두) (\d)개/.exec(l);
if (m) t[m[1]!]! += Number(m[2]);
}
return t;
}
const PLANS: Record<string, Plan> = {
'halli-galli': {
// PC host + 2 phones. Real flips (Enter on PC, taps on phones); whenever some fruit totals exactly five a player
// rings the bell (PC: Space, phone: tap), plus at least one deliberate wrong bell. 24+ flips through the UI.
players: 3,
lobby: async (host) => {
await host.getByRole('group', { name: '뒤집기 제한 시간' }).getByRole('button', { name: '5초' }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/뒤집기 5초/).first().waitFor();
},
drive: async (pages) => {
const [host, guest] = pages as [Page, Page, Page];
const myTurn = (p: Page) => p.evaluate(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'));
const ended = () => host.getByRole('dialog', { name: '게임 결과' }).count();
await host.locator('.hg-seat').first().waitFor();
// 3-second countdown first.
await host.waitForFunction(() => !document.querySelector('.hg-count'), null, { timeout: 8000 });
let flips = 0;
let rights = 0;
let wrongs = 0;
let lates = 0;
let shots = 0;
let ringer = 0;
const until = Date.now() + 150_000;
const bubble = (p: Page) => p.locator('.hg-bubble').first().innerText({ timeout: 2500 }).catch(() => '');
while (Date.now() < until && !(await ended()) && (flips < 24 || rights < 1 || wrongs < 1)) {
const totals = await halliTotals(host);
const five = Object.values(totals).includes(5);
const wantWrong = !five && wrongs === 0 && flips >= 4 && Object.values(totals).some((n) => n > 0);
if (five || wantWrong) {
const p = pages[ringer++ % pages.length]!;
const bell = p.locator('.hg-bell');
if (await bell.isEnabled().catch(() => false)) {
if (p === host) {
await host.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
await host.keyboard.press('Space');
} else await bell.tap();
const text = await bubble(p);
if (text.includes('땡')) rights++;
else if (text.includes('삐')) wrongs++;
else if (text.includes('늦었어요')) lates++;
if (shots === 0 && text) {
shots++;
await host.screenshot({ path: join(SHOTS, 'halli-galli-bell-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'halli-galli-bell-phone.png') });
}
await host.waitForTimeout(400);
continue;
}
}
let flipped = false;
for (const p of pages) {
if (!(await myTurn(p))) continue;
const btn = p.getByRole('button', { name: '뒤집기', exact: true });
if (!(await btn.isEnabled().catch(() => false))) break;
const before = await p.locator('.hg-seat[data-me="true"] .hg-meta').innerText();
if (p === host) {
await host.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
await host.keyboard.press('Enter');
} else await btn.tap();
await p.waitForFunction((b) => document.querySelector('.hg-seat[data-me="true"] .hg-meta')?.textContent !== b, before, { timeout: 3000 }).catch(() => {});
flips++;
flipped = true;
break;
}
if (flips === 12 && flipped && shots < 2) {
shots = 2;
await host.waitForTimeout(300);
await host.screenshot({ path: join(SHOTS, 'halli-galli-mid-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'halli-galli-mid-phone.png') });
await guest.emulateMedia({ colorScheme: 'dark' });
await guest.waitForTimeout(400); // let the theme transition finish
await guest.screenshot({ path: join(SHOTS, 'halli-galli-mid-phone-dark.png') });
await guest.emulateMedia({ colorScheme: 'light' });
await host.emulateMedia({ colorScheme: 'dark' });
await host.waitForTimeout(400);
await host.screenshot({ path: join(SHOTS, 'halli-galli-mid-pc-dark.png') });
await host.emulateMedia({ colorScheme: 'light' });
}
await host.waitForTimeout(flipped ? 650 : 120);
}
console.log(` halli-galli: ${flips} UI flips, ${rights} correct bells, ${wrongs} wrong bells, ${lates} late`);
if (!(await ended()) && (flips < 20 || wrongs < 1)) throw new Error('not enough flips / bells');
if (rights < 1 && !(await ended())) throw new Error('no correct bell happened');
},
},
rummikub: {