M7: 보석 상인 규칙·화면·테스트

- 규칙 모듈 packages/games/src/splendor: 카드 90장·귀족 10장 데이터 파일 분리(cards.ts),
  보석 가져오기(3색/같은 색 2개, allowFewerTokens), 예약(공개/더미, 황금), 구매(자동·수동 지불),
  10개 한도 반납, 귀족 방문·선택, 라운드 끝까지 진행 후 종료, 동점은 카드 적은 쪽,
  넘기기와 모두 넘기면 종료, 기권, 시간 초과 자동 행동(3번 연속이면 자리 비움),
  view는 더미·블라인드 예약·제외 귀족을 숨기고 살 수 있는 카드·지불 미리보기를 계산
- 테스트: 문서 10절 시나리오 42개 + 무작위 대국 1,000판(보존·정보 유출·onTimeout 합법·결정성)
  + 엔진 randomPlayout
- 화면 apps/web/src/games/splendor: 자체 카드·귀족·보석 그림(색+모양+글자), 3단계 시장과 더미,
  보석 은행, 내 보너스·보석·예약, 상대 요약, 살 수 있는 카드 강조, 반납·귀족 선택, PC/휴대폰·다크 모드
- 등록: GAMES, 카탈로그, 화면 레지스트리, 아이콘
- E2E: PC 방장 + 휴대폰 친구가 10점 판을 결과까지 진행

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-05 14:53:35 +09:00
parent 559fb8507c
commit 11ad2be147
10 changed files with 3322 additions and 0 deletions

View File

@@ -260,6 +260,97 @@ async function marbleStep(p: Page, stats: Record<string, number>): Promise<strin
return null;
}
/**
* 보석 상인: one UI action for whoever has the turn on `p` — return tokens / choose a noble / pass when
* asked, otherwise buy a highlighted card, reserve now and then, take 2 of a colour, or 3 different gems.
* Returns the kind of action or null when it isn't this page's turn.
*/
async function splendorStep(p: Page, stats: Record<string, number>, turn: number): Promise<string | null> {
const status = (await p.locator('.turn-status').innerText().catch(() => '')).trim();
if (!status.startsWith('내 차례예요')) return null;
const phone = (p.viewportSize()?.width ?? 0) < 600;
const press = async (l: ReturnType<Page['locator']>) => {
if (phone) await l.tap({ timeout: 2000 });
else if (turn % 2 === 0) {
// Keyboard path on PC every other turn: focus + Enter.
await l.focus({ timeout: 2000 });
await p.keyboard.press('Enter');
} else await l.click({ timeout: 2000 });
};
const panel = p.locator('.sp-panel');
if (status.includes('돌려놓아')) {
const need = Number(status.match(/(\d+)개를 돌려놓아/)?.[1] ?? 0);
for (let i = 0; i < need; i++) await press(panel.locator('.sp-basket').first().locator('button.sp-token').first());
await press(panel.getByRole('button', { name: `돌려놓기 (${need}/${need})` }));
return 'discard';
}
if (status.includes('귀족 중 한 명')) {
await press(panel.getByRole('button', { name: /이 귀족 고르기$/ }).first());
return 'noble';
}
if (status.includes('넘겨야')) {
await press(panel.getByRole('button', { name: '차례 넘기기' }));
const ok = p.getByRole('button', { name: '넘기기', exact: true });
if (await ok.isVisible({ timeout: 500 }).catch(() => false)) await ok.click();
return 'pass';
}
const affordable = p.locator('.sp-market button.sp-card.can, .sp-market button.sp-card.gold, .sp-mine button.sp-card.can, .sp-mine button.sp-card.gold');
const reserveTurn = (stats.reserve ?? 0) < 2 && turn >= 2 && turn % 5 === 2;
// The phone player hoards gems early on (no buying) until it has had to give some back once.
const hoard = phone && !stats.discard && turn < 30;
if ((await affordable.count()) && !reserveTurn && !hoard) {
const card = affordable.first();
await press(card);
if ((await p.locator('.sp-card.gold.sel').count()) && !stats.shotPay) {
stats.shotPay = 1;
await p.waitForTimeout(250);
await p.screenshot({ path: join(SHOTS, `splendor-pay-${phone ? 'phone' : 'pc'}.png`), fullPage: phone });
}
await press(panel.getByRole('button', { name: '🛒 사기' }));
return 'buy';
}
const reserveBtn = panel.getByRole('button', { name: /^📌 예약/ });
if (reserveTurn) {
// Tier 1 open card on even turns, the tier 2 deck (blind) otherwise.
const target = (stats.reserve ?? 0) === 0 ? p.locator('.sp-market .sp-row').nth(2).locator('button.sp-card').first() : p.locator('.sp-market .sp-row').nth(1).locator('button.sp-deck');
await press(target);
if (await reserveBtn.isEnabled({ timeout: 800 }).catch(() => false)) {
await press(reserveBtn);
return 'reserve';
}
await press(panel.getByRole('button', { name: '닫기' }));
}
const tokens = p.locator('.sp-bank button.sp-token:not([disabled])');
const counts = await tokens.evaluateAll((els) => els.map((e) => Number(e.querySelector('.ct')?.textContent ?? 0)));
const four = counts.findIndex((n) => n >= 4);
if ((stats.same ?? 0) < 2 && four >= 0 && turn % 3 === 1) {
await press(tokens.nth(four));
await press(tokens.nth(four));
await press(panel.getByRole('button', { name: '💎 가져오기 (2개)' }));
return 'same';
}
const want = Number((await panel.locator('.sp-basket').innerText()).match(/서로 다른 색 (\d)개/)?.[1] ?? 3);
let picked = 0;
for (let i = 0; i < counts.length && picked < want; i++) {
if (counts[i]! > 0) {
await press(tokens.nth(i));
picked++;
}
}
const takeBtn = panel.getByRole('button', { name: /^💎 가져오기/ });
if (picked > 0 && (await takeBtn.isEnabled().catch(() => false))) {
await press(takeBtn);
return 'different';
}
// Nothing to take: reserve whatever is first, or buy anything.
await press(p.locator('.sp-market button.sp-card').first());
if (await reserveBtn.isEnabled({ timeout: 500 }).catch(() => false)) {
await press(reserveBtn);
return 'reserve';
}
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.
@@ -879,6 +970,62 @@ const PLANS: Record<string, Plan> = {
console.log(` marble: finished after ${actions} UI actions ${JSON.stringify(stats)}`);
},
},
splendor: {
// PC host + phone guest, short game (10 points) through the UI to the result: 3 different gems, 2 of a
// colour, reserve an open card and a blind deck card (gold), buy highlighted cards (with gold too),
// return tokens over 10 and choose nobles when it happens. Mid-game shots in light and dark.
lobby: async (host) => {
await host.getByRole('group', { name: '끝나는 점수' }).getByRole('button', { name: '10점(짧은 판)' }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/^10점 · 한 차례 60초/).first().waitFor();
},
drive: async (pages) => {
const [host, guest] = pages as [Page, Page];
const stats: Record<string, number> = {};
let turns = 0;
let shot = false;
const until = Date.now() + 480_000;
const over = () => host.getByRole('dialog', { name: '게임 결과' }).count();
while (Date.now() < until && !(await over())) {
let acted: string | null = null;
for (const p of pages) {
const before = (await p.locator('.turn-status').innerText().catch(() => '')).trim();
acted = await splendorStep(p, stats, turns);
if (acted) {
stats[acted] = (stats[acted] ?? 0) + 1;
turns++;
// Wait until the server answered (status or log changed).
await p
.waitForFunction((b) => (document.querySelector('.turn-status')?.textContent ?? '').trim() !== b || !!document.querySelector('[role="dialog"]'), before, { timeout: 5000 })
.catch(() => {});
break;
}
}
if (turns >= 14 && !shot) {
shot = true;
await host.waitForTimeout(700);
await host.screenshot({ path: join(SHOTS, 'splendor-mid-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'splendor-mid-phone.png') });
await guest.screenshot({ path: join(SHOTS, 'splendor-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, 'splendor-mid-pc-dark.png') });
await guest.screenshot({ path: join(SHOTS, 'splendor-mid-phone-dark.png'), fullPage: true });
await host.emulateMedia({ colorScheme: 'light' });
await guest.emulateMedia({ colorScheme: 'light' });
}
if (!acted) await host.waitForTimeout(150);
}
if (!(await over())) throw new Error(`game did not finish (${turns} UI turns: ${JSON.stringify(stats)})`);
await guest.getByRole('dialog', { name: '게임 결과' }).waitFor();
await host.waitForTimeout(600);
await host.screenshot({ path: join(SHOTS, 'splendor-result-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'splendor-result-phone.png') });
if (turns < 15 || !stats.buy || !stats.reserve || !stats.different || !stats.same || !stats.discard) throw new Error(`too few kinds of turns: ${JSON.stringify(stats)}`);
console.log(` splendor: finished after ${turns} UI turns ${JSON.stringify(stats)}`);
},
},
};
const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS);