M5·M6: 마작·고스톱·뱅·할리갈리·주사위 땅따먹기 합치기
- 다섯 게임 브랜치 병합, 등록 목록·E2E 계획 충돌 정리(E2E 계획은 기존 게임 계획 그대로 + 새 계획 5개) - 홈 '곧 나와요' 목록을 M7·M8 게임으로 갱신 - 로드맵 M5·M6 진행 결과 기록 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
758
e2e/games.e2e.ts
758
e2e/games.e2e.ts
@@ -203,6 +203,19 @@ async function gostopStep(p: Page, state: { go: number; stop: number; picks: num
|
||||
if (await lifted.count()) await lifted.click({ timeout: 1500 });
|
||||
state.plays++;
|
||||
return 'play';
|
||||
}
|
||||
|
||||
/** 할리갈리: fruit totals of the face-up tops, read from the seat labels ("펼친 카드 딸기 3개"). */
|
||||
async function halliTotals(p: Page): Promise<Record<string, number>> {
|
||||
const labels = await p.locator('.hg-seat').evaluateAll((els) => els.map((e) => e.getAttribute('aria-label') ?? ''));
|
||||
const t: Record<string, number> = { 딸기: 0, 바나나: 0, 라임: 0, 자두: 0 };
|
||||
for (const l of labels) {
|
||||
const m = /펼친 카드 (딸기|바나나|라임|자두) (\d)개/.exec(l);
|
||||
if (m) t[m[1]!]! += Number(m[2]);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
/**
|
||||
* 주사위 땅따먹기: one UI step for whoever can act on `p` — answer a trade, sell, buy/build (or skip),
|
||||
* pick a space-travel square on the board, escape the island or roll. Returns the label pressed.
|
||||
@@ -248,151 +261,6 @@ async function marbleStep(p: Page, stats: Record<string, number>): Promise<strin
|
||||
}
|
||||
|
||||
const PLANS: Record<string, Plan> = {
|
||||
gostop: {
|
||||
// 맞고: PC host + phone guest, 1판. Real turns through the UI — hand cards (tap twice on the phone), floor-card choices,
|
||||
// 흔들기/폭탄 sheets, one 고 and then 스톱 — until the hand ends (스톱 or 나가리) and the game result shows.
|
||||
lobby: async (host) => {
|
||||
await host.getByRole('group', { name: '판 수' }).getByRole('button', { name: '1판', exact: true }).click();
|
||||
await host.getByRole('button', { name: '설정 저장' }).click();
|
||||
await host.getByText(/^1판 · /).first().waitFor();
|
||||
},
|
||||
drive: async (pages) => {
|
||||
const [host, guest] = pages as [Page, Page];
|
||||
const state = { go: 0, stop: 0, picks: 0, plays: 0, sheets: 0 };
|
||||
let shot = false;
|
||||
const until = Date.now() + 240_000;
|
||||
const over = async () => (await host.getByRole('dialog', { name: '게임 결과' }).count()) > 0;
|
||||
while (Date.now() < until && !(await over())) {
|
||||
let acted = false;
|
||||
for (const p of pages) {
|
||||
const did = await gostopStep(p, state).catch(() => null);
|
||||
if (did) {
|
||||
acted = true;
|
||||
await p.waitForTimeout(250);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (state.plays >= 8 && !shot) {
|
||||
shot = true;
|
||||
await host.waitForTimeout(1500);
|
||||
await host.screenshot({ path: join(SHOTS, 'gostop-mid-pc.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'gostop-mid-phone.png'), fullPage: true });
|
||||
await guest.emulateMedia({ colorScheme: 'dark' });
|
||||
await host.emulateMedia({ colorScheme: 'dark' });
|
||||
await guest.screenshot({ path: join(SHOTS, 'gostop-mid-phone-dark.png'), fullPage: true });
|
||||
await host.screenshot({ path: join(SHOTS, 'gostop-mid-pc-dark.png') });
|
||||
await guest.emulateMedia({ colorScheme: 'light' });
|
||||
await host.emulateMedia({ colorScheme: 'light' });
|
||||
}
|
||||
if (!acted) await host.waitForTimeout(150);
|
||||
}
|
||||
if (!(await over())) throw new Error(`hand did not end (${JSON.stringify(state)})`);
|
||||
await guest.getByRole('dialog', { name: '게임 결과' }).waitFor();
|
||||
await host.screenshot({ path: join(SHOTS, 'gostop-result-pc.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'gostop-result-phone.png') });
|
||||
// Close the result sheet to see the round breakdown underneath.
|
||||
await host.keyboard.press('Escape');
|
||||
await host.getByText(/1판 결과/).first().waitFor();
|
||||
await host.screenshot({ path: join(SHOTS, 'gostop-breakdown-pc.png') });
|
||||
if (state.plays < 10) throw new Error(`only ${state.plays} cards were played`);
|
||||
console.log(` gostop: ${state.plays} plays, ${state.sheets} choice sheets (${state.picks} card picks), 고 ${state.go}, 스톱 ${state.stop}`);
|
||||
/** 할리갈리: fruit totals of the face-up tops, read from the seat labels ("펼친 카드 딸기 3개"). */
|
||||
async function halliTotals(p: Page): Promise<Record<string, number>> {
|
||||
const labels = await p.locator('.hg-seat').evaluateAll((els) => els.map((e) => e.getAttribute('aria-label') ?? ''));
|
||||
const t: Record<string, number> = { 딸기: 0, 바나나: 0, 라임: 0, 자두: 0 };
|
||||
for (const l of labels) {
|
||||
const m = /펼친 카드 (딸기|바나나|라임|자두) (\d)개/.exec(l);
|
||||
if (m) t[m[1]!]! += Number(m[2]);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
const PLANS: Record<string, Plan> = {
|
||||
'halli-galli': {
|
||||
// PC host + 2 phones. Real flips (Enter on PC, taps on phones); whenever some fruit totals exactly five a player
|
||||
// rings the bell (PC: Space, phone: tap), plus at least one deliberate wrong bell. 24+ flips through the UI.
|
||||
players: 3,
|
||||
lobby: async (host) => {
|
||||
await host.getByRole('group', { name: '뒤집기 제한 시간' }).getByRole('button', { name: '5초' }).click();
|
||||
await host.getByRole('button', { name: '설정 저장' }).click();
|
||||
await host.getByText(/뒤집기 5초/).first().waitFor();
|
||||
},
|
||||
drive: async (pages) => {
|
||||
const [host, guest] = pages as [Page, Page, Page];
|
||||
const myTurn = (p: Page) => p.evaluate(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'));
|
||||
const ended = () => host.getByRole('dialog', { name: '게임 결과' }).count();
|
||||
await host.locator('.hg-seat').first().waitFor();
|
||||
// 3-second countdown first.
|
||||
await host.waitForFunction(() => !document.querySelector('.hg-count'), null, { timeout: 8000 });
|
||||
let flips = 0;
|
||||
let rights = 0;
|
||||
let wrongs = 0;
|
||||
let lates = 0;
|
||||
let shots = 0;
|
||||
let ringer = 0;
|
||||
const until = Date.now() + 150_000;
|
||||
const bubble = (p: Page) => p.locator('.hg-bubble').first().innerText({ timeout: 2500 }).catch(() => '');
|
||||
while (Date.now() < until && !(await ended()) && (flips < 24 || rights < 1 || wrongs < 1)) {
|
||||
const totals = await halliTotals(host);
|
||||
const five = Object.values(totals).includes(5);
|
||||
const wantWrong = !five && wrongs === 0 && flips >= 4 && Object.values(totals).some((n) => n > 0);
|
||||
if (five || wantWrong) {
|
||||
const p = pages[ringer++ % pages.length]!;
|
||||
const bell = p.locator('.hg-bell');
|
||||
if (await bell.isEnabled().catch(() => false)) {
|
||||
if (p === host) {
|
||||
await host.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
|
||||
await host.keyboard.press('Space');
|
||||
} else await bell.tap();
|
||||
const text = await bubble(p);
|
||||
if (text.includes('땡')) rights++;
|
||||
else if (text.includes('삐')) wrongs++;
|
||||
else if (text.includes('늦었어요')) lates++;
|
||||
if (shots === 0 && text) {
|
||||
shots++;
|
||||
await host.screenshot({ path: join(SHOTS, 'halli-galli-bell-pc.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'halli-galli-bell-phone.png') });
|
||||
}
|
||||
await host.waitForTimeout(400);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let flipped = false;
|
||||
for (const p of pages) {
|
||||
if (!(await myTurn(p))) continue;
|
||||
const btn = p.getByRole('button', { name: '뒤집기', exact: true });
|
||||
if (!(await btn.isEnabled().catch(() => false))) break;
|
||||
const before = await p.locator('.hg-seat[data-me="true"] .hg-meta').innerText();
|
||||
if (p === host) {
|
||||
await host.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
|
||||
await host.keyboard.press('Enter');
|
||||
} else await btn.tap();
|
||||
await p.waitForFunction((b) => document.querySelector('.hg-seat[data-me="true"] .hg-meta')?.textContent !== b, before, { timeout: 3000 }).catch(() => {});
|
||||
flips++;
|
||||
flipped = true;
|
||||
break;
|
||||
}
|
||||
if (flips === 12 && flipped && shots < 2) {
|
||||
shots = 2;
|
||||
await host.waitForTimeout(300);
|
||||
await host.screenshot({ path: join(SHOTS, 'halli-galli-mid-pc.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'halli-galli-mid-phone.png') });
|
||||
await guest.emulateMedia({ colorScheme: 'dark' });
|
||||
await guest.waitForTimeout(400); // let the theme transition finish
|
||||
await guest.screenshot({ path: join(SHOTS, 'halli-galli-mid-phone-dark.png') });
|
||||
await guest.emulateMedia({ colorScheme: 'light' });
|
||||
await host.emulateMedia({ colorScheme: 'dark' });
|
||||
await host.waitForTimeout(400);
|
||||
await host.screenshot({ path: join(SHOTS, 'halli-galli-mid-pc-dark.png') });
|
||||
await host.emulateMedia({ colorScheme: 'light' });
|
||||
}
|
||||
await host.waitForTimeout(flipped ? 650 : 120);
|
||||
}
|
||||
console.log(` halli-galli: ${flips} UI flips, ${rights} correct bells, ${wrongs} wrong bells, ${lates} late`);
|
||||
if (!(await ended()) && (flips < 20 || wrongs < 1)) throw new Error('not enough flips / bells');
|
||||
if (rights < 1 && !(await ended())) throw new Error('no correct bell happened');
|
||||
},
|
||||
},
|
||||
rummikub: {
|
||||
// PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI.
|
||||
lobby: async (host) => {
|
||||
@@ -436,7 +304,6 @@ const PLANS: Record<string, Plan> = {
|
||||
await guest.screenshot({ path: join(SHOTS, 'yut-mid-phone-dark.png') });
|
||||
await guest.emulateMedia({ colorScheme: 'light' });
|
||||
}
|
||||
if (acted && !stats.toll && (await host.locator('.mb-log li', { hasText: /→.*통행료/ }).count())) stats.toll = 1;
|
||||
if (!acted) await host.waitForTimeout(120);
|
||||
}
|
||||
if (!(await over())) throw new Error(`game did not finish (${actions} UI actions)`);
|
||||
@@ -444,59 +311,6 @@ const PLANS: Record<string, Plan> = {
|
||||
console.log(` yut: finished after ${actions} UI actions`);
|
||||
},
|
||||
},
|
||||
marble: {
|
||||
// PC host + phone guest, 15 rounds (shortest limit) played through the UI to the result: roll, buy or
|
||||
// build (skip when short), pay tolls, sell when short, space travel via a board square. Mid-game shots.
|
||||
lobby: async (host) => {
|
||||
await host.getByRole('group', { name: '라운드 제한' }).getByRole('button', { name: '15라운드' }).click();
|
||||
await host.getByRole('group', { name: '시작 포인트' }).getByRole('button', { name: '200P' }).click();
|
||||
await host.getByRole('button', { name: '설정 저장' }).click();
|
||||
await host.getByText(/^15라운드 · 시작 200P/).first().waitFor();
|
||||
},
|
||||
drive: async (pages) => {
|
||||
const [host, guest] = pages as [Page, Page];
|
||||
const stats: Record<string, number> = {};
|
||||
let actions = 0;
|
||||
let shot = false;
|
||||
const until = Date.now() + 420_000;
|
||||
const over = () => host.getByRole('dialog', { name: '게임 결과' }).count();
|
||||
while (Date.now() < until && !(await over())) {
|
||||
let acted = false;
|
||||
for (const p of pages) {
|
||||
if (await marbleStep(p, stats)) {
|
||||
acted = true;
|
||||
actions++;
|
||||
await p.waitForTimeout(80);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (actions >= 40 && !shot && (stats['사기'] ?? 0) >= 3) {
|
||||
shot = true;
|
||||
await host.waitForTimeout(1300);
|
||||
await host.screenshot({ path: join(SHOTS, 'marble-mid-pc.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'marble-mid-phone.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'marble-mid-phone-full.png'), fullPage: true });
|
||||
await host.emulateMedia({ colorScheme: 'dark' });
|
||||
await guest.emulateMedia({ colorScheme: 'dark' });
|
||||
await host.waitForTimeout(400); // let the button colour transitions settle
|
||||
await host.screenshot({ path: join(SHOTS, 'marble-mid-pc-dark.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'marble-mid-phone-dark.png') });
|
||||
await host.emulateMedia({ colorScheme: 'light' });
|
||||
await guest.emulateMedia({ colorScheme: 'light' });
|
||||
}
|
||||
if (acted && !stats.toll && (await host.locator('.mb-log li', { hasText: /→.*통행료/ }).count())) stats.toll = 1;
|
||||
if (!acted) await host.waitForTimeout(120);
|
||||
}
|
||||
if (!(await over())) throw new Error(`game did not finish (${actions} UI actions: ${JSON.stringify(stats)})`);
|
||||
await guest.getByRole('dialog', { name: '게임 결과' }).waitFor();
|
||||
await host.waitForTimeout(600); // result dialog fade-in
|
||||
await host.screenshot({ path: join(SHOTS, 'marble-result-pc.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'marble-result-phone.png') });
|
||||
const rolls = (stats['🎲 주사위 굴리기'] ?? 0) + (stats['🎲 한 번 더 굴리기'] ?? 0) + (stats['🎲 탈출 굴리기'] ?? 0);
|
||||
if (rolls < 15 || !stats['사기']) throw new Error(`too few real turns: ${JSON.stringify(stats)}`);
|
||||
console.log(` marble: finished after ${actions} UI actions ${JSON.stringify(stats)}`);
|
||||
},
|
||||
},
|
||||
'one-card': {
|
||||
// 3 players, default 원카드: whoever has the turn plays a highlighted card (tap to lift, tap again to play,
|
||||
// pick a suit for 7) or draws; the 원카드 button is pressed whenever it lights up. 60 turns or until the game ends.
|
||||
@@ -545,184 +359,6 @@ const PLANS: Record<string, Plan> = {
|
||||
console.log(` one-card: ${turns} turns (${plays} plays, ${draws} draws)${finished ? ', game finished' : ''}`);
|
||||
},
|
||||
},
|
||||
mahjong: {
|
||||
// 4인 리치 마작 (PC host + 3 phones): whoever has the turn taps the right-most legal tile (lift) and presses
|
||||
// the "… 버리기" button; claim windows are answered with 패스 (론/쯔모 when offered). Plays until the first hand
|
||||
// ends (win or exhaustive draw, ~70 discards), checks the result screen on every device and confirms it.
|
||||
players: 4,
|
||||
drive: async (pages) => {
|
||||
const statusOf = (p: Page) => p.locator('.turn-status').innerText().catch(() => '');
|
||||
let discards = 0;
|
||||
let passes = 0;
|
||||
let wins = 0;
|
||||
let results = 0;
|
||||
const shotClaim = new Set<string>();
|
||||
for (let t = 0; t < 4000 && discards < 100 && results < 1; t++) {
|
||||
let acted = false;
|
||||
for (const [pi, p] of pages.entries()) {
|
||||
const status = await statusOf(p);
|
||||
const win = p.getByRole('button', { name: /^(쯔모|론)!$/ });
|
||||
if ((await win.count()) && (await win.first().isEnabled().catch(() => false))) {
|
||||
await win.first().click({ timeout: 1000 }).catch(() => {});
|
||||
wins++;
|
||||
acted = true;
|
||||
break;
|
||||
}
|
||||
const pass = p.getByRole('group', { name: '울기·론 선택' }).getByRole('button', { name: '패스' });
|
||||
if (await pass.count()) {
|
||||
const dev = pi === 0 ? 'pc' : 'phone';
|
||||
if (!shotClaim.has(dev)) {
|
||||
await p.screenshot({ path: join(SHOTS, `mahjong-3-claim-${dev}.png`) });
|
||||
shotClaim.add(dev);
|
||||
}
|
||||
await pass.click({ timeout: 1000 }).catch(() => {});
|
||||
passes++;
|
||||
acted = true;
|
||||
break;
|
||||
}
|
||||
const next = p.getByRole('button', { name: /^(다음 국으로|최종 결과 보기)$/ });
|
||||
if ((await next.count()) && (await next.isEnabled().catch(() => false))) {
|
||||
// The hand is over: every device shows the result, then everyone confirms → the next hand starts.
|
||||
await pages[0]!.screenshot({ path: join(SHOTS, 'mahjong-4-result-pc.png') });
|
||||
await pages[1]!.screenshot({ path: join(SHOTS, 'mahjong-4-result-phone.png'), fullPage: true });
|
||||
for (const q of pages) {
|
||||
await q.getByRole('region', { name: /결과$/ }).waitFor({ timeout: 5000 });
|
||||
const b = q.getByRole('button', { name: /^(다음 국으로|최종 결과 보기)$/ });
|
||||
if (await b.isEnabled().catch(() => false)) await b.click({ timeout: 1000 }).catch(() => {});
|
||||
}
|
||||
await pages[0]!.getByText(/^동1국|^동2국/).first().waitFor({ timeout: 10_000 });
|
||||
results++;
|
||||
acted = true;
|
||||
break;
|
||||
}
|
||||
if (!status.includes('내 차례예요')) continue;
|
||||
const tiles = p.locator('button.mj-htile[data-legal="true"]');
|
||||
const n = await tiles.count();
|
||||
if (!n) continue;
|
||||
const tile = tiles.nth(n - 1);
|
||||
await tile.click({ timeout: 1000 }).catch(() => {});
|
||||
if (discards === 4 && pi > 0) await p.screenshot({ path: join(SHOTS, 'mahjong-2-selected-phone.png') });
|
||||
const send = p.getByRole('button', { name: /버리기$/ });
|
||||
if ((await send.count()) && (await send.isEnabled().catch(() => false))) await send.click({ timeout: 1000 }).catch(() => {});
|
||||
await p.waitForFunction((s) => document.querySelector('.turn-status')?.textContent?.trim() !== s, status.trim(), { timeout: 5000 }).catch(() => {});
|
||||
discards++;
|
||||
acted = true;
|
||||
break;
|
||||
}
|
||||
if (!acted) await pages[0]!.waitForTimeout(120);
|
||||
}
|
||||
if (results === 0) throw new Error(`no hand finished after ${discards} discards`);
|
||||
// The next hand has started: the table and my hand are back on every screen.
|
||||
for (const q of pages) await q.locator('.mj-table').waitFor({ timeout: 10_000 });
|
||||
console.log(` mahjong: ${discards} discards, ${passes} passes, ${wins} wins, ${results} hand finished → next hand started`);
|
||||
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 "여기서 멈추기".
|
||||
@@ -875,6 +511,374 @@ const PLANS: Record<string, Plan> = {
|
||||
['ArrowUp', 'ArrowUp', 'Enter', 'ArrowUp', 'Enter'],
|
||||
],
|
||||
},
|
||||
mahjong: {
|
||||
// 4인 리치 마작 (PC host + 3 phones): whoever has the turn taps the right-most legal tile (lift) and presses
|
||||
// the "… 버리기" button; claim windows are answered with 패스 (론/쯔모 when offered). Plays until the first hand
|
||||
// ends (win or exhaustive draw, ~70 discards), checks the result screen on every device and confirms it.
|
||||
players: 4,
|
||||
drive: async (pages) => {
|
||||
const statusOf = (p: Page) => p.locator('.turn-status').innerText().catch(() => '');
|
||||
let discards = 0;
|
||||
let passes = 0;
|
||||
let wins = 0;
|
||||
let results = 0;
|
||||
const shotClaim = new Set<string>();
|
||||
for (let t = 0; t < 4000 && discards < 100 && results < 1; t++) {
|
||||
let acted = false;
|
||||
for (const [pi, p] of pages.entries()) {
|
||||
const status = await statusOf(p);
|
||||
const win = p.getByRole('button', { name: /^(쯔모|론)!$/ });
|
||||
if ((await win.count()) && (await win.first().isEnabled().catch(() => false))) {
|
||||
await win.first().click({ timeout: 1000 }).catch(() => {});
|
||||
wins++;
|
||||
acted = true;
|
||||
break;
|
||||
}
|
||||
const pass = p.getByRole('group', { name: '울기·론 선택' }).getByRole('button', { name: '패스' });
|
||||
if (await pass.count()) {
|
||||
const dev = pi === 0 ? 'pc' : 'phone';
|
||||
if (!shotClaim.has(dev)) {
|
||||
await p.screenshot({ path: join(SHOTS, `mahjong-3-claim-${dev}.png`) });
|
||||
shotClaim.add(dev);
|
||||
}
|
||||
await pass.click({ timeout: 1000 }).catch(() => {});
|
||||
passes++;
|
||||
acted = true;
|
||||
break;
|
||||
}
|
||||
const next = p.getByRole('button', { name: /^(다음 국으로|최종 결과 보기)$/ });
|
||||
if ((await next.count()) && (await next.isEnabled().catch(() => false))) {
|
||||
// The hand is over: every device shows the result, then everyone confirms → the next hand starts.
|
||||
await pages[0]!.screenshot({ path: join(SHOTS, 'mahjong-4-result-pc.png') });
|
||||
await pages[1]!.screenshot({ path: join(SHOTS, 'mahjong-4-result-phone.png'), fullPage: true });
|
||||
for (const q of pages) {
|
||||
await q.getByRole('region', { name: /결과$/ }).waitFor({ timeout: 5000 });
|
||||
const b = q.getByRole('button', { name: /^(다음 국으로|최종 결과 보기)$/ });
|
||||
if (await b.isEnabled().catch(() => false)) await b.click({ timeout: 1000 }).catch(() => {});
|
||||
}
|
||||
await pages[0]!.getByText(/^동1국|^동2국/).first().waitFor({ timeout: 10_000 });
|
||||
results++;
|
||||
acted = true;
|
||||
break;
|
||||
}
|
||||
if (!status.includes('내 차례예요')) continue;
|
||||
const tiles = p.locator('button.mj-htile[data-legal="true"]');
|
||||
const n = await tiles.count();
|
||||
if (!n) continue;
|
||||
const tile = tiles.nth(n - 1);
|
||||
await tile.click({ timeout: 1000 }).catch(() => {});
|
||||
if (discards === 4 && pi > 0) await p.screenshot({ path: join(SHOTS, 'mahjong-2-selected-phone.png') });
|
||||
const send = p.getByRole('button', { name: /버리기$/ });
|
||||
if ((await send.count()) && (await send.isEnabled().catch(() => false))) await send.click({ timeout: 1000 }).catch(() => {});
|
||||
await p.waitForFunction((s) => document.querySelector('.turn-status')?.textContent?.trim() !== s, status.trim(), { timeout: 5000 }).catch(() => {});
|
||||
discards++;
|
||||
acted = true;
|
||||
break;
|
||||
}
|
||||
if (!acted) await pages[0]!.waitForTimeout(120);
|
||||
}
|
||||
if (results === 0) throw new Error(`no hand finished after ${discards} discards`);
|
||||
// The next hand has started: the table and my hand are back on every screen.
|
||||
for (const q of pages) await q.locator('.mj-table').waitFor({ timeout: 10_000 });
|
||||
console.log(` mahjong: ${discards} discards, ${passes} passes, ${wins} wins, ${results} hand finished → next hand started`);
|
||||
},
|
||||
},
|
||||
gostop: {
|
||||
// 맞고: PC host + phone guest, 1판. Real turns through the UI — hand cards (tap twice on the phone), floor-card choices,
|
||||
// 흔들기/폭탄 sheets, one 고 and then 스톱 — until the hand ends (스톱 or 나가리) and the game result shows.
|
||||
lobby: async (host) => {
|
||||
await host.getByRole('group', { name: '판 수' }).getByRole('button', { name: '1판', exact: true }).click();
|
||||
await host.getByRole('button', { name: '설정 저장' }).click();
|
||||
await host.getByText(/^1판 · /).first().waitFor();
|
||||
},
|
||||
drive: async (pages) => {
|
||||
const [host, guest] = pages as [Page, Page];
|
||||
const state = { go: 0, stop: 0, picks: 0, plays: 0, sheets: 0 };
|
||||
let shot = false;
|
||||
const until = Date.now() + 240_000;
|
||||
const over = async () => (await host.getByRole('dialog', { name: '게임 결과' }).count()) > 0;
|
||||
while (Date.now() < until && !(await over())) {
|
||||
let acted = false;
|
||||
for (const p of pages) {
|
||||
const did = await gostopStep(p, state).catch(() => null);
|
||||
if (did) {
|
||||
acted = true;
|
||||
await p.waitForTimeout(250);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (state.plays >= 8 && !shot) {
|
||||
shot = true;
|
||||
await host.waitForTimeout(1500);
|
||||
await host.screenshot({ path: join(SHOTS, 'gostop-mid-pc.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'gostop-mid-phone.png'), fullPage: true });
|
||||
await guest.emulateMedia({ colorScheme: 'dark' });
|
||||
await host.emulateMedia({ colorScheme: 'dark' });
|
||||
await guest.screenshot({ path: join(SHOTS, 'gostop-mid-phone-dark.png'), fullPage: true });
|
||||
await host.screenshot({ path: join(SHOTS, 'gostop-mid-pc-dark.png') });
|
||||
await guest.emulateMedia({ colorScheme: 'light' });
|
||||
await host.emulateMedia({ colorScheme: 'light' });
|
||||
}
|
||||
if (!acted) await host.waitForTimeout(150);
|
||||
}
|
||||
if (!(await over())) throw new Error(`hand did not end (${JSON.stringify(state)})`);
|
||||
await guest.getByRole('dialog', { name: '게임 결과' }).waitFor();
|
||||
await host.screenshot({ path: join(SHOTS, 'gostop-result-pc.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'gostop-result-phone.png') });
|
||||
// Close the result sheet to see the round breakdown underneath.
|
||||
await host.keyboard.press('Escape');
|
||||
await host.getByText(/1판 결과/).first().waitFor();
|
||||
await host.screenshot({ path: join(SHOTS, 'gostop-breakdown-pc.png') });
|
||||
if (state.plays < 10) throw new Error(`only ${state.plays} cards were played`);
|
||||
console.log(` gostop: ${state.plays} plays, ${state.sheets} choice sheets (${state.picks} card picks), 고 ${state.go}, 스톱 ${state.stop}`);
|
||||
},
|
||||
},
|
||||
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');
|
||||
},
|
||||
},
|
||||
'halli-galli': {
|
||||
// PC host + 2 phones. Real flips (Enter on PC, taps on phones); whenever some fruit totals exactly five a player
|
||||
// rings the bell (PC: Space, phone: tap), plus at least one deliberate wrong bell. 24+ flips through the UI.
|
||||
players: 3,
|
||||
lobby: async (host) => {
|
||||
await host.getByRole('group', { name: '뒤집기 제한 시간' }).getByRole('button', { name: '5초' }).click();
|
||||
await host.getByRole('button', { name: '설정 저장' }).click();
|
||||
await host.getByText(/뒤집기 5초/).first().waitFor();
|
||||
},
|
||||
drive: async (pages) => {
|
||||
const [host, guest] = pages as [Page, Page, Page];
|
||||
const myTurn = (p: Page) => p.evaluate(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'));
|
||||
const ended = () => host.getByRole('dialog', { name: '게임 결과' }).count();
|
||||
await host.locator('.hg-seat').first().waitFor();
|
||||
// 3-second countdown first.
|
||||
await host.waitForFunction(() => !document.querySelector('.hg-count'), null, { timeout: 8000 });
|
||||
let flips = 0;
|
||||
let rights = 0;
|
||||
let wrongs = 0;
|
||||
let lates = 0;
|
||||
let shots = 0;
|
||||
let ringer = 0;
|
||||
const until = Date.now() + 150_000;
|
||||
const bubble = (p: Page) => p.locator('.hg-bubble').first().innerText({ timeout: 2500 }).catch(() => '');
|
||||
while (Date.now() < until && !(await ended()) && (flips < 24 || rights < 1 || wrongs < 1)) {
|
||||
const totals = await halliTotals(host);
|
||||
const five = Object.values(totals).includes(5);
|
||||
const wantWrong = !five && wrongs === 0 && flips >= 4 && Object.values(totals).some((n) => n > 0);
|
||||
if (five || wantWrong) {
|
||||
const p = pages[ringer++ % pages.length]!;
|
||||
const bell = p.locator('.hg-bell');
|
||||
if (await bell.isEnabled().catch(() => false)) {
|
||||
if (p === host) {
|
||||
await host.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
|
||||
await host.keyboard.press('Space');
|
||||
} else await bell.tap();
|
||||
const text = await bubble(p);
|
||||
if (text.includes('땡')) rights++;
|
||||
else if (text.includes('삐')) wrongs++;
|
||||
else if (text.includes('늦었어요')) lates++;
|
||||
if (shots === 0 && text) {
|
||||
shots++;
|
||||
await host.screenshot({ path: join(SHOTS, 'halli-galli-bell-pc.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'halli-galli-bell-phone.png') });
|
||||
}
|
||||
await host.waitForTimeout(400);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let flipped = false;
|
||||
for (const p of pages) {
|
||||
if (!(await myTurn(p))) continue;
|
||||
const btn = p.getByRole('button', { name: '뒤집기', exact: true });
|
||||
if (!(await btn.isEnabled().catch(() => false))) break;
|
||||
const before = await p.locator('.hg-seat[data-me="true"] .hg-meta').innerText();
|
||||
if (p === host) {
|
||||
await host.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
|
||||
await host.keyboard.press('Enter');
|
||||
} else await btn.tap();
|
||||
await p.waitForFunction((b) => document.querySelector('.hg-seat[data-me="true"] .hg-meta')?.textContent !== b, before, { timeout: 3000 }).catch(() => {});
|
||||
flips++;
|
||||
flipped = true;
|
||||
break;
|
||||
}
|
||||
if (flips === 12 && flipped && shots < 2) {
|
||||
shots = 2;
|
||||
await host.waitForTimeout(300);
|
||||
await host.screenshot({ path: join(SHOTS, 'halli-galli-mid-pc.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'halli-galli-mid-phone.png') });
|
||||
await guest.emulateMedia({ colorScheme: 'dark' });
|
||||
await guest.waitForTimeout(400); // let the theme transition finish
|
||||
await guest.screenshot({ path: join(SHOTS, 'halli-galli-mid-phone-dark.png') });
|
||||
await guest.emulateMedia({ colorScheme: 'light' });
|
||||
await host.emulateMedia({ colorScheme: 'dark' });
|
||||
await host.waitForTimeout(400);
|
||||
await host.screenshot({ path: join(SHOTS, 'halli-galli-mid-pc-dark.png') });
|
||||
await host.emulateMedia({ colorScheme: 'light' });
|
||||
}
|
||||
await host.waitForTimeout(flipped ? 650 : 120);
|
||||
}
|
||||
console.log(` halli-galli: ${flips} UI flips, ${rights} correct bells, ${wrongs} wrong bells, ${lates} late`);
|
||||
if (!(await ended()) && (flips < 20 || wrongs < 1)) throw new Error('not enough flips / bells');
|
||||
if (rights < 1 && !(await ended())) throw new Error('no correct bell happened');
|
||||
},
|
||||
},
|
||||
marble: {
|
||||
// PC host + phone guest, 15 rounds (shortest limit) played through the UI to the result: roll, buy or
|
||||
// build (skip when short), pay tolls, sell when short, space travel via a board square. Mid-game shots.
|
||||
lobby: async (host) => {
|
||||
await host.getByRole('group', { name: '라운드 제한' }).getByRole('button', { name: '15라운드' }).click();
|
||||
await host.getByRole('group', { name: '시작 포인트' }).getByRole('button', { name: '200P' }).click();
|
||||
await host.getByRole('button', { name: '설정 저장' }).click();
|
||||
await host.getByText(/^15라운드 · 시작 200P/).first().waitFor();
|
||||
},
|
||||
drive: async (pages) => {
|
||||
const [host, guest] = pages as [Page, Page];
|
||||
const stats: Record<string, number> = {};
|
||||
let actions = 0;
|
||||
let shot = false;
|
||||
const until = Date.now() + 420_000;
|
||||
const over = () => host.getByRole('dialog', { name: '게임 결과' }).count();
|
||||
while (Date.now() < until && !(await over())) {
|
||||
let acted = false;
|
||||
for (const p of pages) {
|
||||
if (await marbleStep(p, stats)) {
|
||||
acted = true;
|
||||
actions++;
|
||||
await p.waitForTimeout(80);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (actions >= 40 && !shot && (stats['사기'] ?? 0) >= 3) {
|
||||
shot = true;
|
||||
await host.waitForTimeout(1300);
|
||||
await host.screenshot({ path: join(SHOTS, 'marble-mid-pc.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'marble-mid-phone.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'marble-mid-phone-full.png'), fullPage: true });
|
||||
await host.emulateMedia({ colorScheme: 'dark' });
|
||||
await guest.emulateMedia({ colorScheme: 'dark' });
|
||||
await host.waitForTimeout(400); // let the button colour transitions settle
|
||||
await host.screenshot({ path: join(SHOTS, 'marble-mid-pc-dark.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'marble-mid-phone-dark.png') });
|
||||
await host.emulateMedia({ colorScheme: 'light' });
|
||||
await guest.emulateMedia({ colorScheme: 'light' });
|
||||
}
|
||||
if (acted && !stats.toll && (await host.locator('.mb-log li', { hasText: /→.*통행료/ }).count())) stats.toll = 1;
|
||||
if (!acted) await host.waitForTimeout(120);
|
||||
}
|
||||
if (!(await over())) throw new Error(`game did not finish (${actions} UI actions: ${JSON.stringify(stats)})`);
|
||||
await guest.getByRole('dialog', { name: '게임 결과' }).waitFor();
|
||||
await host.waitForTimeout(600); // result dialog fade-in
|
||||
await host.screenshot({ path: join(SHOTS, 'marble-result-pc.png') });
|
||||
await guest.screenshot({ path: join(SHOTS, 'marble-result-phone.png') });
|
||||
const rolls = (stats['🎲 주사위 굴리기'] ?? 0) + (stats['🎲 한 번 더 굴리기'] ?? 0) + (stats['🎲 탈출 굴리기'] ?? 0);
|
||||
if (rolls < 15 || !stats['사기']) throw new Error(`too few real turns: ${JSON.stringify(stats)}`);
|
||||
console.log(` marble: finished after ${actions} UI actions ${JSON.stringify(stats)}`);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS);
|
||||
|
||||
Reference in New Issue
Block a user