M6: 뱅 규칙·화면·테스트
- packages/games/src/bang: 뱅!(4~7명) 규칙 모듈. 카드 80장·캐릭터 16종 데이터, 거리/사거리, 효과·반응 스택(뱅!/개틀링/인디언!/결투/잡화점/빈사/킷 칼슨/choose2), 다이너마이트·감옥 판정, 처치 보상·보안관 벌칙·벌처 샘, 승리 판정, 역할·손패·덱 비공개 view, onTimeout/onLeave, legalActions·secretsOf. 하우스 룰 옵션 4종. - 테스트 62개: 문서 §10 시나리오 전부, 시드 1,000개 4~7명 무작위 대국(카드 총수 보존, 생명 범위, 정보 유출, onTimeout 합법, 결정성) + 엔진 randomPlayout 실행, 재생 결정성. - apps/web/src/games/bang: 원형 테이블(생명 탄환·장착·거리·역할 배지), 꾹 눌러 보는 내 역할 카드, 카드 선택 → 대상 강조 → 확인, 반응 시트와 타이머, 기록·카드 도감, PC/휴대폰, 라이트/다크. - 등록: GAMES, 카탈로그(available), 웹 레지스트리, 아이콘(UPCOMING에서 '뱅' 제거). - e2e: bang 플랜(PC 방장 + 휴대폰 3명, 반응 25초, 실제 턴 10번: 뱅!·기타 카드·반응·버리기). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
108
e2e/games.e2e.ts
108
e2e/games.e2e.ts
@@ -249,6 +249,114 @@ const PLANS: Record<string, Plan> = {
|
||||
console.log(` one-card: ${turns} turns (${plays} plays, ${draws} draws)${finished ? ', game finished' : ''}`);
|
||||
},
|
||||
},
|
||||
bang: {
|
||||
// PC host + 3 phone guests, reaction time 25s: real turns through the UI — draw, play usable cards
|
||||
// (뱅! at a highlighted target → confirm, other untargeted cards by double tap), answer reaction
|
||||
// sheets (빗나감!/맥주/결투/인디언!/잡화점), discard down to the hand limit, end the turn.
|
||||
players: 4,
|
||||
lobby: async (host) => {
|
||||
await host.getByRole('group', { name: '반응 제한 시간' }).getByRole('button', { name: '25초' }).click();
|
||||
await host.getByRole('button', { name: '설정 저장' }).click();
|
||||
await host.getByText(/반응 25초/).first().waitFor();
|
||||
},
|
||||
drive: async (pages) => {
|
||||
const stats = { turns: 0, bangs: 0, plays: 0, reactions: 0, discards: 0 };
|
||||
let finished = false;
|
||||
let shot = false;
|
||||
let midShot = false;
|
||||
const tryClick = (l: ReturnType<Page['locator']>) => l.click({ timeout: 1500 }).then(() => true, () => false);
|
||||
/** One UI step on this page; true when something was pressed. */
|
||||
const step = async (p: Page): Promise<boolean> => {
|
||||
const modal = p.locator('.bg-modal');
|
||||
if (await modal.count()) {
|
||||
const store = modal.locator('button.bg-card');
|
||||
const title = (await modal.locator('h2').innerText().catch(() => '')).trim();
|
||||
if (title.startsWith('3장 중')) {
|
||||
await store.nth(0).click();
|
||||
await store.nth(1).click();
|
||||
await modal.getByRole('button', { name: '이 2장 받기' }).click();
|
||||
} else if (title.startsWith('잡화점')) await store.first().click();
|
||||
else {
|
||||
const use = modal.getByRole('button', { name: /(내기|마시기)$/ });
|
||||
if (!(await use.count()) || !(await tryClick(use.first()))) {
|
||||
if (!(await tryClick(modal.getByRole('button', { name: /^(포기하고 맞기|맞기|쓰러지기)/ }).first()))) return false;
|
||||
}
|
||||
}
|
||||
stats.reactions++;
|
||||
return true;
|
||||
}
|
||||
const draw = p.getByRole('button', { name: '카드 더미에서 2장 받기' });
|
||||
if (await draw.count()) return tryClick(draw);
|
||||
const discardBtn = p.getByRole('button', { name: /장 버리기$/ });
|
||||
if (await discardBtn.count()) {
|
||||
const need = Number((await p.locator('.bg-actions').innerText()).match(/버릴 카드 (\d+)장/)?.[1] ?? 0);
|
||||
const cards = p.locator('.bg-me .bg-hand button.bg-card');
|
||||
for (let i = 0; i < need; i++) await cards.nth(i).click();
|
||||
await discardBtn.click();
|
||||
stats.discards++;
|
||||
return true;
|
||||
}
|
||||
const end = p.getByRole('button', { name: '턴 마치기' });
|
||||
if (!(await end.count()) || !(await end.isEnabled())) return false;
|
||||
// Shoot first (once per turn is enough), then other untargeted cards, then end the turn.
|
||||
const bang = p.locator('.bg-me button.bg-card[data-kind="bang"][data-ok="true"]').first();
|
||||
if (await bang.count()) {
|
||||
await bang.click();
|
||||
const target = p.locator('button.bg-seat[data-target="ok"]').first();
|
||||
await target.waitFor({ timeout: 2000 });
|
||||
await target.click();
|
||||
const fire = p.getByRole('button', { name: /에게 뱅!$/ });
|
||||
await fire.click();
|
||||
stats.bangs++;
|
||||
if (!shot) {
|
||||
shot = true;
|
||||
await p.waitForTimeout(400);
|
||||
for (const [i, q] of pages.entries()) await q.screenshot({ path: join(SHOTS, `bang-3-shot-${i === 0 ? 'pc' : `phone${i}`}.png`) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const simple = ['beer', 'stagecoach', 'wellsFargo', 'saloon', 'barrel', 'mustang', 'scope', 'schofield', 'remington', 'carabine', 'winchester', 'volcanic', 'indians', 'gatling', 'generalStore'];
|
||||
for (const k of simple) {
|
||||
const c = p.locator(`.bg-me button.bg-card[data-kind="${k}"][data-ok="true"]`).first();
|
||||
if (!(await c.count())) continue;
|
||||
await c.click();
|
||||
await c.click();
|
||||
stats.plays++;
|
||||
return true;
|
||||
}
|
||||
await end.click();
|
||||
stats.turns++;
|
||||
return true;
|
||||
};
|
||||
const until = Date.now() + 240_000;
|
||||
while (Date.now() < until && stats.turns < 10) {
|
||||
if (await pages[0]!.getByRole('dialog', { name: '게임 결과' }).count()) {
|
||||
finished = true;
|
||||
break;
|
||||
}
|
||||
let acted = false;
|
||||
for (const p of pages) {
|
||||
if (await step(p)) {
|
||||
acted = true;
|
||||
await p.waitForTimeout(250);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (stats.turns >= 4 && !midShot) {
|
||||
midShot = true;
|
||||
await pages[0]!.screenshot({ path: join(SHOTS, 'bang-4-mid-pc.png') });
|
||||
await pages[1]!.screenshot({ path: join(SHOTS, 'bang-4-mid-phone.png'), fullPage: true });
|
||||
await pages[1]!.emulateMedia({ colorScheme: 'dark' });
|
||||
await pages[1]!.screenshot({ path: join(SHOTS, 'bang-4-mid-phone-dark.png') });
|
||||
await pages[1]!.emulateMedia({ colorScheme: 'light' });
|
||||
}
|
||||
if (!acted) await pages[0]!.waitForTimeout(150);
|
||||
}
|
||||
console.log(` bang: ${stats.turns} turns, ${stats.bangs} 뱅!, ${stats.plays} other cards, ${stats.reactions} reactions, ${stats.discards} discards${finished ? ', game finished' : ''}`);
|
||||
if (!finished && stats.turns < 8) throw new Error(`only ${stats.turns} turns were played`);
|
||||
if (stats.bangs < 1) throw new Error('no 뱅! was played');
|
||||
},
|
||||
},
|
||||
'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 "여기서 멈추기".
|
||||
|
||||
Reference in New Issue
Block a user