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

8.4 KiB
Raw Permalink Blame History

13 · 德州扑克Holdem游戏

做什么

用户要求在 tts-like 里扩充一个德州扑克对局游戏,最多 9 人入座,按德州扑克规则(盲注 / 翻牌 / 转牌 / 河牌 / 摊牌)走完整一手牌。

这是 dev-log 12 末尾记的"加新游戏不是调 Lua 文件"那条路径的首次落地:纯 TypeScript 写 reducer + 自定义 React 牌桌 + 隐藏信息(每人只看自己底牌)。

范围

  • 引擎:packages/engine/src/games/holdem.ts + holdem-eval.ts
  • UIapps/web/src/HoldemBoard.tsx(自定义牌桌,不复用通用 <Board>
  • 大厅:packages/ui/src/LobbyRoomList.tsx 支持 2..9 可变人数 + setupData 表单
  • Appapps/web/src/App.tsx 注册 holdem + 本地 9 座切换 / 联机可变人数
  • 测试:holdem.test.ts25 用例)+ holdem-eval.test.ts16 用例)+ holdem-multiplayer.spec.tsE2E

设计要点

1. 牌型评估器(holdem-eval.ts,纯函数)

  • evaluate5/evaluate77 张牌取最大 5 张组合,返回 Evaluation { category, kickers }
  • compareHands:类别 → kicker 逐位比
  • makeDeck52 张 ♠♥♦♣ × 13 点
  • cardLabel:中文牌面("红桃 A"、"方块 10"
  • 10 个类别常量(CATEGORY_HIGH_CARD .. CATEGORY_ROYAL_FLUSH

2. 引擎(holdem.ts

StateHoldemState房间配置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 也走这里

Move8 个):sitDown / startHand / fold / check / call / raise / allInraise 别名)/ sitDown。全部 client: false(不在 turn.activePlayers 内的玩家调会被拒)。

隐藏信息playerView 剥掉 deck / hands,只注入当前玩家自己的 myHand。摊牌时 results.revealedHands 才把所有人的底牌公开。

盲注 / 按钮轮转dealerButton 每手 +1mod seatedWithChipsSB = nextSeat(button)BB = nextSeat(SB)。heads-up 时 SB 先行动(标准规则)。

边池结算buildPotstotalBetThisHand 分层,resolveHand 对每层跑 evaluate7,并列赢家平分。

局终 endIf:只在 showdown/waiting 且 handNumber > 0 时判定 seatedWithChips.length === 1。开局没人入座不算局终。

validateSetupData:校验 startingStack/smallBlind/bigBlind 为正整数且 bigBlind >= smallBlind,非法返回 400。

3. UIHoldemBoard.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 }

  • HoldemLocalView9 座切换调试(下拉选 P0..P8key={seat} 重建 Client 触发 playerView 重算)
  • HoldemOnlineViewconfig.numPlayers 驱动 Client({ numPlayers }),切房间若 numPlayers 变化则重建 Client

OnlineConfignumPlayers 字段,创建/加入时由 LobbyRoomList 写入。

遇到什么问题

问题 1机器重启导致 HoldemBoard.tsx 末尾 128 字节 NULL

上一轮会话最后正在写 HoldemBoard.tsx 时机器重启,文件末尾被填充了 128 字节 0x00wc -c = 16161最后 128 字节全是 NULL。TypeScript 报 TS1127: Invalid character 在第 427 行的 128 个字符上。

修法data.rstrip(b'\x00') 清掉尾部 NULL文件实际内容到第 426 行 }; 完整结束。教训:跨会话恢复时先 xxd 检查文件末尾是否有 NULLTypeScript 的"Invalid character"往往是 NULL 字节而非编码问题。

问题 2ViewG 不能赋给 HoldemStateplayerView 剥了 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'>,把只读 helperpids / nameOf / canAct / unfoldedPids / handOver / host / seatedWithChips / nextSeat / nextActor / eligibleCount)的签名从 HoldemState 放宽到 HoldemView。这些函数只读 G.players+ G.acted/G.currentBet,都在 HoldemView 里),结构类型让 HoldemStateViewG 都自动满足。buildPots / resolveHandhands,保持 HoldemState 不变。

好处UI 无需 as HoldemState 强转,类型安全 + playerView 保密约束在编译期可见。

问题 3pnpm dev / pnpm start 在新 shell 找不到 pnpm

重启后新 shell 没有加载 nvmwhich pnpm 报 NOT FOUND。

修法export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"; corepack enablecorepack enable 在 node bin 目录创建 pnpm shim之后 pnpm / pnpm -r ts 正常。见 lessons-learned §1.1。

问题 4heads-up 先行动者判定

2 人房时按钮位 = P0nextSeat(P0) = P1SBnextSeat(P1) = P0BBfirstToAct = 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.tsx426 行,椭圆桌 SVG + 行动条 + 结果面板 + 日志。WaitingControls 子组件管就座 / 开始。

packages/ui/src/LobbyRoomList.tsx:可变人数 + setupData 表单 + 动态加入按钮。

apps/web/src/App.tsxHoldemLocalView / HoldemOnlineView + GameMeta 元数据 + OnlineConfig.numPlayers

测试

  • holdem-eval.test.ts16 用例(类别排序 / wheel / 踢脚 / 平局 / 中文名)
  • holdem.test.ts25 用例(盲注 / fold-win / 全下 / 边池 / 按钮轮转 / 局终 / validateSetupData
  • holdem-multiplayer.spec.tsE2E 走完一手 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.tsx426 行)
    • 新增 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.tsgames 数组加 Holdem已在上轮完成

关联

  • 12-unified-port-and-player-names.md — 命名系统为本游戏打基础
  • lessons-learned §12.1-12.3 — 本轮的坑NULL 损坏 / HoldemView / pnpm
  • decisions D30-D32 — HoldemView 类型、可变人数大厅、9 座本地切换