从0到1实现Balatro游戏后端(7):Boss Blind与特殊规则实现

这篇文章开始一个新的长期系列:从 0 到 1 实现一个 Balatro 风格的游戏后端系统,并逐步将其工程化为可扩展的实时服务项目。

  • balatro-realtime-backend -> GitHub地址GitCode地址
    • 📌 本文对应代码分支:origin/feature/boss-blind
    • ⚠️ 注意:由于项目持续迭代,当前仓库代码可能已发生变化,本文内容基于该分支版本进行说明。

✅ 本篇实现了什么

本篇正式开始接入 Boss Blind 系统,并完成了第一版特殊规则架构的落地。

当前阶段主要完成了:

  • Boss Blind 规则分类与边界划分
  • Boss Blind 配置结构设计
  • initGame 生命周期拆分
  • Blind 推进与 ante 配置管理
  • SelectCardsResultDealResult 返回结构重构
  • nextBlindConfignextAnteConfig 的状态推进
  • disableSuitdisableHandType 两种结算型规则实现

本篇重点并不是一次性实现所有 Boss Blind,而是让特殊规则系统第一次真正进入当前后端架构

且本阶段,不考虑过多重连等情况。


文章目录

  • [一、Boss Blind 规则的分类与边界](#一、Boss Blind 规则的分类与边界)
    • [1. 显示层规则](#1. 显示层规则)
    • [2. 结算层规则](#2. 结算层规则)
    • [3. 本篇最终规则实现选择](#3. 本篇最终规则实现选择)
  • [二、Boss Blind 配置的结构设计](#二、Boss Blind 配置的结构设计)
    • [1. 为什么不能直接把规则写死](#1. 为什么不能直接把规则写死)
    • [2. 第一版的设计](#2. 第一版的设计)
    • [3. type 能解决所有问题么?](#3. type 能解决所有问题么?)
    • [4. 最终结构调整](#4. 最终结构调整)
  • [三、为什么需要拆分 initGame 接口](#三、为什么需要拆分 initGame 接口)
    • [1. startGame 开始承担越来越多的初始化逻辑](#1. startGame 开始承担越来越多的初始化逻辑)
    • [2. ante 配置到底应该由谁维护?](#2. ante 配置到底应该由谁维护?)
    • [3. initGame 最终负责什么?](#3. initGame 最终负责什么?)
    • [4. initGame 代码实现](#4. initGame 代码实现)
      • [4.1 initGame](#4.1 initGame)
      • [4.2 createInitialGameState](#4.2 createInitialGameState)
      • [4.3 getAnteConfig](#4.3 getAnteConfig)
  • [四、SelectCardsResult 返回结构的重构](#四、SelectCardsResult 返回结构的重构)
    • [1. 为什么要做 SelectCardsResult 的重构](#1. 为什么要做 SelectCardsResult 的重构)
    • [2. 按职责拆分返回结构](#2. 按职责拆分返回结构)
    • [3. 为什么仍然会保留可选字段](#3. 为什么仍然会保留可选字段)
    • [4. DealResult 也同步调整](#4. DealResult 也同步调整)
  • 五、特殊规则实现
    • [1. 规则实现](#1. 规则实现)
      • [1.1 applyDisableSuitEffect](#1.1 applyDisableSuitEffect)
      • [1.2 isHandTypeDisabled](#1.2 isHandTypeDisabled)
      • [1.3 特殊规则生效示例](#1.3 特殊规则生效示例)
    • [2. 生命周期拆分后引发的状态问题](#2. 生命周期拆分后引发的状态问题)
  • 六、总结
    • [1. 当前阶段完成内容](#1. 当前阶段完成内容)
    • [2. 本阶段最大的变化](#2. 本阶段最大的变化)
    • [3. 当前 Boss Blind 系统状态](#3. 当前 Boss Blind 系统状态)
    • [4. 当前阶段遗留问题](#4. 当前阶段遗留问题)
    • [5. 下一步计划](#5. 下一步计划)

一、Boss Blind 规则的分类与边界

在写代码前,我先想的是 Boss Blind 的特殊规则有哪些?比如:

  • 某个花色不让用户看到?
  • 人头牌不让用户看到?
  • 某个花色不计算分?
  • 某个牌型不计算分?

但真正开始往下想后,我发现事情开始变复杂了。

因为这些规则里,有的是视觉层面的影响,有的则会真正影响游戏结算。

而且如果本篇一次性把所有 Boss Blind 都实现掉,代码量和逻辑边界都会瞬间扩大,感觉会直接炸掉。

后来我发现本篇其实不是实现所有的 Boss Blind而是先建立 Boss Blind 规则系统的雏形

所以这里,我先把 Boss Blind 的规则分成了两类。
#mermaid-svg-1U3MJPiDGrRl3LTG{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-1U3MJPiDGrRl3LTG .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-1U3MJPiDGrRl3LTG .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-1U3MJPiDGrRl3LTG .error-icon{fill:#552222;}#mermaid-svg-1U3MJPiDGrRl3LTG .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-1U3MJPiDGrRl3LTG .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-1U3MJPiDGrRl3LTG .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-1U3MJPiDGrRl3LTG .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-1U3MJPiDGrRl3LTG .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-1U3MJPiDGrRl3LTG .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-1U3MJPiDGrRl3LTG .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-1U3MJPiDGrRl3LTG .marker{fill:#333333;stroke:#333333;}#mermaid-svg-1U3MJPiDGrRl3LTG .marker.cross{stroke:#333333;}#mermaid-svg-1U3MJPiDGrRl3LTG svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-1U3MJPiDGrRl3LTG p{margin:0;}#mermaid-svg-1U3MJPiDGrRl3LTG .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-1U3MJPiDGrRl3LTG .cluster-label text{fill:#333;}#mermaid-svg-1U3MJPiDGrRl3LTG .cluster-label span{color:#333;}#mermaid-svg-1U3MJPiDGrRl3LTG .cluster-label span p{background-color:transparent;}#mermaid-svg-1U3MJPiDGrRl3LTG .label text,#mermaid-svg-1U3MJPiDGrRl3LTG span{fill:#333;color:#333;}#mermaid-svg-1U3MJPiDGrRl3LTG .node rect,#mermaid-svg-1U3MJPiDGrRl3LTG .node circle,#mermaid-svg-1U3MJPiDGrRl3LTG .node ellipse,#mermaid-svg-1U3MJPiDGrRl3LTG .node polygon,#mermaid-svg-1U3MJPiDGrRl3LTG .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-1U3MJPiDGrRl3LTG .rough-node .label text,#mermaid-svg-1U3MJPiDGrRl3LTG .node .label text,#mermaid-svg-1U3MJPiDGrRl3LTG .image-shape .label,#mermaid-svg-1U3MJPiDGrRl3LTG .icon-shape .label{text-anchor:middle;}#mermaid-svg-1U3MJPiDGrRl3LTG .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-1U3MJPiDGrRl3LTG .rough-node .label,#mermaid-svg-1U3MJPiDGrRl3LTG .node .label,#mermaid-svg-1U3MJPiDGrRl3LTG .image-shape .label,#mermaid-svg-1U3MJPiDGrRl3LTG .icon-shape .label{text-align:center;}#mermaid-svg-1U3MJPiDGrRl3LTG .node.clickable{cursor:pointer;}#mermaid-svg-1U3MJPiDGrRl3LTG .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-1U3MJPiDGrRl3LTG .arrowheadPath{fill:#333333;}#mermaid-svg-1U3MJPiDGrRl3LTG .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-1U3MJPiDGrRl3LTG .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-1U3MJPiDGrRl3LTG .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1U3MJPiDGrRl3LTG .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-1U3MJPiDGrRl3LTG .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1U3MJPiDGrRl3LTG .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-1U3MJPiDGrRl3LTG .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-1U3MJPiDGrRl3LTG .cluster text{fill:#333;}#mermaid-svg-1U3MJPiDGrRl3LTG .cluster span{color:#333;}#mermaid-svg-1U3MJPiDGrRl3LTG div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-1U3MJPiDGrRl3LTG .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-1U3MJPiDGrRl3LTG rect.text{fill:none;stroke-width:0;}#mermaid-svg-1U3MJPiDGrRl3LTG .icon-shape,#mermaid-svg-1U3MJPiDGrRl3LTG .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-1U3MJPiDGrRl3LTG .icon-shape p,#mermaid-svg-1U3MJPiDGrRl3LTG .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-1U3MJPiDGrRl3LTG .icon-shape .label rect,#mermaid-svg-1U3MJPiDGrRl3LTG .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-1U3MJPiDGrRl3LTG .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-1U3MJPiDGrRl3LTG .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-1U3MJPiDGrRl3LTG :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Boss Blind
显示层规则
结算层规则
隐藏花色
隐藏人头牌
禁用花色
禁用牌型
前端负责处理
后端负责处理

1. 显示层规则

顾名思义,就是所有影响"视觉展示"的规则。比如:

  • 某个花色不让用户看到
  • 人头牌不让用户看到

这类的规则其根本:是后端仍然知道这张牌是什么,只是前端展示时不显示。例如:

ts 复制代码
['AS', 'KH', 'QD']

后端仍然正常持有这些牌的数据。只是前端在渲染时,根据规则把部分牌显示成背面。

所以对于这类规则,后端其实不需要真正修改牌本身,而是只需要提供规则配置。例如:

ts 复制代码
hiddenSuit: 'S'

真正去处理这个规则的是前端,而后端需要做的,是提供当前 Boss Blind 的规则信息。

2. 结算层规则

另一类,则是会真正影响游戏结算的规则。

比如:

  • 某个花色不计算分
  • 某个牌型不计算分

这类必须是由后端处理,因为它影响实际结算的结果。而目前牌型的分数计算,牌型的判断,也都是由后端处理。

例如当前 Boss Blind 的规则配置是:

ts 复制代码
disabledSuit: 'H'

那么玩家出了:

ts 复制代码
['AH', 'KH', 'QH', 'JH', '10H']

虽然看起来是同花顺,但如果红桃不计分,那这些牌在得分计算时就要被过滤或失效。

这类规则,本质上属于:会干预游戏结算结果的规则。所以必须由后端处理。

3. 本篇最终规则实现选择

如果本篇一次性到位,同时去做:

  • 盖住花色
  • 盖住人头牌
  • 某花色不计分
  • 某牌型不计分
  • 首次出牌必须包含某花色
  • 手牌减少
  • 出牌次数减少
  • 特定条件限制

对我来说周期太长,开发边界也会瞬间扩大,感觉会炸。

所以本篇最终决定:先只实现部分"结算层规则"。先做一个最小闭环:

复制代码
Boss Blind 配置
↓
当前 ante 读取 Boss 规则
↓
selectCards 结算时应用规则
↓
测试验证规则生效

👉 也就是说:这一篇真正的重点,并不是一次性实现多少 Boss Blind。而是:让规则系统第一次真正进入后端架构

但真正让我意识到 Boss Blind 复杂的,并不是规则本身,而是我发现:从这一阶段开始,游戏已经不再只是"算分",而是开始出现了"修改规则"。

而修改规则,往往比新增功能更容易破坏原有结构。因为新增功能通常只是扩展能力,而规则系统会直接影响已有流程

二、Boss Blind 配置的结构设计

1. 为什么不能直接把规则写死

在真正开始设计 Boss Blind 配置的时候,我一开始想的是:既然当前已经确定,本篇只做结算层规则,那是不是直接写对应逻辑就好了?

比如:

  • 某个花色不计分
  • 某个牌型不计分

但真正开始往下写后,我发现问题没有想象的那么简单。

因为现在虽然只实现结算层规则 ,但未来 Boss Blind 一定不只是后端自己用。

例如之前提到的一些显示层的规则:

  • 某个花色不让用户看到
  • 人头牌不让用户看到

虽然当前不会真正去实现,但如果现在直接把配置结构写死,未来前端接入的时候,整体结构可能又要重新推翻。

所以这里其实有一个很重要的问题:

Boss Blind 的配置,不仅后端要能读懂,未来前端也要能读懂。

也就是说:这个配置最终应该是一个规则描述 ,而不是单纯的一对 if-else

2. 第一版的设计

最开始的时候,我的想法其实很简单。直接使用Object key-value 的形式:

  • key 对应 ante
  • value 对应当前 Boss Blind 的配置

例如:

ts 复制代码
export const BOSS_BLIND_CONFIG = {
    1: {
        name: "The Club",
        effect: {
            type: "disableSuit",
            suit: "C",
        },
    },
    2: {
        name: "The Diamond",
        effect: {
            type: "disableSuit",
            suit: "D",
        },
    },
    ...
}

这样写的最大好处就是简单。当前 ante 对应哪个 Boss Blind, 直接读取即可。

但真正写完后,我发现这里其实有不少的问题。

首先是 name, 如果未来是通过:name === 'The Club' 的方式去判断规则,那么问题会很多。

因为:

  • 大小写问题
  • 空格问题
  • 拼写问题
  • 前后端字符串是否统一的问题

这些问题都可能会出现。而且 name 本身更适合作为展示字段,而不是决策字段。

如果后面真正进入规则逻辑阶段,还继续依赖字符串判断,会越来越乱。

3. type 能解决所有问题么?

然后我又开始想:既然 name 不适合作为规则表示,那是不是直接通过 type 去区分就好了?

比如 type: "disableSuit" 代表的是:某个花色不计分。

但继续往后想,我又发现: type 其实只能描述规则类型。例如 disableSuit 代表的是:禁用某个花色。

但未来如果还存在:

  • 禁用某个牌型
  • 首次出牌必须包含某个花色
  • 隐藏某个花色
  • 隐藏人头牌

等等更多规则时。

真正的问题其实就变成了:当前到底是哪一个 Boss Blind?

也就是说:

  • Boss Blind 本身需要有自己的唯一标识
  • Boss Blind 的规则效果则应该独立描述

4. 最终结构调整

所以最后,我决定把 Boss Blind 的身份、Boss Blind 的规则效果拆成两个部分。
#mermaid-svg-EBhFNMsbLIWTNvMc{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-EBhFNMsbLIWTNvMc .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-EBhFNMsbLIWTNvMc .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-EBhFNMsbLIWTNvMc .error-icon{fill:#552222;}#mermaid-svg-EBhFNMsbLIWTNvMc .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-EBhFNMsbLIWTNvMc .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-EBhFNMsbLIWTNvMc .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-EBhFNMsbLIWTNvMc .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-EBhFNMsbLIWTNvMc .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-EBhFNMsbLIWTNvMc .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-EBhFNMsbLIWTNvMc .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-EBhFNMsbLIWTNvMc .marker{fill:#333333;stroke:#333333;}#mermaid-svg-EBhFNMsbLIWTNvMc .marker.cross{stroke:#333333;}#mermaid-svg-EBhFNMsbLIWTNvMc svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-EBhFNMsbLIWTNvMc p{margin:0;}#mermaid-svg-EBhFNMsbLIWTNvMc .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-EBhFNMsbLIWTNvMc .cluster-label text{fill:#333;}#mermaid-svg-EBhFNMsbLIWTNvMc .cluster-label span{color:#333;}#mermaid-svg-EBhFNMsbLIWTNvMc .cluster-label span p{background-color:transparent;}#mermaid-svg-EBhFNMsbLIWTNvMc .label text,#mermaid-svg-EBhFNMsbLIWTNvMc span{fill:#333;color:#333;}#mermaid-svg-EBhFNMsbLIWTNvMc .node rect,#mermaid-svg-EBhFNMsbLIWTNvMc .node circle,#mermaid-svg-EBhFNMsbLIWTNvMc .node ellipse,#mermaid-svg-EBhFNMsbLIWTNvMc .node polygon,#mermaid-svg-EBhFNMsbLIWTNvMc .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-EBhFNMsbLIWTNvMc .rough-node .label text,#mermaid-svg-EBhFNMsbLIWTNvMc .node .label text,#mermaid-svg-EBhFNMsbLIWTNvMc .image-shape .label,#mermaid-svg-EBhFNMsbLIWTNvMc .icon-shape .label{text-anchor:middle;}#mermaid-svg-EBhFNMsbLIWTNvMc .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-EBhFNMsbLIWTNvMc .rough-node .label,#mermaid-svg-EBhFNMsbLIWTNvMc .node .label,#mermaid-svg-EBhFNMsbLIWTNvMc .image-shape .label,#mermaid-svg-EBhFNMsbLIWTNvMc .icon-shape .label{text-align:center;}#mermaid-svg-EBhFNMsbLIWTNvMc .node.clickable{cursor:pointer;}#mermaid-svg-EBhFNMsbLIWTNvMc .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-EBhFNMsbLIWTNvMc .arrowheadPath{fill:#333333;}#mermaid-svg-EBhFNMsbLIWTNvMc .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-EBhFNMsbLIWTNvMc .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-EBhFNMsbLIWTNvMc .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-EBhFNMsbLIWTNvMc .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-EBhFNMsbLIWTNvMc .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-EBhFNMsbLIWTNvMc .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-EBhFNMsbLIWTNvMc .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-EBhFNMsbLIWTNvMc .cluster text{fill:#333;}#mermaid-svg-EBhFNMsbLIWTNvMc .cluster span{color:#333;}#mermaid-svg-EBhFNMsbLIWTNvMc div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-EBhFNMsbLIWTNvMc .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-EBhFNMsbLIWTNvMc rect.text{fill:none;stroke-width:0;}#mermaid-svg-EBhFNMsbLIWTNvMc .icon-shape,#mermaid-svg-EBhFNMsbLIWTNvMc .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-EBhFNMsbLIWTNvMc .icon-shape p,#mermaid-svg-EBhFNMsbLIWTNvMc .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-EBhFNMsbLIWTNvMc .icon-shape .label rect,#mermaid-svg-EBhFNMsbLIWTNvMc .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-EBhFNMsbLIWTNvMc .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-EBhFNMsbLIWTNvMc .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-EBhFNMsbLIWTNvMc :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Boss Blind 配置
Boss 身份信息
Boss 规则效果
code: 101
name: The Club
effect.type: disableSuit
effect.suit: C
用于唯一标识 Boss
用于前端展示
决定规则类型
决定规则作用目标

首先新增 Code

ts 复制代码
export const BOSS_BLIND_CODE = {
    THE_CLUB: 101,
    THE_DIAMOND: 102,
    THE_HEART: 103,
    THE_SPADE: 104,
    THE_STRAIGHT_FLUSH: 105,
    THE_FOUR_OF_A_KIND: 106,
    THE_FULL_HOUSE: 107,
    THE_FLUSH: 108,
    THE_STRAIGHT: 109,
    THE_THREE_OF_A_KIND: 110,
    THE_TWO_PAIR: 111,
    THE_ONE_PAIR: 112,
    THE_HIGH_CARD: 113
};

然后再通过 code 去对应真正的配置:

ts 复制代码
export const BOSS_BLIND_CONFIG = {
    [BOSS_BLIND_CODE.THE_CLUB]: {
        code: BOSS_BLIND_CODE.THE_CLUB,
        name: "The Club",
        effect: { type: "disableSuit", suit: "C" },
    },
    [BOSS_BLIND_CODE.THE_DIAMOND]: {
        code: BOSS_BLIND_CODE.THE_DIAMOND,
        name: "The Diamond",
        effect: { type: "disableSuit", suit: "D" },
    },
    ...
}

这样拆分后,整体职责会清晰很多:

  • code: 标识当前是哪一个 Boss Blind.
  • name: 用于展示
  • effect.type: 标识当前规则属于哪种类型
  • effect 里的参数:描述具体规则作用到哪里

这样未来不管是

  • 前端展示层规则
  • 后端结算层规则
  • 新增更多的 Boss Blind

整体结构都会更容易扩展,至少下一次新增 Boss Blind 时,我需要修改的可能只是配置,而不一定是规则代码本身,这样也避免了代码最终变成一堆无限嵌套的 if-else.

三、为什么需要拆分 initGame 接口

1. startGame 开始承担越来越多的初始化逻辑

最开始的时候,我其实没打算再本篇新增接口。

因为我感觉,当前已有的:startGameselectCards 已经足够承担这次 Boss Blind 的特殊规则相关的逻辑了。

但随着 Boss Blind 的配置和逻辑逐渐开始落地后,我发现: startGame 开始承担越来越多的初始化流程了。

之前项目还比较简单的时候,我一直觉得:初始化放在 startGame 里并没有什么问题。

因为那时候:

  • 游戏状态简单
  • Blind 逻辑还没接入
  • ante 也并不存在
  • Boss Blind 更不存在

所以:
startGame = 初始化 + 发牌
#mermaid-svg-cJOxyqBDoRzTcODP{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-cJOxyqBDoRzTcODP .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-cJOxyqBDoRzTcODP .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-cJOxyqBDoRzTcODP .error-icon{fill:#552222;}#mermaid-svg-cJOxyqBDoRzTcODP .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-cJOxyqBDoRzTcODP .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-cJOxyqBDoRzTcODP .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-cJOxyqBDoRzTcODP .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-cJOxyqBDoRzTcODP .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-cJOxyqBDoRzTcODP .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-cJOxyqBDoRzTcODP .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-cJOxyqBDoRzTcODP .marker{fill:#333333;stroke:#333333;}#mermaid-svg-cJOxyqBDoRzTcODP .marker.cross{stroke:#333333;}#mermaid-svg-cJOxyqBDoRzTcODP svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-cJOxyqBDoRzTcODP p{margin:0;}#mermaid-svg-cJOxyqBDoRzTcODP .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-cJOxyqBDoRzTcODP .cluster-label text{fill:#333;}#mermaid-svg-cJOxyqBDoRzTcODP .cluster-label span{color:#333;}#mermaid-svg-cJOxyqBDoRzTcODP .cluster-label span p{background-color:transparent;}#mermaid-svg-cJOxyqBDoRzTcODP .label text,#mermaid-svg-cJOxyqBDoRzTcODP span{fill:#333;color:#333;}#mermaid-svg-cJOxyqBDoRzTcODP .node rect,#mermaid-svg-cJOxyqBDoRzTcODP .node circle,#mermaid-svg-cJOxyqBDoRzTcODP .node ellipse,#mermaid-svg-cJOxyqBDoRzTcODP .node polygon,#mermaid-svg-cJOxyqBDoRzTcODP .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-cJOxyqBDoRzTcODP .rough-node .label text,#mermaid-svg-cJOxyqBDoRzTcODP .node .label text,#mermaid-svg-cJOxyqBDoRzTcODP .image-shape .label,#mermaid-svg-cJOxyqBDoRzTcODP .icon-shape .label{text-anchor:middle;}#mermaid-svg-cJOxyqBDoRzTcODP .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-cJOxyqBDoRzTcODP .rough-node .label,#mermaid-svg-cJOxyqBDoRzTcODP .node .label,#mermaid-svg-cJOxyqBDoRzTcODP .image-shape .label,#mermaid-svg-cJOxyqBDoRzTcODP .icon-shape .label{text-align:center;}#mermaid-svg-cJOxyqBDoRzTcODP .node.clickable{cursor:pointer;}#mermaid-svg-cJOxyqBDoRzTcODP .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-cJOxyqBDoRzTcODP .arrowheadPath{fill:#333333;}#mermaid-svg-cJOxyqBDoRzTcODP .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-cJOxyqBDoRzTcODP .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-cJOxyqBDoRzTcODP .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-cJOxyqBDoRzTcODP .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-cJOxyqBDoRzTcODP .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-cJOxyqBDoRzTcODP .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-cJOxyqBDoRzTcODP .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-cJOxyqBDoRzTcODP .cluster text{fill:#333;}#mermaid-svg-cJOxyqBDoRzTcODP .cluster span{color:#333;}#mermaid-svg-cJOxyqBDoRzTcODP div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-cJOxyqBDoRzTcODP .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-cJOxyqBDoRzTcODP rect.text{fill:none;stroke-width:0;}#mermaid-svg-cJOxyqBDoRzTcODP .icon-shape,#mermaid-svg-cJOxyqBDoRzTcODP .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-cJOxyqBDoRzTcODP .icon-shape p,#mermaid-svg-cJOxyqBDoRzTcODP .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-cJOxyqBDoRzTcODP .icon-shape .label rect,#mermaid-svg-cJOxyqBDoRzTcODP .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-cJOxyqBDoRzTcODP .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-cJOxyqBDoRzTcODP .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-cJOxyqBDoRzTcODP :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 拆分前:startGame
初始化 GameState
初始化 playerState
初始化 blindState
生成 ante 配置
发牌并进入游戏

是完全够用的,但现在已经开始不一样了。

2. ante 配置到底应该由谁维护?

随着 anteBlindBoss Blind 的加入,一个新的问题开始出现了。

前端需要在游戏开始前,就拿到当前 ante 对应的配置:

  • 大盲
  • 小盲
  • Boss Blind

我最开始落地的第一版代码里,是直接把这些配置放进 DealResult

也就是说:每次 startGame 的时候,前端都能拿到当前 ante 的全部配置。

但继续往后写的时候,又有了新的问题:

当前 ante 可以放在 startGame,那下一个 ante 的配置怎么办?

再往 selectCardsResult 塞一个字段?或者直接在 startGame 的时候,把所有 ante 的配置一次性全部返回给前端?

但这个方案我很快就放弃了。因为这样会让前端开始承担游戏进度推导:

text 复制代码
Boss 结束
↓
前端自己 ante + 1
↓
读取下一关配置

当前看起来可能没什么问题。但未来一旦出现:

  • 跳过 Blind
  • Boss 特殊效果
  • 游戏失败重开
  • 中途恢复
  • 难度调整
  • 临时事件

前端自己推导状态,就非常容易和后端产生不一致。

所以最后,我还是遵循了一个最基础的原则:

前端负责展示状态,后端负责推进状态。

也就是说,anteBlind 的推进应该由后端统一维护,前端只消费后端返回的当前状态和下一步展示配置。

3. initGame 最终负责什么?

但新的问题又出现了...

如果 selectCards 负责返回推进后的配置,那么:游戏第一次初始化时的 ante 配置,又该由谁返回

答案是:

没有。

因为现在已有的接口里:

  • startGame:更偏向 -> 正式开始当前 Blind
  • selectCards:更偏向 -> 结算与推进

它们都已经不太适合继续承担:"整局游戏初始化"这件事了

所以最终,我决定新增:initGame 接口。

它主要负责:

  • 初始化整局游戏状态
  • 初始化 blindState
  • 初始化 ante 配置
  • 初始化未来可能新增的状态数据

startGame 则只负责:开始当前 Blind,并完成发牌。

这样拆分后,每一个接口的职责也会开始变得更加清晰。
#mermaid-svg-dHrv1u4NQlQQzT1f{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-dHrv1u4NQlQQzT1f .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-dHrv1u4NQlQQzT1f .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-dHrv1u4NQlQQzT1f .error-icon{fill:#552222;}#mermaid-svg-dHrv1u4NQlQQzT1f .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-dHrv1u4NQlQQzT1f .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-dHrv1u4NQlQQzT1f .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-dHrv1u4NQlQQzT1f .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-dHrv1u4NQlQQzT1f .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-dHrv1u4NQlQQzT1f .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-dHrv1u4NQlQQzT1f .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-dHrv1u4NQlQQzT1f .marker{fill:#333333;stroke:#333333;}#mermaid-svg-dHrv1u4NQlQQzT1f .marker.cross{stroke:#333333;}#mermaid-svg-dHrv1u4NQlQQzT1f svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-dHrv1u4NQlQQzT1f p{margin:0;}#mermaid-svg-dHrv1u4NQlQQzT1f .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-dHrv1u4NQlQQzT1f .cluster-label text{fill:#333;}#mermaid-svg-dHrv1u4NQlQQzT1f .cluster-label span{color:#333;}#mermaid-svg-dHrv1u4NQlQQzT1f .cluster-label span p{background-color:transparent;}#mermaid-svg-dHrv1u4NQlQQzT1f .label text,#mermaid-svg-dHrv1u4NQlQQzT1f span{fill:#333;color:#333;}#mermaid-svg-dHrv1u4NQlQQzT1f .node rect,#mermaid-svg-dHrv1u4NQlQQzT1f .node circle,#mermaid-svg-dHrv1u4NQlQQzT1f .node ellipse,#mermaid-svg-dHrv1u4NQlQQzT1f .node polygon,#mermaid-svg-dHrv1u4NQlQQzT1f .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-dHrv1u4NQlQQzT1f .rough-node .label text,#mermaid-svg-dHrv1u4NQlQQzT1f .node .label text,#mermaid-svg-dHrv1u4NQlQQzT1f .image-shape .label,#mermaid-svg-dHrv1u4NQlQQzT1f .icon-shape .label{text-anchor:middle;}#mermaid-svg-dHrv1u4NQlQQzT1f .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-dHrv1u4NQlQQzT1f .rough-node .label,#mermaid-svg-dHrv1u4NQlQQzT1f .node .label,#mermaid-svg-dHrv1u4NQlQQzT1f .image-shape .label,#mermaid-svg-dHrv1u4NQlQQzT1f .icon-shape .label{text-align:center;}#mermaid-svg-dHrv1u4NQlQQzT1f .node.clickable{cursor:pointer;}#mermaid-svg-dHrv1u4NQlQQzT1f .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-dHrv1u4NQlQQzT1f .arrowheadPath{fill:#333333;}#mermaid-svg-dHrv1u4NQlQQzT1f .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-dHrv1u4NQlQQzT1f .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-dHrv1u4NQlQQzT1f .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-dHrv1u4NQlQQzT1f .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-dHrv1u4NQlQQzT1f .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-dHrv1u4NQlQQzT1f .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-dHrv1u4NQlQQzT1f .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-dHrv1u4NQlQQzT1f .cluster text{fill:#333;}#mermaid-svg-dHrv1u4NQlQQzT1f .cluster span{color:#333;}#mermaid-svg-dHrv1u4NQlQQzT1f div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-dHrv1u4NQlQQzT1f .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-dHrv1u4NQlQQzT1f rect.text{fill:none;stroke-width:0;}#mermaid-svg-dHrv1u4NQlQQzT1f .icon-shape,#mermaid-svg-dHrv1u4NQlQQzT1f .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-dHrv1u4NQlQQzT1f .icon-shape p,#mermaid-svg-dHrv1u4NQlQQzT1f .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-dHrv1u4NQlQQzT1f .icon-shape .label rect,#mermaid-svg-dHrv1u4NQlQQzT1f .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-dHrv1u4NQlQQzT1f .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-dHrv1u4NQlQQzT1f .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-dHrv1u4NQlQQzT1f :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} initGame
初始化 GameState
初始化 playerState
初始化 blindState
生成 currentAnteConfig
startGame
基于当前 Blind 发牌
重置 plays/discards
进入 playing 状态

这样一来:

  • initGame:负责整局游戏初始化
  • startGame:负责当前 Blind 开始
  • selectCards:负责出牌结算与 Blind 推进

三个接口的职责会更清楚。

4. initGame 代码实现

gateway 文件中的代码不再展示,主要以 game.service.ts 为主

4.1 initGame

ts 复制代码
    /**
     * Initializes the full game lifecycle.
     *
     * This method is responsible for:
     * - creating initial game state
     * - initializing blind progression
     * - generating ante configuration
     *
     * It does not deal cards.
     * Actual gameplay starts in startGame().
     */
    initGame(playerId: string): GameState {
        if (this.gameStates[playerId]) delete this.gameStates[playerId];
        if (this.bossBlindAssignments[playerId]) delete this.bossBlindAssignments[playerId];

        const gameState: GameState = this.createInitialGameState(playerId);
        this.gameStates[playerId] = gameState;
        return gameState;
    }

4.2 createInitialGameState

这里最重要的是:

  • gameStatus 初始化为 initialized
  • currentAnteConfig 会在游戏真正开始前就生成
  • blindState 正式开始独立承担 Blind 生命周期
ts 复制代码
    private createInitialGameState(playerId: string): GameState {
        const currentAnteConfig = this.getAnteConfig(playerId, 1);
        this.gameStates[playerId] = {
            playerId: playerId,
            playerState: {...},
            blindState: {
                round: 1,
                ante: 1,
                blindType: "small",
                targetScore: currentAnteConfig.small.score,
                currentAnteConfig: currentAnteConfig,
                currentBlindScore: 0,
            },
            gameStatus: "initialized",
        };

        return this.gameStates[playerId];
    }

4.3 getAnteConfig

这里 Boss Blind 会在 ante 初始化时就确定,并且由后端统一维护 Blind 配置。

ts 复制代码
    private getAnteConfig(playerId: string, ante: number): AnteConfig {
        let bossBlindAssignmentsByPlayer = this.bossBlindAssignments[playerId];
        ...
        const code = bossBlindAssignmentsByPlayer.pop();

        const currentAnteConfig = {
            ante,
            small: {...},
            big: {...},
            boss: {
                score: ...,
                code,
                name: ...
            }
        };
        ...
        return currentAnteConfig;
    }

四、SelectCardsResult 返回结构的重构

1. 为什么要做 SelectCardsResult 的重构

在本篇准备接入 Boss Blind 相关逻辑后,我发现项目里很多原本还能用的结构,都开始有点撑不住了。

比如前面拆了 initGame 接口,现在又轮到了 SelectCardsResult。都是一开始没有想过会动的。因为 SelectCardsResult 本来只是用来返回一次出牌后的结果。

但随着 BlindanteBoss Blind、结算状态这些内容逐渐加入后,它返回的信息越来越多,结构也开始变得很散。

原来的结构大概是这样的:

ts 复制代码
export type SelectCardsResult = {
    code: number;
    selectedCards?: string[];
    gameOver?: boolean;
    blindOver?: boolean;
    remainingDeckCount?: number;
    playerState?: GameStateResponse;
    cardType?: number;
    validCards?: string[];
    baseScore?: number;
    multiplier?: number;
    round?: number;
    ante?: number;
    blindType?: BlindType;
    settlement?: {
        finalScore: number;
        targetScore: number;
        result: "WIN" | "LOSE";
    };
    currentAnteConfig?: AnteConfig;
    nextAnteConfig?: AnteConfig;
};

就目前而言,它已经有点像大杂烩了。

有的是本次出牌的结果,比如⬇️:

  • selectedCards
  • cardType
  • validCards
  • baseScore
  • multiplier

有的是玩家当前状态,比如⬇️:

  • remainingDeckCount
  • playerState

有的是 Blind 相关的状态,比如⬇️:

  • round
  • ante
  • blindType

有的是关卡推进和结算结果,比如⬇️:

  • blindOver
  • gameOver
  • settlement
  • currentAnteConfig
  • nextAnteConfig

也就是说,SelectCardsResult 已经不只是出牌结果了。它现在同时包含了:

  • 本次动作结果
  • 玩家状态
  • Blind 状态
  • 关卡推进信息

如果继续把所有字段都平铺在最外层,后面再继续加 Boss Blind 的规则效果,只会越来越乱。所以这里我决定做一次结构重构。

2. 按职责拆分返回结构

这次重构的目标,不是为了让代码看起来更高级。而是为了让返回值本身更清楚。

所以我把 SelectCardsResult 按职责拆分了几部分:

ts 复制代码
export type SelectCardsResult = {
    code: number;
    message: string;

    action?: "play" | "discard";

    scoreDetail?: {
        selectedCards: string[];
        cardType: number;
        validCards: string[];
        baseScore: number;
        multiplier: number;
    };

    blindState?: {
        round: number;
        ante: number;
        blindType: BlindType;
        targetScore: number;
        currentBlindScore: number;
    };

    playerState?: GameStateResponse;

    progress?: Progress;
};

/**
 * Represents progression-related game information.
 *
 * Includes:
 * - blind completion
 * - settlement result
 * - next blind preview
 * - next ante preview
 */
export type Progress = {
    gameOver: boolean;
    blindOver: boolean;

    settlement?: {
        finalScore: number;
        targetScore: number;
        result: "WIN" | "LOSE";
    };

    currentAnteConfig: AnteConfig;
    nextAnteConfig?: AnteConfig;
    nextBlindConfig?: NextBlindConfig;
};

拆完后,结构会清楚很多。

  • scoreDetail: 表示本次出牌动作的结果
  • playerState: 表示玩家当前状态
  • blindState: 表示当前 Blind 状态
  • progress: 表示关卡是否结束、是否通关、下一关配置等推进信息

✅这样一来,返回值就不再是一堆字段堆在一起了。而是能看出来每一部分分别在表达什么。

3. 为什么仍然会保留可选字段

这里还有一个问题。因为有些请求可能在最前面就被拦截了。

比如:

  • 参数错误
  • 游戏不存在
  • 游戏已经结束
  • 选择的牌不合法

像这些情况下,其实没有必要返回完整的 scoreDetailplayerStateblindStateprogress

只需要返回:

ts 复制代码
{ code, message}

就已经足够了。

所以重构后,并不是完全不需要 ?

而是把原来散落在最外层的一堆可选字段,收敛成几个明确的模块:

ts 复制代码
scoreDetail?
playerState?
blindState?
progress?

失败时可以只返回错误信息。成功时再返回完整的结构。

这样既保留了接口的灵活性,也不会让整个返回类型变得特别散。

4. DealResult 也同步调整

在调整 SelectCardsResult 的时候,我顺手也把 DealResult 做了同样的整理。

因为 DealResult 其实也有类似的问题。

它既包含玩家手牌信息,也包含当前 Blind 信息。所以最后也拆成了:

ts 复制代码
export type DealResult = {
    code: number;

    playerState?: {
        hand: string[];
        remainingDeckCount: number;
        playsLeft: number;
        discardsLeft: number;
    };

    blindState?: {
        round: number;
        ante: number;
        blindType: BlindType;
        targetScore: number;
        currentAnteConfig: AnteConfig;
    };
};

这样 startGame 的返回值也会更清楚:

  • playerState: 当前发牌后的玩家状态
  • blindState: 当前 Blind 相关状态

这次重构虽然改动不算小,但它不是为了'好看'而改。而是因为随着 Boss Blind 和关卡推进逻辑的加入,返回结构已经开始承载更多的职责。

如果不提前整理,后面真正接入特殊规则的时候,只会越来越难维护。

📌 至于为什么没有在最开始设计参数结构的时候,就提前完成职责划分。

原因很简单:当时并没有想到后面会接入这么多状态和返回数据。

所以当前能做的,也只能是在每一次重构后,让结构比之前更清晰一些,并尽量为未来的扩展提前留出空间。

五、特殊规则实现

碎碎念: 终于可以开始写特殊规则实现了。。。原本本篇只需要写特殊规则实现的,真的是不动不知道,一动吓一跳😂

1. 规则实现

本次只针对两种结算型的特殊规则做处理:

  • 某种花色不计分
  • 某种牌型不计分

前面花了很多篇幅调整配置、接口和返回结构,但真正接入 disableSuitdisableHandType 时,代码反而没有想象中复杂。

因为规则已经被配置描述出来了 ,后端只需要在计分入口根据 effect.type 做不同处理。

这次提前考虑到了后续可能还有的特殊规则,所以并没有把特殊规则的逻辑全部都放到 calculateHandScore

而是进行拆分出了两个小接口,只在 calculateHandScore 做了判断。

这里其实还有一个设计选择。最开始的时候,我也想过直接在 calculateHandScore 里面写:

ts 复制代码
  if (...)
  if (...)
  if (...)

这样其实也能跑。但很快我发现一个问题:当前只有 disableSuitdisableHandType 两个规则。

未来一定还会有:

  • 隐藏花色
  • 隐藏人头牌
  • 出牌限制
  • 手牌限制
  • 特殊条件触发

如果所有规则都不断堆积在 calculateHandScore 中,那么这个接口最终只会越来越难维护。

所以虽然当前只实现了两个特殊规则,但我还是提前把规则逻辑拆成独立方法。这样未来新增规则时,更多是在扩展,而不是不断修改已有逻辑。

ts 复制代码
    public calculateHandScore(cards: string[], bossCode: number): {...} {
        const effect: BossEffect | null = BOSS_BLIND_CONFIG[bossCode]?.effect as BossEffect | null;
        let scoringCards: string[] = cards;
        scoringCards = this.applyDisableSuitEffect(scoringCards, effect);

        if (scoringCards.length == 0) {
            return {
                baseScore: 0,
                multiplier: 0,
                handType: CARD_TYPE.highCard,
                validCards: [],
            };
        }

        ...

        if (this.isHandTypeDisabled(cardType, effect)) {
            baseScore = 0;
            multiplier = 0;
            validCards = [];
        }

        return { ... };
    }

这样一来,后续再增加其他的特殊规则判断,也只需要根据情况再扩展新的接口就够了。

并且在未来也可以灵活的迁移到其他的文件中。但目前因只实现了两个结算型规则,而当前 effect 数量还比较少,所以暂时没有单独拆 effect 系统。。

1.1 applyDisableSuitEffect

ts 复制代码
    private applyDisableSuitEffect(cards: string[], effect: BossEffect | null): string[] {
        if (effect?.type !== "disableSuit") return cards;

        return cards.filter((card) => !card.endsWith(effect.suit));
    }

1.2 isHandTypeDisabled

ts 复制代码
    private isHandTypeDisabled(cardType: number, effect: BossEffect | null): boolean {
        return effect?.type === "disableHandType" && cardType === CARD_TYPE[effect.handType];
    }

1.3 特殊规则生效示例

  • 例如当前 Boss Blind 为:
ts 复制代码
{
    type: "disableSuit",
    suit: "H"
}
  • 玩家出牌:
ts 复制代码
['AH', 'KH', 'QH', 'JH', '10H']
  • 过滤后:
ts 复制代码
[]
  • 最终结果:
ts 复制代码
baseScore = 0
multiplier = 0
validCards = []

也就是说:虽然玩家看起来出了同花顺。但因为红桃被禁用,最终这一手不会获得任何分数。

2. 生命周期拆分后引发的状态问题

在实现后的测试过程中,还发现了一个问题。就是用户出牌的次数返回给前端的结果中,是最新的,但实际服务器的缓存中,随着每个 round 每个 blind 的使用,并没有实际的更新。

在查明问题的时候,发现本次拆分了原本的 startGame 衍生了 initGame

initGame 的时候虽然进行了用户 playsLeftdiscardsLeftcurrentActionScorecurrentBlindScore 的初始化。

但是原有在 startGame 去重置出牌、弃牌次数的相关逻辑,也被删除掉了,从而发现了当前的问题。

最终在 startGame 的时候添加了用户重置数据的逻辑

ts 复制代码
        playerState.playsLeft = GAME_RULE.INITIAL_PLAYS_LEFT;
        playerState.discardsLeft = GAME_RULE.INITIAL_DISCARDS_LEFT;
        playerState.currentActionScore = 0;
        blindState.currentBlindScore = 0;

这次问题也让我意识到:接口职责拆分以后,除了要迁移业务逻辑,还要重新检查生命周期。

因为很多初始化代码原本隐藏在旧接口内部,当接口被拆开时,这些逻辑未必会一起被迁移。代码结构虽然完成了解耦,但状态生命周期却可能被意外拆断。

而很多 Bug 并不是新功能本身导致的,反而是在重构过程中,原本隐藏在旧流程里的生命周期逻辑被遗漏了。

相比于功能开发,这类问题往往更难发现...

六、总结

1. 当前阶段完成内容

本篇正式完成了 Boss Blind 系统的第一版接入,并完成了部分特殊规则的落地。

当前阶段主要实现了:

Boss Blind 规则分类与边界划分

Boss Blind 配置结构设计

initGame 生命周期拆分

Blindante 状态推进

nextBlindConfig/nextAnteConfig 返回

SelectCardsResult 返回结构重构

DealResult 返回结构重构

disableSuit 特殊规则实现

disableHandType 特殊规则实现

2. 本阶段最大的变化

本篇最大的变化,其实并不是 Boss Blind 规则本身。

而是:随着 Boss BlindBlind 推进、ante 等系统逐渐加入后,原本很多还能继续使用的结构,已经开始慢慢撑不住了。

所以这一篇里,出现了大量原本没有计划的重构:

  • initGame 接口拆分
  • 生命周期重新划分
  • 返回结构职责拆分
  • Blind 推进状态管理
  • 前后端职责重新划分

也是在这个过程中,我越来越明显地感觉到:

真正复杂的,往往不是功能本身,而是旧结构还能不能继续承载新功能。

3. 当前 Boss Blind 系统状态

目前这一版 Boss Blind 还只是一个开始。

当前只实现了:

  • 某个花色不计分
  • 某个牌型不计分

也就是:只完成了部分"结算层规则"。

而像:

  • 隐藏花色
  • 隐藏人头牌
  • 行为限制
  • 状态限制
  • 更复杂的规则组合

这些都还没有真正接入。

但至少现在,整个 Boss Blind 的规则系统已经第一次真正进入了当前后端架构。

4. 当前阶段遗留问题

虽然当前结构已经比之前清晰很多,但还有一些问题暂时没有继续处理。

例如:

  • Boss Blind effect 还没有真正独立成 effect system
  • 特殊规则仍然集中在 poker.service.ts
  • 规则之间还不存在组合机制
  • 当前仍然缺少真正完整的游戏流程联调

不过这些问题,我暂时没有继续提前拆。

因为当前 effect 数量还不算多,过早拆分反而可能会让结构变得过度复杂。

5. 下一步计划

下一阶段,会开始继续完善 Boss Blind 与游戏流程本身。

包括:

  • 更多特殊规则扩展
  • Blind 生命周期继续完善
  • Boss Blind 与回合流程联动
  • 更完整的游戏状态推进
  • 可能开始接入跳过 Blind 的逻辑

不过真正写完这一篇之后,我发现 Boss Blind 最麻烦的地方,可能还不是这些特殊规则本身。

因为 disableSuitdisableHandType 目前都还能通过几个独立函数解决。

但如果未来同时出现:

  • 禁用花色
  • 禁用牌型
  • 手牌减少
  • 出牌次数减少
  • 特殊条件限制

这些规则开始叠加的时候,当前结构还能不能继续承载?

这可能才是下一阶段真正需要面对的问题。而这,也是后面 ModifierJoker,以及更复杂规则系统最终会带来的挑战。

相关推荐
XovH1 小时前
第 44篇 k8s之实战:将 Web 应用迁移到 Kubernetes(上)
后端
MariaH1 小时前
Node.js 架构理解
后端
我登哥MVP1 小时前
Spring Boot 从“会用”到“精通”:请求映射原理
java·spring boot·后端·spring·servlet·maven·intellij-idea
MariaH1 小时前
Node-fs模块
后端
峰子20121 小时前
PG 管控系统技术方案
数据库·后端·pg
晓杰'1 小时前
从0到1实现Balatro游戏后端(6):Blind关卡状态设计与回合推进实现
后端·websocket·typescript·游戏开发·项目实战·nestjs·状态管理
墨香幽梦客2 小时前
GraphQL在ERP数据集成中的革命性应用:从N+1查询到批量优化的实践
后端·graphql
chimchim662 小时前
Azure Data Factory (ADF)‌ 之databricks使用
后端·python·flask
喵个咪2 小时前
技术复盘:基于 GoWind Admin 实现 Kratos 框架单体轻量化落地
后端·架构·go