- 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>
159 lines
6.8 KiB
TypeScript
159 lines
6.8 KiB
TypeScript
/**
|
||
* 拱猪 — 联机 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);
|
||
});
|
||
});
|