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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user