Files
huajishe-tts/apps/web/e2e/pig-names-and-trick-delay.spec.ts
e2hang bc374350c4 fix(holdem+pig): 筹码不足修复 + 拱猪墩满 3 秒延迟 + 真实姓名同步 + 联机 E2E
- holdem 引擎与 UI:筹码不足大盲的「坐板凳」状态补全
  * eligibleCount 从 seatedWithChips 改用 seatedCanPlay(只看筹码足够大盲的玩家)
  * broke 判定从 stack <= 0 改为 stack < bigBlind(短筹码也能补码)
  * 座位卡新增「筹码不足」徽章(提示玩家补码)
  * 行动者切换时 raiseAmt 重置(避免上一位玩家拖动的值残留)
  * WaitingControls 接收 gameover prop,便于 UI 显示结算

- holdem.test.ts:覆盖新逻辑(broke / eligible / 补码 / 行动者切换 raise 重置)

- pig 引擎与 UI:墩满 3 秒延迟收牌 + 玩家名同步
  * G.trickWinner 字段:满墩但未收牌期间用于 UI 高亮;3 秒后由 collectTrick 收牌推进
  * G.playerNames 字段:setName move 写入,server join 时 best-effort 派发 + 客户端 mount 兜底
  * pig.test.ts:新增 trickWinner / collectTrick / setName 单测

- PigBoard.tsx:座位卡显示真实姓名(优先 G.playerNames → prop → 玩家N 回退)
  * 挂载时调 setName 把本人真实名写入引擎,广播后所有人可见
  * 墩满时显示赢家高亮 + 3 秒倒计时收牌

- rooms.ts:注释说明 join 阶段 setName 派发为 best-effort(boardgame.io 对非 active
  玩家在 stateID=0 拦截 move),真实可靠的姓名来自客户端连上后的 setName

- 新增联机 E2E(playwright):
  * holdem-3p-allin.spec.ts:3 人德州 all-in 流局 + 边池分配
  * holdem-allin-repro.spec.ts:all-in 重现回归
  * pig-names-and-trick-delay.spec.ts:拱猪 4 人真实姓名显示 + 墩满延迟收牌

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-05 22:15:50 +08:00

159 lines
6.8 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:玩家加入后 UI 应显示真实玩家名(server setName 派发 + 客户端兜底)。
* 问题2:4 人出牌,第 4 张出完后所有客户端都能看到 4 张牌 + 赢家高亮 3 秒,再收牌。
*/
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 = `gz${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('pig');
await page.getByTestId('mode-online').click();
await expect(page.getByTestId('lobby-room-list')).toBeVisible({ timeout: 15_000 });
return { ctx, page, pid, name: auth.username };
}
/** 当前可出牌的玩家:优先取「其页面显示『出牌中』且确有合法牌」的座位(避免点到已过期仍能见牌的旧行动者)。 */
async function currentActor(seats: Seat[]): Promise<Seat | null> {
const infos = await Promise.all(seats.map(async (s) => {
const legal = await s.page.locator('[data-testid^="pig-card-"][data-legal="true"]').count();
const turn = await s.page.locator(`[data-testid="pig-seat-${s.pid}"]`).innerText().catch(() => '');
return { s, legal, isTurn: turn.includes('出牌中') };
}));
// 先选「轮到你」且「有合法牌」的;其次退化为「有合法牌」的(过渡窗口)。
const turnActor = infos.find((i) => i.isTurn && i.legal > 0);
if (turnActor) return turnActor.s;
const anyActor = infos.find((i) => i.legal > 0);
return anyActor ? anyActor.s : null;
}
test.describe('拱猪 — 玩家名 + 3 秒延迟收牌', () => {
test('4 人:真实玩家名显示 + 满墩 3 秒后收牌', 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 创建 4 座房间
await seats[0]!.page.getByTestId('lobby-create-seats').selectOption('4');
await seats[0]!.page.getByTestId('lobby-create-room').click();
await expect(seats[0]!.page.getByTestId('pig-board')).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('pig-board')).toBeVisible({ timeout: 15_000 });
}
// 房主开始
await expect(async () => {
const btn = seats[0]!.page.getByTestId('pig-start-game');
await expect(btn).toBeEnabled({ timeout: 2_000 });
await btn.click();
}).toPass({ timeout: 15_000 });
// 等发牌完成(任一玩家手牌出现)
await expect(async () => {
expect(await seats[0]!.page.locator('[data-testid^="pig-card-"]').count()).toBeGreaterThan(0);
}).toPass({ timeout: 10_000 });
// ===== 问题1:所有座位显示真实玩家名(非 P0/P1/P2/P3) =====
for (const seat of seats) {
await expect(async () => {
for (const s of seats) {
const seatEl = seat.page.getByTestId(`pig-seat-${s.pid}`);
const text = await seatEl.innerText();
expect(text).toContain(s.name);
}
}).toPass({ timeout: 10_000 });
}
// ===== 问题2:4 人出完一墩 → 4 张牌都可见 3 秒 → 再收牌 =====
// 依次让当前行动者出第一张合法牌
for (let i = 0; i < 4; i++) {
await expect(async () => {
const actor = await currentActor(seats);
if (!actor) throw new Error('no actor');
const card = actor.page.locator('[data-testid^="pig-card-"][data-legal="true"]').first();
await card.click();
}).toPass({ timeout: 15_000 });
}
// 第 4 张出完:trickWinner 高亮 + 4 张牌仍在桌中央(所有客户端可见)
for (const seat of seats) {
await expect(async () => {
// 中央 4 张牌(CardFace 元素)
const centerCards = seat.page.locator('[data-testid="pig-board"] > div > div > div > div > div[style*="border-radius: 5px"], [data-testid^="pig-card-"]');
// 更直接:检查"墩满 · 3 秒后收牌"提示
const hint = seat.page.getByTestId('pig-board').locator('text=墩满');
expect(await hint.count()).toBeGreaterThan(0);
}).toPass({ timeout: 5_000 });
}
// 此时所有客户端都看到赢家高亮
for (const seat of seats) {
await expect(seat.page.locator('text=赢得本墩').first()).toBeVisible({ timeout: 2_000 });
}
// 等 3 秒收牌(+ 余量)
await seats[0]!.page.waitForTimeout(4000);
// 收牌后:墩数变 1,桌中央清空,赢家拿到 4 张
await expect(async () => {
const text = await seats[0]!.page.getByTestId('pig-board').innerText();
expect(text).toContain('已出 1/13 墩');
}).toPass({ timeout: 5_000 });
console.log('matchID:', mid);
});
});