Files
huajishe-tts/apps/web/e2e/holdem-allin-repro.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

168 lines
7.0 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.
/**
* Holdem — 复现用户报告问题的 E2E(联机,真实 UI + server + engine):
*
* 1. All-in 后没有摊牌比大小 / 亮牌
* 2. 补码(rebuy)牌局结束后无法点击、房主无法重置
*
* 场景:2 人房,起始筹码 30(盲注 10/20),一手 all-in 打到一方破产。
*/
import { test, expect, type Browser, type Page, type BrowserContext } from '@playwright/test';
interface Seat {
ctx: BrowserContext;
page: Page;
pid: string;
}
async function authUser(suffix: string): Promise<{ token: string; username: string }> {
const uname = `hk${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('/');
await page.getByTestId('game-select').selectOption('holdem');
await page.getByTestId('mode-online').click();
await expect(page.getByTestId('lobby-room-list')).toBeVisible({ timeout: 15_000 });
return { ctx, page, pid };
}
test.describe('Holdem — all-in + 破产复现', () => {
test('all-in 摊牌 → 亮牌可见 → 破产者补码 → 房主重置 → 再开局', async ({ browser }) => {
const auth0 = await authUser('p0');
const auth1 = await authUser('p1');
const p0 = await newSeat(browser, '0', auth0);
const p1 = await newSeat(browser, '1', auth1);
// P0 创建 2 座房间,起始筹码 30
await p0.page.getByTestId('lobby-create-seats').selectOption('2');
await p0.page.getByTestId('lobby-create-startingStack').fill('30');
await p0.page.getByTestId('lobby-create-room').click();
await expect(p0.page.getByTestId('holdem-board')).toBeVisible({ timeout: 15_000 });
// 读 P0 "在房间" 显示的 matchID(避免误加入上一轮遗留的旧房间)
const roomText = await p0.page.getByTestId('lobby-current-room').innerText();
const mid = roomText.match(/在房间\s+(\S+)\s+作为/)?.[1] ?? '';
// P1 加入 P0 那个房间
await expect(async () => {
await p1.page.getByTestId('lobby-refresh').click();
const joinBtn = p1.page.getByTestId(`lobby-join-1-${mid}`);
expect(await joinBtn.count()).toBeGreaterThan(0);
}).toPass({ timeout: 15_000 });
await p1.page.getByTestId(`lobby-join-1-${mid}`).click();
await expect(p1.page.getByTestId('holdem-board')).toBeVisible({ timeout: 15_000 });
// 等双方就座 + 开始按钮可用,然后开始
await expect(async () => {
const startBtn = p0.page.getByTestId('holdem-start');
await expect(startBtn).toBeEnabled({ timeout: 2_000 });
await startBtn.click();
}).toPass({ timeout: 20_000 });
// heads-up: 按钮=SB 先行动。等行动条出现。
await expect(async () => {
const b0 = p0.page.getByTestId('holdem-btn-allin');
const b1 = p1.page.getByTestId('holdem-btn-allin');
expect(await b0.count() + await b1.count()).toBeGreaterThan(0);
}).toPass({ timeout: 15_000 });
// 谁有全下按钮谁全下(SB 先手)
const p0HasBtn = await p0.page.getByTestId('holdem-btn-allin').isVisible().catch(() => false);
const raiser = p0HasBtn ? p0 : p1;
const caller = p0HasBtn ? p1 : p0;
await raiser.page.getByTestId('holdem-btn-allin').click();
// 对方跟注
await expect(async () => {
const btn = caller.page.getByTestId('holdem-btn-call');
if (await btn.isVisible({ timeout: 1_000 }).catch(() => false)) {
await btn.click();
return;
}
throw new Error('call button not visible yet');
}).toPass({ timeout: 15_000 });
// ===== 关键验证 1: 摊牌结果 =====
// 等 board 出现 5 张公共牌 + 结果面板(两个客户端都要验证)
for (const seat of [p0, p1]) {
await expect(async () => {
const cards = seat.page.locator('[data-testid^="holdem-board-card-"]');
expect(await cards.count()).toBe(5);
}).toPass({ timeout: 15_000 });
await expect(async () => {
const results = await seat.page.getByTestId('holdem-log').innerText();
expect(results).toContain('赢得');
}).toPass({ timeout: 15_000 });
// 摊牌后 4 张底牌全部可见(亮牌)
await expect(async () => {
const holeCards = seat.page.locator('[data-testid^="holdem-hole-"]');
expect(await holeCards.count()).toBe(4);
}).toPass({ timeout: 5_000 });
}
// ===== 关键验证 2: 破产者补码 =====
// 一方筹码为 0( bankrupt),等 waiting 阶段的补码按钮出现
const brokeSeat = (await p0.page.getByTestId('holdem-rebuy').isVisible().catch(() => false)) ? p0 : p1;
await expect(brokeSeat.page.getByTestId('holdem-rebuy')).toBeVisible({ timeout: 15_000 });
await brokeSeat.page.getByTestId('holdem-rebuy').click();
// 补码后筹码应恢复 30
await expect(async () => {
const stackText = await brokeSeat.page
.locator(`[data-testid="holdem-seat-${brokeSeat.pid}"]`)
.innerText();
expect(stackText).toContain('筹码 30');
}).toPass({ timeout: 15_000 });
// ===== 关键验证 3: 房主重置房间 =====
await expect(p0.page.getByTestId('holdem-reset')).toBeVisible({ timeout: 5_000 });
await p0.page.getByTestId('holdem-reset').click();
await expect(async () => {
const stack0 = await p0.page.getByTestId('holdem-seat-0').innerText();
expect(stack0).toContain('筹码 30');
}).toPass({ timeout: 15_000 });
// ===== 关键验证 4: 重开后能开新一手 =====
await expect(async () => {
const startBtn = p0.page.getByTestId('holdem-start');
await expect(startBtn).toBeEnabled({ timeout: 2_000 });
await startBtn.click();
}).toPass({ timeout: 20_000 });
// 进入 preflop(出现行动者高亮)
await expect(async () => {
const a0 = await p0.page.locator('[data-actor="true"]').count();
const a1 = await p1.page.locator('[data-actor="true"]').count();
expect(a0 + a1).toBeGreaterThan(0);
}).toPass({ timeout: 15_000 });
console.log('matchID:', mid);
});
});