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:
EJClaw
2026-10-05 14:55:36 +09:00
parent 559fb8507c
commit c34497a445
10 changed files with 2379 additions and 0 deletions

View File

@@ -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);