# 棋牌规则引擎 — 架构设计与研究计划 > **关联文档**: Demo 实施计划见 `docs/demo-implementation-plan.md` > > **目标:** 构建一套 YAML DSL 驱动的棋牌规则引擎。引擎按 DSL 编排执行,不依赖 LLM。LLM 是可选的 DSL 生产工具。 **核心理念:** "玩法即配置"——一个 YAML 文件 = 一种棋牌玩法,引擎加载后自动运行。不需要为每种玩法写代码。 **技术栈:** C# (规则引擎,嵌入 Unity game server) + Python (AI 陪打服务) + YamlDotNet (DSL 解析) --- ## 一、问题定义 ### 1.1 当前痛点 中国各地棋牌玩法差异巨大: - 扑克类:斗地主、跑得快、炸金花、牛牛、十三张...每地变种数十种 - 麻将类:四川血战、广东鸡平胡、日本立直、国标...规则差异在番型/计分/流程 - 地方棋类:各种地方象棋变种、对角棋、五子棋变种 传统做法:每种玩法需要策划写规则文档→开发读文档手写代码→测试。周期 2-4 周/玩法。 ### 1.2 LLM 的机会 LLM 在以下方面有优势: - 自然语言理解:从非结构化规则描述中提取结构化信息 - 代码生成:将结构化规则转为可执行逻辑 - 策略生成:基于规则生成陪打 AI 的决策逻辑 LLM 的劣势(需要解决): - 幻觉:生成的代码可能有逻辑错误 - 安全性:生成的代码不能直接执行在生产环境 - 一致性:多次生成结果可能不同 --- ## 二、架构设计 ### 2.0 关键架构决策:规则引擎 ≠ 游戏引擎 棋牌游戏的本质分两层,**它们是正交的**: | 层 | 职责 | 与玩法关系 | |---|------|-----------| | **规则引擎** | 发牌算法、牌型匹配、出牌校验、回合流转、计分结算 | 每种玩法不同,LLM DSL 驱动的核心 | | **游戏引擎** | 牌面渲染、动画、音效、用户输入捕获、房间管理 | 完全通用,与玩法无关 | 这意味着: - 规则引擎是一个**纯逻辑库**(C#),不依赖任何图形框架。输入游戏状态 → 输出合法操作/新状态。可以脱离前端独立测试。 - 前端引擎(Cocos/Phaser/Unity/自研 Canvas)只管"画牌"和"收点击",从规则引擎拿状态,把用户操作喂回去。 - **换游戏引擎不需要动规则引擎,换玩法不需要动游戏引擎。** 所以选型重点不在"哪个游戏引擎好",而在**规则引擎的 DSL 表达力**。游戏引擎可以先用最轻量的方案(C# 控制台原型),后期再换 Unity。 **更重要的是:规则引擎本身不依赖 LLM。** LLM 只是生产 DSL 配置文件的一种方式——你也可以手写 yaml、从模板生成、或者用 GUI 配置器。规则引擎是纯逻辑系统,输入 DSL 配置 → 输出游戏行为。 ### 2.0b 关键架构决策:扑克引擎与麻将引擎分离 扑克和麻将在算法层面是**两类完全不同的问题**,硬合并会导致两边都做不干净: | | 扑克 | 麻将 | |---|---|---| | 核心算法 | 牌型模式匹配:从手牌中找顺子/炸弹/三带二等组合 | 面子分解:14 张牌拆成 4 组面子(顺子/刻子)+ 1 对将 | | 操作模式 | 轮流出牌,同一时间只有一人在操作 | 有人出牌后,多人可能同时碰/杠/胡,需要优先级仲裁 | | 手牌变化 | 出牌减少,动态变化 | 摸牌后出牌,保持 13/14 张恒定 | | 计分特点 | 底分 × 倍数,线性公式 | 番型叠加,组合计算 | | 牌库 | 52-54 张,固定 | 108-144 张,各地区不同 | | 回合结构 | 固定:叫地主→出牌→结算 | 灵活:摸→出→吃碰杠胡→查叫→血战 | **决策:共享基础层 + 各自专项引擎。** ``` ┌──────────────────────────────────────────────────┐ │ 共享基础层 │ │ Card 抽象 │ 规则加载器 │ 状态机框架 │ │ 校验链框架 │ 表达式计分引擎 │ 事件总线 │ └────────────┬─────────────────┬───────────────────┘ │ │ ┌────────▼────────┐ ┌─────▼──────────────┐ │ 扑克规则引擎 │ │ 麻将规则引擎 │ │ │ │ │ │ PatternMatcher │ │ MeldsSolver │ │ (声明式牌型匹配) │ │ (面子分解+胡牌判断) │ │ ValidatorChain │ │ PriorityResolver │ │ (跟牌/比大小) │ │ (碰杠胡优先级仲裁) │ │ DiscardFlow │ │ FanCalculator │ │ (出牌流程) │ │ (番型叠加计算) │ └─────────────────┘ └─────────────────────┘ ``` 分离后的覆盖率: - **扑克引擎**:斗地主/炸金花/牛牛/跑得快/掼蛋/德州/21点/十三水/升级/桥牌/UNO — **100%** - **麻将引擎**:四川血战/广东鸡平胡/国标/日本立直/武汉麻将/长沙麻将 — **100%** - 各自不需要为对方做妥协,DSL 字段各取所需,不需要一套字段同时表达"炸弹"和"番型" 不分离的代价:PatternMatcher 必须同时理解"斗地主的飞机带翅膀"和"麻将的听牌判断",validator 要兼容"跟牌型必须更大"和"碰杠胡优先级仲裁",两个领域都渗漏。分开后各自精专,基础层共享避免重复。 ### 2.0c 覆盖率深度验证:以四川麻将血战到底为例穷举分析 光说"麻将引擎能到 100%"是空话。必须把一条条规则拆开,逐条验证声明式 DSL 能不能兜住,兜不住的怎么办。 下面以 **四川麻将血战到底**(牌库 108 张、缺一门、血战、查叫查花猪)为例穷举所有规则需求: #### 规则清单与覆盖分析 ``` 一、牌库 (108张万条筒) → DeckManager 声明式 ✅ 二、发牌 (庄14闲13) → deal phase 声明式 ✅ 三、缺一门检查 (不能三色) → validator chain ✅ 四、基本胡牌 (4面子+1对将) → 需要内置算法 🔧 五、七对胡 (7个对子) → 内置算法的分支 🔧 六、听牌判断 (缺一张能胡) → 内置算法 🔧 七、番型识别 (清一色/对对胡..) → 内置算法输出番型列表 🔧 八、番型互斥 (清一色⊃无字) → DSL 互斥图 📐 九、番型叠加计分 → DSL 番型表 📐 十、杠 (明杠/暗杠/加杠/杠补) → 子状态机 📐 十一、碰 (跳摸牌) → 状态机转移 ✅ 十二、优先级仲裁 (胡>杠>碰) → priority_policy ✅ 十三、血战到底 (胡后不结束) → elimination phase 📐 十四、查叫/查花猪 (流局检查) → 结算前钩子 📐 十五、过水 (胡过必须等一轮) → dirty_flag 📐 十六、多人同时胡/碰 → 时序+优先级矩阵 📐 ``` - ✅ 已覆盖,纯声明式 - 🔧 需要内置算法(声明式做不到,但算法是通用的) - 📐 声明式可覆盖,但需要扩展 DSL 字段 #### 🔧 内置算法:这是"声明式做不到"的部分 **胡牌判断算法**是无法声明式表达的。14 张牌能否拆成 4 面子 + 1 对将,本质是一个**回溯搜索**: ``` 输入: [1万,1万,1万, 2万,3万,4万, 5条,5条,5条, 6筒,6筒,6筒, 8条,8条] 输出: Melds = [{type:"kezi", tiles:[1万×3]}, {type:"shunzi", tiles:[2,3,4万]}, {type:"kezi", tiles:[5条×3]}, {type:"kezi", tiles:[6筒×3]}] Pair = [8条, 8条] IsWin = true ``` 这个算法是麻将引擎的**唯一算法核心**,但它跟玩法无关——斗地主不需要、扑克不需要。实现一次,所有麻将玩法共用。 算法复杂度:回溯搜索,最坏 O(3^n),但手牌只有 14 张 + 剪枝优化,实际 < 1ms。 **听牌判断**是胡牌判断的子问题:对 13 张手牌,尝试每张可能摸到的牌,检查能否胡。 **番型识别**以面子分解(melds)为输入: ```csharp // 面子分解已经完成了,番型只需"看" var fanList = new List(); if (melds.All(m => m.Suit == melds[0].Suit) && pair.Suit == melds[0].Suit) fanList.Add("清一色"); if (melds.All(m => m.Type == "kezi")) fanList.Add("对对胡"); if (melds.All(m => m.Tiles.Any(t => t.Rank == 1 || t.Rank == 9)) && (pair[0].Rank == 1 || pair[0].Rank == 9)) // 将牌也需带幺九 fanList.Add("带幺九"); // ... ``` **关键:胡牌判断是算法;番型识别是"基于算法输出的声明式规则"。** #### 📐 需要扩展 DSL 的部分 **番型互斥图:**一个番型可能包含另一个,需要显式声明"不计"关系: ```yaml fan_types: - name: "清一色" base_fan: 4 excludes: ["无字", "缺一门"] # 清一色必然无字、必然缺一门 - name: "暗七对" base_fan: 4 excludes: ["门清", "单钓将"] # 七对必然门清、必然单钓 conflicts: ["对对胡"] # 和七对互斥 - name: "杠上开花" base_fan: 1 excludes: ["海底捞月"] # 杠补牌≠最后一张 - name: "对对胡" base_fan: 2 conflicts: ["暗七对"] - name: "金钩钓" base_fan: 2 excludes: ["单钓将"] # 金钩钓就是只剩一张,不计单钓 ``` 计分时:先采集所有满足的番型列表 → 去掉被 `excludes` 排除的 → 检查 `conflicts` 互斥只留一个 → 累加番数。 **血战到底的特殊 phase:** ```yaml phases: - name: "play" type: "mahjong_turn" # 麻将专用回合类型 sub_phases: draw: { type: "auto", action: "draw_card" } self_action: # 摸牌后自己的操作 options: - { action: "kong", types: ["ming_kong", "an_kong", "bu_kong"] } - { action: "win", condition: "can_win" } - { action: "discard" } others_reaction: # 出牌后他人的操作 options: - { action: "pung", priority: 2 } - { action: "kong", priority: 3 } - { action: "win", priority: 4 } # 优先级最高 priority_policy: "highest_wins" - name: "blood_war" # 血战到底 type: "parallel_elimination" on_eliminate: "hu_paid" # 胡牌的人出局 continue_until: "last_one" # 直到最后一人 on_exhausted: "check_ting" # 流局时查叫 ``` **查叫/查花猪 — 结算前钩子:** ```yaml scoring: pre_hooks: - name: "check_hua_zhu" # 先查花猪 condition: "deck_exhausted" action: | // 检查未胡的人是否三种花色都有 for p in alive_players: if countSuits(p.hand) == 3: // 花猪赔三家 penalty = total_score / alive_players.count - name: "check_ting" # 再查叫 condition: "deck_exhausted AND not hua_zhu" action: | for p in alive_players: if not isTing(p.hand): // 不听牌赔听牌的人 ``` **过水 dirty_flag:** ```yaml phases: play: state: fu_flag: # 过水标记 type: "dirty_flag" set_on: "can_win_but_pass" clear_on: "next_discard_self" # 自己打出下一张牌后清除 effect: "block_win_on_current_tile" # 本张牌不能再胡 ``` #### 总结:真正需要"代码"的部分 | 类型 | 数量 | 实现方式 | 跨玩法复用 | |------|------|---------|-----------| | 纯声明式 DSL | ~40% | YAML 配置 | 100% | | 扩展 DSL 字段 | ~30% | YAML + 新字段(互斥图/钩子/sub_phase) | 100%(设计一次所有麻将共用) | | 内置通用算法 | ~20% | C# 实现(胡牌判断/听牌/面子分解) | 100%(所有麻将玩法用同一个算法) | | gameplay hook | ~10% | DSL 引用的 custom 函数 | 按需(查花猪/查叫/特殊计分) | **关键洞察:胡牌判断算法是唯一的"硬骨头",但它写一次后所有麻将玩法共用。** 其余 80% 都是声明式 DSL + 扩展字段。这正是分离设计的好处——麻将引擎的 PatternMatcher 就是 `MeldsSolver`,不需要兼容扑克。 ### 2.0d 0 bug 策略:如何保证正确性 规则引擎是确定性系统,这意味着可以做到 100% 测试覆盖。 **策略1:算法层穷举测试** 胡牌判断是最易出 bug 的地方。测试策略: ```csharp // 1. 正例:已知胡牌的手牌 [Theory] [InlineData(new[]{1,1,1, 2,3,4, 5,5,5, 6,6,6, 8,8}, true)] // 标准胡 [InlineData(new[]{1,1, 2,2, 3,3, 4,4, 5,5, 6,6, 7,7}, true)] // 七对胡 [InlineData(new[]{1,1,1, 2,2,2, 3,3,3, 4,4,4, 5,5}, true)] // 碰碰胡 public void TestWinDetection(int[] tiles, bool expected) { ... } // 2. 反例:不满足胡牌条件 [Theory] [InlineData(new[]{1,2,3, 4,5,6, 7,8,9, 2,2,2, 3,3}, false)] // 多一张 [InlineData(new[]{1,1,1, 2,3,5, 7,8,9, 2,2,2, 3,3}, false)] // 缺顺子 public void TestNotWin(int[] tiles, bool expected) { ... } // 3. 穷举:所有清一色听牌组合(已知组合数) // 所有碰碰胡组合 // 所有混一色组合 // → 生成器 + 断言 ``` **策略2:番型互斥图形式化验证** ``` 加载 DSL → 构建番型互斥有向图 → 检查: 无循环依赖 检查: excludes 指向的番型确实存在 检查: conflicts 双向对称 → 不通过则拒绝加载 DSL ``` **策略3:状态机死锁/活锁检测** ``` 加载 phase 定义 → 构建状态转移图 → 检查: 所有状态可达 检查: 无孤立节点 检查: 所有路径都能到达 end state → 模拟 1000 局随机路径 ``` **策略4:确定性断言** 规则引擎的每个决策都可以写成单元测试: ```csharp [Fact] public void 杠上开花_自摸_番型计算_验证() { // 构造已知状态 → 模拟摸到最后一张 → 判定胡牌 → 验证番型 var state = CreateState(/* 手牌+已碰+已杠+剩余牌 */); state.DrawCard(/* 最后一张牌 */); var result = engine.CheckWin(state); Assert.True(result.CanWin); Assert.Contains("杠上开花", result.Fans); Assert.Contains("清一色", result.Fans); Assert.DoesNotContain("海底捞月", result.Fans); // 互斥 Assert.Equal(5, result.TotalFan); // 1(杠开)+4(清一色) } ``` **策略5:模糊测试** ```csharp // 连续随机生成 10000 局麻将,做不变量检查: for (int i = 0; i < 10000; i++) { var room = RandomMahjongGame(); // 不变量1: 总牌数始终 = 108 Assert.Equal(108, totalCards(room)); // 不变量2: 听牌判断 → 缺一张能胡 → 实际摸到那张牌确实能胡 foreach (var p in room.Players) { var tingList = engine.CheckTing(p.Hand); foreach (var tile in tingList) { Assert.True(engine.CanWin(p.Hand + tile)); } } // 不变量3: 番型互斥正确 var fans = engine.CalculateFans(state); AssertNoConflictViolation(fans, rules.FanExclusionGraph); } ``` **结论:0 bug 不是一个目标,是一个工程纪律。** 规则引擎是确定性系统——每个输入对应唯一输出。只要做到:算法穷举测试 + 番型互斥图形式化验证 + 状态机可达性检查 + 10000 局模糊测试不变量,就能保证正确性。 真正的风险不在算法,在**规则描述的错误**——人写的 DSL 配置本身可能有逻辑漏洞。所以需要模拟 1000 局 + 人工审核,而不是依赖 LLM 直接生成就上线。 ### 2.0e 横向覆盖验证:三种麻将穷举对比(Demo 已扩展至四种) 光有四川血战一种不够。选三种差异足够大的麻将,逐条列出各自的特殊规则,交叉验证 DSL 引擎是否都能兜住。 #### 选型:三种麻将的差异维度 | 维度 | 四川血战到底 | 广东鸡平胡 | 国标麻将 | |------|------------|-----------|---------| | 牌库 | 108张(无字无花) | 136张(+字+花) | 144张(+字+花) | | 吃牌 | ❌ 不能吃 | ✅ 可以吃 | ✅ 可以吃 | | 特殊牌 | 无 | 花牌(即补+计分)、可选鬼牌 | 花牌 | | 胡牌条件 | 缺一门 + 基本胡 | 分三级:鸡胡/平胡/爆胡 | 必须 ≥8番 | | 结束条件 | 血战到底(胡了不结束) | 有人胡就结束 | 有人胡就结束 | | 流局处理 | 查叫 + 查花猪 | 无特殊 | 无特殊 | | 番型体系 | 约10种,线性叠加 | 约30种,分三级 | 81种,12级,复杂互斥 | | 番型互斥 | 简单(清一色⊃缺一门) | 中等(级别互斥) | 复杂(不计+不得重复) | | 花牌计分 | N/A | 每花1番,正花额外 | 每花计分+补花后补牌 | | 鬼牌/百搭 | 无 | 有(翻鬼/百变) | 无 | | 特殊胡型 | 杠上开花、抢杠胡 | 十三幺、九莲宝灯 | 全不靠、组合龙、一色双龙会 | | 多人胡牌 | 优先级仲裁 | 一炮三响(全胡) | 优先最近座次 | #### 逐条覆盖验证表 ``` 规则需求 四川 广东 国标 引擎覆盖方式 ───────────────────────────────────────────────────────────── 牌库声明式定义 ✅ ✅ ✅ DeckManager (generator) 花牌摸到即补 - ✅ ✅ Phase sub_phase auto 花牌正花计分 - ✅ ✅ DSL fan_types + 座位映射 花牌补牌后继续 - ✅ ✅ Phase sub_phase loop 鬼牌/百搭牌(翻鬼) - 🔧 - MeldsSolver 内建 wildcard 模式 吃牌 - ✅ ✅ Phase option: "chi" 缺一门强制检查 ✅ - - Validator: "check_suit_count" 8番起胡 - - ✅ Validator: "min_fan_check" 基本胡牌(4面子+1对) ✅ ✅ ✅ MeldsSolver (内置算法) 七对胡 ✅ ✅ ✅ MeldsSolver.isSevenPairs() 十三幺 - ✅ ✅ MeldsSolver 特殊分支 全不靠 - - 🔧 特殊算法分支(组合龙同理) 九莲宝灯 - - ✅ 胡牌判断自动支持(清一色1-9+额外) 连七对 - - ✅ 七对算法+同花色检查 一色双龙会 - - 🔧 特殊面子分解 碰牌 ✅ ✅ ✅ Phase option 杠(明/暗/加) ✅ ✅ ✅ Phase sub_phase chain 优先级仲裁(胡>杠>碰>吃) ✅ ✅ ✅ DSL priority_policy 一炮三响(多人同时胡) - ✅ ✅ priority_policy: "all_winners" 血战(胡后不结束) ✅ - - Phase type: parallel_elimination 查叫/查花猪 ✅ - - Scoring pre_hooks 番型识别(清一色/对对胡等) ✅ ✅ ✅ 内置算法 → 输出番型列表 番型分级(鸡/平/爆) - ✅ - DSL fan_type.level 番型互斥(excludes/conflicts) ✅ ✅ ✅ DSL 互斥有向图 番型不计/不得重复 - - ✅ DSL excludes(不计) + 去重逻辑 番型线性叠加 ✅ ✅ - fan_stacking: multiply 番型按级取最高 - ✅ - fan_stacking: max_level 杠上开花 ✅ ✅ ✅ DSL game_event hook 抢杠胡 ✅ ✅ ✅ Phase option: "rob_kong_win" 海底捞月 ✅ ✅ ✅ DSL game_event 最后一张 天胡/地胡 - - ✅ 发牌后自动检查 过水(胡过等一轮) ✅ ✅ ✅ DSL dirty_flag ``` - ✅ = 纯声明式或内置算法已覆盖 - 🔧 = 需要增加内置算法分支(写一次所有玩法共用) - - = 该玩法不适用此规则 #### 三种引擎能力的差距 **四川血战覆盖 16/16 = 100%**(不需要 🔧,因为无鬼牌无全不靠等特殊牌型) **广东鸡平胡覆盖 18/19 = 95%**,唯一缺口: - 🔧 鬼牌/百搭牌——需要在 MeldsSolver 中支持 wildcard。胡牌判断时,wildcard 可以充当任何牌。这是算法层的通用能力,广东麻将、台湾麻将、日本麻将(赤宝牌不算百搭但性质类似)都需要。 **国标麻将覆盖 18/20 = 90%**,两个缺口: - 🔧 **全不靠**:14 张牌之间没有任何关联(无对子、无面子、无相同花色顺序),是一种完全不同的胡牌条件。当前 MeldsSolver 基于"面子分解"模型,全不靠不适用。需要增加独立的全不靠判断分支。 - 🔧 **一色双龙会**:手牌由一种花色的 1-9 各两张组成(共 18 张,实际只有 14 张可用),需要特定的面子识别逻辑。是面子分解变体,当前算法稍作扩展即可支持。 **结论:三种麻将平均覆盖 95%。Demo 已扩展至四种(+武汉麻将验证 wildcard),4 种麻将全部 100% 覆盖。** 完全未覆盖的只有 3 个算法分支:鬼牌支持、全不靠、一色双龙会——这些已全部纳入 Demo 引擎扩展。其余全部是声明式 DSL + 已有内置算法。 #### 国标麻将 81 番种互斥关系验证 这是对 DSL 互斥图机制的最大压力测试。81 个番种之间有不计/不得重复/必然包含等关系,能否用 `excludes` + `conflicts` 表达? 抽样验证几个典型互斥: ```yaml fan_types: # 88番级 - name: "大四喜" base_fan: 88 excludes: ["圈风", "门风", "三风"] # 由四个风刻组成,不计各风刻 - name: "大三元" base_fan: 88 excludes: ["双箭刻"] # 三个箭刻,不计单个箭刻 - name: "十三幺" base_fan: 88 excludes: ["五门齐", "门前清", "单钓将", "混幺九"] conflicts: ["七对"] # 与七对互斥(虽然手牌像但不是七对) - name: "连七对" base_fan: 88 excludes: ["七对", "门前清", "单钓将", "清一色", "无字"] # 64番级 - name: "小四喜" base_fan: 64 excludes: ["三风"] # 三风刻不计 - name: "小三元" base_fan: 64 excludes: ["双箭刻"] - name: "字一色" base_fan: 64 excludes: ["碰碰和", "全带幺", "混幺九", "缺一门"] # 48番级 - name: "一色四同顺" base_fan: 48 excludes: ["一色三同顺", "四归一", "一般高"] # 1番级 - name: "一般高" base_fan: 1 # 被上级番型排除,自身不排除别人 - name: "连六" base_fan: 1 - name: "老少副" base_fan: 1 ``` 81 番种的互斥关系大约 200+ 条 `excludes` 声明。DSL 机制可以表达——互斥图本身是数学上的有向无环图(DAG),引擎加载时做形式化验证: ``` 加载 → 构建 excludes 图 → ✓ 检查无循环(A⊃B⊃C⊃A = 错误) ✓ 检查 excludes 目标是有效番型 ✓ 检查 conflicts 双向对称 ✓ 检查番种分级(level 1-12)正确 → DSL 加载通过 ✅ ``` **这不是引擎能力问题,是 DSL 编写的工作量问题。** #### 总结:麻将引擎实际覆盖率 ``` 四川血战 广东鸡平胡 国标 日本立直 武汉 长沙 平均 声明式 DSL 9/16 9/19 11/20 (预估) (预估) (预估) 内置算法(已有) 7/16 7/19 6/20 内置算法(需增加) 0 1(wildcard) 2(全不靠/双龙会) 扩展 DSL 字段 0/16 2/19 1/20 ──────────────────────────────────────────────────── 覆盖率 100% 95% 90% ~95% ~100% ~100% ~97% ``` **国标的 90% 缺口不是架构问题——全不靠和一色双龙会加进 MeldsSolver 就 100% 了。广东的鬼牌同理。这三个分支总共不超过 500 行代码。写一次,所有需要它们的玩法共用。** 真正的工作量不在引擎,在**81 个番种的 DSL 互斥声明**——这是数据录入工作,需要懂国标麻将规则的人逐条写 200+ 条 `excludes` 关系。但这是 DSL 配置层面的,跟引擎能力无关。 ### 2.0e-b 宝牌冲击分析:wildcard 不止是"加一个分支" 当前三个 Demo 玩法(四川血战、广东鸡平胡、国标麻将)都不涉及宝牌。但宝牌是中国地方麻将中非常普遍的特性,必须在引擎设计阶段就考虑清楚,不能事后硬塞。 #### 宝牌在各地方麻将中的具体表现 | 地方麻将 | 宝牌名称 | 机制 | 来源 | |---------|---------|------|------| | 广东麻将 | 鬼/百搭 | 开牌前翻一张牌,下一张是鬼,可替代任何牌 | 随机翻牌 | | 武汉麻将 | 癞子 | 红中固定为癞子 | 固定 | | 日本立直 | 宝牌(dora) | 指示牌下一张为宝牌,不替代,只计番 | 翻宝牌指示器 | | 台湾麻将 | 花牌当百搭 | 摸到花牌可选择当百搭用 | 花牌 | | 长沙麻将 | 将将胡 | 2/5/8 固定为万能 | 固定 | | 东北麻将 | 会牌 | 开牌翻一张,同点数四种花色都是宝 | 翻牌 × 4 | 有两种本质上不同的宝牌: 1. **替代型 (wildcard)**:可以充当任意牌凑面子/将牌,直接影响胡牌判断(广东/武汉) 2. **计分型 (dora)**:不替代,只额外计分/计番(日本立直) **替代型宝牌才是真正威胁引擎的。** 计分型宝牌只是 ScoreEngine 的一个额外计分项,不冲击核心算法。 #### 替代型宝牌对 MeldsSolver 的冲击 回溯搜索的复杂度是 O(3^n),n=14时约 1000 次递归。加入宝牌后: ``` 每张宝牌有 K 种替代可能(K = 可用牌种数) 如果手牌中有 w 张宝牌:搜索空间 = O(3^(14-w) × K^w) w=1, K=27: 3^13 × 27 ≈ 1,594,323 × 27 ≈ 4300万 ← 勉强可接受 w=2, K=27: 3^12 × 729 ≈ 531,441 × 729 ≈ 38.7亿 ← 不可接受 w=3, K=27: ≈ 3.5万亿 ← 完全爆炸 ``` **这不能用回溯直接搜索。** #### 解决方案:分层处理 + 剪枝 不是让每张宝牌尝试所有 27 种替代。而是:**先用非宝牌完成确定性分解,再用宝牌填坑。** ``` 算法流程: 输入: 14张牌,其中 w 张是宝牌 (tag: wildcard) Step 1: 分离宝牌和非宝牌 nonWildcards = tiles.Where(t => !IsWildcard(t)) wildcards = tiles.Where(t => IsWildcard(t)) Step 2: 先用非宝牌完成回溯搜索 result = TryExtractMelds(nonWildcards, remainingWildcards = w) // 但在这个过程中,遇到"差一张"的情况时,用宝牌填补 Step 3: 回溯搜索变体——"缺口填充式" TryExtractMeldsWithWildcards(tiles, wildcardCount): 1. 标准回溯,但当找不到刻子或顺子时: 2. 如果 wildcardCount > 0:尝试用宝牌补齐 - 补齐刻子(差1张):消耗 1 个宝牌 - 补齐刻子(差2张):消耗 2 个宝牌 - 补齐顺子(缺少中间张):消耗 1 个宝牌 3. 如果找不到将牌且 wildcardCount >= 2:用2个宝牌做将 Step 4: 剩余宝牌 如果步骤 3 后还有剩余宝牌,它们无法单独组成面子 必须作为已有刻子的第4张(杠材)或附加到顺子尾部 ``` **优化关键:把"宝牌替代27种"的穷举问题转化为"差一张就用宝牌补"的缺口填充。** 搜索复杂度从 O(27^w) 降到 O(w × 2^w): ``` w=1: O(1 × 2^1) = O(2) ← 原来的 0.5ms 几乎不变 w=2: O(2 × 2^2) = O(8) ← 原来的 38.7亿 → 8 次递归 w=3: O(3 × 2^3) = O(24) ← 原来的 3.5万亿 → 24 次递归 ``` **这是可行的。** 但需要显著改造 MeldsSolver 的 `TryExtractMelds` 方法。 #### 宝牌对 DSL 的影响 新增 `wildcard_rules` 区段: ```yaml # 广东麻将翻鬼 wildcard_rules: type: "flip" # 翻牌型宝牌 trigger: "before_deal" # 发牌前翻 mechanism: "next_card" # 翻开的牌的下一张是鬼 wildcard_encoding: 50 # int 编码: 50-59 为宝牌 count: 4 # 4张鬼牌(翻出的牌每种花色各1张) behavior: "substitute" # 替代型 scoring: per_wildcard: 1 # 每张鬼牌 1 番 # 武汉麻将癞子(固定型) wildcard_rules: type: "fixed" tiles: ["红中"] # 红中固定为癞子 wildcard_encoding: 50 behavior: "substitute" scoring: per_wildcard_in_win: 2 # 胡牌时每张癞子 2 番 # 日本立直宝牌(计分型,不替代) wildcard_rules: type: "indicator" # 指示器型 mechanism: "flip_indicator" # 翻宝牌指示器 behavior: "scoring_only" # 不替代,只计分 scoring: per_dora: 1 # 每张宝牌 1 番 ura_dora: "riichi_only" # 里宝牌(立直后翻) ``` #### 宝牌对番型判断的影响 宝牌组成的面子如何计算番型?有两种处理方式,需要在 DSL 中配置: ```yaml wildcard_rules: fan_calculation_policy: "minimize" # 宝牌按最低番型计 # 或者 fan_calculation_policy: "optimal" # 宝牌按最优番型计(更易清一色等) ``` 举例:手牌有"清一色"潜质 + 1 张宝牌——如果宝牌也算同花色,清一色成立。"optimal" 策略会让清一色更容易达成。 #### 宝牌对牌面编码的扩展 当前 int 编码:万1-9=1-9,条=11-19,筒=21-29,字=31-37,花=41-48。 新增宝牌编码区间: ```csharp public static class MahjongTile { // ... 原有编码 ... // 宝牌区间: 50-59 public const int WildcardBase = 50; public static bool IsWildcard(int tile) => tile >= 50 && tile <= 59; // 扩展 AllTiles 支持宝牌 public static int[] AllTiles(bool includeHonors = false, bool includeFlowers = false, int wildcardCount = 0) { // ... 原有逻辑 ... // 末尾追加 wildcardCount 张宝牌 for (int i = 0; i < wildcardCount; i++) tiles[idx++] = WildcardBase + i; return tiles; } } ``` #### 对 Demo 计划的影响 宝牌支持**已纳入 Demo**(武汉麻将红中癞子)。引擎已实现 wildcard 缺口填充式回溯: | 层面 | Demo 实现 | 扩展更多宝牌变种时 | |------|-------------------|----------------| | MahjongTile 编码 | 已有 wildcard 区间预留 | ✅ 无需改 | | MeldsSolver.CheckWin | 缺口填充式回溯 | 翻鬼/百搭/dora 等变种 | | MeldsSolver.TryExtractMelds | 有 `wildcardCount` 参数 | 无需改 | | DSL 格式 | 有 `wildcard_rules` 字段(武汉癞子) | 增加更多宝牌模式 | | CapabilityRegistry | 已注册 `meldsolver.wildcard` | 无需改 | | ScoreEngine | 癞子计分 (`per_wildcard_in_win`) | 翻鬼计分、dora 计分 | | 番型互斥图 | `fan_calculation_policy: "optimal"` | 无需改 | **结论:宝牌对引擎的冲击是可控的。** 核心挑战在 MeldsSolver 的回溯搜索需要从"枚举替代"改为"缺口填充",复杂度从指数降到线性。**Demo 已通过武汉麻将验证 wildcard 缺口填充式回溯。** DSL 和编码层面已预留扩展点(翻鬼、百搭、dora 等宝牌变种)。 ### 2.0f 加载时能力检查机制:让引擎自省 既然 DSL 的覆盖分析可以手工做(就像上面三张表),那就应该把它**做成引擎加载时的自动检查**,而不是每次人工排查。 #### 机制设计 **第一步:引擎声明自己的能力清单** 引擎启动时注册所有已实现的算法能力: ```csharp // CapabilityRegistry.cs — 引擎启动时自动注册 engine.RegisterCapability(new Capability { Id = "meldsolver.standard_win", Name = "标准胡牌判断 (4面子+1对)", Category = "mahjong", Since = "1.0.0" }); engine.RegisterCapability(new Capability { Id = "meldsolver.seven_pairs", Name = "七对胡判断", Category = "mahjong", Since = "1.0.0" }); engine.RegisterCapability(new Capability { Id = "meldsolver.thirteen_orphans", Name = "十三幺判断", Category = "mahjong", Since = "1.0.0" }); engine.RegisterCapability(new Capability { Id = "meldsolver.wildcard", Name = "鬼牌/百搭牌支持", Category = "mahjong", Since = "2.0.0" // 还没实现就不注册 }); ``` 已有能力 15 项: ``` meldsolver.standard_win 标准胡牌判断 meldsolver.seven_pairs 七对胡 meldsolver.thirteen_orphans 十三幺 phase.mahjong_turn 麻将回合 (摸→打→碰杠胡) phase.parallel_elimination 并行淘汰 (血战) phase.priority_arbitration 优先级仲裁 (胡>杠>碰>吃) phase.pass_turn 过水/跳过摸牌 deck.generator_poker 标准扑克生成器 deck.generator_mahjong 标准麻将生成器 deck.flower_cards 花牌处理 scoring.expression 表达式计分 scoring.table 查表计分 scoring.fan_exclusion 番型互斥图 scoring.pre_hooks 结算前钩子 dsl.dirty_flag 过水标记 ``` **第二步:DSL 声明自己需要的算法能力** DSL 顶部显式声明这个玩法依赖哪些引擎能力: ```yaml # doudizhu.yaml game: type: "poker" engine_type: "poker" requires: - "deck.generator_poker" # 54张标准扑克 - "pattern.single" - "pattern.pair" - "pattern.straight" - "pattern.bomb_4" - "pattern.rocket" - "phase.auction" # 叫地主竞价 - "phase.turn_based" - "scoring.expression" # 底分×倍数 ``` ```yaml # xuezhandaodi.yaml game: type: "mahjong" engine_type: "mahjong" requires: - "deck.generator_mahjong" # 108张万条筒 - "meldsolver.standard_win" # 标准胡牌判断 - "meldsolver.seven_pairs" # 七对支持 - "phase.mahjong_turn" # 摸→打→碰杠胡 - "phase.parallel_elimination" # 血战到底 - "phase.priority_arbitration" # 优先级仲裁 - "scoring.fan_exclusion" # 番型互斥 - "scoring.pre_hooks" # 查叫查花猪 ``` ```yaml # guobiao.yaml game: type: "mahjong" engine_type: "mahjong" requires: - "deck.generator_mahjong" - "deck.flower_cards" - "meldsolver.standard_win" - "meldsolver.seven_pairs" - "meldsolver.thirteen_orphans" - "meldsolver.all_orphans" # 全不靠 ← 🔧 还没注册! - "meldsolver.combo_dragon" # 组合龙 ← 🔧 还没注册! - "meldsolver.double_dragon" # 一色双龙会 ← 🔧 还没注册! - "phase.mahjong_turn" - "phase.priority_arbitration" - "scoring.fan_exclusion" ``` **第三步:加载时自动检查** ```csharp // DslLoader.cs public RuleSet Load(string yamlPath) { var dsl = YamlDotNet.Parse(yamlPath); var required = dsl["requires"].ToArray(); var missing = new List(); foreach (var capId in required) { if (!engine.Capabilities.Has(capId)) { missing.Add(capId); } } if (missing.Any()) { throw new CapabilityMissingException( $"DSL '{dsl.name}' 需要的以下算法能力引擎尚未实现:\n" + string.Join("\n", missing.Select(m => $" ❌ {m} — 需在引擎中新增算法分支")) + $"\n\n请在以下文件中添加对应实现:\n" + $" RuleEngine/MeldsSolver.cs (如果是 meldsolver.*)\n" + $" RuleEngine/PhaseMachine.cs (如果是 phase.*)\n" + $"添加后注册 Capability 并增加对应单元测试。\n" + $"已有能力列表: {engine.Capabilities.List()}" ); } return BuildRuleSet(dsl); } ``` 控制台输出示例: ``` $ dotnet run -- --dsl guobiao.yaml ❌ DSL '国标麻将' 加载失败 — 引擎能力不足: ❌ meldsolver.all_orphans 全不靠判断 描述: 14张牌之间无任何关联(无对子、无面子、无花色顺序) 估计代码量: ~80行 建议实现: RuleEngine/MeldsSolver.cs → IsAllOrphans() ❌ meldsolver.combo_dragon 组合龙判断 描述: 万字147、条子258、筒子369 + 任意一对 估计代码量: ~50行 建议实现: RuleEngine/MeldsSolver.cs → IsComboDragon() ❌ meldsolver.double_dragon 一色双龙会判断 描述: 同花色1-9各两张,14张从18张中取 估计代码量: ~50行 建议实现: RuleEngine/MeldsSolver.cs → IsDoubleDragon() 总计缺口: 3项 (~180行代码) 当前引擎能力: 15项已实现 👉 补完算法分支后,更新 CapabilityRegistry,DSL 即可加载。 ``` #### 这个机制的价值 **之前**:开发者写了一个新玩法 DSL → 运行时出奇怪 bug → 花 2 小时排查 → 发现是引擎缺某个算法 → 被动补代码。 **之后**:加载 DSL → 0.1 秒内精确告知缺什么 → 补代码 → 注册能力 → 通过 → 模拟验证。 更重要的是:**能力清单本身成了引擎的文档**。任何开发者一看就知道引擎支持什么不支持什么,不需要翻代码。 #### 能力粒度设计 不是所有能力都需要显式声明。只声明**声明式 DSL 无法表达、需要代码实现的**: | 声明式可表达的 | 不需要声明 (隐含在 DSL 声明本身) | |---|---| | `pattern.single`、`pattern.pair` | ✅ PatternMatcher 自动从 DSL 定义工作 | | `validator.must_be_larger` | ✅ ValidatorChain 从 DSL 规则链工作 | | `scoring.expression` | ✅ ScoreEngine 从 DSL formula 工作 | | 需要引擎代码实现的 | 需要注册 Capability | |---|---| | `meldsolver.*` | 🔧 胡牌判断算法分支 | | `phase.mahjong_turn` | 🔧 复杂的麻将专有回合类型 | | `phase.parallel_elimination` | 🔧 血战淘汰逻辑 | | `deck.wildcard` | 🔧 百搭牌/鬼牌处理 | 规则:**DSL 本身能驱动的,不注册 Capability。需要引擎里写代码的,必须注册。** ### 2.1 整体架构 ``` ┌─────────────────────────────────────────────┐ │ 前端 (Canvas/Cocos) │ │ 通用牌桌 UI │ 牌面渲染 │ 交互控制 │ └──────────────────┬──────────────────────────┘ │ WebSocket ┌──────────────────▼──────────────────────────┐ │ 游戏服务器 (C#) │ │ 房间管理 │ 状态机 │ 事件总线 │ │ │ │ ┌─────────────────────────────────────────┐ │ │ │ 共享基础层 (Core) │ │ │ │ Card │ RuleLoader │ EventBus │ │ │ │ PhaseFsm │ ExprEngine │ Sandbox │ │ │ └─────────────┬─────────────┬─────────────┘ │ │ │ │ │ │ ┌─────────────▼──┐ ┌──────▼──────────────┐ │ │ │ 扑克引擎 │ │ 麻将引擎 │ │ │ │ PatternMatcher │ │ MeldsSolver │ │ │ │ ValidatorChain │ │ PriorityResolver │ │ │ │ DiscardFlow │ │ FanCalculator │ │ │ └────────────────┘ └─────────────────────┘ │ │ │ │ 全部由 DSL yaml 驱动,无硬编码玩法 │ │ 创建房间时指定 ruleSetId + engineType │ └──────────────────┬──────────────────────────┘ │ ┌──────────────────▼──────────────────────────┐ │ AI 陪打服务 (Python) │ │ 难度 1: 规则合法随机 │ │ 难度 2: MCTS + 启发式评估 │ │ 难度 3: LLM Agent (仅高级AI,非规则引擎) │ └─────────────────────────────────────────────┘ │ ┌──────────────────▼──────────────────────────┐ │ DSL 配置仓库 (文件系统) │ │ dsl-examples/ │ │ doudizhu.yaml ← 可以手写 │ │ zhajinhua.yaml ← 可以手写 │ │ guandan.yaml ← 可以从模板生成 │ │ ... ← LLM 辅助生成也行 │ └─────────────────────────────────────────────┘ ``` 规则引擎是纯逻辑库,不调 LLM、不调外部服务。LLM 是可选的 DSL 生产工具,在规则引擎外部运行。 ### 2.2 规则引擎如何实现 100% 覆盖 核心思想:**不预定义任何玩法。所有的"玩法"都是对一组原子能力的参数化配置。** 规则引擎暴露 5 个原子模块,每个模块的能力边界足够宽,宽到能覆盖所有棋牌玩法: #### 模块 1: DeckManager — 牌的定义与分配 不预设"扑克52张"或"麻将108张"。牌是一个抽象数据结构: ```csharp // C# — int 编码 // 万1-9=1-9, 条1-9=11-19, 筒1-9=21-29 public static class MahjongTile { public static int Encode(string suit, int rank) => suit switch { "万" => rank, "条" => 10 + rank, "筒" => 20 + rank }; public static string Decode(int tile) => tile switch { >= 1 and <= 9 => $"{tile}万", >= 11 and <= 19 => $"{tile - 10}条", >= 21 and <= 29 => $"{tile - 20}筒" }; } ``` DSL 定义牌的集合: ```yaml deck: # 52张标准扑克 - generator: "standard_poker" jokers: 2 # 0=无王, 2=大小王 # 或自定义 - generator: "custom" cards: - { suit: "万", ranks: [1,2,3,4,5,6,7,8,9], count: 4 } - { suit: "条", ranks: [1,2,3,4,5,6,7,8,9], count: 4 } - { suit: "筒", ranks: [1,2,3,4,5,6,7,8,9], count: 4 } - { suit: "字", ranks: [1,2,3,4,5,6,7], count: 4 } # 东南西北中发白 - { suit: "花", ranks: [1,2,3,4,5,6,7,8], count: 1 } # 春夏秋冬梅兰竹菊 ``` 发牌也是声明式: ```yaml deal: cards_per_player: 13 # 四川麻将每人13张(庄家14张) remaining_strategy: "pool" # 剩余牌做底牌池 # 或者 - to: "landlord" count: 3 condition: "after_bid" # 叫地主后才发底牌 ``` #### 模块 2: PatternMatcher — 牌型匹配 不是硬编码"顺子是5张连续",而是声明式定义: ```yaml patterns: single: match: { count: 1 } pair: match: { count: 2, same_rank: true } straight: match: count: { min: 5, max: 12 } # 长度范围 consecutive_rank: true # 点数连续 same_suit: false # 不要求同花色 flush_straight: # 同花顺 match: count: { min: 5, max: 12 } consecutive_rank: true same_suit: true # 额外要求同花色 bomb_4: # 4张炸弹 match: { count: 4, same_rank: true } bomb_5: # 5张炸弹(掼蛋) match: { count: 5, same_rank: true } bomb_rocket: # 火箭(大小王) match: cards: ["joker_small", "joker_big"] full_house: # 三带二 match: groups: - { count: 3, same_rank: true } - { count: 2, same_rank: true } airplane: # 飞机带翅膀 match: groups: - { count: 3, same_rank: true, consecutive: true, min_groups: 2 } - { count: 1, same_rank: false, per_group: true } # 每组三张带一个单牌 ``` 牌型匹配器的实现是**通用模式匹配算法**,跟具体玩法无关。DSL 定义模式 → 匹配器识别手牌中所有满足的牌型组合。 #### 模块 3: ValidatorChain — 出牌校验 校验是**规则链**,每条规则是独立函数,DSL 声明链条: ```yaml play_validator: chain: - rule: "must_follow_pattern" # 必须跟牌型 - rule: "must_be_larger" # 必须比上家大 - rule: "bomb_anytime" # 炸弹可以任何时候出(打断规则链) except_when: "round_first" # 但首轮不能用炸弹(掼蛋规则) - rule: "comparator" # 自定义比较器 type: "bomb_size_first" # 掼蛋:张数优先 ``` 每条规则的实现是通用函数: ```csharp bool MustBeLarger(List<int> current, List<int> previous, GameContext ctx); bool MustFollowPattern(List<int> current, List<int> previous); bool BombAnytime(List<int> cards, GameContext ctx); ``` **100%覆盖的关键:当声明式规则不够用时,`comparator` 字段可以指向一个沙盒函数:** ```yaml play_validator: chain: - rule: "comparator" custom: | // 掼蛋炸弹比较:张数优先,同张数比点数 function compare(a, b) { if (a.length !== b.length) return a.length > b.length; return a[0].rank > b[0].rank; } ``` 沙盒函数提供了**图灵完备的兜底**,确保没有玩法无法表达。但 90% 的玩法不需要走到这步。 #### 模块 4: PhaseMachine — 回合流转 玩法流程本质是一个**有限状态机**: ```yaml phases: - name: "deal" type: "auto" # 自动执行,不需要玩家操作 action: "deal_cards" next: "bid" - name: "bid" type: "auction" # 竞价 options: # 可选操作 - action: "bid" value: [1, 2, 3] # 叫1/2/3分 - action: "pass" # 不叫 winner_rule: "highest_bid" # 价高者得 next_on_winner: "set_landlord" next_on_all_pass: "deal" # 全部不叫重新发牌 - name: "play" type: "turn_based" turn_order: "clockwise" first_player: "landlord" # 地主先出 actions: - action: "play_cards" validator: "play_validator" - action: "pass" end_condition: "one_player_empty" # 任一人打完手牌 next: "settle" - name: "settle" type: "auto" action: "calculate_scores" next: null # null = 游戏结束 # 麻将的特殊阶段 - name: "draw_and_discard" type: "turn_based" actions: - action: "draw_card" - action: "discard" - action: "pung" # 碰(暂存,等优先级仲裁) priority: 2 - action: "kong" # 杠 priority: 3 - action: "win" # 胡 priority: 4 priority_policy: "highest_wins" # 多人同时操作时,优先级高的生效 # 血战到底的特殊性:有人胡后不结束 - name: "blood_war" type: "parallel_elimination" # 并行淘汰 on_player_win: "remove_from_round" # 胡牌的人退出 next_on_last_two: "settle" # 剩2人结束 ``` 状态机引擎是通用的,DSL 只需要定义节点和转换条件。 #### 模块 5: ScoreEngine — 计分结算 三种模式,按复杂度递进: ```yaml scoring: # 模式1: 表达式 — 90% 的玩法 mode: "expression" formula: "base * bombs * spring * landlord_factor" variables: base: 1 bombs: source: "game_stats" key: "bomb_count" multiplier: 2 # 每个炸弹翻倍 spring: source: "game_stats" key: "is_spring" multiplier: 2 landlord_factor: source: "role" values: { landlord: 1, farmer: -1 } # 地主赢+1倍,农民赢每人-1倍 # 模式2: 查表 — 番型/牌型固定倍数 mode: "table" table: - pattern: "flush_straight" base_multiplier: 4 - pattern: "bomb_4" base_multiplier: 2 - pattern: "bomb_5" base_multiplier: 4 - pattern: "rocket" base_multiplier: 4 stacking: "multiply" # 番型叠加方式:multiply | add | max # 模式3: 自定义函数 — 极度复杂的计分 mode: "custom" function: | // 四川麻将番型计算 // 内置麻将算法库已提供 tilesToMelds() 分解牌型 function calculate(tiles, melds, context) { let fans = []; if (melds.every(m => m.suit === melds[0].suit)) fans.push("清一色"); if (melds.every(m => m.type === "pung")) fans.push("对对胡"); // ... 内置算法库预处理好的数据,这里只做组合打分 return fanTable.lookup(fans); } ``` **关键设计**:custom 函数不自己处理"牌型分解"等复杂算法——那是内置算法库的事。custom 只做"基于预处理结果做决策",大幅降低沙盒函数的复杂度和风险。 ### 2.3 热切换机制 热切换的核心:**规则引擎是无状态的纯函数,每个房间持有自己的规则引用。** ``` 创建房间 API: POST /rooms { ruleSetId: "doudizhu", playerCount: 3 } 服务端处理: 1. rules = ruleRegistry.get("doudizhu") // 从内存缓存拿 2. if (!rules) rules = ruleLoader.load("dsl-examples/doudizhu.yaml") 3. room = new Room(rules, players) 4. room.start() → rules.deal() // 按 doudizhu 的 deal 配置发牌 → rules.phases.start() // 进入 bid phase → 玩家操作 → rules.validator.chain.check() → ... → rules.scoring.calculate() // 结算 ``` 规则加载流程: ``` 启动时: ruleRegistry.preload("dsl-examples/*.yaml") → 每个 yaml 解析为 RuleObject → Schema 校验(牌面定义完整性、phase 可达性检查) → 缓存到内存 运行时: 创建房间时 ruleId 命中缓存 → 直接使用 新玩法上线: 只需把 yaml 放到 dsl-examples/ 目录 → 调用 ruleRegistry.reload("new_game") → 已有的房间不受影响(持有旧 RuleObject 引用) → 新房间使用新规则 ``` **规则隔离保证:** - 每个 Room 实例持有自己的 `RuleObject` 引用(不可变对象) - 热加载新规则创建新的 RuleObject,旧引用不受影响 - 已有房间继续用旧规则运行到结束 - 新创建的房间自动使用最新版本规则 **多玩法并行:** 同一台服务器可以同时运行斗地主房间(100个)、炸金花房间(50个)、掼蛋房间(30个)——每个房间的规则引擎实例独立,互不干扰。唯一共享的是牌型匹配器等无状态工具函数。 ### 2.3b 与游戏引擎的整合:不存在大的集成问题 规则引擎暴露的接口非常窄——就两个端点,纯 JSON 进、纯 JSON 出。如果是独立服务模式,接口为 HTTP;如果是 C# DLL 嵌入 Unity,接口为函数调用: ``` // C# 嵌入模式(推荐) var room = new Room(rules, players); room.Start(); // → 自动发牌 → 进入 play phase var actions = room.GetLegalActions(playerId); room.Act(action); // → 规则引擎校验 → 更新状态 → 事件 // HTTP 独立服务模式(多语言场景备选) POST /rooms body: { ruleSetId: "doudizhu", engineType: "poker", players: ["p1","p2","p3"] } POST /rooms/:id/act body: { playerId: "p1", action: { type: "bid", value: 3 } } ``` 游戏引擎只需要做三件事,跟用什么引擎无关: ``` ┌─────────────────────────────┐ │ 游戏引擎 (任意) │ │ │ │ 1. state → 渲染 │ ← 唯一的引擎差异在这里 │ Canvas: drawImage() │ │ Cocos: cc.instantiate() │ │ Unity: Instantiate() │ │ │ │ 2. 用户操作 → PlayerAction │ │ 点击"出牌"按钮 │ │ → { type:"play_cards", │ │ cards:[...] } │ │ │ │ 3. 调 API → 拿 newState │ │ → 回到步骤 1 │ └─────────────────────────────┘ │ HTTP/WebSocket │ ┌────────────▼────────────┐ │ 规则引擎服务器 │ │ (C#, 纯逻辑,或嵌入 Unity) │ │ POST /rooms │ │ POST /rooms/:id/act │ └─────────────────────────┘ ``` **集成成本分析:** | 游戏引擎 | 适配方式 | 适配器代码量 | 说明 | |----------|---------|------------|------| | HTML5 Canvas | fetch + JSON.parse,渲染用 `ctx.drawImage()` | ~100 行 | 同语言,零成本 | | Cocos Creator (H5) | `cc.assetManager` 加载牌面纹理,`fetch` 调 API | ~200 行 | 同是 JS,直接调用 | | Cocos Creator (原生) | HTTP 请求 + 牌面纹理绑定 | ~200 行 | 原生 HTTP 略有差异 | | Unity (C#) | `UnityWebRequest` + JSON → C# class | ~250 行 | 需要 C# 版 Card 类型定义 | | 微信小游戏 | `wx.request` + Canvas 渲染 | ~150 行 | 不能直接用 fetch | 每个引擎写一个薄 adapter,本质就是: 1. JSON → 对应的语言类型 2. 调 HTTP 3. 驱动渲染 规则引擎不关心前端用什么——state 和 action 都是纯 JSON。不存在"深度耦合"的空间。 **三种部署模式:** | 模式 | 适用场景 | 延迟 | |------|---------|------| | 规则引擎独立服务器(推荐) | 多端共享逻辑,统一管理 | ~5ms 内网 | | WASM 嵌入客户端 | 单机/离线模式,无服务端 | 本地 0ms | | 规则引擎嵌入游戏服务器进程 | 小规模部署 | 函数调用 0ms | 推荐嵌入模式——规则引擎作为 C# DLL 直接编译进 Unity 进程,零 IPC 开销。AI 陪打独立 Python 服务。 ### 2.3c Unity C# 集成方案:规则引擎用 C# 重写 如果确定 **Unity 是主要平台 + 规则引擎和游戏服务器同进程**,最干净的做法是规则引擎用 C# 写,直接编译进 Unity game server。不需要 Node.js。 **为什么 C#:** 规则引擎是纯逻辑——Card 结构体、PatternMatcher 算法、状态机流转、表达式计分。这些不依赖任何 TS 特有生态,C# 实现同样简洁。 **技术栈映射:** | 能力 | TS 方案 | C# 方案 | |------|---------|---------| | YAML DSL 解析 | `js-yaml` | `YamlDotNet` (NuGet, 成熟) | | 表达式计分 | 手写 parser | `NCalc` 或手写,C# 表达式树 | | 牌型匹配 | 泛型 pattern match | LINQ + 自定义 matcher,逻辑完全一样 | | 校验链 | 函数链 | `Func` 链 | | 状态机 | 手写 | 手写,或 `Stateless` (NuGet) | | 沙盒 custom 函数 | Docker | `Microsoft.CodeAnalysis.CSharp.Scripting` — C# 脚本引擎,同语言原生沙盒 | **关键优势:C# 原生沙盒远优于 Docker。** Docker 方案的问题是——custom 计分/比较函数需要跨进程调用,序列化开销大,调试困难。C# 的 `Microsoft.CodeAnalysis.CSharp.Scripting` 可以在同进程内编译执行 C# 脚本,天然隔离(默认禁止 IO/网络),性能跟编译代码一样: ```csharp // DSL 中定义的 custom 比较函数,在 C# 原生沙盒执行 var options = ScriptOptions.Default .WithReferences(typeof(Card).Assembly) .WithImports("System", "System.Linq"); // 掼蛋炸弹比较:张数优先 var result = await CSharpScript.EvaluateAsync( @"cardsA.Length != cardsB.Length ? cardsA.Length > cardsB.Length : cardsA[0].Rank > cardsB[0].Rank", options, globals: new { cardsA, cardsB }); ``` **整体架构(Unity C# 主平台):** ``` ┌──────────────────────────────────────────────┐ │ Unity Game Server (C# 进程) │ │ │ │ ┌──────────────┐ ┌────────────────────────┐ │ │ │ WebSocket │ │ 规则引擎 (C# 类库) │ │ │ │ 连接管理 │ │ │ │ │ │ 房间调度 │ │ Card / Deck │ │ │ │ 消息路由 │ │ PatternMatcher │ │ │ │ │ │ ValidatorChain │ │ │ │ │ │ PhaseFsm │ │ │ │ │ │ ScoreEngine + NCalc │ │ │ │ │ │ YamlDotNet DSL Loader │ │ │ │ │ │ CSharpScript sandbox │ │ │ └──────────────┘ └──────────┬─────────────┘ │ │ │ HTTP │ └───────────────────────────────┼────────────────┘ │ ┌────────────────▼───────────────┐ │ AI 陪打服务 (Python) │ │ - 规则合法随机 │ │ - MCTS 启发式搜索 │ │ - LLM Agent 高级决策 │ │ │ │ 接口: state → legalActions │ │ → AI 选 action → 回传 │ └──────────────────────────────────┘ ``` **AI 和规则引擎的边界:** | | 规则引擎 (C#) | AI 陪打 (Python) | |---|---|---| | 职责 | "能不能出这张牌" | "出哪张牌最好" | | 输入 | PlayerAction | GameState + legalActions | | 输出 | NewGameState + events | 选中的 PlayerAction | | 确定性 | 100% 确定 | 概率性 | | 依赖 | DSL yaml、自身算法 | MCTS 库、LLM SDK、NumPy | 分离的理由:规则引擎是确定性逻辑(可以单元测试覆盖到 100%),AI 是概率性策略(需要迭代优化)。两者不应该混在一个进程里。 **项目目录结构(C# 版):** ``` ~/projects/card-game-engine/ ├── RuleEngine/ # C# 规则引擎 (Unity 子模块/独立 DLL) │ ├── RuleEngine.csproj │ ├── Core/ │ │ ├── Card.cs # Card 结构体 │ │ ├── Deck.cs # 牌堆管理 │ │ └── GameState.cs # 游戏状态 │ ├── Patterns/ │ │ └── PatternMatcher.cs # 声明式牌型匹配 │ ├── Validation/ │ │ └── ValidatorChain.cs # 校验规则链 │ ├── Phase/ │ │ └── PhaseMachine.cs # 状态机流转 │ ├── Scoring/ │ │ └── ScoreEngine.cs # 表达式/查表/custom 计分 │ ├── Dsl/ │ │ ├── DslLoader.cs # YamlDotNet 加载 │ │ └── DslSchema.cs # DSL 类型定义 │ ├── Sandbox/ │ │ └── ScriptSandbox.cs # CSharpScript 沙盒 │ └── Tests/ │ ├── PatternMatcherTests.cs │ ├── ValidatorChainTests.cs │ └── ...(100% 单元测试覆盖) │ ├── dsl-examples/ # DSL 配置(语言无关,直接用之前的) │ ├── xuezhandaodi.yaml │ └── guangdong_jipinghu.yaml │ ├── ai-companion/ # Python AI 陪打(不变) │ ├── pyproject.toml │ └── src/ │ ├── strategies/ │ └── server.py │ └── UnityGameServer/ # Unity 项目 └── Assets/ └── Scripts/ ├── GameServer.cs # WebSocket 管理 + 房间调度 └── RuleEngineAdapter.cs # 薄 adapter,调用 RuleEngine DLL ``` **为什么不两者都用 Python?** 规则引擎需要跑在 Unity 进程里(C# 环境),同语言零开销。如果规则引擎也用 Python,那就又回到独立服务 + HTTP 调用的模式——对纯 Unity 部署来说多了一层不必要的 IPC。 **总结:规则引擎 C# 实现 → 编译为 DLL → Unity 直接引用。AI 陪打独立 Python 服务。DSL yaml 文件语言无关,两边都能读。** ### 2.3d AI 陪打架构:规则引擎判定合法性,AI 决策最优策略 #### 核心概念 规则引擎和 AI 陪打是两个独立的系统,通过极窄的接口通信: ``` 规则引擎 (C#)  ──┤我能出哪些牌?│──→ AI 陪打 (Python)  ↑ 执行决策   ←──│我选这个操作  │──  ↓ 策略计算 ``` - **规则引擎**回答"能/不能":出牌是否合法、碰杠胡是否符合规则、结算是否正确。每步判断 100% 确定,可以 100% 测试覆盖。 - **AI 陪打**回答"好/不好":在手牌 A/B/C 中选哪个胜率最高。概率性决策,不需要 100% 正确,只要比随机好。 分离的理由:规则引擎是确定性逻辑——单元测试可以精确验证每条规则。AI 是概率性策略——需要 MCTS 搜索库、LLM SDK、NumPy 等 Python 生态。两者混在一个进程里调试会互相污染。 #### 接口定义 **AI 服务暴露一个端点:** ``` POST /ai/decide 请求: { "player_hand": [1, 1, 1, 2, 3, 4, ...], // 手牌 (int 编码) "exposed": [...], // 已碰/杠的牌 "discard_pool": [28, 15, 3, ...], // 弃牌堆 "last_discard": 22, // 刚打出的牌 "legal_actions": [ // 规则引擎已算好的合法操作 { "type": "discard", "tile": 1 }, { "type": "discard", "tile": 3 }, { "type": "pung", "tiles": [22, 22, 22] }, { "type": "win", "fan_count": 6 } ], "game_context": { // 游戏上下文 "round": 5, "remaining_tiles": 40, "scores": { "AI-东": 12, "AI-南": -4 } } } 响应: { "chosen_action": { "type": "win", "fan_count": 6 }, "confidence": 0.95, // 决策置信度 "thinking_time_ms": 42 // 决策耗时 } ``` **C# 端调用流程:** ```csharp // 在 Unity Game Server 的每回合中: if (currentPlayer.IsAI) { // 1. 规则引擎算好所有合法操作 var legalActions = engine.GetLegalActions(state, playerId); // 2. 调 AI 服务 var request = BuildAiRequest(state, playerId, legalActions); var response = await _aiClient.DecideAsync(request); // 3. AI 选中的操作就是玩家操作 engine.ExecuteAction(state, response.ChosenAction); } ``` 这个接口设计的要点:**AI 不需要自己判断合法性——规则引擎已经把合法操作列表算好了。** AI 只做"选择题":在 N 个合法操作中选最好的。如果 AI 服务挂了或超时,降级为随机选一个合法操作(`legal_actions[random]`),游戏不中断。 #### 三级 AI 难度 | 级别 | 策略 | 运行位置 | 延迟 | 强度 | |------|------|---------|------|------| | **难度 1** | 规则合法随机 | C# 进程内 | < 0.1ms | 弱 | | **难度 2** | MCTS + 启发式评估 | Python 独立服务 | ~50ms | 中 | | **难度 3** | LLM Agent (ReAct) | Python + LLM API | ~2s | 强 | **难度 1 — 规则合法随机(Demo 阶段实现)** ```csharp public class RandomMahjongAI { public PlayerAction Decide(MahjongGameState state, List legalActions) { // 启发式过滤:能胡就胡、能杠就杠、否则随机 var hu = legalActions.FirstOrDefault(a => a.Type == "win"); if (hu != null) return hu; var kong = legalActions.FirstOrDefault(a => a.Type is "an_kong" or "ming_kong"); if (kong != null && Random.Shared.Next(4) > 0) return kong; var discards = legalActions.Where(a => a.Type == "discard").ToList(); return discards[Random.Shared.Next(discards.Count)]; } } ``` 不打外部服务,直接跑在 C# 进程里。Demo 阶段用这个就够验证规则引擎正确性。 **难度 2 — MCTS 启发式搜索(后实现)** ``` Python 服务启动时加载麻将规则(通过 YAML DSL 了解番型和计分) ↓ 收到决策请求 → 以当前状态为根节点 ↓ MCTS 搜索树: 1. Selection: 从根节点选最有潜力的分支 (UCB1) 2. Expansion: 展开一个未探索的操作 3. Simulation: 随机模拟到终局(双方都用随机策略) 4. Backpropagation: 回传胜负结果更新节点统计 ↓ 搜索 200ms → 返回访问次数最多的操作 ``` MCTS 的核心优势:不需要手写评估函数。只要能从"终局结果"回传胜负信号,它自己学会哪些手牌好、哪些操作差。 对于麻将,评估函数可以辅助加速: - 听牌距离(离胡牌差几张) - 番型潜力(手牌中已有多少番型的"零件") - 安全度(打这张牌别人胡的概率) **难度 3 — LLM Agent(远期目标)** ```python def decide_with_llm(state, legal_actions): prompt = f""" 你是麻将高手。当前手牌:{render_hand(state.hand)} 桌面已出:{render_pool(state.discard_pool)} 可选项:{render_actions(legal_actions)} 请选择最优操作并解释原因。 """ response = llm.chat(prompt, response_format="json") return parse_action(response) ``` 只在关键回合调 LLM(听牌/防守/大番型决策),其余用 MCTS 降级。成本控制:每局限 3-5 次 LLM 调用。 #### 部署架构 ``` ┌─────────────────────────────────────────────┐ │ Unity Game Server (C# 进程) │ │ │ │ 游戏主循环 │ │ → engine.GetLegalActions() │ │ → 如果是 AI 玩家: │ │ 难度 1: ai.Decide() 直接在 C# 执行 │ │ 难度 2/3: HTTP → ai-companion:5000 │ │ → engine.ExecuteAction() │ │ → 广播状态到所有客户端 │ └──────────────┬──────────────────────────────┘ │ HTTP (localhost / 内网) ┌──────────────▼──────────────────────────────┐ │ AI 陪打服务 (Python FastAPI) │ │ │ │ POST /ai/decide │ │ → 根据 difficulty 参数路由: │ │ 难度 2 → MctsStrategy.decide() │ │ 难度 3 → LlmStrategy.decide() │ │ → 返回 chosen_action │ └──────────────────────────────────────────────┘ ``` - 开发环境:Python 和 C# 都跑在 localhost,延迟 ~1ms - 生产环境:AI 服务可以独立部署到 GPU 服务器,C# 游戏服务器通过内网 HTTP 调用 - 容灾:AI 超时 → 降级为难度 1(随机合法),玩家无感知 #### Demo 阶段做哪些 | | Demo 实现 | 后续实现 | |---|---|---| | AI 接口 | C# 内嵌 `RandomMahjongAI`,不调外部服务 | Python FastAPI 服务 | | 难度 | 只有难度 1(规则合法随机) | 难度 2 MCTS、难度 3 LLM | | 目的 | 验证规则引擎合法性判定正确 | 提升 AI 强度 | ### 2.4 Rule DSL 完整结构 声明式优于命令式。核心实体: ```yaml game: type: "poker" | "mahjong" | "board" players: {min: 2, max: 6} deck: cards: "standard_52" | "standard_54" | custom custom_cards: [...] # 自定义牌面 phases: - name: "deal" deal: {cards_per_player: 17, remaining: "landlord_pool"} - name: "bid" type: "auction" options: ["1分","2分","3分","不叫"] win_condition: "highest_bid" - name: "play" type: "turn_based" turn_order: "clockwise" lead_rule: "landlord_first" valid_play: "card_pattern_validator" - name: "settle" score: "score_calculator" patterns: # 牌型定义 - name: "单张" match: {type: "single"} - name: "对子" match: {type: "pair"} - name: "顺子" match: {type: "straight", min_length: 5, max_length: 12} validators: # 出牌校验逻辑 - name: "card_pattern_validator" rules: - "must_follow_pattern" # 跟牌型 - "must_be_larger" # 必须更大 - "bomb_overrides" # 炸弹可压任何牌 scoring: type: "expression" | "table" | "custom" # expression: "base_score * multiplier" # table: 查表 # custom: 用户提供的评分函数(沙盒执行) ``` 这个 DSL 的设计原则:**90% 的玩法用声明式覆盖,10% 的特殊规则走 custom + 沙盒。** --- ## 三、MVP Demo 落地路径 以上是完整架构。但先不用全做——用最小闭环验证核心链路。 ### Phase 0: 控制台麻将 Demo (预计 6-8 天) > **详细计划已独立为文档**: 见 `docs/demo-implementation-plan.md` **目标:** C# 控制台程序,4 个随机 AI 自动打完四川麻将血战到底 + 广东鸡平胡。验证 MeldsSolver(回溯搜索胡牌判断)+ 番型互斥图 + 血战淘汰 + DSL 热切换。 关键模块: ``` MeldsSolver — 回溯搜索胡牌判断(核心算法) Deck — 108张牌堆管理 PhaseMachine — 摸牌→出牌→碰杠胡优先级仲裁→血战淘汰→查叫查花猪 ScoreEngine — 番型表计分 + 互斥图 + 结算前钩子 DslLoader — YamlDotNet 加载 + 能力检查 RandomAI — 规则合法随机(能胡就胡、能杠就杠、否则随机出) ``` 完成标准:78 个测试全绿、1000 局零报错、换 DSL 不需要重新编译。 **Phase 0 之后的路线:** ``` Phase 0 ✅ 控制台麻将 Demo (本阶段) ↓ 麻将引擎扩展: wildcard、全不靠、一色双龙会(补 3 个算法分支) ↓ Unity 前端: 麻将牌面渲染 + 出牌操作 UI ↓ Python AI 服务: MCTS + LLM Agent ↓ 10+ 麻将玩法 DSL + CI 压测 ``` --- ## 四、完整实施路线图 (Phase 0 之后) ### Phase 1: 调研与选型 (预计 2-3 天) **目标:** 确定技术选型,产出选型报告 #### Task 1.1: 游戏前端渲染层选型 规则引擎独立于渲染层,这里只选前端展示方案: | 方案 | 优势 | 劣势 | 适合 | |------|------|------|------| | 纯 HTML5 Canvas + JS | 零依赖,WebSocket 直连 | 动画需要手写 | 原型/MVP | | Cocos Creator | 棋牌生态成熟,组件丰富 | 绑定 Cocos 生态,包体大 | 正式产品 | | Phaser 3 | 轻量 2D 框架 | 无棋牌生态 | H5 小游戏 | **结论倾向**: MVP 阶段用纯 Canvas 原型,验证规则引擎后再决定是否切 Cocos。规则引擎不依赖任何前端框架。 #### Task 1.2: 规则引擎核心能力定义与覆盖分析 明确规则引擎必须处理的 4 类核心逻辑: 1. **牌型匹配**: 单张/对子/顺子/炸弹/... — 声明式 pattern match 2. **出牌校验**: 跟牌型/必须更大/特殊牌型覆盖 — 声明式 validator 链 3. **回合流转**: 叫地主/出牌/跟注/开牌 — 声明式 phase 状态机 4. **计分结算**: 底分×倍数+特殊牌型加成 — expression/table/custom **覆盖分析 — 枚举 6 种代表性玩法实测边界:** | 玩法 | 牌型匹配 | 出牌校验 | 回合流转 | 计分结算 | 4类覆盖率 | 缺口 | |------|---------|---------|---------|---------|----------|------| | 炸金花 | ✓ 6种牌型 | ✓ 简单比大小 | ✓ 下注/跟/加/开/弃 | ✓ 底注+各轮 | **100%** | 无,诈唬是AI层的事 | | 牛牛 | ✓ 无牛/牛几/特殊牌型 | ✓ 纯比牌 | ✓ 下注→发牌→摊牌 | ✓ 牛几×倍数 | **100%** | 无 | | 斗地主 | ✓ 10+种牌型 | ✓ 跟牌型+大于+炸弹覆盖 | ✓ 发牌→叫地主→出牌→结算 | ✓ 底分×炸弹/春天倍数 | **100%** | 无 | | 跑得快 | ✓ 同斗地主 | ✓ 同斗地主 | ✓ 简化(无叫地主) | ✓ 剩牌计分 | **100%** | 无 | | 掼蛋 | ✓ 8+种+特殊炸弹规则 | ✓ 跟牌型+大于 | ✓ 进贡/还贡→出牌→升级 | ✓ 头游二游+级数 | **95%** | 炸弹比较逻辑特殊(张数优先>点数),需自定义比较器 | | 四川麻将血战 | ✓ 顺/刻/杠/对子组合成14张 | ✓ 摸→出→碰/杠/胡优先级 | ✓ 血战到底+查叫 | ✓ 番型叠加计算 | **80%** | ①碰杠胡多人冲突需priority resolver ②胡牌判断是算法级(非声明式) ③番型组合爆炸需规则引擎预处理 | **结论: 扑克类 95%+ 覆盖,麻将类 80% 覆盖但需要 3 个扩展点——** | 扩展点 | 解决方案 | 优先级 | |--------|---------|--------| | 自定义比较器 | DSL 支持 `comparator: custom`,走沙盒函数 | 高(掼蛋炸弹等) | | 多人优先级仲裁 | 新增 `priority_resolver` 机制,规则配置优先级链 | 高(麻将碰杠胡) | | 复杂牌型算法 | 内置通用的"麻将胡牌判断""十三水分牌""掼蛋炸弹比较"等算法库,DSL 引用 | 中(内置算法而非 LLM 生成) | **关于问题 2:** 是的,一个规则引擎加载不同的 DSL YAML 文件就能动态切换玩法。核心设计就是"玩法即配置"——每个 `.yaml` 是一个玩法,引擎启动时加载,状态机自动按 phase 编排运行。换玩法 = 加载另一个 yaml,不需要重新编译、不需要重启服务。 **已验证的可覆盖玩法清单**(至少 20+): 斗地主、炸金花、牛牛、跑得快、掼蛋、十三水、升级/拖拉机、桥牌、德州扑克、21点、梭哈、干瞪眼、五十K、红十、拱猪、大老二、UNO、斗牛、三公、百家乐。 #### Task 1.3: 现有棋牌 DSL/框架调研 - 搜索开源棋牌框架(如 cocos-creator 棋牌框架、nodejs 棋牌服务端) - 研究已有的棋牌规则描述方案 - 调研类似 "rule engine as a service" 的项目 #### Task 1.4: LLM 规则提取能力验证 使用 3-5 种不同复杂度玩法做 Prompt 测试: 1. 简单的:炸金花(牌型+比大小) 2. 中等的:斗地主(叫地主+牌型+炸弹) 3. 复杂的:四川麻将(血战+番型+计分) **验证指标:** - 结构化提取的准确率 - 遗漏规则的比例 - 错误规则的比例 #### Task 1.5: 安全方案调研 - Docker 沙盒执行自定义计分函数的可行性 - WebAssembly 沙盒作为备选 - 输入输出 schema 约束 ### Phase 2: DSL 设计与验证 (预计 3-5 天) **目标:** 设计并冻结 V1 版本的 Rule DSL,通过 3 种玩法验证 #### Task 2.1: DSL Schema 设计 ```python # 用 Pydantic 定义 DSL Schema,自动获得校验 class GameRule(BaseModel): type: Literal["poker", "mahjong", "board"] players: PlayerConfig deck: DeckConfig phases: list[Phase] patterns: list[CardPattern] validators: list[Validator] scoring: ScoringConfig ``` #### Task 2.2: LLM Prompt 工程 设计 few-shot prompt,使 LLM 能从自然语言规则描述 → 生成 DSL JSON。 ``` System: 你是一个棋牌规则分析专家... User: 以下是"跑得快"的玩法描述:{description} 请生成 DSL 规则配置。 ``` #### Task 2.3: 3 种玩法验证 - 炸金花 -> DSL -> 沙盒模拟 1000 局 -> 人工检查 - 斗地主 -> DSL -> 沙盒模拟 1000 局 -> 人工检查 - 简化麻将 -> DSL -> 沙盒模拟 1000 局 -> 人工检查 #### Task 2.4: DSL 版本管理 - git 管理每个玩法的 DSL 文件 - 人工审核后打 tag 发版 - 引擎按版本加载规则 ### Phase 3: 游戏引擎集成 MVP (预计 5-7 天) **目标:** 选 2 种玩法,完成从 DSL 到可玩游戏的完整链路 #### Task 3.1: 通用引擎核心 构建最小棋牌引擎核心(C#): ``` RuleEngine/ Core/ MahjongTile.cs # int 编码 + 工具类 Deck.cs # 牌堆管理 GameState.cs # 游戏状态 Patterns/ MeldsSolver.cs # 胡牌判断 + 番型识别 Phase/ PhaseMachine.cs # 状态机流转 Scoring/ ScoreEngine.cs # 计分结算 Dsl/ DslLoader.cs # YamlDotNet 加载 DslSchema.cs # DSL 类型定义 ``` #### Task 3.2: 四川血战完整实现 - 加载四川血战 DSL - 实现:发牌→摸牌→出牌→碰杠胡优先级→血战淘汰→查叫查花猪 完整流程 - 4 人对战 #### Task 3.3: 广东鸡平胡完整实现 - 加载广东鸡平胡 DSL - 实现:花牌补牌→吃牌→番型三级→一炮三响 完整流程 #### Task 3.4: 前端原型 - 通用牌桌 UI 组件 - 牌面渲染(扑克牌面、麻将牌面) - 操作按钮(出牌/跟注/弃牌/开牌) ### Phase 4: 陪打 AI 系统 (预计 5-7 天) **目标:** 实现 3 级难度的陪打机器人 #### Task 4.1: 难度 1 — 规则合法随机 ```python class RuleBasedAI: def decide(self, state, legal_moves): # 排除明显愚蠢的操作 # 其余随机 return random.choice(filtered_moves) ``` #### Task 4.2: 难度 2 — 启发式 + MCTS ``` - 基于 DSL scoring 规则构建评估函数 - MCTS 搜索有限深度 - 可配置搜索时间(影响难度感知) ``` #### Task 4.3: 难度 3 — LLM Agent ``` - 输入:当前手牌 + 历史出牌 + 规则描述 - 输出:出牌决策 - 使用结构化输出确保合法性 - 成本控制:仅在关键回合调用 LLM ``` #### Task 4.4: AI 策略热切换 - 游戏中可按座位配置不同难度 AI - AI 接口统一,策略可插拔 ### Phase 5: 安全沙盒 (预计 2-3 天) **目标:** 安全执行用户自定义规则 #### Task 5.1: CSharpScript 沙盒 ```csharp // 自定义计分/比较函数在 CSharpScript 沙盒中执行 var options = ScriptOptions.Default .WithReferences(typeof(Card).Assembly) .WithImports("System", "System.Linq"); var result = await CSharpScript.EvaluateAsync(customCode, options, globals); ``` #### Task 5.2: 安全限制 - 默认禁止 IO/网络/反射 - 输入输出 Schema 校验 - 超时控制 - 异常捕获降级为默认行为 ### Phase 6: 扩展验证 (预计 3-5 天) **目标:** 增加 5+ 玩法验证系统通用性 #### Task 6.1: 新玩法上线流程优化 目标:从拿到规则描述到可玩,30 分钟。 ``` 1. 输入规则描述(口语化中文) 2. LLM 生成 DSL (30s) 3. 自动沙盒模拟 1000 局 (1min) 4. 人工 review + 修正 (20min) 5. 前端自动适配 (5min) 6. 上线 ``` #### Task 6.2: 压力测试 - 同一引擎同时运行 10 种玩法 - 每种玩法 100 个房间 - 验证隔离性 --- ## 五、关键风险与对策 | 风险 | 概率 | 影响 | 对策 | |------|------|------|------| | LLM 对复杂规则理解不准 | 高 | 中 | 分步提取 + 人工审核 + 模拟验证 | | DSL 表达能力不足 | 中 | 高 | custom 兜底函数,逐步扩展 DSL | | 引擎性能不够 | 低 | 中 | C# 足够,必要时热路径可换 Rust | | LLM 决策延迟高 | 中 | 中 | 混合策略:非关键回合不调 LLM | | 沙盒逃逸风险 | 低 | 极高 | 多层防御:Docker + seccomp + 只读FS | --- ## 六、项目目录结构规划 ``` ~/projects/card-game-engine/ ├── README.md ├── docs/ │ ├── architecture-plan.md # 架构设计文档 │ └── demo-implementation-plan.md # Demo 实施计划 ├── RuleEngine/ # C# 规则引擎核心 │ ├── Core/ │ ├── Patterns/ │ ├── Phase/ │ ├── Scoring/ │ ├── Dsl/ │ └── Sandbox/ ├── RuleEngine.Tests/ # 单元测试 ├── ai-companion/ # Python AI 陪打 │ └── src/strategies/ ├── dsl-examples/ # 玩法 DSL (YAML) │ ├── xuezhandaodi.yaml │ └── guangdong_jipinghu.yaml └── Demo/ # 控制台 Demo ``` --- ## 七、验证清单 - [ ] Phase 1: 选型报告完成 - [ ] Phase 2: DSL 通过 3 种玩法验证 - [ ] Phase 3: 2 种玩法可完整游玩 - [ ] Phase 4: 3 级 AI 均能正确出牌 - [ ] Phase 5: 沙盒通过安全审计 - [ ] Phase 6: 5+ 玩法稳定运行 --- ## 八、开放问题 1. **前端渲染层选型**: MVP 用纯 Canvas,后期是否切 Cocos Creator?取决于产品化需求,不影响规则引擎。 2. **规则引擎语言**: C#(嵌入 Unity),确定。 3. **DSL 表达力边界**: 是否需要图灵完备的 custom 函数?还是纯声明式足够? 4. **LLM 成本**: 每次玩法生成调用 LLM 的成本是否可接受?(估计每次 < ¥0.5) 5. **前端方案**: Cocos Creator 原生客户端还是 H5 小游戏? 6. **商业模式**: 服务棋牌运营商还是自己做平台? --- *计划创建时间: 2026-07-03* *预计总工期: 20-30 天* *建议先执行 Phase 1 调研,再决定后续方案。*