M7: 달무티 규칙·화면·테스트

- 규칙 모듈 packages/games/src/dalmuti: 80장(광대 2장) 덱, 첫 신분 뽑기(동점 재뽑기),
  신분 순서 분배, 혁명 확인 단계(혁명/대혁명), 세금 자동 이동과 돌려주기, 트릭 진행
  (광대 와일드·13, passLocksOut), 라운드 종료와 순위 점수/마지막 라운드 순위,
  시간 초과 자동 행동(리드: 가장 큰 숫자 전부, 따라 내기: 패스, 세금: 가장 큰 숫자),
  연속 3회 시간 초과 시 5초 마감, 나가도 탈락 없이 자동 진행, view 정보 숨김
- 테스트: 문서 10절 시나리오 전부 + 1,000 시드 무작위 대국(카드 보존, 유출 검사,
  onTimeout 합법, 결정성) + 엔진 randomPlayout
- 화면 apps/web/src/games/dalmuti: 자체 카드 그림, 숫자별 묶음 선택·장수 조절·광대 섞기,
  내기/패스, 바닥 기준 표시, 신분 자리, 혁명 확인·세금 교환 화면, 규칙·옵션
- 등록: GAMES, catalog, registry, GAME_ICON
- E2E: PC 방장 + 휴대폰 3명, 1라운드 끝까지 진행 후 2라운드 세금 교환 화면 확인

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-05 14:49:44 +09:00
parent 559fb8507c
commit dfe4ce1ca4
9 changed files with 2855 additions and 0 deletions

View File

@@ -260,6 +260,71 @@ async function marbleStep(p: Page, stats: Record<string, number>): Promise<strin
return null;
}
/**
* 달무티: one UI step for `p` — confirm the hand, give tax cards back (suggested cards), or on its turn pick a
* number group (a "광대 필요" group first; leads mix a jester in via the toggle) and press "내기", passing now
* and then. The host uses the keyboard (focus + Enter) for the group. Returns what it did, or null.
*/
async function dalmutiStep(p: Page, isHost: boolean, st: Record<string, number>): Promise<string | null> {
const enabled = async (l: ReturnType<Page['locator']>) => (await l.count()) > 0 && (await l.first().isEnabled().catch(() => false));
const confirm = p.getByRole('button', { name: '손패 확인', exact: true });
if (await enabled(confirm)) {
await confirm.click({ timeout: 1500 });
return 'confirm';
}
const giveBack = p.getByRole('button', { name: /^돌려주기/ });
if (await enabled(giveBack)) {
if (!st[`taxshot-${tag(p)}`] && (await p.getByText('1라운드 결과 → 새 신분').count())) {
st[`taxshot-${tag(p)}`] = 1;
await p.screenshot({ path: join(SHOTS, `dalmuti-tax-${tag(p)}.png`) });
await p.emulateMedia({ colorScheme: 'dark' });
await p.waitForTimeout(400);
await p.screenshot({ path: join(SHOTS, `dalmuti-tax-${tag(p)}-dark.png`) });
await p.emulateMedia({ colorScheme: 'light' });
}
await giveBack.click({ timeout: 1500 });
return 'return';
}
const status = (await p.locator('.turn-status').innerText().catch(() => '')).trim();
if (!status.includes('내 차례예요')) return null;
const pass = p.getByRole('button', { name: '패스', exact: true });
const canPass = await enabled(pass);
const legal = p.locator('button.dm-group[data-legal="true"]');
const n = await legal.count();
if (n === 0 || (canPass && st.turns! % 4 === 3)) {
if (!canPass) return null;
await pass.click({ timeout: 1500 });
st.turns!++;
st.passes = (st.passes ?? 0) + 1;
return 'pass';
}
const needs = p.locator('button.dm-group[data-legal="true"]', { hasText: '광대 필요' });
const lead = status.includes('새로 시작');
const jesterGroup = p.locator('button.dm-group[data-legal="true"][data-n="13"]');
let target = legal.last();
if (await needs.count()) target = needs.last();
else if (lead && (await jesterGroup.count()) && n > 1) target = legal.nth(n - 2);
if (isHost) {
await target.focus();
await p.keyboard.press('Enter');
st.hostKeys = (st.hostKeys ?? 0) + 1;
} else await target.click({ timeout: 1500 });
const toggle = p.locator('.dm-toggle');
if (lead && (await enabled(toggle)) && (await toggle.getAttribute('aria-pressed')) === 'false') await toggle.click({ timeout: 1500 });
const play = p.getByRole('button', { name: /^내기 \(/ });
if (!(await enabled(play))) return null;
const summary = await p.locator('.dm-pick .small').innerText().catch(() => '');
if (!st[`pickshot-${tag(p)}`]) {
st[`pickshot-${tag(p)}`] = 1;
await p.screenshot({ path: join(SHOTS, `dalmuti-pick-${tag(p)}.png`) });
}
if (summary.includes('광대')) st.jesterPlays = (st.jesterPlays ?? 0) + 1;
await play.click({ timeout: 1500 });
st.turns!++;
st.plays = (st.plays ?? 0) + 1;
return 'play';
}
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 +944,70 @@ const PLANS: Record<string, Plan> = {
console.log(` marble: finished after ${actions} UI actions ${JSON.stringify(stats)}`);
},
},
dalmuti: {
// PC host + 3 phone guests, 9~12 removed (38 cards) for a shorter round: real turns through the UI (sets with
// jesters, passes) until round 1 ends, then the round-2 hand check and the tax exchange (세금 카드 교환) screen.
players: 4,
lobby: async (host) => {
await host.getByRole('button', { name: '▼ 자세한 설정' }).click();
await host.getByRole('group', { name: '9~12 카드 빼기' }).getByRole('button', { name: '켜기' }).click();
await host.getByRole('group', { name: '한 턴 제한 시간' }).getByRole('button', { name: '45초' }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/9~12 빼기/).first().waitFor();
},
drive: async (pages) => {
const [host, guest] = pages as [Page, Page];
const st: Record<string, number> = { turns: 0 };
let actions = 0;
let midShot = false;
let round2 = false;
const until = Date.now() + 300_000;
const playingAgain = () => host.locator('.dm-table').count();
while (Date.now() < until) {
let acted = false;
for (const [i, p] of pages.entries()) {
if (await dalmutiStep(p, i === 0, st)) {
acted = true;
actions++;
await p.waitForTimeout(120);
break;
}
}
if (!midShot && (st.plays ?? 0) >= 6 && (await host.locator('.dm-set.top').count())) {
midShot = true;
await host.waitForTimeout(400);
await host.screenshot({ path: join(SHOTS, 'dalmuti-mid-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'dalmuti-mid-phone.png') });
await guest.screenshot({ path: join(SHOTS, 'dalmuti-mid-phone-full.png'), fullPage: true });
for (const p of [host, guest]) await p.emulateMedia({ colorScheme: 'dark' });
await host.waitForTimeout(400);
await host.screenshot({ path: join(SHOTS, 'dalmuti-mid-pc-dark.png') });
await guest.screenshot({ path: join(SHOTS, 'dalmuti-mid-phone-dark.png') });
for (const p of [host, guest]) await p.emulateMedia({ colorScheme: 'light' });
}
if (!round2 && (await host.getByText('1라운드 결과 → 새 신분').count())) {
round2 = true;
await host.waitForTimeout(300);
await host.screenshot({ path: join(SHOTS, 'dalmuti-round2-check-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'dalmuti-round2-check-phone.png'), fullPage: true });
}
if (round2 && st.taxDone === undefined && (await host.getByRole('heading', { name: '세금 카드 교환' }).count())) {
st.taxDone = 0;
await host.waitForTimeout(300);
await host.screenshot({ path: join(SHOTS, 'dalmuti-round2-tax-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'dalmuti-round2-tax-phone.png'), fullPage: true });
}
// Done: round 2's tax exchange happened (or a revolution skipped it) and the table is back, with a jester set seen.
if (round2 && (await playingAgain()) && (st.jesterPlays ?? 0) > 0) break;
if (!acted) await host.waitForTimeout(120);
}
if (!round2) throw new Error(`round 1 did not finish (${actions} UI actions: ${JSON.stringify(st)})`);
if (!(await playingAgain())) throw new Error(`round 2 did not start playing (${JSON.stringify(st)})`);
if (!st.passes || (st.plays ?? 0) < 8 || !st.jesterPlays) throw new Error(`too few real turns / no jester set: ${JSON.stringify(st)}`);
await host.screenshot({ path: join(SHOTS, 'dalmuti-round2-pc.png') });
console.log(` dalmuti: ${actions} UI actions ${JSON.stringify(st)}${st.taxDone === 0 ? ', tax screen shown' : ', revolution (no tax)'}`);
},
},
};
const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS);