M5: 마작 규칙·점수 계산·화면·테스트

- 규칙 모듈 packages/games/src/mahjong (tiles·agari·scoring·types·index): 4인 리치 마작(동풍전/반장전)과
  3인 산마, 옵션 전부 한국어 meta, 응답 창(론 > 퐁·깡 > 치, 조기 확정, 최소 400ms, 시간 초과 = 패스),
  리치·일발·더블리치, 후리텐(버림패·동순·리치), 구이가에, 깡 도라 시점, 사깡·사풍·사가리치·구종구패·삼가화,
  유국만관, 책임지불, 북 뽑기, 고점법 점수 계산(역·부·만관~더블 역만, 본장·공탁, 산마 쯔모손/북가절반),
  토비·아가리야메·서입 서든데스, 우마·오카 결과, 초보 모드 힌트, autoPilot·나가기 처리
- 응답 창 타이머는 아직 응답하지 않은 응답 가능자에게만 걸어 연결 끊긴 사람은 바로 패스, 확정 뒤에는 방총자
  타이머로 닫음(끊긴 방총자 때문에 타이머가 헛도는 일 방지). 공개 시계에는 응답 창 남은 시간을 싣지 않음
- 테스트: 점수 계산기 161건(1~4판 × 20~110부 표 전 칸, 만관 이상, 역·부 예시, 산마 지불),
  규칙 시나리오 77건(§10 체크리스트), 무작위 대국 1,000판(4인 반장전 240 · 동풍전 400 · 산마 360,
  옵션 무작위, 패·점수 보존, onTimeout 합법, 재현성) + 엔진 randomPlayout과 매 단계 정보 누출 검사
- 화면 apps/web/src/games/mahjong: 직접 그린 SVG 패, 4방향(3인은 3방향) 버림패 테이블, 탭 두 번 버리기,
  리치·쯔모·깡·북·구종구패, 론·퐁·치(조합 선택)·깡·패스 응답, 국 결과(역·판·부·점수 변화)와 최종 순위,
  초보 모드(텐파이 타패·대기·남은 장수·역 힌트·후리텐 배지), PC 2단 배치·휴대폰 한 줄 손패, 키보드·스크린리더
- 등록: GAMES, 카탈로그(available), 웹 registry, '곧 나와요'에서 마작 제거
- E2E: PC 방장 + 휴대폰 3명 4인 대국으로 한 국을 끝까지(타패·패스·유국 결과 확인·다음 국 시작)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-05 14:06:45 +09:00
parent 6321ea8c0d
commit c0fb44e395
18 changed files with 6511 additions and 1 deletions

View File

@@ -249,6 +249,78 @@ const PLANS: Record<string, Plan> = {
console.log(` one-card: ${turns} turns (${plays} plays, ${draws} draws)${finished ? ', game finished' : ''}`);
},
},
mahjong: {
// 4인 리치 마작 (PC host + 3 phones): whoever has the turn taps the right-most legal tile (lift) and presses
// the "… 버리기" button; claim windows are answered with 패스 (론/쯔모 when offered). Plays until the first hand
// ends (win or exhaustive draw, ~70 discards), checks the result screen on every device and confirms it.
players: 4,
drive: async (pages) => {
const statusOf = (p: Page) => p.locator('.turn-status').innerText().catch(() => '');
let discards = 0;
let passes = 0;
let wins = 0;
let results = 0;
const shotClaim = new Set<string>();
for (let t = 0; t < 4000 && discards < 100 && results < 1; t++) {
let acted = false;
for (const [pi, p] of pages.entries()) {
const status = await statusOf(p);
const win = p.getByRole('button', { name: /^(쯔모|론)!$/ });
if ((await win.count()) && (await win.first().isEnabled().catch(() => false))) {
await win.first().click({ timeout: 1000 }).catch(() => {});
wins++;
acted = true;
break;
}
const pass = p.getByRole('group', { name: '울기·론 선택' }).getByRole('button', { name: '패스' });
if (await pass.count()) {
const dev = pi === 0 ? 'pc' : 'phone';
if (!shotClaim.has(dev)) {
await p.screenshot({ path: join(SHOTS, `mahjong-3-claim-${dev}.png`) });
shotClaim.add(dev);
}
await pass.click({ timeout: 1000 }).catch(() => {});
passes++;
acted = true;
break;
}
const next = p.getByRole('button', { name: /^(다음 국으로|최종 결과 보기)$/ });
if ((await next.count()) && (await next.isEnabled().catch(() => false))) {
// The hand is over: every device shows the result, then everyone confirms → the next hand starts.
await pages[0]!.screenshot({ path: join(SHOTS, 'mahjong-4-result-pc.png') });
await pages[1]!.screenshot({ path: join(SHOTS, 'mahjong-4-result-phone.png'), fullPage: true });
for (const q of pages) {
await q.getByRole('region', { name: /결과$/ }).waitFor({ timeout: 5000 });
const b = q.getByRole('button', { name: /^(다음 국으로|최종 결과 보기)$/ });
if (await b.isEnabled().catch(() => false)) await b.click({ timeout: 1000 }).catch(() => {});
}
await pages[0]!.getByText(/^동1국|^동2국/).first().waitFor({ timeout: 10_000 });
results++;
acted = true;
break;
}
if (!status.includes('내 차례예요')) continue;
const tiles = p.locator('button.mj-htile[data-legal="true"]');
const n = await tiles.count();
if (!n) continue;
const tile = tiles.nth(n - 1);
await tile.click({ timeout: 1000 }).catch(() => {});
if (discards === 4 && pi > 0) await p.screenshot({ path: join(SHOTS, 'mahjong-2-selected-phone.png') });
const send = p.getByRole('button', { name: /버리기$/ });
if ((await send.count()) && (await send.isEnabled().catch(() => false))) await send.click({ timeout: 1000 }).catch(() => {});
await p.waitForFunction((s) => document.querySelector('.turn-status')?.textContent?.trim() !== s, status.trim(), { timeout: 5000 }).catch(() => {});
discards++;
acted = true;
break;
}
if (!acted) await pages[0]!.waitForTimeout(120);
}
if (results === 0) throw new Error(`no hand finished after ${discards} discards`);
// The next hand has started: the table and my hand are back on every screen.
for (const q of pages) await q.locator('.mj-table').waitFor({ timeout: 10_000 });
console.log(` mahjong: ${discards} discards, ${passes} passes, ${wins} wins, ${results} hand finished → next hand started`);
},
},
'davinci-code': {
// 2 players, default options (choose colours): both pick 2+2 start tiles, then several real turns:
// draw from a pile, tap an opponent's hidden tile, pick a number, confirm; after a hit press "여기서 멈추기".