引擎(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
8.4 KiB
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 ts5/5 包通过pnpm -r testengine 75 + server 9 = 84 通过pnpm test:e2e5/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 — 命名系统为本游戏打基础
- lessons-learned §12.1-12.3 — 本轮的坑(NULL 损坏 / HoldemView / pnpm)
- decisions D30-D32 — HoldemView 类型、可变人数大厅、9 座本地切换