从0到1实现Balatro游戏后端(8):Skip Blind与Tag奖励机制设计与实现

本系列记录我从 0 到 1 实现一个 Balatro 风格的游戏后端系统,并逐步将其工程化为可扩展的实时服务项目。

  • 该项目的代码已开源,可在 GitHubGitCode 上获取。
    • 📌 本文对应代码分支:origin/feature/tag-system
    • ⚠️ 注意:由于项目持续迭代,当前仓库代码可能已发生变化,本文内容基于该分支版本进行说明。

✅ 本篇实现了什么

本篇主要围绕 skip blindTag reward 机制,完成了从机制分析到基础代码落地的第一版实现。

当前阶段主要完成了:

  • 梳理 skip blind 的机制意义与玩家选择动机
  • 明确 skiptag 的关系,区分奖励预览与玩家真实获得状态
  • 新增 tag.config.ts,建立当前阶段的基础 Tag 配置结构
  • initGame 阶段为 small blind / big blind 提前分配 tag
  • 新增 skipBlind 独立接口,打通跳过 blind 的基础流程
  • 实现两个代表性 Tag
    • Boss Tag:即时改写当前 anteBoss Blind
    • Juggle Tag:延迟到下一局 startGame 生效
  • 增加 activeTagscleanupExpiredTagsclearPlayerRuntimeState 等运行时状态处理逻辑

也就是说,这一篇真正完成的,并不是一次性把全部 Tag 都实现完,而是先把 Skip Blind + Tag Reward 的第一条可运行链路 跑通。

文章目录

  • [一、为什么需要跳过 Blind 机制](#一、为什么需要跳过 Blind 机制)
  • [二、玩家为什么会选择 skip blind](#二、玩家为什么会选择 skip blind)
    • [2.1 前期关卡太简单,想快速跳关](#2.1 前期关卡太简单,想快速跳关)
    • [2.2 当前展示出来的 Tag 很有吸引力](#2.2 当前展示出来的 Tag 很有吸引力)
    • [2.3 当前构筑偏弱,想赌一个变量](#2.3 当前构筑偏弱,想赌一个变量)
    • [2.4 当前构筑与 skip 有联动](#2.4 当前构筑与 skip 有联动)
  • [三、Skip 与 Tag 在机制上的关系](#三、Skip 与 Tag 在机制上的关系)
    • [3.1 skip 不是一个普通按钮](#3.1 skip 不是一个普通按钮)
    • [3.2 skip 真正替换掉的是什么](#3.2 skip 真正替换掉的是什么)
    • [3.3 Tag 不是 Blind 自己的属性](#3.3 Tag 不是 Blind 自己的属性)
    • [3.4 skip 不是永久改线,而是局部分叉](#3.4 skip 不是永久改线,而是局部分叉)
    • [3.5 为什么 skip + tag 会让整局变得更有意思](#3.5 为什么 skip + tag 会让整局变得更有意思)
  • [四、状态设计:Tag 如何进入当前游戏状态](#四、状态设计:Tag 如何进入当前游戏状态)
    • [4.1 初始化阶段,先生成当前 blind 对应的 skip reward](#4.1 初始化阶段,先生成当前 blind 对应的 skip reward)
    • [4.2 挂载到 blindState 的,是奖励预览,不是玩家状态](#4.2 挂载到 blindState 的,是奖励预览,不是玩家状态)
    • [4.3 玩家真正获得 Tag 的时机,是执行 skip 之后](#4.3 玩家真正获得 Tag 的时机,是执行 skip 之后)
    • [4.4 为什么不想把 reward 直接写死在 Blind 配置里](#4.4 为什么不想把 reward 直接写死在 Blind 配置里)
  • [五、🎯 奖励触发与结算流程实现](#五、🎯 奖励触发与结算流程实现)
    • [5.1 Tag 配置放到哪里?](#5.1 Tag 配置放到哪里?)
      • [5.1.1 当前配置相关的目录结构](#5.1.1 当前配置相关的目录结构)
      • [5.1.2 tag.config 代码示例](#5.1.2 tag.config 代码示例)
      • [5.1.3 tag.config 的使用](#5.1.3 tag.config 的使用)
    • [5.2 skipBlind](#5.2 skipBlind)
      • [5.2.1 为什么单独做 skipBlind 接口](#5.2.1 为什么单独做 skipBlind 接口)
      • [5.2.2 skipBlind 代码实现](#5.2.2 skipBlind 代码实现)
      • [5.2.3 为什么需要 activeTags](#5.2.3 为什么需要 activeTags)
      • [5.2.4 Juggle Tag 如何在下一局生效](#5.2.4 Juggle Tag 如何在下一局生效)
      • [5.2.5 为什么还需要 cleanupExpiredTags](#5.2.5 为什么还需要 cleanupExpiredTags)
      • [5.2.6 blind 结束后的推进逻辑为什么要拆出去](#5.2.6 blind 结束后的推进逻辑为什么要拆出去)
      • [5.2.7 当前链路验证结果](#5.2.7 当前链路验证结果)
  • [六、当前实现边界:先支持影响 Blind 的 Tag](#六、当前实现边界:先支持影响 Blind 的 Tag)
    • [6.1 为什么现在不适合把 tag 做得太大](#6.1 为什么现在不适合把 tag 做得太大)
    • [6.2 为什么先按"影响哪个系统"来分类](#6.2 为什么先按“影响哪个系统”来分类)
    • [6.3 为什么不是一次性支持所有的 tag](#6.3 为什么不是一次性支持所有的 tag)
    • [6.4 在 blind 内,为什么我最先想到的是 target / rule](#6.4 在 blind 内,为什么我最先想到的是 target / rule)
    • [6.5 当前阶段的实现策略:先跑通最小链路](#6.5 当前阶段的实现策略:先跑通最小链路)
  • 七、总结
    • [7.1 当前阶段完成内容](#7.1 当前阶段完成内容)
    • [7.2 这一阶段真正难的地方](#7.2 这一阶段真正难的地方)
    • [7.3 当前并不是完整 Tag 系统](#7.3 当前并不是完整 Tag 系统)
    • [7.4 下一步会继续补什么](#7.4 下一步会继续补什么)

一、为什么需要跳过 Blind 机制

一开始我去看 Balatro 这个游戏的时候,发现一个很有意思的点:并不是所有的 Blind 都能跳过,而是只有 small blindbig blind 可以,boss blind 是不行的。

因为玩家真正放弃的,不只是当前这一关的对局本身,而是这次正常推进后能拿到的一整套稳定收益。比如正常打完一个 blind,后面还能进 shop,还能继续买卖特殊牌从而调整当前的特殊牌构筑,看看有没有更适合的小丑牌或者别的资源。

所以 skip blind 的本质,并不是"白拿奖励",而是:

放弃当前一次稳定的成长机会,去换一个不同方向的收益。

也正是因为这样, skip 才不是一个可有可无的小按钮。如果没有这个机制,整个流程其实会很稳定,从而变成流水线:small blind -> big blind -> boss blind -> shop -> next ante。这一套组合拳下来,玩家能做的就是一路按照既定节奏往下打。

skip 的出现,相当于在阶段流程里插了一个分叉口。你可以正常走,也可以放弃这一次的正常路径,去换取一个当前看起来更值,或者更适合当前局面的奖励。

所以我现在会觉得,skip blind 机制存在的意义,不只是"让玩家少打一关",而是为了让每个阶段都多一个选择,让游戏变得更有意思。

二、玩家为什么会选择 skip blind

如果只是从表面看,玩家选择 skip blind,好像只是因为"奖励不错"。但实际上,我觉得玩家会跳过 blind ,动机并不止是一个。

我目前能想到的大概有下面几类(分析为个人的思路理解,仅供参考):

如果你有别的想法,或者觉得还有我没想到的情况,欢迎帮我一起拓展这块的思路。

2.1 前期关卡太简单,想快速跳关

有些玩家已经循环玩过很多次了,对前期的节奏比较熟悉。这时候某些前期 blind 对她来说,可能已经不算挑战,更多只是重复劳动。

那这种情况下,玩家可能就会想:

这关我不是打不过,而是懒得再打一遍。

这种动机更像是一种 efficiency choice,不是因为缺资源,而是因为当前关卡的时间成本已经高于它本身的挑战价值了。

2.2 当前展示出来的 Tag 很有吸引力

这个就更直接了。

如果当前 small blindbig blind 展示出来的 tag 很香,而且我现在这套牌组也还比较满意,没有特别急着进 shop 去修改,那我就会很自然地开始做权衡:

  • 我是正常打一关,然后进 shop
  • 还是现在直接 skip,把这个 tag 拿走

这时候玩家比较的,其实不是"跳关 vs 不跳关",而是:

  • 当前这一次稳定成长机会
  • 和当前这个可见奖励,到底谁更值

2.3 当前构筑偏弱,想赌一个变量

还有一种情况,就是自己刚过上一个 blind 时已经有点强弩之末了... 现在再往后走,target score 更高,当前这套构筑看起来也不太稳定。

这个时候继续正常打,也不一定能救回来。反过来,如果当前给出的 tag 能让我拿到一个新的变量,那哪怕有点赌,我也愿意试。

所以这里的 skip,更像是:

正常走下去未必能活,不如主动换条当下更有希望的分支看看。

2.4 当前构筑与 skip 有联动

这个是我现在比较倾向认为更关键的一层。

因为有些 Joker 本身就会和 skip blind 这个动作产生联动。也就是说,玩家之所以跳过,并不只是因为 tag 本身,而是因为:

  • 跳过能拿 tag
  • 同时又能触发当前 Joker 的额外收益
  • 这样一来,这一次 skip 的整体价值就被放大了

纯粹的博弈,当然也会让人瞬间上头。但如果只是这次值不值,那次值不值,这种刺激未必能持续太久。

反而是那种:

  • 当前构筑和 skip 有关系
  • 当前 tag 又正好能接上
  • 这次跳过后,后面整局的 shape 都开始变了

所以我认为真正让 skip 机制更耐玩的,不只是单次博弈,而是它能和当前构筑发生联动,让一次阶段选择变成整局收益放大的节点。

三、Skip 与 Tag 在机制上的关系

3.1 skip 不是一个普通按钮

表面上看,skip blind 只是玩家在 small blindbig blind 前多了一个按钮。但如果只把它理解成"按钮点击",那其实会把这个机制看得太轻。

因为从交互层看,它当然只是一个动作;但从机制层看,它更像是一次 阶段决策结果

👉 也就是说,玩家按下去的不是一个普通按钮,而是在这个阶段里做了一次分叉选择。

3.2 skip 真正替换掉的是什么

skip 替换掉的,不只是当前这一场对局。它真正替换掉的,其实是这一整个阶段原本会发生的那条正常路径。

正常情况下,玩家会:

  • 进入当前 blind
  • 完成对局
  • 进行结算
  • 进入 shop
  • 再进入下一个阶段

而一旦选择 skip,玩家放弃掉的,其实是这次 正常推进后的稳定成长机会

所以 skip 的重点并不是"少打一关",而是:

用当前一次阶段选择,换掉这一次正常成长机会。

3.3 Tag 不是 Blind 自己的属性

我现在会更倾向于理解成:

  • skip 是一次阶段选择
  • tag 是这个选择之后发放出来的奖励结果

所以 tag 不是 blind 自带的挑战属性。它不是 blind 本身的一部分,而是玩家做出 skip 决策后,系统给出的 reward layer

也正因为这样,我觉得:

  • blind config
  • skip reward config

这两者应该拆开,而不是混在一起。

3.4 skip 不是永久改线,而是局部分叉

最开始我以为选择了 skip 就是改走另一条成长路线,但后来发现自己想错了。因为每次 small blindbig blind 前,玩家都只是面对一次 当前节点的选择

  • 这一次正常打
  • 还是这一次直接 skip

它并不是说,从这一次开始,整局就永久切换成某种固定模式了。

所以更准确的说法应该是:

skip 不像永久改线,更像主流程里不断出现的几个局部分叉口。

每一个分叉口都不会立刻决定整局,但会一点点把整局推向不同的结果。

3.5 为什么 skip + tag 会让整局变得更有意思

因为玩家每一次面对 skip 的时候,比较的都不是单一收益,而是:

  • 当前这次稳定成长
  • 和当前这个 tag 能带来的另一种收益

短期看,它只是一次选择。但随着这些选择不断积累,最后整局的运行形态就会越来越不同。

所以 skip + tag 好玩的地方,不只是"这次奖励值不值",而是:

每一次阶段分叉,都会逐渐改写整局后续的可能性。

四、状态设计:Tag 如何进入当前游戏状态

4.1 初始化阶段,先生成当前 blind 对应的 skip reward

我倾向的做法是:在 initGame 的时候,先去读取独立的 skip reward config,然后通过 shuffle 打乱奖励顺序,再把打乱后的结果分配到对应的 small blindbig blind 上。

这样做最直接的原因,就是方便前端展示。因为前端只有在每个 ante 一开始就拿到当前 blindskip reward,才能在玩家做出选择之前,先把:

  • 当前 small blind 跳过后会拿到什么
  • 当前 big blind 跳过后会拿到什么

直接展示出来。

这样玩家才是在"看见奖励之后"做决定,而不是先点了 skip,系统才临时告诉她会拿到什么。

4.2 挂载到 blindState 的,是奖励预览,不是玩家状态

这里我觉得特别容易混,所以要单独说清楚。

虽然我现在是想把 skip reward 在初始化阶段就挂载到 blindState 上,但它的含义并不是:

玩家一开局就已经拥有了这些 Tag

挂在 blindState 上,只是因为当前这个 blind 是可以被 skip 的,而这个 blind 一旦被 skip,系统会给出对应的奖励。

所以这一层更像是:

当前 blind 的运行时奖励预览信息。

它存在的目的是:

  • 让前端能提前展示
  • 让玩家在选择前先看到奖励
  • 让当前阶段的 skip 机制真正成立

所以这时候奖励还属于 blind 的运行时信息,并不代表它已经进入玩家状态。

4.3 玩家真正获得 Tag 的时机,是执行 skip 之后

initGame 阶段做的事情,更多是:

  • 读取配置
  • 打乱 reward pool
  • 给当前可跳过 blind 分配 tag
  • 让前端能先展示出来

但这还不等于玩家已经拿到了奖励。真正把 tag 转成玩家当前状态的一刻,应该发生在:

玩家实际执行 skip blind 之后。

也就是说,前面挂在 blindState 上的是"当前可见的 skip reward",只有当玩家真的点了 skip,这个奖励才会真正变成玩家当前获得的 Tag

这个顺序对我来说更自然。因为它更符合整个交互逻辑:

  1. 系统先展示当前 blindskip reward
  2. 玩家根据当前构筑和局面做判断
  3. 玩家选择是否 skip
  4. 如果真的 skip,对应奖励才正式进入玩家状态

这样之后,状态含义会清楚很多。
#mermaid-svg-T3H1qRnwfhslzW4c{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-T3H1qRnwfhslzW4c .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-T3H1qRnwfhslzW4c .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-T3H1qRnwfhslzW4c .error-icon{fill:#552222;}#mermaid-svg-T3H1qRnwfhslzW4c .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-T3H1qRnwfhslzW4c .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-T3H1qRnwfhslzW4c .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-T3H1qRnwfhslzW4c .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-T3H1qRnwfhslzW4c .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-T3H1qRnwfhslzW4c .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-T3H1qRnwfhslzW4c .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-T3H1qRnwfhslzW4c .marker{fill:#333333;stroke:#333333;}#mermaid-svg-T3H1qRnwfhslzW4c .marker.cross{stroke:#333333;}#mermaid-svg-T3H1qRnwfhslzW4c svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-T3H1qRnwfhslzW4c p{margin:0;}#mermaid-svg-T3H1qRnwfhslzW4c .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-T3H1qRnwfhslzW4c .cluster-label text{fill:#333;}#mermaid-svg-T3H1qRnwfhslzW4c .cluster-label span{color:#333;}#mermaid-svg-T3H1qRnwfhslzW4c .cluster-label span p{background-color:transparent;}#mermaid-svg-T3H1qRnwfhslzW4c .label text,#mermaid-svg-T3H1qRnwfhslzW4c span{fill:#333;color:#333;}#mermaid-svg-T3H1qRnwfhslzW4c .node rect,#mermaid-svg-T3H1qRnwfhslzW4c .node circle,#mermaid-svg-T3H1qRnwfhslzW4c .node ellipse,#mermaid-svg-T3H1qRnwfhslzW4c .node polygon,#mermaid-svg-T3H1qRnwfhslzW4c .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-T3H1qRnwfhslzW4c .rough-node .label text,#mermaid-svg-T3H1qRnwfhslzW4c .node .label text,#mermaid-svg-T3H1qRnwfhslzW4c .image-shape .label,#mermaid-svg-T3H1qRnwfhslzW4c .icon-shape .label{text-anchor:middle;}#mermaid-svg-T3H1qRnwfhslzW4c .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-T3H1qRnwfhslzW4c .rough-node .label,#mermaid-svg-T3H1qRnwfhslzW4c .node .label,#mermaid-svg-T3H1qRnwfhslzW4c .image-shape .label,#mermaid-svg-T3H1qRnwfhslzW4c .icon-shape .label{text-align:center;}#mermaid-svg-T3H1qRnwfhslzW4c .node.clickable{cursor:pointer;}#mermaid-svg-T3H1qRnwfhslzW4c .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-T3H1qRnwfhslzW4c .arrowheadPath{fill:#333333;}#mermaid-svg-T3H1qRnwfhslzW4c .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-T3H1qRnwfhslzW4c .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-T3H1qRnwfhslzW4c .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-T3H1qRnwfhslzW4c .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-T3H1qRnwfhslzW4c .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-T3H1qRnwfhslzW4c .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-T3H1qRnwfhslzW4c .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-T3H1qRnwfhslzW4c .cluster text{fill:#333;}#mermaid-svg-T3H1qRnwfhslzW4c .cluster span{color:#333;}#mermaid-svg-T3H1qRnwfhslzW4c 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-T3H1qRnwfhslzW4c .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-T3H1qRnwfhslzW4c rect.text{fill:none;stroke-width:0;}#mermaid-svg-T3H1qRnwfhslzW4c .icon-shape,#mermaid-svg-T3H1qRnwfhslzW4c .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-T3H1qRnwfhslzW4c .icon-shape p,#mermaid-svg-T3H1qRnwfhslzW4c .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-T3H1qRnwfhslzW4c .icon-shape .label rect,#mermaid-svg-T3H1qRnwfhslzW4c .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-T3H1qRnwfhslzW4c .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-T3H1qRnwfhslzW4c .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-T3H1qRnwfhslzW4c :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 即时生效
延迟生效
initGame
读取 Tag reward config
shuffle reward pool
为当前 ante 的

small / big blind 预分配 tag
写入 blindState.currentAnteConfig
前端展示当前可获得的 skip reward
玩家执行 skipBlind
服务端根据 blindType

读取当前 tagCode
对应 Tag 进入运行时处理流程
Tag 生效方式
直接修改当前状态

如 Boss Tag
写入 activeTags

如 Juggle Tag
继续推进后续流程

4.4 为什么不想把 reward 直接写死在 Blind 配置里

我更倾向让 skip reward config 单独存在,而不是直接写死在 blind config 里。

原因一方面是因为它们本来就不是一类东西。

  • blind 描述的是挑战本身
  • skip reward 描述的是跳过之后能拿到的收益

它们虽然最终都会一起体现在当前 blindState 的运行时结果里,但来源不应该混在一起。

另一方面,如果 tag 直接写死在 blind 里,那每次重新开局的时候,对应 blindskip reward 很容易就变成固定搭配。这样一来,玩家每次重新 initGame,当前 blind 的变化感就会弱很多。

所以我更倾向于:

  • 配置层分开
  • 运行时再组合

也就是:

  • tag 不写死在 blind config
  • 但在 initGame 阶段,把当前 blind 实际抽到并分配到的结果挂到 blindState

这样既保留了变化,也方便前端直接取值。

五、🎯 奖励触发与结算流程实现

前面几节主要是在说机制和状态关系。

从这一章开始,就进入具体实现部分:Tag 配置放在哪里、skipBlind 接口怎么处理、延迟生效的 Tag 怎么进入 activeTags,以及 blind 结束后这些临时状态应该如何清理。

这部分的目标不是把全部 Tag 都做完,而是先把一条完整链路跑通。
#mermaid-svg-Lf8c1zhKGPdbCsEA{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-Lf8c1zhKGPdbCsEA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Lf8c1zhKGPdbCsEA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Lf8c1zhKGPdbCsEA .error-icon{fill:#552222;}#mermaid-svg-Lf8c1zhKGPdbCsEA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Lf8c1zhKGPdbCsEA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Lf8c1zhKGPdbCsEA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Lf8c1zhKGPdbCsEA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Lf8c1zhKGPdbCsEA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Lf8c1zhKGPdbCsEA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Lf8c1zhKGPdbCsEA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Lf8c1zhKGPdbCsEA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Lf8c1zhKGPdbCsEA .marker.cross{stroke:#333333;}#mermaid-svg-Lf8c1zhKGPdbCsEA svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Lf8c1zhKGPdbCsEA p{margin:0;}#mermaid-svg-Lf8c1zhKGPdbCsEA .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Lf8c1zhKGPdbCsEA .cluster-label text{fill:#333;}#mermaid-svg-Lf8c1zhKGPdbCsEA .cluster-label span{color:#333;}#mermaid-svg-Lf8c1zhKGPdbCsEA .cluster-label span p{background-color:transparent;}#mermaid-svg-Lf8c1zhKGPdbCsEA .label text,#mermaid-svg-Lf8c1zhKGPdbCsEA span{fill:#333;color:#333;}#mermaid-svg-Lf8c1zhKGPdbCsEA .node rect,#mermaid-svg-Lf8c1zhKGPdbCsEA .node circle,#mermaid-svg-Lf8c1zhKGPdbCsEA .node ellipse,#mermaid-svg-Lf8c1zhKGPdbCsEA .node polygon,#mermaid-svg-Lf8c1zhKGPdbCsEA .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Lf8c1zhKGPdbCsEA .rough-node .label text,#mermaid-svg-Lf8c1zhKGPdbCsEA .node .label text,#mermaid-svg-Lf8c1zhKGPdbCsEA .image-shape .label,#mermaid-svg-Lf8c1zhKGPdbCsEA .icon-shape .label{text-anchor:middle;}#mermaid-svg-Lf8c1zhKGPdbCsEA .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Lf8c1zhKGPdbCsEA .rough-node .label,#mermaid-svg-Lf8c1zhKGPdbCsEA .node .label,#mermaid-svg-Lf8c1zhKGPdbCsEA .image-shape .label,#mermaid-svg-Lf8c1zhKGPdbCsEA .icon-shape .label{text-align:center;}#mermaid-svg-Lf8c1zhKGPdbCsEA .node.clickable{cursor:pointer;}#mermaid-svg-Lf8c1zhKGPdbCsEA .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Lf8c1zhKGPdbCsEA .arrowheadPath{fill:#333333;}#mermaid-svg-Lf8c1zhKGPdbCsEA .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Lf8c1zhKGPdbCsEA .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Lf8c1zhKGPdbCsEA .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Lf8c1zhKGPdbCsEA .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Lf8c1zhKGPdbCsEA .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Lf8c1zhKGPdbCsEA .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Lf8c1zhKGPdbCsEA .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Lf8c1zhKGPdbCsEA .cluster text{fill:#333;}#mermaid-svg-Lf8c1zhKGPdbCsEA .cluster span{color:#333;}#mermaid-svg-Lf8c1zhKGPdbCsEA 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-Lf8c1zhKGPdbCsEA .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Lf8c1zhKGPdbCsEA rect.text{fill:none;stroke-width:0;}#mermaid-svg-Lf8c1zhKGPdbCsEA .icon-shape,#mermaid-svg-Lf8c1zhKGPdbCsEA .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Lf8c1zhKGPdbCsEA .icon-shape p,#mermaid-svg-Lf8c1zhKGPdbCsEA .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Lf8c1zhKGPdbCsEA .icon-shape .label rect,#mermaid-svg-Lf8c1zhKGPdbCsEA .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Lf8c1zhKGPdbCsEA .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Lf8c1zhKGPdbCsEA .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Lf8c1zhKGPdbCsEA :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Boss Tag
Juggle Tag


initGame
读取 skip reward config
shuffle Tag reward pool
为 small / big blind 预分配 tag
挂载到 currentAnteConfig
前端展示 skip reward
玩家执行 skipBlind
服务端根据 blindType 读取 tagCode
Tag 类型
立即改写当前 ante 的 Boss Blind 预设
推进到下一个 Blind
写入 activeTags

status = pending
startGame
是否存在 pending 的 Juggle Tag
当前局 handSize + 3

状态改为 applied
正常开始当前 Blind
Blind 结束
cleanupExpiredTags
移除已 applied 的临时 Tag

5.1 Tag 配置放到哪里?

每一次到"代码该放哪"的时候,都是一个很难的抉择,因为我目前还没有可以很快的分清边界线。

而目前项目中已有的两个配置:boss.config.tsblind.config.ts 还被分别放置到了 poker/game/ 下,那一瞬间是恍惚的...

回看了一眼曾经的创作历程,也就是主线7的博文 ->《Boss Blind与特殊规则实现》,想翻找一下曾经把 boss.config.ts 放到 poker/ 下是因为啥,但当时没写???

但我也没再纠结,干脆顺手把 boss.config.ts 挪到了 game/ 下,也把本次新增的 tag.config.ts 一起放了进去。

5.1.1 当前配置相关的目录结构

当前关于配置的目录结构:

复制代码
src/game/
  blind.config.ts
  boss.config.ts
  tag.config.ts
  game.types.ts
  game.constants.ts

src/poker/
  poker.constants.ts
  poker.types.ts
  poker.service.ts

5.1.2 tag.config 代码示例

  • tag 目前只写了2个特殊实现,当前做的只是把 skip blindtag reward 这条第一条链路跑通。先搭好框架,为后续扩展做准备。
  • 这里并不是在定义完整的 Tag 池,而是先选两个最有代表性的 Tag,分别演示"即时生效型"和"延迟生效型"的处理方式。
ts 复制代码
export const TAG_CODE = {
    BOSS_TAG: 201,
    JUGGLE_TAG: 202,
} as const;

export const TAG_CONFIG = {
    [TAG_CODE.BOSS_TAG]: {
        code: TAG_CODE.BOSS_TAG,
        name: "Boss Tag",
        description: "Rerolls the next Boss Blind",
    },
    [TAG_CODE.JUGGLE_TAG]: {
        code: TAG_CODE.JUGGLE_TAG,
        name: "Juggle Tag",
        description: "+3 Hand Size for the next round only",
    },
} as const;

export type TagCode = (typeof TAG_CODE)[keyof typeof TAG_CODE];

5.1.3 tag.config 的使用

在配置生成的过程中,tag.config.ts 我们延续了 boss.config.ts 的结构。

在实际代码使用的过程中,获取到配置后延续在洗牌时的 Fisher-Yates shuffle 算法。

这里生成的是当前运行中每个可跳过 blind 的奖励预览,真正执行 skipBlind 后,对应奖励才会进入玩家运行时状态。

ts 复制代码
    const TOTAL_ANTE_COUNT = Object.keys(BLIND_SCORE_CONFIG).length;

    const totalSkipBlindCount = TOTAL_ANTE_COUNT * 2;
    for (let i = 0; i < totalSkipBlindCount; i++) {
        allTagCodes.push(baseTagCodeList[i % baseTagCodeList.length]);
    }

    tagAssignmentsByPlayer = this.shuffleConfig(allTagCodes);

5.2 skipBlind

5.2.1 为什么单独做 skipBlind 接口

当前对外的接口有:

  • initGame 游戏初始化
  • startGame 开始游戏
  • selectCards 选择出牌/弃牌并拿到决策结果

本次的跳过 Blind 并未在原有的接口扩展,而是重新创建了一个新的接口 skipBlind

因为从行为上看,skipBlind 不是 startGame 的一种小分支,而是一个独立决策。单独接口会比塞进别的接口里更清楚。

5.2.2 skipBlind 代码实现

在代码实现的过程中:

  • TAG_CODE.BOSS_TAG:直接改写当前 anteboss blind 的预设配置,因此不需要额外维护延迟状态。
  • TAG_CODE.JUGGLE_TAG:不在当前立即生效,而是先暂存到 activeTags 中,等到下一局 startGame 时再触发,并在该局结束后清理。

一开始也考虑过让前端把 tagCode 一起传回来,但后面还是收回到了服务端内部。因为 tagCodeAnteConfig 里已经提前分配好了,skip 时由后端根据当前 blindType 自行读取会更安全,也能避免把本来属于服务端状态的信息再次暴露给前端输入。

ts 复制代码
    public skipBlind(blindType: SkippableBlindType, round: number, playerId: string): GameActionResult {
        ...

        // Rerolls the next Boss Blind
        if (tagCode === TAG_CODE.BOSS_TAG) {
            const currentBossCode = blindState.currentAnteConfig.boss.code;

            //在重掷 Boss Blind 时,还额外排除了当前已经存在的 boss code,避免重掷后结果不变,导致玩家感知不到这个 Tag 已经生效。
            const bossBlindCodeList: BossBlindCode[] = Object.values(BOSS_BLIND_CODE).filter(
                (code) => code !== currentBossCode,
            );

            const newBossBlindCode = bossBlindCodeList[Math.floor(Math.random() * bossBlindCodeList.length)];

            //这里对 Boss Tag 的处理,本质上不是"把 Tag 挂起来等待未来触发",而是"立即改写当前 ante 下尚未开始的 Boss Blind 预设"。
            blindState.currentAnteConfig.boss.code = newBossBlindCode;
            blindState.currentAnteConfig.boss.name = BOSS_BLIND_CONFIG[newBossBlindCode].name;

            // this.logger.log(
            //     `Player ${playerId} used ${tagConfig.name} to reroll Boss Blind. New Boss Blind: ${blindState.currentAnteConfig.boss.name}`,
            // );
        } else if (tagCode == TAG_CODE.JUGGLE_TAG) {
            this.activeTags[playerId].push({
                code: TAG_CODE.JUGGLE_TAG,
                status: "pending",
            });
        }
        ...
    }

5.2.3 为什么需要 activeTags

一开始我只想把 tagCode 存起来,也就是说 this.activeTags[playerId].push(tagCode),但后来实际运行的时候发现这不够。

因为像 Juggle Tag 这种不是当前立即结算,而是在下一局 startGame 时生效的效果,可能未来不止一个,并且仅靠一个 tagCode 无法判断它当前是否已经应用,是否还需要清理。

所以这里额外的引入了 activeTags,用来维护玩家当前还处于运行时生效链路中的 Tag 状态。

ts 复制代码
export type PlayerActiveTag = {
    code: TagCode;
    status: "pending" | "applied";
};

private readonly activeTags: Record<string, PlayerActiveTag[]> = {};

5.2.4 Juggle Tag 如何在下一局生效

对于延迟触发的 JUGGLE_TAG, 在实际逻辑中,涉及到了 startGame 的判断和结束时的清除。

startGame 时,先检查当前玩家是否存在状态为 pendingJUGGLE_TAG。如果存在,则只在当前局的临时 handSize 上追加数值,并将对应 Tag 的状态更新为 applied

这里尽量不直接改动 playerState.handSize 这种基础状态,而是只在当前局的临时计算值上追加效果。这样可以避免后续流程里出现不必要的状态污染。

ts 复制代码
    public startGame(playerId: string): DealResult {
        ...
        let handSize: number = playerState.handSize;

        if (this.activeTags[playerId]?.some((tag) => tag.code === TAG_CODE.JUGGLE_TAG && tag.status === "pending")) {
            handSize += 3;
            this.activeTags[playerId].forEach((tag) => {
                if (tag.code === TAG_CODE.JUGGLE_TAG && tag.status === "pending") {
                    tag.status = "applied";
                }
            });
        }
        ...
    }

5.2.5 为什么还需要 cleanupExpiredTags

Juggle Tag 这种只对下一局生效的临时效果,并不能在 startGame 时应用完就彻底结束。因为它还需要经历一个"本轮已生效,但尚未清理"的状态。

所以在当前 blind 结束后的推进逻辑里,我额外增加了 cleanupExpiredTags,用于移除已经完成生命周期的临时 Tag

ts 复制代码
    private cleanupExpiredTags(playerId: string): void {
        this.activeTags[playerId] = (this.activeTags[playerId] ?? []).filter(
            (tag) => !(tag.code === TAG_CODE.JUGGLE_TAG && tag.status === "applied"),
        );
    }

5.2.6 blind 结束后的推进逻辑为什么要拆出去

这次 skipBlind 的返回结构并没有重新定义一套新类型,而是继续沿用了统一的 GameActionResult(原 SelectCardsResult)。

这样前端处理流程时,不需要额外再为"跳过 blind"单独维护一套全新的结果结构。

与此同时,随着 skipBlindTag cleanupblind settlement 这些逻辑逐渐变多,原本集中写在 buildActionResult 里的 blind 结束处理开始变得过重。

所以这里把 blind 结束后的推进逻辑额外拆成了 resolveProgressAfterBlind,让"状态推进"和"结果组装"至少先做一层分离。

ts 复制代码
    private resolveProgressAfterBlind(gameState: GameState, blindStates: BlindState, progress: Progress): void {
        const result = blindStates.currentBlindScore >= blindStates.targetScore ? "WIN" : "LOSE";

        progress.settlement = {
            finalScore: blindStates.currentBlindScore,
            targetScore: blindStates.targetScore,
            result: result,
        };

        if (result === "LOSE") {
            progress.gameOver = true;

            this.clearPlayerRuntimeState(gameState.playerId);
        } else {
            const nextProgress = this.getNextBlindProgress(gameState);

            progress.nextBlindConfig = nextProgress.nextBlindConfig;

            if (nextProgress.nextAnteConfig) {
                progress.nextAnteConfig = nextProgress.nextAnteConfig;
            }

            this.advanceToNextBlind(gameState, nextProgress);
            this.cleanupExpiredTags(gameState.playerId);
        }
    }

同时,为了避免 gameover 或重新初始化时残留旧的运行时数据,这里也额外增加了 clearPlayerRuntimeState,统一清理当前玩家的运行时状态。

ts 复制代码
    private clearPlayerRuntimeState(playerId: string): void {
        delete this.gameStates[playerId];
        delete this.activeTags[playerId];
        delete this.bossBlindAssignments[playerId];
        delete this.tagAssignments[playerId];
    }

#mermaid-svg-6e7fI6E1OJqQZAoB{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-6e7fI6E1OJqQZAoB .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-6e7fI6E1OJqQZAoB .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-6e7fI6E1OJqQZAoB .error-icon{fill:#552222;}#mermaid-svg-6e7fI6E1OJqQZAoB .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-6e7fI6E1OJqQZAoB .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-6e7fI6E1OJqQZAoB .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-6e7fI6E1OJqQZAoB .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-6e7fI6E1OJqQZAoB .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-6e7fI6E1OJqQZAoB .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-6e7fI6E1OJqQZAoB .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-6e7fI6E1OJqQZAoB .marker{fill:#333333;stroke:#333333;}#mermaid-svg-6e7fI6E1OJqQZAoB .marker.cross{stroke:#333333;}#mermaid-svg-6e7fI6E1OJqQZAoB svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-6e7fI6E1OJqQZAoB p{margin:0;}#mermaid-svg-6e7fI6E1OJqQZAoB .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-6e7fI6E1OJqQZAoB .cluster-label text{fill:#333;}#mermaid-svg-6e7fI6E1OJqQZAoB .cluster-label span{color:#333;}#mermaid-svg-6e7fI6E1OJqQZAoB .cluster-label span p{background-color:transparent;}#mermaid-svg-6e7fI6E1OJqQZAoB .label text,#mermaid-svg-6e7fI6E1OJqQZAoB span{fill:#333;color:#333;}#mermaid-svg-6e7fI6E1OJqQZAoB .node rect,#mermaid-svg-6e7fI6E1OJqQZAoB .node circle,#mermaid-svg-6e7fI6E1OJqQZAoB .node ellipse,#mermaid-svg-6e7fI6E1OJqQZAoB .node polygon,#mermaid-svg-6e7fI6E1OJqQZAoB .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-6e7fI6E1OJqQZAoB .rough-node .label text,#mermaid-svg-6e7fI6E1OJqQZAoB .node .label text,#mermaid-svg-6e7fI6E1OJqQZAoB .image-shape .label,#mermaid-svg-6e7fI6E1OJqQZAoB .icon-shape .label{text-anchor:middle;}#mermaid-svg-6e7fI6E1OJqQZAoB .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-6e7fI6E1OJqQZAoB .rough-node .label,#mermaid-svg-6e7fI6E1OJqQZAoB .node .label,#mermaid-svg-6e7fI6E1OJqQZAoB .image-shape .label,#mermaid-svg-6e7fI6E1OJqQZAoB .icon-shape .label{text-align:center;}#mermaid-svg-6e7fI6E1OJqQZAoB .node.clickable{cursor:pointer;}#mermaid-svg-6e7fI6E1OJqQZAoB .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-6e7fI6E1OJqQZAoB .arrowheadPath{fill:#333333;}#mermaid-svg-6e7fI6E1OJqQZAoB .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-6e7fI6E1OJqQZAoB .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-6e7fI6E1OJqQZAoB .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6e7fI6E1OJqQZAoB .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-6e7fI6E1OJqQZAoB .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6e7fI6E1OJqQZAoB .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-6e7fI6E1OJqQZAoB .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-6e7fI6E1OJqQZAoB .cluster text{fill:#333;}#mermaid-svg-6e7fI6E1OJqQZAoB .cluster span{color:#333;}#mermaid-svg-6e7fI6E1OJqQZAoB 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-6e7fI6E1OJqQZAoB .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-6e7fI6E1OJqQZAoB rect.text{fill:none;stroke-width:0;}#mermaid-svg-6e7fI6E1OJqQZAoB .icon-shape,#mermaid-svg-6e7fI6E1OJqQZAoB .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6e7fI6E1OJqQZAoB .icon-shape p,#mermaid-svg-6e7fI6E1OJqQZAoB .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-6e7fI6E1OJqQZAoB .icon-shape .label rect,#mermaid-svg-6e7fI6E1OJqQZAoB .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6e7fI6E1OJqQZAoB .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-6e7fI6E1OJqQZAoB .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-6e7fI6E1OJqQZAoB :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 重构前
buildActionResult
组装返回结果
判断 blind 是否结束
结算 WIN / LOSE
推进到下一个 Blind
清理临时 Tag
game over 时清理运行时状态
重构后
WIN
LOSE
buildActionResult
组装返回结果
blindOver 时调用

resolveProgressAfterBlind
结算 WIN / LOSE
advanceToNextBlind
cleanupExpiredTags
clearPlayerRuntimeState

5.2.7 当前链路验证结果

验证场景 操作 预期结果
初始化奖励预览 调用 initGame small blind / big blind 都会拿到预分配的 skip reward
跳过 Small Blind 调用 skipBlind('small') 服务端根据当前 blindType 读取对应 tagCode,不依赖前端传入
Boss Tag 生效 当前 tagCodeBOSS_TAG 当前 ante 下尚未开始的 Boss Blind 会被重新分配
Juggle Tag 延迟生效 当前 tagCodeJUGGLE_TAG 先写入 activeTags,状态为 pending
下一局开始 调用 startGame 如果存在 pendingJuggle Tag,当前局 handSize + 3,并将状态改为 applied
当前局结束 blind 结算完成 cleanupExpiredTags 清理已经 applied 的临时 Tag

也就是说,目前这版验证的是:

initGame 预览奖励 -> skipBlind 获得 Tag -> Tag 生效 -> blind 结束清理 这条最小闭环。

六、当前实现边界:先支持影响 Blind 的 Tag

6.1 为什么现在不适合把 tag 做得太大

虽然理论上 tag 可以影响很多系统,但当前这个项目阶段,并不适合一开始就把它做成一个特别大的通用系统。

因为现在真正比较清楚、已经成型的,还主要是 blind 这一块。而像:shopeconomyjoker engine这些要么还没开始,要么还没有稳定结构。

如果现在一上来就想把所有 tag 都抽成统一规则、统一生命周期、统一结算入口,那很容易先把抽象做大,但实际没有足够的落点去承接。写起来会很热闹,落到代码里反而会越来越虚。

所以我最后还是决定,先把边界收住,先解决眼前真正已经成型的问题。

6.2 为什么先按"影响哪个系统"来分类

一开始我也想过,是不是应该先按"立即生效 / 延迟生效 / 持续生效"这种方式去分。

但后来想了一圈,还是觉得当前阶段更适合先按:

tag 首先影响哪个系统

来分类。因为在当前项目里,先搞清楚"它归谁管",其实比先搞清楚"它持续多久"更重要。

也就是先知道:

  • 它首先作用于 blind
  • 还是首先作用于 player
  • 或者以后再作用于 shop

这样分,会更贴近我现在项目里的模块边界,也更容易决定代码到底该往哪边挂。

6.3 为什么不是一次性支持所有的 tag

因为现在项目里最成熟的系统就是 blind。而且我们前面聊下来,也已经明显感觉到:

  • 有些 tag 首先影响的是玩家
  • 有些 tag 首先影响的是 blind

所以现在最稳的做法,不是一次性支持所有可能,当前实现仍然优先围绕 blind 流程展开,但在具体 Tag 选择上,也加入了一个作用于玩家临时状态的代表性 Tag(Juggle Tag),用来验证延迟生效链路。

6.4 在 blind 内,为什么我最先想到的是 target / rule

当我去想"影响 blind 的 tag"时,最先有感觉的,其实是:

  • target score
  • blind rule
  • boss blind 的特殊优势或限制

因为这些东西:

  • 当前项目里已经有结构
  • 当前 blindState 里本来就有落点
  • 也最符合 tag 改变当前阶段结果这件事

相比之下,像出牌次数 +1、弃牌次数 +1、手牌上限 +1 这些效果,虽然也会影响对局结果,但它们更像是在改玩家本身,而不是直接改 blind

不过在真正实现时,我额外保留了一个作用于玩家临时状态的 Juggle Tag,这样一来,不仅能验证作用于 blind 的即时效果,也能顺手把延迟生效型 tag 的处理链路一起跑通。

6.5 当前阶段的实现策略:先跑通最小链路

所以当前阶段,我并不是要一次性把全部 tag 都设计完,而是先把第一条最小链路跑通:

  • 先有独立的 skip reward config
  • 先能在 initGame 阶段生成并挂到 blindState
  • 前端先能看到当前可获得的 skip reward
  • 玩家 skip 后,再把对应 tag 接入当前运行时状态
  • 最后在对应时机触发 effect

只要这一条最小链路先跑通,后面再往别的系统扩,才会更稳。

也就是说,这一篇真正完成的不是"完整 Tag 系统",而是:

** Skip Blind + Tag Reward 的第一条可运行链路。**

七、总结

7.1 当前阶段完成内容

这一篇里,我主要完成了 skip blindTag reward 这条基础链路的落地。

当前阶段主要实现了:

  • skip blind 机制的设计拆解
  • skiptag 的关系梳理
  • Tag 奖励预览与玩家实际获得状态的区分
  • tag.config.ts 的基础配置结构
  • small blind / big blindtag 预分配
  • skipBlind 独立接口实现
  • Boss TagJuggle Tag 两种代表性 Tag 的基础接入

7.2 这一阶段真正难的地方

这篇里最麻烦的,其实不是多写了几个接口,而是要先把状态和生命周期关系理清楚。

比如:

  • Tag 什么时候生成
  • 玩家什么时候才算真正获得 Tag
  • 即时生效和延迟生效怎么区分
  • 运行时状态该挂在哪里、什么时候清理

这些问题如果没想清楚,代码写着写着就会开始打架。

7.3 当前并不是完整 Tag 系统

这一篇并不是一次性把全部 Tag 都实现完了,而是先围绕当前已经比较成熟的 blind 流程,把最小闭环跑通。

所以当前真正完成的,其实是:

Skip Blind + Tag Reward 的第一条可运行链路

7.4 下一步会继续补什么

等这条链路稳定下来后,后面再继续补:

  • 更多真实 Tag 类型
  • shopeconomy 相关的奖励效果
  • Joker 更复杂的联动
  • 更完整的奖励池与分配策略

至少到这里,第二阶段里和 skip blind 相关的核心基础已经打下来了,后面再继续扩,心里会更有底一些。

相关推荐
叫我:松哥2 小时前
基于Flask框架的校园二手书籍交易平台,注重校园场景的特殊需求,通过学号认证保障用户真实性
后端·python·sqlite·flask·bootstrap
终将老去的穷苦程序员2 小时前
基于SpringBoot的餐饮管理系统
java·spring boot·后端
张忠琳2 小时前
【Go 1.26.4】Golang Map 深度解析
开发语言·后端·golang
一条泥憨鱼3 小时前
Java开发效率神器:Lombok从入门到精通!
java·后端·学习·开发·lombok
熠熠仔3 小时前
Spring Boot 与 MyBatis-Plus 空间几何数据集成指南
spring boot·后端·mybatis
AI 小老六3 小时前
Google AX 控制面拆解:分布式 Agent 如何把断点恢复、审计策略和执行调度收进同一条链路
人工智能·分布式·后端·ai·架构·ai编程
YHHLAI3 小时前
从零搭建一个 RESTful Todo 服务 —— Bun + TypeScript 全栈最小闭环
后端·typescript·restful
小闹5493 小时前
一个 65 行的小需求,我让 Claude Code 跑了 25 个 agent、整整两小时
后端·claude
天青色等烟雨..3 小时前
智慧农林核心遥感技术99个案例实践
运维·人工智能·spring boot·后端·自动化