引擎(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
148 lines
8.4 KiB
Markdown
148 lines
8.4 KiB
Markdown
# 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` 每手 +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<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。
|
||
|
||
### 问题 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 座本地切换
|