Files
huajishe-tts/apps/web/e2e/double-up-full-game.spec.ts
e2hang ddcb28472c feat(double-up): 双升游戏 + 单端口 start.sh 部署 + 文档统一更新
- 新增双升(DoubleUp)完整实现:引擎 double-up-core.ts / double-up.ts、牌桌 UI DoubleUpBoard.tsx、单测与 E2E
- 甩牌跟牌修复:validateFollow 新增 mixed 分支,甩牌后其他家可正常跟单张同花色牌
- start.sh:PORT=5173 单端口一键启动(Koa 同时 serve 页面+/api+/socket.io),浏览器直连无需 Vite 代理
- 玩家名 server 权威化(join 时写入 G.playerNames);LobbyRoomList 支持 fixedSeats(双升固定 4 人)
- 文档:新增 dev-log/14、更新 dev-log README 索引与进度、更新根 README 启动/部署说明
- .gitignore 忽略根目录 data/*.db 运行时 SQLite
2026-08-30 02:11:04 +08:00

369 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 双升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 :5173playwright.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<Seat> {
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<void> {
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<string> {
return page.getByTestId('du-info').innerText().catch(() => '');
}
async function currentPhase(page: Page): Promise<string> {
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<boolean> {
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<boolean> {
return (await page.getByTestId('du-set-bottom').count()) > 0 && (await page.getByTestId('du-set-bottom').isVisible());
}
async function playLegal(page: Page): Promise<void> {
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<string, { idx: number; suit: string; rank: number; isTrump: boolean }[]>();
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<string, { idx: number; suit: string; rank: number; isTrump: boolean }[]>();
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<void> {
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<boolean> {
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<number>();
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();
});
});