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:
EJClaw
2026-10-05 14:53:47 +09:00
parent 559fb8507c
commit fa7ceea668
10 changed files with 2339 additions and 1 deletions

View File

@@ -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);