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:
129
e2e/games.e2e.ts
129
e2e/games.e2e.ts
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user