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:
147
e2e/games.e2e.ts
147
e2e/games.e2e.ts
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user