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:
EJClaw
2026-10-05 13:38:00 +09:00
parent 6321ea8c0d
commit d6496dc1fa
13 changed files with 4727 additions and 1 deletions

View File

@@ -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 "여기서 멈추기".