# 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`(自定义牌桌,不复用通用 ``) - 大厅:`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` + 牌局进行(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` 每手 +1(mod seatedWithChips),SB = 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` 检查文件末尾是否有 NULL,TypeScript 的"Invalid character"往往是 NULL 字节而非编码问题。 ### 问题 2:`ViewG` 不能赋给 `HoldemState`(playerView 剥了 deck/hands) `HoldemBoard` 拿到的 G 是 playerView 剥过 secret 的 `ViewG = Omit & { myHand }`,但 `HOLDEM_HELPERS.host(G)` / `seatedWithChips(G)` / `nextSeat(G,...)` 的签名是 `G: HoldemState` → TS 报 `ViewG is missing properties: deck, hands`。 **修法**:引擎新增 `export type HoldemView = Omit`,把只读 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。 ### 问题 4:heads-up 先行动者判定 2 人房时按钮位 = P0,`nextSeat(P0) = P1`(SB),`nextSeat(P1) = P0`(BB)。`firstToAct = nextSeat(BB) = nextSeat(P0) = P1`。所以 heads-up preflop P1(SB)先行动——符合标准规则(SB 先动 preflop,BB 先动 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 座本地切换