- 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 用例
276 lines
13 KiB
TypeScript
276 lines
13 KiB
TypeScript
/**
|
||
* 桥牌 — 联机 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);
|
||
});
|
||
}); |