引擎(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
129 lines
5.7 KiB
TypeScript
129 lines
5.7 KiB
TypeScript
/**
|
||
* 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-start(P0 视角)或 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 中 SB(P1)先行动。
|
||
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 });
|
||
});
|
||
});
|