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

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

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

📚 系列文章:

(1)🔗 项目规划与牌型判断实现

(2)🔗 NestJS框架搭建与项目结构设计

(3)🔗 洗牌、发牌与服务端牌堆状态管理

(4)🔗 玩家手牌操作(出牌 / 弃牌 / 补牌)与状态流转设计

(5)🔗 得分计算与单局结算流程实现

(6)🔗 Blind关卡状态设计与回合推进实现

(7)Boss Blind与特殊规则实现(当前)✅

(8)Skip Blind与Tag奖励机制设计与实现

(9)Blind奖励结算与金币状态设计(规划中)


✅ 本篇实现了什么

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

当前阶段主要完成了:

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

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

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


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

前一篇里,我终于把 Blind 生命周期跑通了。

整个游戏已经具备: small blind → big blind → boss blind → next ante 这样的推进能力。

但很快新的问题又来了,如果 Boss Blind 只是:目标分数更高,那它其实和普通 Blind 没什么区别。

真正让 Boss Blind 有意思的,从来不是分数,而是规则,比如:

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

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

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

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

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

所以这里,我先把 Boss Blind 的规则分成了两类。

flowchart TD A[Boss Blind] A --> B[显示层规则] A --> C[结算层规则] B --> D[隐藏花色] B --> E[隐藏人头牌] C --> F[禁用花色] C --> G[禁用牌型] D --> H[前端负责处理] E --> H F --> I[后端负责处理] G --> I

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 的规则效果拆成两个部分。

flowchart TD A[Boss Blind 配置] A --> B[Boss 身份信息] A --> C[Boss 规则效果] B --> B1[code: 101] B --> B2[name: The Club] C --> C1[effect.type: disableSuit] C --> C2[effect.suit: C] B1 --> D[用于唯一标识 Boss] B2 --> E[用于前端展示] C1 --> F[决定规则类型] C2 --> G[决定规则作用目标]

首先新增 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 = 初始化 + 发牌

flowchart TD A[拆分前:startGame] --> B[初始化 GameState] A --> C[初始化 playerState] A --> D[初始化 blindState] A --> E[生成 ante 配置] A --> F[发牌并进入游戏]

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

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,并完成发牌。

这样拆分后,每一个接口的职责也会开始变得更加清晰。

flowchart TD A[initGame] A --> B[初始化 GameState] A --> C[初始化 playerState] A --> D[初始化 blindState] A --> E[生成 currentAnteConfig] F[startGame] F --> G[基于当前 Blind 发牌] F --> H[重置 plays/discards] F --> I[进入 playing 状态] A --> F

这样一来:

  • 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,以及更复杂规则系统最终会带来的挑战。

相关推荐
爱勇宝6 小时前
第 1 章:别把“需求文档”当成真正的需求
前端·后端·程序员
IT_陈寒10 小时前
闭包陷阱让我加了两天班,JavaScript你真行
前端·人工智能·后端
易协同低代码11 小时前
通达OA核心类库TD类深度解析
后端
Gopher_HBo11 小时前
Go语言学习笔记(十八)Gin处理Session
后端
谭光志12 小时前
工具塞满上下文窗口怎么办?深度拆解 AI Agent Tool Search 按需加载实现原理
前端·后端·ai编程
她说..12 小时前
Java 默认值设置方式
java·开发语言·后端·springboot
foggyprojects13 小时前
从0开始,一句话启动AI DataAgent
后端·数据分析·ai编程
郡杰13 小时前
一些基础和问题解决
后端
陈随易13 小时前
前端项目部署只要30秒
前端·后端·程序员
YIAN13 小时前
从零手写文件读取 MCP 服务:一文吃透 Model Context Protocol 全链路通信原理
前端·后端·mcp