/** * 双升(DoubleUp)— 4 人完整对局 E2E(联机,真实 UI + 真实 server + 真实 engine)。 * * 覆盖: * - 完整一局:创建房间 → 4 人加入 → 发牌亮主 → 庄家扣底 → 出牌至四家手牌空 → 结算(底牌分层展示)。 * - 升级后多打几局:点「开始下一局」连续重开,验证每局都能正常发牌并打到手牌清空(验证升级后主打不同级数时逻辑无碍)。 * * 出牌策略(保证合法、不卡死):每轮到本人,点第一张 data-legal="true" 的牌 + 「出牌」按钮(只出单张)。 * 亮主阶段不主动亮主;每家点「确认」。扣底选前 8 张点「确认扣底」。 * * 运行:pnpm --filter @tts-like/web exec playwright test e2e/double-up-full-game.spec.ts * (需先起 server :8000 与 web :5173;playwright.config 已 reuseExistingServer。) */ import { test, expect, type Browser, type Page, type BrowserContext } from '@playwright/test'; const GAME = 'double-up'; interface Seat { ctx: BrowserContext; page: Page; pid: string; errors: string[]; } async function authUser(suffix: string): Promise<{ token: string; username: string }> { // 用户名必须 3-20 位字母/数字/下划线/连字符(server 校验),故用短随机名。 const uname = `du${suffix}${Math.floor(Math.random() * 1e6)}`.slice(0, 18); const pwd = 'pw123456'; const base = process.env.TTS_API_BASE ?? 'http://localhost:8000'; const reg = await fetch(`${base}/api/auth/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: uname, password: pwd }), }); if (reg.ok) { const j = (await reg.json()) as { token: string; username: string }; return { token: j.token, username: j.username }; } const login = await fetch(`${base}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: uname, password: pwd }), }); const j = (await login.json()) as { token: string; username: string }; return { token: j.token, username: j.username }; } async function newSeat(browser: Browser, pid: string, auth?: { token: string; username: string }): Promise { const ctx = await browser.newContext(); const page = await ctx.newPage(); const errors: string[] = []; page.on('console', (msg) => { if (msg.type() === 'error') errors.push(`[console.error] ${msg.text()}`); }); page.on('pageerror', (err) => errors.push(`[pageerror] ${err.message}`)); if (auth) { await page.addInitScript( ([token, username]) => { localStorage.setItem('tts-like.token', token); localStorage.setItem('tts-like.username', username); }, [auth.token, auth.username] as [string, string], ); } await page.goto('/'); await page.getByTestId('game-select').selectOption(GAME); await page.getByTestId('mode-online').click(); await expect(page.getByTestId('lobby-room-list')).toBeVisible({ timeout: 15_000 }); return { ctx, page, pid, errors }; } async function joinSeat(seat: Seat, matchID: string): Promise { await expect(async () => { await seat.page.getByTestId('lobby-refresh').click(); const btn = seat.page.getByTestId(`lobby-join-${seat.pid}-${matchID}`); if (await btn.count()) await btn.first().click(); await expect(seat.page.getByTestId('online-need-room')).not.toBeVisible({ timeout: 5_000 }); }).toPass({ timeout: 20_000 }); } function readInfo(page: Page): Promise { return page.getByTestId('du-info').innerText().catch(() => ''); } async function currentPhase(page: Page): Promise { const txt = await readInfo(page); if (txt.includes('结算')) return 'score'; if (txt.includes('出牌')) return 'play'; if (txt.includes('扣底')) return 'bottom'; if (txt.includes('亮主') || txt.includes('定主')) return 'trump'; if (txt.includes('发牌')) return 'deal'; if (txt.includes('等待')) return 'waiting'; return 'unknown'; } /** 读取 info 条里的「级数 X」与「已打 X 墩」。 */ async function infoNumbers(page: Page): Promise<{ level: number; tricks: number }> { const txt = await readInfo(page); const lv = txt.match(/级数\s*(\S+)/); const tr = txt.match(/已打\s*(\d+)\s*墩/); return { level: lv ? Number(lv[1].replace(/[^0-9]/g, '')) || 0 : 0, tricks: tr ? Number(tr[1]) : 0, }; } async function isActor(page: Page, pid: string): Promise { const seat = page.getByTestId(`du-seat-${pid}`); if (await seat.count() === 0) return false; return (await seat.getAttribute('data-actor')) === 'true'; } async function isSettingBottom(page: Page): Promise { return (await page.getByTestId('du-set-bottom').count()) > 0 && (await page.getByTestId('du-set-bottom').isVisible()); } async function playLegal(page: Page): Promise { const board = page.getByTestId('du-board'); const leadCountStr = (await board.getAttribute('data-lead-count')) ?? '0'; const leadCount = Number(leadCountStr) || 0; const leadSuit = await board.getAttribute('data-lead-suit'); const leadIsTrump = (await board.getAttribute('data-lead-istrump')) === 'true'; const leadType = await board.getAttribute('data-lead-type'); const cards = page.locator('[data-testid^="du-card-"]'); const n = await cards.count(); const infos: { idx: number; id: string; suit: string; rank: number; isTrump: boolean }[] = []; for (let i = 0; i < n; i++) { const el = cards.nth(i); const id = (await el.getAttribute('data-testid')) ?? ''; const suit = (await el.getAttribute('data-suit')) ?? ''; const rank = Number((await el.getAttribute('data-rank')) ?? '0'); const isTrump = (await el.getAttribute('data-istrump')) === 'true'; infos.push({ idx: i, id, suit, rank, isTrump }); } const byLowRank = (a: typeof infos[0], b: typeof infos[0]) => a.rank - b.rank; // 领出类别:主牌领出→所有主牌;副牌领出→该花色副牌 const catOf = (c: typeof infos[0]) => leadIsTrump ? c.isTrump : !c.isTrump && c.suit === (leadSuit ?? ''); const handInCat = infos.filter(catOf).sort(byLowRank); const handHasCat = handInCat.length > 0; let pick: number[] = []; if (leadCount === 0 || leadType === 'none') { // 首出:出最小非主牌(无则最小主牌) const nonTrump = infos.filter((c) => !c.isTrump).sort(byLowRank); const pool = nonTrump.length ? nonTrump : infos.slice().sort(byLowRank); if (pool.length) pick = [pool[0]!.idx]; } else if (leadType === 'single') { if (handHasCat) pick = [handInCat[0]!.idx]; else pick = [infos.slice().sort(byLowRank)[0]!.idx]; } else if (leadType === 'pair') { // 找同点同花色(或同点主)的对子 const pair = findPair(handInCat); if (pair) { pick = pair.map((c) => c.idx); } else if (handHasCat) { pick = handInCat.slice(0, 2).map((c) => c.idx); } else { pick = infos.slice().sort(byLowRank).slice(0, 2).map((c) => c.idx); } } else if (leadType === 'tractor') { // 优先找拖拉机(连续对子),其次对子,否则任意 requiredCount 张 const tractor = findTractor(handInCat); if (tractor) { pick = tractor.map((c) => c.idx); } else { const pair = findPair(handInCat); if (pair) pick = pair.map((c) => c.idx); else if (handHasCat) pick = handInCat.slice(0, leadCount).map((c) => c.idx); else pick = infos.slice().sort(byLowRank).slice(0, leadCount).map((c) => c.idx); } } else { // mixed / 甩牌领出:同类别优先,否则任意 if (handHasCat) pick = handInCat.slice(0, leadCount || 1).map((c) => c.idx); else pick = infos.slice().sort(byLowRank).slice(0, leadCount || 1).map((c) => c.idx); } if (pick.length === 0) return; for (const idx of pick) { const el = cards.nth(idx); if ((await el.getAttribute('data-selected')) !== 'true') { await el.click({ force: true }); } } const playBtn = page.getByTestId('du-play'); if (await playBtn.isEnabled().catch(() => false)) { await playBtn.click({ force: true }); } } /** 在同类牌里找两张同点同花色/同点主的「对子」。 */ function findPair(cards: { idx: number; suit: string; rank: number; isTrump: boolean }[]): { idx: number; suit: string; rank: number; isTrump: boolean }[] | null { const key = (c: { suit: string; rank: number; isTrump: boolean }) => (c.isTrump ? `T${c.rank}` : `${c.suit}-${c.rank}`); const map = new Map(); for (const c of cards) { const k = key(c); if (!map.has(k)) map.set(k, []); map.get(k)!.push(c); } for (const arr of map.values()) { if (arr.length >= 2) return [arr[0]!, arr[1]!]; } return null; } /** 在同类牌里找连续对子(拖拉机);返回 L*2 张(按 rank 升序取前 L 个对子)。 */ function findTractor(cards: { idx: number; suit: string; rank: number; isTrump: boolean }[]): { idx: number; suit: string; rank: number; isTrump: boolean }[] | null { const key = (c: { suit: string; rank: number; isTrump: boolean }) => (c.isTrump ? `T${c.rank}` : `${c.suit}-${c.rank}`); const map = new Map(); for (const c of cards) { const k = key(c); if (!map.has(k)) map.set(k, []); map.get(k)!.push(c); } const pairs = [...map.entries()] .filter(([, arr]) => arr.length >= 2) .map(([k, arr]) => ({ k, rank: arr[0]!.rank, arr })) .sort((a, b) => a.rank - b.rank); if (pairs.length >= 2) { // 取前 2 个对子(L=2 简单拖拉机;更长的暂不强求连续,先取 2 对) const out: { idx: number; suit: string; rank: number; isTrump: boolean }[] = []; for (const p of pairs.slice(0, 2)) out.push(p.arr[0]!, p.arr[1]!); return out; } return null; } async function setBottom(page: Page): Promise { const cards = page.locator('[data-testid^="du-card-"]'); const n = await cards.count(); for (let i = 0; i < n && i < 8; i++) { const sel = await cards.nth(i).getAttribute('data-selected'); if (sel !== 'true') await cards.nth(i).click({ force: true }); } await page.getByTestId('du-set-bottom').click({ force: true }); } /** 驱动一整局直到进入结算;返回是否到达结算。 */ async function driveGameToScore(seats: Seat[], maxIter = 6000): Promise { let reached = false; for (let iter = 0; iter < maxIter && !reached; iter++) { for (const s of seats) { try { const phase = await currentPhase(s.page); if (phase === 'score') { reached = true; break; } if (phase === 'bottom') { if (await isSettingBottom(s.page)) await setBottom(s.page); continue; } if (phase === 'deal') { const confirmBtn = s.page.getByTestId('du-confirm-trump'); if (await confirmBtn.count()) await confirmBtn.click({ force: true }).catch(() => {}); continue; } if (phase === 'play') { if (await isActor(s.page, s.pid)) await playLegal(s.page); continue; } } catch (e) { console.log(`[loop] seat ${s.pid} error:`, String(e).slice(0, 160)); } } if (reached) break; if (iter > 0 && iter % 200 === 0) { const snaps = await Promise.all(seats.map(async (s) => { const ph = await currentPhase(s.page).catch(() => 'err'); const info = await readInfo(s.page).catch(() => ''); const actor = await isActor(s.page, s.pid).catch(() => false); return `p${s.pid}:${ph}${actor ? '*' : ''} (${info.replace(/\s+/g, ' ').slice(0, 60)})`; })); console.log(`[iter ${iter}] ${snaps.join(' | ')}`); } await seats[0]!.page.waitForTimeout(30); } if (!reached) { const snaps = await Promise.all(seats.map(async (s) => { const ph = await currentPhase(s.page).catch(() => 'err'); const info = await readInfo(s.page).catch(() => ''); const actor = await isActor(s.page, s.pid).catch(() => false); return `p${s.pid}:${ph}${actor ? '*' : ''} (${info.replace(/\s+/g, ' ').slice(0, 80)})`; })); console.log(`[STUCK] ${snaps.join(' | ')}`); } return reached; } test.describe('DoubleUp — full game + multi-round upgrade E2E', () => { test('create → 4 join → full game → score (bottom reveal) → 3 more rounds all end', async ({ browser }) => { test.setTimeout(600_000); const seats: Seat[] = []; const auth0 = await authUser('p0'); seats.push(await newSeat(browser, '0', auth0)); await seats[0]!.page.getByTestId('lobby-create-room').click(); await expect(seats[0]!.page.getByTestId('online-need-room')).not.toBeVisible({ timeout: 15_000 }); const auth1 = await authUser('p1'); const p1view = await newSeat(browser, '1', auth1); await expect(async () => { await p1view.page.getByTestId('lobby-refresh').click(); expect(await p1view.page.locator('[data-testid^="lobby-join-1-"]').count()).toBeGreaterThan(0); }).toPass({ timeout: 15_000 }); const joinTestID = await p1view.page.locator('[data-testid^="lobby-join-1-"]').first().getAttribute('data-testid'); const matchID = joinTestID!.replace(/^lobby-join-1-/, ''); await joinSeat(p1view, matchID); seats.push(p1view); for (const pid of ['2', '3']) { const authN = await authUser(`p${pid}`); const s = await newSeat(browser, pid, authN); await joinSeat(s, matchID); seats.push(s); } await expect(seats[0]!.page.getByTestId('du-board')).toBeVisible({ timeout: 15_000 }); await seats[0]!.page.getByTestId('du-start-game').click({ force: true }); await expect.poll(() => currentPhase(seats[0]!.page), { timeout: 15_000 }).not.toBe('waiting'); const levelsSeen = new Set(); let maxLevel = 0; const ROUNDS = 8; for (let round = 1; round <= ROUNDS; round++) { const reached = await driveGameToScore(seats); expect(reached, `第 ${round} 局应进入结算`).toBe(true); // 结算后手牌区不应再有可点的牌(四家手牌清空) const remaining = await seats[0]!.page.locator('[data-testid^="du-card-"]').count(); expect(remaining, `第 ${round} 局结算后不应还有手牌`).toBe(0); // 底牌分层展示出现 await expect(seats[0]!.page.getByTestId('du-bottom-reveal')).toBeVisible({ timeout: 5_000 }); // 升级信息出现 await expect(seats[0]!.page.getByTestId('du-topscore').getByText(/本局闲家得分/)).toBeVisible({ timeout: 5_000 }); // 记录本局级数(info 条「级数 X」) const info = await infoNumbers(seats[0]!.page); levelsSeen.add(info.level); maxLevel = Math.max(maxLevel, info.level); console.log(`[round ${round}] 级数=${info.level} 已打墩数=${info.tricks}`); if (round < ROUNDS) { // 开始下一局 await seats[0]!.page.getByTestId('du-start-game').click({ force: true }); await expect.poll(() => currentPhase(seats[0]!.page), { timeout: 15_000 }).not.toBe('score'); } } // 至少覆盖了 1 个级数;升级系统被触发(多局级数可能变化,也可能不变——只记录,不强制) expect(levelsSeen.size).toBeGreaterThanOrEqual(1); console.log(`[summary] 覆盖级数=${[...levelsSeen].sort().join(',')} 最高级数=${maxLevel}`); // 全程无错误 for (const s of seats) { expect(s.errors, `玩家 ${s.pid} 出现控制台/页面错误`).toEqual([]); } for (const s of seats) await s.ctx.close(); }); });