- 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>
168 lines
7.0 KiB
TypeScript
168 lines
7.0 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
});
|