M7: 라스베가스 규칙·화면·테스트
- 규칙 모듈 packages/games/src/las-vegas: 지폐 54장 더미·카지노 채우기(합 5 이상), 굴리기/놓기(같은 눈 전부), 중립(흰) 주사위 변형(auto/on/off, 3명은 라운드 시작 하우스 주사위 2개 자동 배치), 동점 무효 정산, 하우스·남은 지폐 더미 아래로(카지노 1→6, 큰 금액부터), 라운드마다 선 이동, 장 수 동점 처리, 시간 초과(굴리기 자동 / 개수→지폐→작은 수 순 자동 놓기)와 3회 연속 자리 비움, 기권, hideMoney 시 남의 금액은 장 수만 보이는 view, 놓기 미리 보기(1등/동점 주의) 계산 - 테스트: 문서 10절 시나리오 전부, 1,000시드 무작위 대국(타임아웃·기권 포함, 지폐·주사위 보존, 정보 유출, onTimeout 합법, 결정성) + 엔진 randomPlayout 100시드 - 화면 apps/web/src/games/las-vegas: 카지노 6곳(PC 한 줄, 폰 3열×2행), 색깔 주사위 쌓기, 놀이용 지폐, 굴림 애니메이션, 숫자별 묶음 버튼 → 카지노 강조와 예상 순위 → 확정, 라운드 정산(동점 무효) 표시, 최종 결과표, 폰에서는 굴리기/놓기 영역이 화면 아래에 고정 - 등록: GAMES, 카탈로그, 화면 레지스트리, 아이콘, 놀이용 점수 문구 검사 대상에 추가 - E2E: PC 방장 + 폰 친구, 3라운드로 바꿔 결과까지 실제 플레이(키보드 선택, 카지노 카드 선택, 동점 무효 확인) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
114
e2e/games.e2e.ts
114
e2e/games.e2e.ts
@@ -260,6 +260,68 @@ async function marbleStep(p: Page, stats: Record<string, number>): Promise<strin
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 라스베가스: one UI step for `p` — press "주사위 굴리기", or pick a number group (every other pick prefers a group
|
||||
* marked "동점 주의" so duplicate-tie cancellations really happen) and confirm "N번 카지노에 M개 놓기".
|
||||
* The PC host's first pick uses the keyboard, its second one taps the casino card instead of the group.
|
||||
*/
|
||||
async function lasVegasStep(
|
||||
p: Page,
|
||||
phone: boolean,
|
||||
st: { rolls: number; places: number; keyboard: boolean; casinoPick: boolean; shots: Set<string> },
|
||||
): Promise<boolean> {
|
||||
const press = (l: ReturnType<Page['locator']>) => (phone ? l.tap({ timeout: 1500 }) : l.click({ timeout: 1500 }));
|
||||
const roll = p.getByRole('button', { name: /^🎲 주사위 굴리기/ });
|
||||
if ((await roll.count()) && (await roll.isEnabled().catch(() => false))) {
|
||||
if (!(await press(roll).then(() => true, () => false))) return false;
|
||||
st.rolls++;
|
||||
return true;
|
||||
}
|
||||
const groups = p.locator('.lv-group:not([disabled])');
|
||||
if (!(await groups.count())) return false;
|
||||
try {
|
||||
const tie = p.locator('.lv-group:not([disabled])[data-standing="tie"]');
|
||||
const target = st.places % 2 === 1 && (await tie.count()) ? tie.first() : groups.first();
|
||||
const confirm = p.getByRole('button', { name: /번 카지노에 \d+개 놓기$/ });
|
||||
if (!phone && !st.keyboard) {
|
||||
// Keyboard only: Enter on a number group, then Enter on the confirm button.
|
||||
await target.focus();
|
||||
await p.keyboard.press('Enter');
|
||||
await confirm.waitFor({ timeout: 1500 });
|
||||
await confirm.focus();
|
||||
await p.keyboard.press('Enter');
|
||||
st.keyboard = true;
|
||||
} else if (!phone && !st.casinoPick) {
|
||||
// Choose by tapping the highlighted casino card instead of the group button.
|
||||
await p.locator('button.lv-casino').first().click({ timeout: 1500 });
|
||||
await confirm.waitFor({ timeout: 1500 });
|
||||
st.casinoPick = true;
|
||||
await confirm.click({ timeout: 1500 });
|
||||
} else {
|
||||
await press(target);
|
||||
await confirm.waitFor({ timeout: 1500 });
|
||||
const tag = phone ? 'phone' : 'pc';
|
||||
if (st.places >= 6 && !st.shots.has(tag)) {
|
||||
// Mid-game: a number group chosen, casino highlighted with its preview tag, light + dark.
|
||||
st.shots.add(tag);
|
||||
if (phone) await p.evaluate(() => window.scrollTo(0, 0));
|
||||
await p.screenshot({ path: join(SHOTS, `las-vegas-mid-${tag}.png`) });
|
||||
if (phone) await p.screenshot({ path: join(SHOTS, `las-vegas-mid-${tag}-full.png`), fullPage: true });
|
||||
await p.emulateMedia({ colorScheme: 'dark' });
|
||||
await p.waitForTimeout(250);
|
||||
await p.screenshot({ path: join(SHOTS, `las-vegas-mid-${tag}-dark.png`) });
|
||||
await p.emulateMedia({ colorScheme: 'light' });
|
||||
}
|
||||
await press(confirm);
|
||||
}
|
||||
st.places++;
|
||||
await p.waitForFunction(() => !document.querySelector('.lv-group[aria-pressed="true"]'), null, { timeout: 5000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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 +941,58 @@ const PLANS: Record<string, Plan> = {
|
||||
console.log(` marble: finished after ${actions} UI actions ${JSON.stringify(stats)}`);
|
||||
},
|
||||
},
|
||||
'las-vegas': {
|
||||
// PC host + phone guest (2 players → 4 white dice each), 3 rounds via the lobby: real rolls and placements
|
||||
// through the UI until round settlements (with a duplicate-tie cancellation) and the final result appear.
|
||||
lobby: async (host) => {
|
||||
await host.getByRole('group', { name: '라운드 수' }).getByRole('button', { name: '3라운드' }).click();
|
||||
await host.getByRole('button', { name: '설정 저장' }).click();
|
||||
await host.getByText(/^3라운드 · 흰 주사위 자동/).first().waitFor();
|
||||
},
|
||||
drive: async (pages) => {
|
||||
const [host, guest] = pages as [Page, Page];
|
||||
const st = { rolls: 0, places: 0, keyboard: false, casinoPick: false, shots: new Set<string>() };
|
||||
let tieSeen = false;
|
||||
let settleShot = false;
|
||||
const over = () => host.getByRole('dialog', { name: '게임 결과' }).count();
|
||||
const until = Date.now() + 300_000;
|
||||
while (Date.now() < until && !(await over())) {
|
||||
let acted = false;
|
||||
for (const [i, p] of pages.entries()) {
|
||||
if (await lasVegasStep(p, i > 0, st)) {
|
||||
acted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!settleShot && (await host.locator('section[aria-label="1라운드 정산"]').count())) {
|
||||
settleShot = true;
|
||||
await host.waitForTimeout(700);
|
||||
await host.screenshot({ path: join(SHOTS, 'las-vegas-settle-pc.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'las-vegas-settle-phone.png'), fullPage: true });
|
||||
}
|
||||
if (!tieSeen && (await host.locator('.lv-settle .lv-tieout').count())) tieSeen = true;
|
||||
if (!acted) await host.waitForTimeout(150);
|
||||
}
|
||||
if (!(await over())) throw new Error(`game did not finish (${JSON.stringify({ ...st, shots: [...st.shots] })})`);
|
||||
await guest.getByRole('dialog', { name: '게임 결과' }).waitFor();
|
||||
if (!tieSeen && (await host.locator('.lv-settle .lv-tieout').count())) tieSeen = true;
|
||||
await host.waitForTimeout(600);
|
||||
await host.screenshot({ path: join(SHOTS, 'las-vegas-result-pc.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'las-vegas-result-phone.png') });
|
||||
// Close the result sheet to look at the final table on the board.
|
||||
await host.keyboard.press('Escape');
|
||||
await host.locator('section[aria-label="최종 결과"]').waitFor();
|
||||
await host.screenshot({ path: join(SHOTS, 'las-vegas-final-pc.png') });
|
||||
await host.emulateMedia({ colorScheme: 'dark' });
|
||||
await host.waitForTimeout(250);
|
||||
await host.screenshot({ path: join(SHOTS, 'las-vegas-final-pc-dark.png') });
|
||||
await host.emulateMedia({ colorScheme: 'light' });
|
||||
if (!(await host.locator('section[aria-label="3라운드 정산"]').count())) throw new Error('no settlement for round 3');
|
||||
if (!tieSeen) throw new Error('no duplicate-tie cancellation appeared in any settlement');
|
||||
if (st.rolls < 10 || st.places < 10 || !st.keyboard || !st.casinoPick) throw new Error(`too few real turns: ${JSON.stringify({ ...st, shots: [...st.shots] })}`);
|
||||
console.log(` las-vegas: ${st.rolls} rolls, ${st.places} placements, tie cancellation seen`);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS);
|
||||
|
||||
Reference in New Issue
Block a user