M7: 구십구 규칙·화면·테스트
- 규칙 모듈 packages/games/src/ninety-nine: 전용 카드(1~8·10, ±9·±10·0, 조커)와 트럼프(전통 99) 두 규칙, 모든 옵션 한국어 메타. 낼 카드가 없으면 같은 apply 안에서 탈락/목숨 감소/새 판을 연쇄 처리, 시간 초과는 결과 합계가 가장 작은 카드를 자동으로 냄, 연속 3번 시간 초과면 마감 3초, 나가면 탈락 처리. view는 남의 손패·더미를 숨기고 카드별 결과 합계 미리보기와 선택지(+/−, A 1/11, 조커 범위)를 계산해 줌. - 테스트: 문서 10절 시나리오 전부, 무작위 1,000판(보존·유출·onTimeout·결정성)과 엔진 randomPlayout 18판. - 화면 apps/web/src/games/ninety-nine: 큰 합계와 게이지 링, 방향 표시, 목숨 하트, 카드 위 결과 미리보기, ± 두 버튼, 조커 숫자 다이얼, 키보드·스크린리더 지원. - 등록(GAMES, 카탈로그, 화면 레지스트리, 아이콘)과 E2E 계획(PC 1 + 휴대폰 2, 끝까지 진행). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
119
e2e/games.e2e.ts
119
e2e/games.e2e.ts
@@ -260,6 +260,70 @@ async function marbleStep(p: Page, stats: Record<string, number>): Promise<strin
|
||||
return null;
|
||||
}
|
||||
|
||||
const nnShots = new Set<string>();
|
||||
/** 구십구: one turn for whoever shows "내 차례예요" — joker via the dial, ± via its buttons, 0, else a number card. */
|
||||
async function ninetyNineStep(p: Page, stats: Record<string, number>): Promise<boolean> {
|
||||
const status = (await p.locator('.turn-status').innerText().catch(() => '')).trim();
|
||||
if (!status.includes('내 차례예요')) return false;
|
||||
const total = Number(/합계 (\d+)/.exec(status)?.[1] ?? 0);
|
||||
const phone = tag(p) === 'phone';
|
||||
const press = async (l: ReturnType<Page['locator']>) => (phone ? l.tap() : l.click());
|
||||
const cards = p.locator('button.nn-card[data-legal="true"]');
|
||||
const n = await cards.count();
|
||||
if (!n) return false;
|
||||
const info: { i: number; label: string; prev: number }[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const label = (await cards.nth(i).getAttribute('aria-label')) ?? '';
|
||||
const prev = Number(/→ (\d+)/.exec(await cards.nth(i).locator('.nn-prev').innerText())?.[1] ?? -1);
|
||||
info.push({ i, label, prev });
|
||||
}
|
||||
const joker = info.find((c) => c.label.startsWith('조커'));
|
||||
const pm = info.find((c) => c.label.startsWith('±'));
|
||||
const zero = info.find((c) => c.label.startsWith('0 카드'));
|
||||
const nums = info.filter((c) => c.prev >= 0 && !c.label.startsWith('0 카드')).sort((a, b) => a.prev - b.prev);
|
||||
if (joker && (total >= 40 || !nums.length)) {
|
||||
await press(cards.nth(joker.i));
|
||||
await press(p.getByRole('button', { name: '99', exact: true }));
|
||||
if (!nnShots.has(`joker-${tag(p)}`)) {
|
||||
nnShots.add(`joker-${tag(p)}`);
|
||||
await p.screenshot({ path: join(SHOTS, `ninety-nine-joker-${tag(p)}.png`) });
|
||||
}
|
||||
await press(p.getByRole('button', { name: '1 작게' }));
|
||||
await press(p.getByRole('button', { name: /^조커 내기 → 98$/ }));
|
||||
stats.joker = (stats.joker ?? 0) + 1;
|
||||
} else if (pm) {
|
||||
await press(cards.nth(pm.i));
|
||||
if (!nnShots.has(`pm-${tag(p)}`)) {
|
||||
nnShots.add(`pm-${tag(p)}`);
|
||||
await p.screenshot({ path: join(SHOTS, `ninety-nine-plusminus-${tag(p)}.png`) });
|
||||
}
|
||||
const plus = p.locator('.nn-pick button', { hasText: /^\+/ });
|
||||
const minus = p.locator('.nn-pick button', { hasText: /^−/ });
|
||||
const usePlus = (await plus.isEnabled()) && (total < 70 || !(await minus.isEnabled()));
|
||||
await press(usePlus ? plus : minus);
|
||||
stats[usePlus ? 'plus' : 'minus'] = (stats[usePlus ? 'plus' : 'minus'] ?? 0) + 1;
|
||||
} else if (zero && (total >= 60 || !nums.length)) {
|
||||
await press(cards.nth(zero.i));
|
||||
await press(cards.nth(zero.i));
|
||||
stats.reverse = (stats.reverse ?? 0) + 1;
|
||||
} else {
|
||||
// Small cards while the total is low (longer game, more cards seen), then the biggest one.
|
||||
const pick = total < 55 ? nums[0]! : nums[nums.length - 1]!;
|
||||
const card = cards.nth(pick.i);
|
||||
if (phone) {
|
||||
await card.tap();
|
||||
await card.tap();
|
||||
} else {
|
||||
await card.focus();
|
||||
await p.keyboard.press('Enter');
|
||||
await p.keyboard.press('Enter');
|
||||
}
|
||||
stats.number = (stats.number ?? 0) + 1;
|
||||
}
|
||||
await p.waitForFunction((s) => document.querySelector('.turn-status')?.textContent?.trim() !== s, status, { timeout: 5000 }).catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
const PLANS: Record<string, Plan> = {
|
||||
rummikub: {
|
||||
// PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI.
|
||||
@@ -879,6 +943,61 @@ const PLANS: Record<string, Plan> = {
|
||||
console.log(` marble: finished after ${actions} UI actions ${JSON.stringify(stats)}`);
|
||||
},
|
||||
},
|
||||
'ninety-nine': {
|
||||
// PC host + 2 phones, 전용 카드 with 6 of each special card and 30-second turns. Real turns through the UI until the
|
||||
// game ends: joker via the number dial (quick 99, then −1), ± cards via their +/− buttons, 0 to turn the direction,
|
||||
// otherwise a number card (PC: keyboard Enter twice, phones: tap twice). Players run out of cards and are eliminated.
|
||||
players: 3,
|
||||
lobby: async (host) => {
|
||||
await host.getByRole('group', { name: '한 턴 제한 시간' }).getByRole('button', { name: '30초' }).click();
|
||||
await host.getByRole('button', { name: '▼ 자세한 설정' }).click();
|
||||
await host.getByRole('group', { name: '특수 카드 장수' }).getByRole('button', { name: '6장' }).click();
|
||||
await host.getByRole('button', { name: '설정 저장' }).click();
|
||||
await host.getByText(/^전용 카드 · 손패 3장 · 목숨 없음 · 30초/).first().waitFor();
|
||||
},
|
||||
drive: async (pages) => {
|
||||
const [host, guest] = pages as [Page, Page, Page];
|
||||
const stats: Record<string, number> = {};
|
||||
let turns = 0;
|
||||
let shot = false;
|
||||
const over = () => host.getByRole('dialog', { name: '게임 결과' }).count();
|
||||
const until = Date.now() + 240_000;
|
||||
while (Date.now() < until && !(await over())) {
|
||||
let acted = false;
|
||||
for (const p of pages) {
|
||||
if (await ninetyNineStep(p, stats)) {
|
||||
acted = true;
|
||||
turns++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!shot && turns >= 5) {
|
||||
shot = true;
|
||||
await host.waitForTimeout(500);
|
||||
await host.screenshot({ path: join(SHOTS, 'ninety-nine-mid-pc.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'ninety-nine-mid-phone.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'ninety-nine-mid-phone-full.png'), fullPage: true });
|
||||
await host.emulateMedia({ colorScheme: 'dark' });
|
||||
await guest.emulateMedia({ colorScheme: 'dark' });
|
||||
await host.waitForTimeout(400);
|
||||
await host.screenshot({ path: join(SHOTS, 'ninety-nine-mid-pc-dark.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'ninety-nine-mid-phone-dark.png') });
|
||||
await host.emulateMedia({ colorScheme: 'light' });
|
||||
await guest.emulateMedia({ colorScheme: 'light' });
|
||||
}
|
||||
if (!acted) await host.waitForTimeout(120);
|
||||
}
|
||||
if (!(await over())) throw new Error(`game did not finish (${turns} turns: ${JSON.stringify(stats)})`);
|
||||
const outs = await host.locator('.nn-log li', { hasText: '탈락했어요' }).count();
|
||||
await guest.getByRole('dialog', { name: '게임 결과' }).waitFor();
|
||||
await host.waitForTimeout(600);
|
||||
await host.screenshot({ path: join(SHOTS, 'ninety-nine-result-pc.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'ninety-nine-result-phone.png') });
|
||||
const specials = (stats.joker ?? 0) + (stats.plus ?? 0) + (stats.minus ?? 0) + (stats.reverse ?? 0);
|
||||
if (turns < 6 || specials < 1 || outs < 1) throw new Error(`too few real turns / special cards / eliminations: ${turns} ${JSON.stringify(stats)} outs=${outs}`);
|
||||
console.log(` ninety-nine: finished after ${turns} UI turns ${JSON.stringify(stats)}, ${outs} eliminations in the log`);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS);
|
||||
|
||||
Reference in New Issue
Block a user