Merge branch 'm7/splendor' into codex/owner/grp_discord_joke-app

This commit is contained in:
EJClaw
2026-10-05 14:56:12 +09:00
10 changed files with 3322 additions and 0 deletions

View File

@@ -325,6 +325,97 @@ async function dalmutiStep(p: Page, isHost: boolean, st: Record<string, number>)
return 'play';
}
/**
* 보석 상인: 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.
@@ -1008,6 +1099,62 @@ const PLANS: Record<string, Plan> = {
console.log(` dalmuti: ${actions} UI actions ${JSON.stringify(st)}${st.taxDone === 0 ? ', tax screen shown' : ', revolution (no tax)'}`);
},
},
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);