M5: 고스톱·맞고 규칙·화면·테스트

- packages/games/src/gostop: 화투 48장 + 보너스패(cards), 족보·국진·최종 공식·박·독박 정산(scoring),
  패 돌리기·바닥 정리·광팔기·총통·먹기 판정(쪽/뻑/따닥/쓸/폭탄/흔들기/보너스패)·피 뺏기·고/스톱·나가리·
  첫뻑/연뻑/삼뻑(round), validate·view(남의 손패·더미 숨김, 매칭/점수 미리 계산)·시간 초과·나가기(index).
  2인 맞고, 3인 고스톱, 4~5인 광팔기, 여러 판 세트(방 점수 제로섬).
- 테스트: 문서 §10 체크리스트 전부, 1,000 시드 무작위 진행(카드·점수 보존, onTimeout 합법),
  engine randomPlayout(누출·JSON·결정성), 정보 누출 속성 테스트.
- 화면 apps/web/src/games/gostop: 월별 바닥, 매칭 하이라이트, 두 장 중 고르기, 흔들기·폭탄·고/스톱·총통·
  국진·광팔기 선택, 먹은 패 종류별 묶음과 족보 진행, 이벤트 알림, 판 결과 계산 과정, PC·휴대폰·다크 모드.
- HwatuCard: 보너스패(2피·3피) 그림 추가. 게임 목록·레지스트리 등록, 홈 "준비 중"에서 고스톱 제거.
- E2E: 맞고 PC 방장 + 휴대폰 친구가 실제 화면 조작으로 고·스톱까지 진행.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-05 13:35:21 +09:00
parent 6321ea8c0d
commit f5e4a7ce59
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) => {