M4: 숫자 타일 규칙·화면·테스트

- 규칙 모듈(packages/games/src/rummikub): 106타일, 선 정하기·분배, 그룹/런 판정과 조커 해석,
  서버 초안(setDraft/undoDraft/resetDraft) + 보존 검사(C1~C3), 확정 검증(받침대 1장 이상,
  첫 등록 점수, 재배치), 시간 초과(그대로면 1장, 올바르면 확정/되돌림, 틀리면 벌칙 장수),
  풀 소진 교착 종료, 점수 계산, 여러 판 매치, 나가기·자리 비움, 숨김 정보 view, secretsOf
- 테스트: 문서 10절 시나리오 전부 + 엔진 randomPlayout 25회 + 시드 1,000개 무작위 대국
  (타일 106장 보존, 매 단계 정보 유출·onTimeout 합법성 검사, 결정성)
- 화면(apps/web/src/games/rummikub): 탭으로 고르고 놓을 곳 누르기(◀ 앞 / ▶ 뒤 / 새 묶음),
  받침대 색깔순·숫자순 정렬, 되돌리기·처음으로, 실시간 묶음 검사(초록/빨강 + 이유),
  첫 등록 진행 막대, 도와줘 힌트, 한 장 가져오기·확정, 색+모양 표시 타일(조커 자체 디자인)
- 등록: GAMES, CATALOG, 화면 레지스트리, 아이콘(곧 나올 게임 목록에서 제거)
- E2E: PC 방장 + 휴대폰 친구가 화면으로 10차례(힌트로 내기, 되돌리기, 가져오기) 진행
- @bg/games 패키지에 './rummikub/rules' 내보내기 추가(화면 청크에 zod가 안 들어가게)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-05 04:34:59 +09:00
parent 5dd8e6ea07
commit 96e406b2c9
10 changed files with 2775 additions and 1 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. */
@@ -53,7 +55,86 @@ async function finishHand(pages: Page[]) {
for (const p of pages) await p.getByText('이번 판 결과').first().waitFor({ timeout: 10_000 });
}
/** 숫자 타일: 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: {
// 3 players, default 2장 섯다 (1장씩 두 번): everyone checks both betting rounds → showdown.
players: 3,
@@ -161,6 +242,7 @@ for (const id of games) {
await g.getByRole('button', { name: '이 이름으로 입장하기' }).click();
await host.getByLabel('자리').getByText(nick, { exact: true }).waitFor();
}
if (plan.lobby) await plan.lobby(host);
await host.screenshot({ path: join(SHOTS, `${id}-0-lobby.png`) });
await host.getByRole('button', { name: '▶ 게임 시작' }).click();
await host.getByRole('button', { name: '시작하기' }).click();