Files
huajishe-tts/apps/web/e2e/holdem-multiplayer.spec.ts
e2hang 25452ce045 feat(holdem): Texas Hold'em game (engine + UI + variable-seat lobby + e2e + docs)
引擎(packages/engine/src/games/):
- holdem.ts: HoldemState + 5 phase (waiting→preflop→flop→turn→river→showdown)
  + 8 moves (sitDown/startHand/fold/check/call/raise/allIn) + playerView 保密
  + 边池结算 + 盲注/按钮轮转 + validateSetupData
  + HoldemView 类型: 只读 helper 放宽签名, UI 复用无需强转
- holdem-eval.ts: 7 张牌评估器 (evaluate5/7/compareHands/makeDeck/cardLabel, 中文牌型名)
- holdem.test.ts (25) + holdem-eval.test.ts (16)

UI:
- apps/web/HoldemBoard.tsx: 椭圆桌 + N 座位环形布局 + 行动条 + 结果面板 + 日志
- packages/ui/LobbyRoomList.tsx: 可变人数 (maxSeats 2..9) + setupData 表单
  + 动态 P0..P(n-1) 加入按钮 + onSelect 携带 numPlayers
- apps/web/App.tsx: 注册 holdem + HoldemLocalView (9 座切换) + HoldemOnlineView

测试:
- apps/web/e2e/holdem-multiplayer.spec.ts: 双浏览器联机 heads-up 完整一手
- 全量验证: ts 5/5 + unit 84 + e2e 5/5

文档:
- docs/dev-log/13-holdem-game.md: 设计要点 + 坑 (NULL 损坏 / HoldemView / pnpm)
- decisions D30-D32 + lessons-learned §12.1-12.4
- README.md: 系统启动 + 迁移到其它机器步骤

server games 数组已加 Holdem (上轮完成)。

Co-authored-by: GLM-5.2
2026-08-24 14:05:14 +08:00

129 lines
5.7 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
*
* 验证最短一手牌的完整联机流程:
* 创建 2 人房 → P1 加入 → 双方自动就座 → 房主开始 → 盲注 → P1 弃牌 →
* P0 弃牌胜 → 回 waiting 阶段。
*
* 单元测试holdem.test.ts覆盖牌型评估、边池、按钮轮转等细节
* 此 E2E 只验证联机接线与 UI 的关键 testid 串得通。
*/
import { test, expect, Browser } from '@playwright/test';
/** 开一个新 context独立 localStorage / credentials选 Holdem + 联机。 */
async function setupOnlineClient(browser: Browser) {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto('/');
await page.getByTestId('game-select').selectOption('holdem');
await page.getByTestId('mode-online').click();
await expect(page.getByTestId('lobby-room-list')).toBeVisible();
return page;
}
/** P0设 2 座 → 创建房间(自动 join 成 P0。 */
async function createRoomAsP0(page: import('@playwright/test').Page) {
// Holdem 创建表单有座位选择器(默认 9选 2 以走最快的 heads-up 流程
await page.getByTestId('lobby-create-seats').selectOption('2');
await page.getByTestId('lobby-create-room').click();
// 创建后应进入"在房间"状态,并显示 HoldemBoard
await expect(page.getByTestId('lobby-current-room')).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId('holdem-board')).toBeVisible({ timeout: 15_000 });
}
/** P1刷新列表 → 加入第一个房间的 P1 座位。 */
async function joinRoomAsP1(page: import('@playwright/test').Page) {
await expect(async () => {
await page.getByTestId('lobby-refresh').click();
// 房间 chip 的 testid 形如 lobby-room-<matchID>,排除 list/refresh/current-room
const rooms = page.locator('[data-testid^="lobby-room-"]:not([data-testid="lobby-room-list"])');
expect(await rooms.count()).toBeGreaterThan(0);
}).toPass({ timeout: 10_000 });
const rooms = page.locator('[data-testid^="lobby-room-"]:not([data-testid="lobby-room-list"])');
const firstRoomAttr = await rooms.first().getAttribute('data-testid');
const matchID = firstRoomAttr!.replace(/^lobby-room-/, '');
await page.getByTestId(`lobby-join-1-${matchID}`).click();
await expect(page.getByTestId('holdem-board')).toBeVisible({ timeout: 15_000 });
}
/** 等待某个座位就座auto sitDown 由 HoldemBoard mount 时触发)。 */
async function expectSeated(page: import('@playwright/test').Page, pid: string) {
// 没有直接的 "seated" testid但开始按钮要 eligibleCount>=2 才会出现;
// 间接验证:等 holdem-startP0 视角)或 holdem-sit 消失(已就座)。
// 这里仅作宽松等待,让 server 状态同步。
await expect(page.getByTestId(`holdem-seat-${pid}`)).toBeVisible({ timeout: 10_000 });
}
test.describe('Holdem — 2-player multiplayer E2E', () => {
test('create → join → sit → start → blinds → fold win → waiting', async ({ browser }) => {
const p0 = await setupOnlineClient(browser);
const p1 = await setupOnlineClient(browser);
await createRoomAsP0(p0);
await joinRoomAsP1(p1);
// 双方自动就座HoldemBoard mount 时 sitDown
await expectSeated(p0, '0');
await expectSeated(p1, '1');
// 等双方就座都同步到 P0 视角holdem-start 从 disabled 变 enabled
// 然后房主点开始 → 进入 preflop出现行动者高亮。
await expect(async () => {
const startBtn = p0.getByTestId('holdem-start');
await expect(startBtn).toBeEnabled({ timeout: 2_000 });
await startBtn.click();
// 开始成功后任一 tab 应出现行动者高亮
const a0 = await p0.locator('[data-actor="true"]').count();
const a1 = await p1.locator('[data-actor="true"]').count();
expect(a0 + a1).toBeGreaterThan(0);
}).toPass({ timeout: 15_000 });
// 确认双方都进入了下注阶段行动者高亮。heads-up 中 SBP1先行动。
await expect(async () => {
const p1Actor = await p1.locator('[data-testid="holdem-seat-1"][data-actor="true"]').count();
const p0Actor = await p0.locator('[data-testid="holdem-seat-0"][data-actor="true"]').count();
// 至少一个 tab 看到行动者
expect(p1Actor + p0Actor).toBeGreaterThan(0);
}).toPass({ timeout: 10_000 });
// 当前行动者点"弃牌" → 另一方弃牌胜 → 回 waiting
const foldP1 = async () => {
const btn = p1.getByTestId('holdem-btn-fold');
if (await btn.isVisible({ timeout: 1_000 }).catch(() => false)) {
await btn.click();
return true;
}
return false;
};
const foldP0 = async () => {
const btn = p0.getByTestId('holdem-btn-fold');
if (await btn.isVisible({ timeout: 1_000 }).catch(() => false)) {
await btn.click();
return true;
}
return false;
};
// 轮流尝试弃牌(谁先有按钮谁弃),直到回到 waiting
let folded = false;
for (let i = 0; i < 10 && !folded; i++) {
if (await foldP1()) folded = true;
else if (await foldP0()) folded = true;
else await p0.waitForTimeout(500);
}
expect(folded).toBeTruthy();
// 弃牌胜后应回到 waiting 阶段holdem-start 重新出现给房主)
await expect(async () => {
// waiting 阶段房主可见开始按钮
const startBtn = p0.getByTestId('holdem-start');
if (await startBtn.isVisible({ timeout: 1_000 }).catch(() => false)) return;
// 或日志里出现"胜"字
const logText = await p0.getByTestId('holdem-log').innerText().catch(() => '');
expect(logText).toContain('胜');
}).toPass({ timeout: 15_000 });
});
});