Merge branch 'm5/gostop' into codex/owner/grp_discord_joke-app

# Conflicts:
#	apps/web/src/games/registry.ts
#	apps/web/src/lib/catalog-ui.ts
#	packages/games/src/index.ts
This commit is contained in:
EJClaw
2026-10-05 14:07:23 +09:00
13 changed files with 4236 additions and 3 deletions

View File

@@ -150,7 +150,112 @@ async function yutStep(p: Page): Promise<boolean> {
}
}
/**
* 고스톱: one UI step for `p` if it has something to do — answer an open decision sheet (고/스톱: 고 once, then 스톱;
* choose a floor card; 흔들기/폭탄; 총통 → 그냥 치기; 국진) or play a hand card (green-bordered match first; phones
* use tap-to-lift, so a lifted card is tapped again). Returns what it did, or null.
*/
async function gostopStep(p: Page, state: { go: number; stop: number; picks: number; plays: number; sheets: number }): Promise<string | null> {
const dialog = p.getByRole('dialog');
if (await dialog.count()) {
const name = (await dialog.first().getAttribute('aria-label')) ?? '';
if (name === '게임 결과') return null;
if (name === '고 또는 스톱') {
const goFirst = state.go === 0;
if (goFirst && state.stop === 0) await p.screenshot({ path: join(SHOTS, 'gostop-gostop-dialog.png') });
await dialog.getByRole('button', { name: goFirst ? /^고/ : /^스톱/ }).click({ timeout: 1500 });
if (goFirst) state.go++;
else state.stop++;
return goFirst ? 'go' : 'stop';
}
if (name === '총통') {
await dialog.getByRole('button', { name: /^그냥 치기/ }).click({ timeout: 1500 });
return 'chongtong';
}
const pick = dialog.locator('.gs-pick button, .gs-big .btn').first();
if (await pick.count()) {
if (state.sheets === 0) await p.screenshot({ path: join(SHOTS, 'gostop-choice-dialog.png') });
await pick.click({ timeout: 1500 });
state.sheets++;
if (name.includes('고르기')) state.picks++;
return name;
}
return null;
}
const status = (await p.locator('.turn-status').innerText().catch(() => '')).trim();
if (!status.includes('내 차례예요')) return null;
const card = p.locator('.gs-hcard[data-match="true"]:not([disabled])').first();
const any = p.locator('.gs-hcard:not([disabled])').first();
const target = (await card.count()) ? card : any;
if (!(await target.count())) {
const dummy = p.getByRole('button', { name: /^빈패 내기/ });
if (await dummy.isEnabled().catch(() => false)) {
await dummy.click({ timeout: 1500 });
state.plays++;
return 'dummy';
}
return null;
}
const label = await target.getAttribute('aria-label');
await target.click({ timeout: 1500 });
const lifted = p.locator(`.gs-hcard[data-lifted="true"][aria-label="${label}"]`);
if (await lifted.count()) await lifted.click({ timeout: 1500 });
state.plays++;
return 'play';
}
const PLANS: Record<string, Plan> = {
gostop: {
// 맞고: PC host + phone guest, 1판. Real turns through the UI — hand cards (tap twice on the phone), floor-card choices,
// 흔들기/폭탄 sheets, one 고 and then 스톱 — until the hand ends (스톱 or 나가리) and the game result shows.
lobby: async (host) => {
await host.getByRole('group', { name: '판 수' }).getByRole('button', { name: '1판', exact: true }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/^1판 · /).first().waitFor();
},
drive: async (pages) => {
const [host, guest] = pages as [Page, Page];
const state = { go: 0, stop: 0, picks: 0, plays: 0, sheets: 0 };
let shot = false;
const until = Date.now() + 240_000;
const over = async () => (await host.getByRole('dialog', { name: '게임 결과' }).count()) > 0;
while (Date.now() < until && !(await over())) {
let acted = false;
for (const p of pages) {
const did = await gostopStep(p, state).catch(() => null);
if (did) {
acted = true;
await p.waitForTimeout(250);
break;
}
}
if (state.plays >= 8 && !shot) {
shot = true;
await host.waitForTimeout(1500);
await host.screenshot({ path: join(SHOTS, 'gostop-mid-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'gostop-mid-phone.png'), fullPage: true });
await guest.emulateMedia({ colorScheme: 'dark' });
await host.emulateMedia({ colorScheme: 'dark' });
await guest.screenshot({ path: join(SHOTS, 'gostop-mid-phone-dark.png'), fullPage: true });
await host.screenshot({ path: join(SHOTS, 'gostop-mid-pc-dark.png') });
await guest.emulateMedia({ colorScheme: 'light' });
await host.emulateMedia({ colorScheme: 'light' });
}
if (!acted) await host.waitForTimeout(150);
}
if (!(await over())) throw new Error(`hand did not end (${JSON.stringify(state)})`);
await guest.getByRole('dialog', { name: '게임 결과' }).waitFor();
await host.screenshot({ path: join(SHOTS, 'gostop-result-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'gostop-result-phone.png') });
// Close the result sheet to see the round breakdown underneath.
await host.keyboard.press('Escape');
await host.getByText(/1판 결과/).first().waitFor();
await host.screenshot({ path: join(SHOTS, 'gostop-breakdown-pc.png') });
if (state.plays < 10) throw new Error(`only ${state.plays} cards were played`);
console.log(` gostop: ${state.plays} plays, ${state.sheets} choice sheets (${state.picks} card picks), 고 ${state.go}, 스톱 ${state.stop}`);
},
},
rummikub: {
// PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI.
lobby: async (host) => {