M6: 주사위 땅따먹기 규칙·화면·테스트

- packages/games/src/marble: 판 데이터(board.ts, 40칸·가격표·행운카드 20장), 상태 기계(rules.ts),
  게임 정의(index.ts). 더블·3더블 무인도, 무인도/탈출권/탈출비, 우주여행·탑승료, 사회복지기금,
  통행료(관광지 2곳 이상 2배·면제권), 빚 갚기·파산(채권자에게 매각 대금), 인수·랜드마크·독점 승리·교환
  옵션, 라운드 제한과 총자산 순위(동점이면 포인트 비교). 카드 더미 순서는 비공개(남은 장 수만),
  시스템 전용 TIMEOUT(굴리기/안 사기/싼 것부터 매각/거절), onLeave = 기권.
- 테스트 80개: 문서 §10 체크리스트 전 항목, 1,000판 시드 무작위 대국(포인트 장부·소유 일관성·
  onTimeout 합법), 엔진 randomPlayout·리플레이 결정성, 시간 초과만으로도 끝나는지.
- 화면: SVG 정사각형 판(도시 이름·소유 색·건물 표시·말), 가운데 주사위·결정 프롬프트, 행운카드 공개,
  우주여행 칸 고르기와 미리 보기, 빚 갚기 막대·매각 목록, 교환 시트, 진행 기록, PC·휴대폰·다크 모드.
  화폐 대신 놀이용 포인트(P) 표기, no-money-wording 검사 대상에 추가.
- 등록: GAMES, CATALOG(주사위 땅따먹기), 웹 registry, GAME_ICON, @bg/games/marble/board export.
- E2E marble 계획: PC 방장 + 휴대폰 손님, 15라운드·200P로 결과 화면까지 실제 진행.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-05 13:31:22 +09:00
parent 6321ea8c0d
commit ad7af30daa
12 changed files with 4315 additions and 1 deletions

View File

@@ -150,6 +150,50 @@ async function yutStep(p: Page): Promise<boolean> {
}
}
/**
* 주사위 땅따먹기: one UI step for whoever can act on `p` — answer a trade, sell, buy/build (or skip),
* pick a space-travel square on the board, escape the island or roll. Returns the label pressed.
*/
const tag = (p: Page) => ((p.viewportSize()?.width ?? 0) < 600 ? 'phone' : 'pc');
async function marbleStep(p: Page, stats: Record<string, number>): Promise<string | null> {
const prompt = p.locator('.mb-prompt.mine');
if (!(await prompt.count())) return null;
const btn = (name: string | RegExp) => prompt.getByRole('button', { name, exact: typeof name === 'string' });
const press = async (name: string | RegExp) => {
const b = btn(name);
if (!(await b.count()) || !(await b.first().isEnabled().catch(() => false))) return false;
return b.first().click({ timeout: 1500 }).then(() => true, () => false);
};
// Space travel: tap a glowing square on the board, check the preview, fly there.
if (await btn(/^출발로 가기/).count()) {
const cell = p.locator('g.mb-cell.pick').nth(5 + (stats.space ?? 0) * 7);
await cell.click({ timeout: 1500 }).catch(() => {});
const go = p.getByRole('button', { name: '🚀 여기로 가기' });
if (await go.waitFor({ timeout: 1500 }).then(() => true, () => false)) {
await p.getByText('여기로 가면').waitFor({ timeout: 1000 });
if (!stats.space) await p.screenshot({ path: join(SHOTS, `marble-space-${tag(p)}.png`) });
await go.click();
stats.space = (stats.space ?? 0) + 1;
return 'space';
}
return (await press(/^출발로 가기/)) ? 'space-start' : null;
}
// First buy prompt on each screen size, for the visual check.
const shotKey = `shot-${tag(p)}`;
if (!stats[shotKey] && (await btn('사기').count())) {
stats[shotKey] = 1;
await p.screenshot({ path: join(SHOTS, `marble-buy-${tag(p)}.png`) });
}
for (const name of ['거절', /^가장 싼 것부터 팔기/, '사기', '짓기', '인수하기', '🎲 탈출 굴리기', '🎲 주사위 굴리기', '🎲 한 번 더 굴리기', '안 사기', '안 짓기', '안 하기'] as const) {
if (await press(name)) {
const key = typeof name === 'string' ? name : 'sell';
stats[key] = (stats[key] ?? 0) + 1;
return key;
}
}
return null;
}
const PLANS: Record<string, Plan> = {
rummikub: {
// PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI.
@@ -194,6 +238,7 @@ const PLANS: Record<string, Plan> = {
await guest.screenshot({ path: join(SHOTS, 'yut-mid-phone-dark.png') });
await guest.emulateMedia({ colorScheme: 'light' });
}
if (acted && !stats.toll && (await host.locator('.mb-log li', { hasText: /→.*통행료/ }).count())) stats.toll = 1;
if (!acted) await host.waitForTimeout(120);
}
if (!(await over())) throw new Error(`game did not finish (${actions} UI actions)`);
@@ -201,6 +246,59 @@ const PLANS: Record<string, Plan> = {
console.log(` yut: finished after ${actions} UI actions`);
},
},
marble: {
// PC host + phone guest, 15 rounds (shortest limit) played through the UI to the result: roll, buy or
// build (skip when short), pay tolls, sell when short, space travel via a board square. Mid-game shots.
lobby: async (host) => {
await host.getByRole('group', { name: '라운드 제한' }).getByRole('button', { name: '15라운드' }).click();
await host.getByRole('group', { name: '시작 포인트' }).getByRole('button', { name: '200P' }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/^15라운드 · 시작 200P/).first().waitFor();
},
drive: async (pages) => {
const [host, guest] = pages as [Page, Page];
const stats: Record<string, number> = {};
let actions = 0;
let shot = false;
const until = Date.now() + 420_000;
const over = () => host.getByRole('dialog', { name: '게임 결과' }).count();
while (Date.now() < until && !(await over())) {
let acted = false;
for (const p of pages) {
if (await marbleStep(p, stats)) {
acted = true;
actions++;
await p.waitForTimeout(80);
break;
}
}
if (actions >= 40 && !shot && (stats['사기'] ?? 0) >= 3) {
shot = true;
await host.waitForTimeout(1300);
await host.screenshot({ path: join(SHOTS, 'marble-mid-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'marble-mid-phone.png') });
await guest.screenshot({ path: join(SHOTS, 'marble-mid-phone-full.png'), fullPage: true });
await host.emulateMedia({ colorScheme: 'dark' });
await guest.emulateMedia({ colorScheme: 'dark' });
await host.waitForTimeout(400); // let the button colour transitions settle
await host.screenshot({ path: join(SHOTS, 'marble-mid-pc-dark.png') });
await guest.screenshot({ path: join(SHOTS, 'marble-mid-phone-dark.png') });
await host.emulateMedia({ colorScheme: 'light' });
await guest.emulateMedia({ colorScheme: 'light' });
}
if (acted && !stats.toll && (await host.locator('.mb-log li', { hasText: /→.*통행료/ }).count())) stats.toll = 1;
if (!acted) await host.waitForTimeout(120);
}
if (!(await over())) throw new Error(`game did not finish (${actions} UI actions: ${JSON.stringify(stats)})`);
await guest.getByRole('dialog', { name: '게임 결과' }).waitFor();
await host.waitForTimeout(600); // result dialog fade-in
await host.screenshot({ path: join(SHOTS, 'marble-result-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'marble-result-phone.png') });
const rolls = (stats['🎲 주사위 굴리기'] ?? 0) + (stats['🎲 한 번 더 굴리기'] ?? 0) + (stats['🎲 탈출 굴리기'] ?? 0);
if (rolls < 15 || !stats['사기']) throw new Error(`too few real turns: ${JSON.stringify(stats)}`);
console.log(` marble: finished after ${actions} UI actions ${JSON.stringify(stats)}`);
},
},
'one-card': {
// 3 players, default 원카드: whoever has the turn plays a highlighted card (tap to lift, tap again to play,
// pick a suit for 7) or draws; the 원카드 button is pressed whenever it lights up. 60 turns or until the game ends.