跳转至

规则引擎构建说明

本文说明当前后端规则引擎的整体构建方式、执行链路和这次联调里修正过的关键点,方便继续开发自定义规则与对局功能。

对应实现主文件:

  • src/domain/rule_engine.rs
  • src/interface/rule.rs
  • src/interface/room.rs

1. 总体目标

后端规则引擎当前承担 3 件事:

  1. 解析规则 JSON,编译成运行时结构
  2. 在房间开局时初始化对局状态
  3. 在玩家操作时按流程图推进对局、判定出牌和结算

因此“规则引擎”不是单纯的牌型识别器,而是:

  • 规则解析器
  • 流程执行器
  • 对局状态机

2. 主要数据结构

2.1 规则定义层

用于承接前端导出的 JSON:

  • ExportedRuleDesign
  • RuleClass
  • RuleCardset
  • RuleCardsetComparison
  • FlowNode

这一层基本对应原始 JSON。

2.2 运行时规则层

解析后会得到:

  • RuntimeRule
  • RuntimeFlow
  • RuntimeNode
  • RuntimeCardset
  • RuntimeComparison

作用:

  • 统一整理节点跳转
  • 固化流程入口
  • 避免每次执行时重复解析 JSON 结构

2.3 对局状态层

核心结构是 GameSession,包含:

  • players
  • table
  • deck
  • hands
  • discard_pile
  • pending_action
  • settlement_results
  • last_successful_play
  • last_action_cards
  • last_action_skipped

可以把它理解成“当前对局完整快照”。

3. 规则加载链路

3.1 规则入口

规则入口在 src/interface/rule.rs

build_rule_store() 会:

  1. 调用 build_builtin_rules()
  2. 生成内置规则
  3. 放入 RuleRepository.published

所以:

  • 房间创建接口拿到的规则列表来自这里
  • 房间开局使用的规则运行时对象也来自这里

3.2 当前内置规则

目前分两类:

  • 历史联调规则:test.json
  • 极简可跑规则:test2.json

其中 test2.json 对应:

  • builtin-test2-rule
  • 名称 Tiny Demo

4. 解析阶段

入口函数:

RuleEngine::parse(name, player_count, description, design)

解析阶段会做几件事:

  1. 校验规则元数据
  2. 校验顶层结构
  3. 编译 match_flow
  4. 编译 end_flow
  5. 编译每个牌型的 build_flow
  6. 编译每个牌型/跨牌型的比较流程

4.1 compile_flow 的作用

compile_flow() 会把原始 FlowGraph 转成 RuntimeFlow,核心工作:

  • 检查是否存在入口节点 "1"
  • 检查组件类型是否在支持列表内
  • 收集节点跳转关系
  • 检查跳转目标是否存在

当前跳转来源有两种:

  • 节点对象外层的 next
  • content 里的 next / next_true / next_false

这使得旧规则和新规则都能兼容。

5. 开局阶段

入口:

RuleEngine::start_session(room_code, runtime_rule, player_ids)

它会依次完成:

  1. build_players()
  2. build_table()
  3. build_deck()
  4. 初始化每个玩家手牌区
  5. 进入 execute_until_blocked()

5.1 build_players

这里会从 classes.player 读取默认属性,然后给每个玩家初始化 properties

关键修正点:

  • GamePlayer 增加了 runtime_index

这是这次通用化修复的关键之一。后续规则执行不能依赖真实用户 ID,而应该依赖运行时座位顺序。

5.2 build_table

牌桌对象会初始化:

  • 规则定义里的 table 属性
  • 引擎固定附加属性:
  • player_index
  • index
  • cur_max
  • settlement_index

此外会把“当前应操作玩家”初始化为第一个运行时玩家。

5.3 build_deck

牌堆来自 classes.card 的属性笛卡尔积。

例如:

  • point 有 2 种取值
  • suit 有 1 种取值

最终会生成 2 x 1 = 2 张牌。

这意味着当前规则系统不是手写卡表,而是按 card 属性组合自动生成整副牌。

6. 主执行循环

入口:

RuleEngine::execute_until_blocked(runtime_rule, session)

这是整个对局引擎的核心循环。

循环终止条件:

  • 遇到等待玩家动作的节点
  • 对局已结束
  • 进入结算且所有玩家都已写入结果
  • 超过 FLOW_STEP_LIMIT

当前支持两种活动流程:

  • match
  • end

6.1 常见节点行为

  • 17 / 23 / 25 / 27 / 29
  • 只是流程起点,直接跳下一节点
  • 4
  • 赋值
  • 16
  • 条件分支
  • 18
  • 从主流程切到结算流程
  • 19
  • 洗牌
  • 20
  • 发牌
  • 21 / 22
  • 设置 pending_action,暂停执行,等待客户端提交动作
  • 24
  • 写入当前结算玩家结果,推进 settlement_index

7. 玩家动作提交链路

入口:

RuleEngine::submit_action(runtime_rule, session, player_id, action)

分两类:

  • 21:出牌
  • 22:做选择

提交后会:

  1. 校验当前 pending action 是否属于该玩家
  2. 处理动作
  3. 清空 pending_action
  4. 从该动作节点的 next 继续执行

8. 出牌判定链路

出牌动作的处理函数是:

handle_play_cards_action()

核心流程:

  1. 取当前玩家手牌
  2. 根据前端提交的 cardIds 取出选中的牌
  3. 如果有上一手成功出牌,则解析上一手牌型
  4. 解析本次出牌牌型
  5. 做压制关系判断
  6. 合法则移出手牌,放入弃牌堆
  7. 更新 last_successful_play

8.1 牌型识别

牌型识别入口:

resolve_play_by_cards()

执行方式:

  • 遍历所有 cardsets
  • 逐个执行其 build_flow
  • 第一个返回匹配成功的牌型即视为当前牌型

8.2 同牌型比较

如果当前牌型和上一手牌型相同:

  • 执行该牌型自己的 compare_flow

返回结果解释:

  • A 胜表示当前出牌可压过上一手

8.3 跨牌型比较

如果牌型不同:

  1. 优先查 cardset_comparisons
  2. 若没查到,再看 successors

9. 表达式求值系统

引擎内部通过 eval_value()eval_int()truthy() 实现一套轻量表达式系统。

支持的运行时值类型包括:

  • Int
  • Ints
  • Bool
  • Table
  • Player
  • Card
  • Cards
  • Players
  • Cardset
  • Choice
  • None

9.1 这次修正过的关键点

1. 玩家数字 ID 不再依赖真实 user id

之前规则里一旦出现 player_0player_1、当前玩家判定等逻辑,容易被真实用户 ID 干扰。

现在统一改成基于:

  • GamePlayer.runtime_index

这样规则只关心“第几个座位的玩家”,不关心登录系统里的真实用户 ID。

2. 增加 current_player_override

在集合逻辑和结算流程里,需要临时把“当前玩家”切换成某个遍历对象。

现在通过:

  • RuntimeEvalContext.current_player_override

实现这一点。

它保证:

  • 结算时可以逐个玩家执行同一段 end_flow
  • 玩家集合逻辑里可以针对正在遍历的玩家求值

3. 集合属性访问支持返回数组

新增了:

  • EvalValue::Ints(Vec<i64>)

这样像“从玩家集合映射出某个属性列表”这类场景可以正常工作,不再只返回一个标量。

4. table_0 访问改为真实运行时 table

之前有些逻辑是围绕静态对象模型做的,导致 table_0 访问不稳定。

现在通过 access_table_property() 统一从 session.table 读取。

5. 卡牌对象查找在 deck / hand / discard 中统一处理

这次修正后,卡牌对象访问与更新会在:

  • 牌堆
  • 手牌区
  • 弃牌堆

之间统一查找,不再因为卡牌被移动后就“找不到对象”。

6. 方法流上下文会继承 cardset / current player 信息

方法调用场景现在会继承:

  • cardset_input
  • cardset_id
  • current_player_override

避免方法内部拿不到外层上下文。

10. 为什么之前会出现“规则变了就跑不动”

根本原因不是一个点,而是两类问题叠加:

10.1 旧 demo 规则本身不完整

例如旧 test.json 里多个分支节点的 condition 为空。

在当前后端逻辑里:

  • condition 为空会被当成 false

所以流程会稳定走错分支。

10.2 引擎之前存在隐式特化假设

例如:

  • 玩家对象索引和真实用户 ID 混用
  • 卡牌对象在移动后查不到
  • table_0 属性访问不走真实 session.table
  • 集合属性访问能力不足

这些问题会导致:

  • 某些 demo 看起来勉强能跑
  • 一旦 JSON 结构稍变,规则就断

这次修正的目标就是把这些假设拆掉,尽量让引擎真正按规则 JSON 驱动。

11. 当前限制

目前仍然有一些边界需要明确:

11.1 规则文件缺少元信息头

规则 JSON 当前只描述 design,本身不带:

  • 规则名
  • 玩家人数
  • 描述

这些信息目前是在后端注册 builtin rule 时单独补的。

11.2 发牌仍然是“按属性筛选后从牌堆取牌”

它不是脚本式自由造牌,而是:

  • build_deck() 生成好的牌堆中拿牌

11.3 结算流程是“对每个玩家重复执行同一套 end_flow”

引擎会依赖:

  • settlement_index
  • current_player_id()

逐个玩家结算。

所以 end_flow 更适合写“如何判断当前这个结算玩家输赢”,而不是一次性写全局结果。

12. 后续建议

12.1 前端导出的规则应优先使用英文/ASCII 属性名

例如:

  • point
  • suit
  • winner_index

这样可以减少中文编码不一致造成的问题。

12.2 为规则文件增加 metadata

建议未来规则导出结构扩展为:

{
  "meta": {
    "name": "Rule Name",
    "playerCount": 2,
    "description": "..."
  },
  "design": {
    "...": "..."
  }
}

这样规则文件能独立表达完整信息,而不是必须依赖后端注册代码补全。

12.3 增加后端规则样例测试

建议至少保留:

  • test2.json 这种极简可跑样例
  • 1 个覆盖牌型比较的样例
  • 1 个覆盖结算分支的样例

然后写成自动化测试,避免以后某次改引擎时又回退到“只能配合某个旧 JSON 勉强运行”的状态。