M6: 할리갈리 규칙·화면·테스트
- packages/games/src/halli-galli: 56장 과일 카드, 차례 뒤집기(flipGuard 예약), 언제나 칠 수 있는 종 (tableVersion으로 늦은 종 "늦었어요" 무벌칙, 서버 도착 순서 우선), 오답 벌칙·탈락, official/시간 제한 종료, 하우스 룰(1대1 오답 패배, 3진 아웃, 벌칙 증가, 벌칙 종 밑), latencyComp light 50ms 창, view는 덱 내용을 본인에게도 숨김(장수만), secretsOf/legalActions 제공 - 테스트: §10 체크리스트 전부, 1,000판 시드 랜덤 플레이아웃(카드 보존·누출·onTimeout 합법·결정성) + 엔진 randomPlayout, 서버 Room 테스트(동시 종 도착 순서, 늦은 종 거절, 자리 비움 자동 뒤집기) - 화면: 원형 좌석 배치, 큰 종 버튼(스페이스 바), 뒤집기(Enter), SVG 과일 카드, 땡/삐 효과음·진동, 카드 이동 애니메이션, 어린이 모드 합계 막대, 다크 모드·동작 줄이기 대응 - 공용 변경(하위 호환): GameDefinition.timeoutNotBefore 선택 훅 + Room 타이머 반영(자리 비움 시 바쁜 반복 방지), feedback.ts에 ding/buzz 소리 추가 - 등록: GAMES, CATALOG, 웹 레지스트리·아이콘, 출시 예정 목록에서 제거, E2E 계획 추가 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -150,7 +150,103 @@ async function yutStep(p: Page): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 할리갈리: 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: {
|
||||
// PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI.
|
||||
lobby: async (host) => {
|
||||
|
||||
Reference in New Issue
Block a user