Files
huajishe-tts/apps/web/e2e/bridge-multiplayer.spec.ts
e2hang bec06a1389 fix(pig+bridge+double-up): 拱猪全红/大满贯计分顺序 + 桥牌叫牌合法性/明手控制 + 联机 E2E
- pig: 全红翻转提前到变压器 ×2 之前;大满贯改为独立定值 800(避免被 ×2 重复计入);补 3 条单测(P-1/P-2)
- bridge: 叫品按钮按 isLegalCall 客户端预判(禁用 + data-legal + title 提示);修正庄家控制明手的时机为 currentPlayer===dummy;CardFace 加 data-playable
- double-up: Board 补 playerLevels 兜底;E2E infoNumbers 正则结果空值保护
- e2e: 新增桥牌联机 E2E 用例
2026-10-03 12:43:41 +08:00

276 lines
13 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.
/**
* 桥牌 — 联机 E2E(真实 UI + server + engine)。
*
* 覆盖:
* 1. 4 人加入 fixedSeats=4 的房间 → 房主点「发牌开始」→ 4 家各得 13 张。
* 2. 叫牌阶段:当前行动者按钮可用,非行动者禁用;依次 1♠ + 3 家 Pass → 进入 play。
* 3. play 阶段:明手牌对所有人公开;庄家可控制自己 + 明手。
* 4. 满墩 3 秒后 collectTrick:trickCount 推进。
* 5. 13 墩打完 → 进入 score 阶段 → 房主可发下一副。
*
* 参考:apps/web/e2e/pig-names-and-trick-delay.spec.ts(4 座房 + 联机流程模式)。
*/
import { test, expect, type Browser, type Page, type BrowserContext } from '@playwright/test';
interface Seat {
ctx: BrowserContext;
page: Page;
pid: string;
name: string;
}
async function authUser(suffix: string): Promise<{ token: string; username: string }> {
const uname = `br${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();
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('/');
page.on('console', (m) => console.log(`[browser:${pid}] ${m.text()}`));
await page.getByTestId('game-select').selectOption('bridge');
await page.getByTestId('mode-online').click();
await expect(page.getByTestId('lobby-room-list')).toBeVisible({ timeout: 15_000 });
return { ctx, page, pid, name: auth.username };
}
/** 当前叫牌者 + 当前 seat 的「合法按钮」列表(用于校验非法按钮已禁用)。 */
async function bidActor(seats: Seat[]): Promise<Seat | null> {
for (const s of seats) {
const text = await s.page.getByTestId(`bridge-seat-${s.pid}`).innerText().catch(() => '');
if (text.includes('叫牌中')) return s;
}
return null;
}
/** 抓取「叫牌中」玩家的所有叫品按钮 + data-legal 标记。
* 返回 { callButtons: { id, label, legal }[] } —— 用于断言「非法叫品已禁用」。 */
async function bidButtonStates(actor: Seat): Promise<Array<{ id: string; label: string; legal: boolean }>> {
return await actor.page.evaluate(() => {
const buttons = Array.from(document.querySelectorAll('[data-testid^="call-"]')) as HTMLButtonElement[];
return buttons
.filter((b) => b.tagName === 'BUTTON')
.map((b) => ({
id: b.getAttribute('data-testid') ?? '',
label: b.textContent?.trim() ?? '',
legal: b.getAttribute('data-legal') === 'true',
}));
});
}
/** 当前可出牌的玩家:找有「可点牌」(data-playable=true)的 seat。
* - 防守方:自己 seat 上有可点牌 → 自己出。
* - 庄家:自己 seat 上有可点牌(轮到自己时) OR 明手 seat 上有可点牌(轮到明手时)。
* - 明手本人:永远没有可点牌(declarer 控)。 */
async function playActor(seats: Seat[]): Promise<Seat | null> {
for (const s of seats) {
const playable = await s.page.locator('[data-playable="true"]').count();
if (playable > 0) return s;
}
return null;
}
test.describe('桥牌 — 4 人联机 端到端', () => {
test('完整流程:发牌 → 叫牌 → 打牌 → 收牌 → 计分', async ({ browser }) => {
const auths = [
await authUser('a'),
await authUser('b'),
await authUser('c'),
await authUser('d'),
];
const seats = [
await newSeat(browser, '0', auths[0]!),
await newSeat(browser, '1', auths[1]!),
await newSeat(browser, '2', auths[2]!),
await newSeat(browser, '3', auths[3]!),
];
// P0 创建桥牌房间(bridge 在 GAMES 中 fixedSeats=4,自动 4 座)
await expect(async () => {
const btn = seats[0]!.page.getByTestId('lobby-create-room');
await expect(btn).toBeEnabled({ timeout: 2_000 });
await btn.click();
}).toPass({ timeout: 15_000 });
await expect(seats[0]!.page.getByTestId('bridge-waiting')).toBeVisible({ timeout: 15_000 });
// 读 P0 matchID,让其他人加入同一房间
const roomText = await seats[0]!.page.getByTestId('lobby-current-room').innerText();
const mid = roomText.match(/在房间\s+(\S+)\s+作为/)?.[1] ?? '';
for (const seat of seats.slice(1)) {
await expect(async () => {
await seat.page.getByTestId('lobby-refresh').click();
const btn = seat.page.getByTestId(`lobby-join-${seat.pid}-${mid}`);
expect(await btn.count()).toBeGreaterThan(0);
}).toPass({ timeout: 15_000 });
await seat.page.getByTestId(`lobby-join-${seat.pid}-${mid}`).click();
await expect(seat.page.getByTestId('bridge-waiting')).toBeVisible({ timeout: 15_000 });
}
// 房主开始发牌
await expect(async () => {
const btn = seats[0]!.page.getByTestId('bridge-start');
await expect(btn).toBeEnabled({ timeout: 2_000 });
await btn.click();
}).toPass({ timeout: 15_000 });
// ===== 1. 发牌完成 → 进入 bidding 阶段 =====
await expect(async () => {
for (const seat of seats) {
await expect(seat.page.getByTestId('bridge-bidding')).toBeVisible({ timeout: 5_000 });
}
}).toPass({ timeout: 15_000 });
// 每人手牌 13 张
for (const seat of seats) {
await expect(async () => {
const cardCount = await seat.page.locator('[data-testid^="card-"]').count();
expect(cardCount).toBeGreaterThanOrEqual(13);
}).toPass({ timeout: 5_000 });
}
// ===== 2. 叫牌:P0 叫 1♠,其余 3 家 Pass =====
// 找出当前 bidTurn 玩家,逐家出牌
for (let i = 0; i < 4; i++) {
await expect(async () => {
const actor = await bidActor(seats);
expect(actor).not.toBeNull();
if (!actor) return;
if (i === 0) {
// P0 (NS) 叫 1♠。第一次开叫:X/XX 应禁用(没有敌方叫品可加倍)
const states = await bidButtonStates(actor);
const xBtn = states.find((s) => s.id === 'call-double');
const xxBtn = states.find((s) => s.id === 'call-redouble');
expect(xBtn?.legal).toBe(false);
expect(xxBtn?.legal).toBe(false);
// 1♠ 应合法(第一家开叫无约束)
const bid1S = states.find((s) => s.id === 'call-bid-1♠');
expect(bid1S?.legal).toBe(true);
await actor.page.getByTestId('call-bid-1♠').click();
} else if (i === 1) {
// P1 (EW):上一叫品是 1♠。1♣..1♥ 应禁用;1♠ / Pass 合法;X 合法(敌方叫品)
const states = await bidButtonStates(actor);
expect(states.find((s) => s.id === 'call-bid-1♣')?.legal).toBe(false);
expect(states.find((s) => s.id === 'call-bid-1♦')?.legal).toBe(false);
expect(states.find((s) => s.id === 'call-bid-1♥')?.legal).toBe(false);
expect(states.find((s) => s.id === 'call-bid-1♠')?.legal).toBe(false); // 等于上一叫品 = 非法
expect(states.find((s) => s.id === 'call-bid-1NT')?.legal).toBe(true);
expect(states.find((s) => s.id === 'call-bid-2♣')?.legal).toBe(true);
expect(states.find((s) => s.id === 'call-double')?.legal).toBe(true); // X 合法
expect(states.find((s) => s.id === 'call-redouble')?.legal).toBe(false); // 没被加倍 → 非法
await actor.page.getByTestId('call-pass').click();
} else if (i === 2) {
// P2 (NS):1♠ 之后 Pass。P2 是 dummy;叫牌 +1♠ 不可能(已叫过);1NT / 2♣ 合法
const states = await bidButtonStates(actor);
expect(states.find((s) => s.id === 'call-bid-1♠')?.legal).toBe(false); // 同阶同花色
expect(states.find((s) => s.id === 'call-bid-1NT')?.legal).toBe(true);
await actor.page.getByTestId('call-pass').click();
} else {
// P3 (EW):Pass
await actor.page.getByTestId('call-pass').click();
}
}).toPass({ timeout: 10_000 });
}
// ===== 3. 进入 play 阶段:contract 1♠,declarer=0, dummy=2, openingLeader=1 =====
await expect(async () => {
for (const seat of seats) {
await expect(seat.page.getByTestId('bridge-play')).toBeVisible({ timeout: 5_000 });
// contract display 应为「契约 1♠」
await expect(seat.page.getByTestId('contract-display')).toContainText('1♠', { timeout: 5_000 });
}
}).toPass({ timeout: 15_000 });
// 明手牌对所有人公开(P2 是 dummy)。等待进入 play 阶段且明手亮出。
await expect(async () => {
for (const seat of seats) {
await expect(seat.page.getByTestId('bridge-play')).toBeVisible({ timeout: 5_000 });
const handArea = seat.page.getByTestId('bridge-hand-area');
await expect(handArea).toContainText('明手', { timeout: 5_000 });
}
}).toPass({ timeout: 15_000 });
// ===== 2.5 明手规则验证 =====
// P0 (declarer) 应能控制明手牌 — 明手牌区显示「你控制」 + dummy 牌 13 张可点(轮到 dummy 时)
const declarerPage = seats[0]!.page;
await expect(declarerPage.getByTestId('bridge-hand-area')).toContainText('明手', { timeout: 3_000 });
await expect(declarerPage.getByTestId('bridge-hand-area')).toContainText('你控制', { timeout: 3_000 });
// 明手牌 13 张可见
const dummyCardsCount = await declarerPage.locator('[data-testid^="card-"]').count();
expect(dummyCardsCount).toBeGreaterThanOrEqual(26); // 13 自己 + 13 明手
// P2 (dummy) 自己看自己的牌:明手本人不能自己出牌(不可点)
const dummySeat = seats[2]!;
// 明手本人页面:手牌区应明示「由庄家 XXX 控制(你不可出牌)」
await expect(dummySeat.page.getByTestId('bridge-hand-area')).toContainText('由庄家', { timeout: 3_000 });
await expect(dummySeat.page.getByTestId('bridge-hand-area')).toContainText('你不可出牌', { timeout: 3_000 });
// 自己的牌 13 张可见但都不可点
const dummySelfCardCount = await dummySeat.page.locator('[data-testid^="card-"]').count();
expect(dummySelfCardCount).toBeGreaterThanOrEqual(13);
const dummySelfPlayable = await dummySeat.page.locator('[data-playable="true"]').count();
expect(dummySelfPlayable).toBe(0); // 明手牌无 data-playable(不能自己出)
// 防守方 (P1) 也能看到明手牌(playerView 公开)
const defenderPage = seats[1]!.page;
await expect(defenderPage.getByTestId('bridge-hand-area')).toContainText('明手', { timeout: 3_000 });
// ===== 4. 出第一墩(4 人各出一张合法牌) =====
for (let i = 0; i < 4; i++) {
// 等到至少有 1 个 seat 有可点的牌(确保状态稳定)
await expect(async () => {
const actor = await playActor(seats);
expect(actor).not.toBeNull();
}).toPass({ timeout: 10_000 });
// 然后点击
const actor = await playActor(seats);
if (!actor) throw new Error(`第 ${i + 1} 张牌:没有 actor`);
const card = actor.page.locator('[data-playable="true"]').first();
await expect(card).toBeVisible({ timeout: 3_000 });
await card.click();
// 等一下让状态传播
await seats[0]!.page.waitForTimeout(500);
}
// ===== 5. 满墩:所有客户端看到「上一墩 · XXX 赢(3 秒后收)」提示 =====
for (const seat of seats) {
await expect(async () => {
await expect(seat.page.getByTestId('last-trick')).toBeVisible({ timeout: 5_000 });
}).toPass({ timeout: 10_000 });
}
// ===== 6. 等 3 秒后收牌:trickCount 应推进到 1 =====
await seats[0]!.page.waitForTimeout(4_000);
await expect(async () => {
for (const seat of seats) {
const boardText = await seat.page.getByTestId('bridge-play').innerText();
expect(boardText).toMatch(/第\s*2\s*\/\s*13\s*墩/);
}
}).toPass({ timeout: 10_000 });
console.log('bridge matchID:', mid);
});
});