规则引擎构建说明¶
本文说明当前后端规则引擎的整体构建方式、执行链路和这次联调里修正过的关键点,方便继续开发自定义规则与对局功能。
对应实现主文件:
src/domain/rule_engine.rssrc/interface/rule.rssrc/interface/room.rs
1. 总体目标¶
后端规则引擎当前承担 3 件事:
- 解析规则 JSON,编译成运行时结构
- 在房间开局时初始化对局状态
- 在玩家操作时按流程图推进对局、判定出牌和结算
因此“规则引擎”不是单纯的牌型识别器,而是:
- 规则解析器
- 流程执行器
- 对局状态机
2. 主要数据结构¶
2.1 规则定义层¶
用于承接前端导出的 JSON:
ExportedRuleDesignRuleClassRuleCardsetRuleCardsetComparisonFlowNode
这一层基本对应原始 JSON。
2.2 运行时规则层¶
解析后会得到:
RuntimeRuleRuntimeFlowRuntimeNodeRuntimeCardsetRuntimeComparison
作用:
- 统一整理节点跳转
- 固化流程入口
- 避免每次执行时重复解析 JSON 结构
2.3 对局状态层¶
核心结构是 GameSession,包含:
playerstabledeckhandsdiscard_pilepending_actionsettlement_resultslast_successful_playlast_action_cardslast_action_skipped
可以把它理解成“当前对局完整快照”。
3. 规则加载链路¶
3.1 规则入口¶
规则入口在 src/interface/rule.rs。
build_rule_store() 会:
- 调用
build_builtin_rules() - 生成内置规则
- 放入
RuleRepository.published
所以:
- 房间创建接口拿到的规则列表来自这里
- 房间开局使用的规则运行时对象也来自这里
3.2 当前内置规则¶
目前分两类:
- 历史联调规则:
test.json - 极简可跑规则:
test2.json
其中 test2.json 对应:
builtin-test2-rule- 名称
Tiny Demo
4. 解析阶段¶
入口函数:
解析阶段会做几件事:
- 校验规则元数据
- 校验顶层结构
- 编译
match_flow - 编译
end_flow - 编译每个牌型的
build_flow - 编译每个牌型/跨牌型的比较流程
4.1 compile_flow 的作用¶
compile_flow() 会把原始 FlowGraph 转成 RuntimeFlow,核心工作:
- 检查是否存在入口节点
"1" - 检查组件类型是否在支持列表内
- 收集节点跳转关系
- 检查跳转目标是否存在
当前跳转来源有两种:
- 节点对象外层的
next content里的next/next_true/next_false
这使得旧规则和新规则都能兼容。
5. 开局阶段¶
入口:
它会依次完成:
build_players()build_table()build_deck()- 初始化每个玩家手牌区
- 进入
execute_until_blocked()
5.1 build_players¶
这里会从 classes.player 读取默认属性,然后给每个玩家初始化 properties。
关键修正点:
GamePlayer增加了runtime_index
这是这次通用化修复的关键之一。后续规则执行不能依赖真实用户 ID,而应该依赖运行时座位顺序。
5.2 build_table¶
牌桌对象会初始化:
- 规则定义里的 table 属性
- 引擎固定附加属性:
player_indexindexcur_maxsettlement_index
此外会把“当前应操作玩家”初始化为第一个运行时玩家。
5.3 build_deck¶
牌堆来自 classes.card 的属性笛卡尔积。
例如:
point有 2 种取值suit有 1 种取值
最终会生成 2 x 1 = 2 张牌。
这意味着当前规则系统不是手写卡表,而是按 card 属性组合自动生成整副牌。
6. 主执行循环¶
入口:
这是整个对局引擎的核心循环。
循环终止条件:
- 遇到等待玩家动作的节点
- 对局已结束
- 进入结算且所有玩家都已写入结果
- 超过
FLOW_STEP_LIMIT
当前支持两种活动流程:
matchend
6.1 常见节点行为¶
17/23/25/27/29- 只是流程起点,直接跳下一节点
4- 赋值
16- 条件分支
18- 从主流程切到结算流程
19- 洗牌
20- 发牌
21/22- 设置
pending_action,暂停执行,等待客户端提交动作 24- 写入当前结算玩家结果,推进
settlement_index
7. 玩家动作提交链路¶
入口:
分两类:
21:出牌22:做选择
提交后会:
- 校验当前 pending action 是否属于该玩家
- 处理动作
- 清空
pending_action - 从该动作节点的
next继续执行
8. 出牌判定链路¶
出牌动作的处理函数是:
核心流程:
- 取当前玩家手牌
- 根据前端提交的
cardIds取出选中的牌 - 如果有上一手成功出牌,则解析上一手牌型
- 解析本次出牌牌型
- 做压制关系判断
- 合法则移出手牌,放入弃牌堆
- 更新
last_successful_play
8.1 牌型识别¶
牌型识别入口:
执行方式:
- 遍历所有
cardsets - 逐个执行其
build_flow - 第一个返回匹配成功的牌型即视为当前牌型
8.2 同牌型比较¶
如果当前牌型和上一手牌型相同:
- 执行该牌型自己的
compare_flow
返回结果解释:
A胜表示当前出牌可压过上一手
8.3 跨牌型比较¶
如果牌型不同:
- 优先查
cardset_comparisons - 若没查到,再看
successors
9. 表达式求值系统¶
引擎内部通过 eval_value()、eval_int()、truthy() 实现一套轻量表达式系统。
支持的运行时值类型包括:
IntIntsBoolTablePlayerCardCardsPlayersCardsetChoiceNone
9.1 这次修正过的关键点¶
1. 玩家数字 ID 不再依赖真实 user id¶
之前规则里一旦出现 player_0、player_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_inputcardset_idcurrent_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_indexcurrent_player_id()
逐个玩家结算。
所以 end_flow 更适合写“如何判断当前这个结算玩家输赢”,而不是一次性写全局结果。
12. 后续建议¶
12.1 前端导出的规则应优先使用英文/ASCII 属性名¶
例如:
pointsuitwinner_index
这样可以减少中文编码不一致造成的问题。
12.2 为规则文件增加 metadata¶
建议未来规则导出结构扩展为:
{
"meta": {
"name": "Rule Name",
"playerCount": 2,
"description": "..."
},
"design": {
"...": "..."
}
}
这样规则文件能独立表达完整信息,而不是必须依赖后端注册代码补全。
12.3 增加后端规则样例测试¶
建议至少保留:
test2.json这种极简可跑样例- 1 个覆盖牌型比较的样例
- 1 个覆盖结算分支的样例
然后写成自动化测试,避免以后某次改引擎时又回退到“只能配合某个旧 JSON 勉强运行”的状态。