Files
huajishe-tts/docs/dev-log/13-holdem-game.md
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

148 lines
8.4 KiB
Markdown
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.
# 13 · 德州扑克Holdem游戏
## 做什么
用户要求在 tts-like 里扩充一个**德州扑克**对局游戏,最多 9 人入座,按德州扑克规则(盲注 / 翻牌 / 转牌 / 河牌 / 摊牌)走完整一手牌。
这是 dev-log 12 末尾记的"加新游戏不是调 Lua 文件"那条路径的首次落地:纯 TypeScript 写 reducer + 自定义 React 牌桌 + 隐藏信息(每人只看自己底牌)。
## 范围
- 引擎:`packages/engine/src/games/holdem.ts` + `holdem-eval.ts`
- UI`apps/web/src/HoldemBoard.tsx`(自定义牌桌,不复用通用 `<Board>`
- 大厅:`packages/ui/src/LobbyRoomList.tsx` 支持 2..9 可变人数 + setupData 表单
- App`apps/web/src/App.tsx` 注册 holdem + 本地 9 座切换 / 联机可变人数
- 测试:`holdem.test.ts`25 用例)+ `holdem-eval.test.ts`16 用例)+ `holdem-multiplayer.spec.ts`E2E
## 设计要点
### 1. 牌型评估器(`holdem-eval.ts`,纯函数)
- `evaluate5/evaluate7`7 张牌取最大 5 张组合,返回 `Evaluation { category, kickers }`
- `compareHands`:类别 → kicker 逐位比
- `makeDeck`52 张 ♠♥♦♣ × 13 点
- `cardLabel`:中文牌面("红桃 A"、"方块 10"
- 10 个类别常量(`CATEGORY_HIGH_CARD` .. `CATEGORY_ROYAL_FLUSH`
### 2. 引擎(`holdem.ts`
**State**`HoldemState`房间配置startingStack / smallBlind / bigBlind+ `players: Record<string, HoldemPlayer>` + 牌局进行deck/hands 为 SECRET+ `results: HoldemResults | null`
**Phase 机**5 阶段):
```
waiting → (startHand move) → preflop → flop → turn → river → showdown → waiting
↑ fold-win 也走这里
```
**Move**8 个):`sitDown` / `startHand` / `fold` / `check` / `call` / `raise` / `allIn`raise 别名)/ `sitDown`。全部 `client: false`(不在 turn.activePlayers 内的玩家调会被拒)。
**隐藏信息**`playerView` 剥掉 `deck` / `hands`,只注入当前玩家自己的 `myHand`。摊牌时 `results.revealedHands` 才把所有人的底牌公开。
**盲注 / 按钮轮转**`dealerButton` 每手 +1mod seatedWithChipsSB = nextSeat(button)BB = nextSeat(SB)。heads-up 时 SB 先行动(标准规则)。
**边池结算**`buildPots``totalBetThisHand` 分层,`resolveHand` 对每层跑 `evaluate7`,并列赢家平分。
**局终 endIf**:只在 showdown/waiting 且 `handNumber > 0` 时判定 `seatedWithChips.length === 1`。开局没人入座不算局终。
**validateSetupData**:校验 startingStack/smallBlind/bigBlind 为正整数且 `bigBlind >= smallBlind`,非法返回 400。
### 3. UI`HoldemBoard.tsx`
- 椭圆桌 + N 座位按角度环形分布(`seatPos(idx, N)`
- 每座显示名字 / 筹码 / 本街下注 / 状态徽章D/SB/BB/弃牌/ALL-IN/行动中)/ 底牌
- 中央公共牌 + 底池;底部行动条(弃牌 / 过牌 / 跟注 / 加注 slider / 全下)
- waiting 阶段:就座 + 开始按钮(房主可见,`eligibleCount >= 2` 才 enable
- showdown结果面板每个 pot 的赢家 / 牌型名)+ 日志面板
- 自动入座mount 时若未入座自动 `sitDown`
### 4. 大厅可变人数(`LobbyRoomList.tsx`
新增 props
- `maxSeats`座位上限holdem=9其余=2
- `setupOptions`创建表单选项holdem 的筹码 / 小盲 / 大盲)
- `onSelect` 回调加 `numPlayers` 参数,供上层 `Client({ numPlayers })`
创建表单:`maxSeats > 2` 时显示座位选择器2..9+ setupData 输入框。加入按钮从硬编码 P0/P1 改为遍历 `m.players` 动态渲染 P0..P(n-1)。
### 5. App.tsx
`GAMES` record 扩展为 `GameMeta``{ game, name, engineName, maxSeats, setupOptions }`
- `HoldemLocalView`9 座切换调试(下拉选 P0..P8`key={seat}` 重建 Client 触发 playerView 重算)
- `HoldemOnlineView``config.numPlayers` 驱动 `Client({ numPlayers })`,切房间若 numPlayers 变化则重建 Client
`OnlineConfig``numPlayers` 字段,创建/加入时由 LobbyRoomList 写入。
## 遇到什么问题
### 问题 1机器重启导致 HoldemBoard.tsx 末尾 128 字节 NULL
上一轮会话最后正在写 `HoldemBoard.tsx` 时机器重启,文件末尾被填充了 128 字节 `0x00``wc -c` = 16161最后 128 字节全是 NULL。TypeScript 报 `TS1127: Invalid character` 在第 427 行的 128 个字符上。
**修法**`data.rstrip(b'\x00')` 清掉尾部 NULL文件实际内容到第 426 行 `};` 完整结束。教训:跨会话恢复时先 `xxd` 检查文件末尾是否有 NULLTypeScript 的"Invalid character"往往是 NULL 字节而非编码问题。
### 问题 2`ViewG` 不能赋给 `HoldemState`playerView 剥了 deck/hands
`HoldemBoard` 拿到的 G 是 playerView 剥过 secret 的 `ViewG = Omit<HoldemState,'deck'|'hands'> & { myHand }`,但 `HOLDEM_HELPERS.host(G)` / `seatedWithChips(G)` / `nextSeat(G,...)` 的签名是 `G: HoldemState` → TS 报 `ViewG is missing properties: deck, hands`
**修法**:引擎新增 `export type HoldemView = Omit<HoldemState, 'deck' | 'hands'>`,把只读 helper`pids` / `nameOf` / `canAct` / `unfoldedPids` / `handOver` / `host` / `seatedWithChips` / `nextSeat` / `nextActor` / `eligibleCount`)的签名从 `HoldemState` 放宽到 `HoldemView`。这些函数只读 `G.players`+ `G.acted`/`G.currentBet`,都在 HoldemView 里),结构类型让 `HoldemState``ViewG` 都自动满足。`buildPots` / `resolveHand``hands`,保持 `HoldemState` 不变。
好处UI 无需 `as HoldemState` 强转,类型安全 + playerView 保密约束在编译期可见。
### 问题 3`pnpm dev` / `pnpm start` 在新 shell 找不到 pnpm
重启后新 shell 没有加载 nvm`which pnpm` 报 NOT FOUND。
**修法**`export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"; corepack enable``corepack enable` 在 node bin 目录创建 pnpm shim之后 `pnpm` / `pnpm -r ts` 正常。见 lessons-learned §1.1。
### 问题 4heads-up 先行动者判定
2 人房时按钮位 = P0`nextSeat(P0) = P1`SB`nextSeat(P1) = P0`BB`firstToAct = nextSeat(BB) = nextSeat(P0) = P1`。所以 heads-up preflop P1SB先行动——符合标准规则SB 先动 preflopBB 先动 flop+。E2E 据此让 P1 弃牌结束一手。
## 怎么解决(具体做法)
### 引擎层
`packages/engine/src/games/holdem.ts`
- `HoldemView` 类型 + 5 个 phase + 8 个 move + `validateSetupData` + `playerView` + 边池结算
- `HOLDEM_HELPERS` 导出纯查询函数给 UI / 测试复用
`packages/engine/src/games/holdem-eval.ts`
- 纯函数评估器,无状态,可独立单测
### UI 层
`apps/web/src/HoldemBoard.tsx`426 行,椭圆桌 SVG + 行动条 + 结果面板 + 日志。`WaitingControls` 子组件管就座 / 开始。
`packages/ui/src/LobbyRoomList.tsx`:可变人数 + setupData 表单 + 动态加入按钮。
`apps/web/src/App.tsx``HoldemLocalView` / `HoldemOnlineView` + `GameMeta` 元数据 + `OnlineConfig.numPlayers`
### 测试
- `holdem-eval.test.ts`16 用例(类别排序 / wheel / 踢脚 / 平局 / 中文名)
- `holdem.test.ts`25 用例(盲注 / fold-win / 全下 / 边池 / 按钮轮转 / 局终 / validateSetupData
- `holdem-multiplayer.spec.ts`E2E 走完一手 heads-up创建 → 加入 → 就座 → 开始 → 弃牌胜 → 回 waiting
## 成果
- 全量验证:
- `pnpm -r ts` 5/5 包通过
- `pnpm -r test` engine 75 + server 9 = 84 通过
- `pnpm test:e2e` 5/5 通过(含新 holdem + 既有 war/mill/drag-test 未回归)
- 文件清单:
- 新增 `packages/engine/src/games/holdem.ts`~840 行)
- 新增 `packages/engine/src/games/holdem-eval.ts`~200 行)
- 新增 `packages/engine/src/games/holdem.test.ts` + `holdem-eval.test.ts`
- 新增 `apps/web/src/HoldemBoard.tsx`426 行)
- 新增 `apps/web/e2e/holdem-multiplayer.spec.ts`
-`packages/ui/src/LobbyRoomList.tsx`(可变人数 + setupData
-`apps/web/src/App.tsx`(注册 holdem + HoldemLocalView/OnlineView
-`packages/server/src/index.ts`games 数组加 Holdem已在上轮完成
## 关联
- [12-unified-port-and-player-names.md](./12-unified-port-and-player-names.md) — 命名系统为本游戏打基础
- lessons-learned §12.1-12.3 — 本轮的坑NULL 损坏 / HoldemView / pnpm
- decisions D30-D32 — HoldemView 类型、可变人数大厅、9 座本地切换