- 新增双升(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
369 lines
15 KiB
TypeScript
369 lines
15 KiB
TypeScript
/**
|
||
* 双升(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<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();
|
||
});
|
||
});
|