M3: 섯다·포커 규칙과 화면, 카드 게임 E2E
- 섯다(packages/games/seotda): 2장·3장 섯다, 1장씩/한 번에 나눠 주기, 족보(광땡·땡·알리…망통), 땡잡이·암행어사·구사/멍텅구리구사 재경기, 동점 재경기·나눔, 리바이·탈락, 사이드 팟. 규칙 테스트 54개 - 포커(packages/games/poker): 홀덤·오마하·7포커(초이스·보스)·7포커 하이로우(동시 선언)·바둑이(아침·점심·저녁)· 5카드 드로우·인디언 포커(이마 카드, 동점 이월, 다이 벌칙). 한국식/노리밋/팟리밋, 블라인드·앤티, 리바이·레벨 상승·최후 1인. 규칙 테스트 52개 + 족보 판정 24개, 무작위 1,000판(칩 보존·비공개 카드 유출 없음) - 웹: 섯다·포커 화면(테이블 좌석, 공용 카드, 사이드 팟, 한국식 버튼/레이즈 슬라이더, 초이스·교환·선언, 체크 가능할 때 포기 확인, 리바이 안내), 섯다 결과 화면에서 가져간 칩 표시 - 엔진 betting.buildPots: 라운드 진행 중에는 아직 콜할 수 있는 플레이어도 각 팟 자격자로 표시 - 서버 테스트: 크래시 흉내 후 소켓 종료로 다시 걸린 타이머가 닫힌 DB를 건드리던 누수 수정 - E2E: 3인(PC + 휴대폰 2) 섯다·7포커 한 판, 6개 게임 모두 통과 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,12 +17,66 @@ mkdirSync(SHOTS, { recursive: true });
|
||||
|
||||
/** Per game: key sequences on the grid, in turn order (first mover, second mover, ...). */
|
||||
interface Plan {
|
||||
/** Total players including the host (default 2). Extra players join on phones. */
|
||||
players?: number;
|
||||
/** Steps run before normal moves (e.g. janggi formation choice). */
|
||||
setup?: (host: Page, guest: Page) => Promise<void>;
|
||||
moves: string[][];
|
||||
/** Board games: key sequences on the grid, in turn order. */
|
||||
moves?: string[][];
|
||||
/** Card games: custom driver over all pages (host first). */
|
||||
drive?: (pages: Page[]) => Promise<void>;
|
||||
}
|
||||
|
||||
/** Card games: whoever has the turn presses the first available safe action (check/call/…) until `rounds` actions were taken. */
|
||||
async function pressActions(pages: Page[], labels: RegExp, rounds: number) {
|
||||
for (let n = 0; n < rounds; n++) {
|
||||
let done = false;
|
||||
for (let t = 0; t < 150 && !done; t++) {
|
||||
for (const p of pages) {
|
||||
const btn = p.getByRole('button', { name: labels }).first();
|
||||
if ((await btn.count()) && (await btn.isEnabled({ timeout: 200 }).catch(() => false))) {
|
||||
if (await btn.click({ timeout: 1000 }).then(() => true, () => false)) {
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!done) await pages[0]!.waitForTimeout(100);
|
||||
}
|
||||
if (!done) throw new Error(`no action button matching ${labels} became available (step ${n + 1})`);
|
||||
await pages[0]!.waitForTimeout(150);
|
||||
}
|
||||
}
|
||||
|
||||
/** Waits until every page shows the hand result, then presses "다음 판으로" where it is offered. */
|
||||
async function finishHand(pages: Page[]) {
|
||||
for (const p of pages) await p.getByText('이번 판 결과').first().waitFor({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
const PLANS: Record<string, Plan> = {
|
||||
seotda: {
|
||||
// 3 players, default 2장 섯다 (1장씩 두 번): everyone checks both betting rounds → showdown.
|
||||
players: 3,
|
||||
drive: async (pages) => {
|
||||
await pressActions(pages, /^(체크|콜)/, 6);
|
||||
await finishHand(pages);
|
||||
},
|
||||
},
|
||||
poker: {
|
||||
// 3 players, default 7포커: 초이스 (discard 1st card, open 2nd), then everyone checks 4 streets → showdown.
|
||||
players: 3,
|
||||
drive: async (pages) => {
|
||||
for (const p of pages) {
|
||||
const cards = p.locator('.pk-mine button');
|
||||
await cards.first().waitFor();
|
||||
await cards.nth(0).click();
|
||||
await cards.nth(1).click();
|
||||
await p.getByRole('button', { name: '확인', exact: true }).click();
|
||||
}
|
||||
await pressActions(pages, /^체크/, 12);
|
||||
await finishHand(pages);
|
||||
},
|
||||
},
|
||||
othello: {
|
||||
// cursor starts at d4 (3,3): black d3 = up; then white c3 = up, left
|
||||
moves: [['ArrowUp', 'Enter'], ['ArrowUp', 'ArrowLeft', 'Enter']],
|
||||
@@ -87,8 +141,12 @@ for (const id of games) {
|
||||
}
|
||||
const errors: string[] = [];
|
||||
const host = await (await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'ko-KR' })).newPage();
|
||||
const guest = await (await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, locale: 'ko-KR' })).newPage();
|
||||
for (const p of [host, guest]) p.on('pageerror', (e) => errors.push(`${id}: ${e.message}`));
|
||||
const guests: Page[] = [];
|
||||
for (let g = 1; g < (plan.players ?? 2); g++) {
|
||||
guests.push(await (await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, locale: 'ko-KR' })).newPage());
|
||||
}
|
||||
const guest = guests[0]!;
|
||||
for (const p of [host, ...guests]) p.on('pageerror', (e) => errors.push(`${id}: ${e.message}`));
|
||||
try {
|
||||
await host.goto(`${ORIGIN}/g/${id}`);
|
||||
await host.getByRole('button', { name: /친구랑 하기/ }).click();
|
||||
@@ -96,20 +154,28 @@ for (const id of games) {
|
||||
await host.getByRole('button', { name: '이 이름으로 시작하기' }).click();
|
||||
await host.waitForURL(/\/r\/\d{6}$/);
|
||||
const code = host.url().slice(-6);
|
||||
await guest.goto(`${ORIGIN}/r/${code}`);
|
||||
await guest.getByLabel('내 이름(닉네임)').fill('친구');
|
||||
await guest.getByRole('button', { name: '이 이름으로 입장하기' }).click();
|
||||
await host.getByLabel('자리').getByText('친구').waitFor();
|
||||
for (const [gi, g] of guests.entries()) {
|
||||
const nick = gi === 0 ? '친구' : `친구${gi + 1}`;
|
||||
await g.goto(`${ORIGIN}/r/${code}`);
|
||||
await g.getByLabel('내 이름(닉네임)').fill(nick);
|
||||
await g.getByRole('button', { name: '이 이름으로 입장하기' }).click();
|
||||
await host.getByLabel('자리').getByText(nick, { exact: true }).waitFor();
|
||||
}
|
||||
await host.screenshot({ path: join(SHOTS, `${id}-0-lobby.png`) });
|
||||
await host.getByRole('button', { name: '▶ 게임 시작' }).click();
|
||||
await host.getByRole('button', { name: '시작하기' }).click();
|
||||
await host.locator('[role="grid"]').first().waitFor();
|
||||
await guest.locator('[role="grid"]').first().waitFor();
|
||||
if (plan.drive) {
|
||||
await host.locator('.turn-status').waitFor();
|
||||
await plan.drive([host, ...guests]);
|
||||
} else {
|
||||
await host.locator('[role="grid"]').first().waitFor();
|
||||
await guest.locator('[role="grid"]').first().waitFor();
|
||||
}
|
||||
if (plan.setup) await plan.setup(host, guest);
|
||||
const status = async (p: Page) => (await p.locator('.turn-status').innerText()).trim();
|
||||
// Sides may be random: whoever shows "내 차례예요" plays the next sequence.
|
||||
const myTurn = (p: Page) => p.evaluate(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'));
|
||||
for (const seq of plan.moves) {
|
||||
for (const seq of plan.moves ?? []) {
|
||||
let mover: Page | null = null;
|
||||
for (let t = 0; t < 100 && !mover; t++) {
|
||||
if (await myTurn(host)) mover = host;
|
||||
|
||||
Reference in New Issue
Block a user