# 麻将规则引擎 Demo 实施计划 > **关联文档**: 架构设计见 `docs/architecture-plan.md` > > 目标:C# 控制台程序,4 个随机 AI 自动打完四川血战、广东鸡平胡、国标麻将、武汉麻将。验证 MeldsSolver(含 wildcard 缺口填充/全不靠/一色双龙会)+ 番型互斥图 + DSL 热切换 + 1000 局零报错。 > 预计:6-8 天。先写测试,后写实现。武汉麻将验证宝牌支持。 > --- ## 零、开源项目参考 开发前先了解已有轮子,避免重复造车。以下是搜到的关键项目: ### 0.1 yuanfengyun/q_algorithm ⭐2090 — C# 胡牌算法库 **地址**: https://github.com/yuanfengyun/q_algorithm 棋牌算法库,含麻将、跑胡子、扑克。**有 C# 版本**(`mjlib_c#` 目录),MIT 协议。核心特色: - **查表法做胡牌判断**:预计算所有可能的胡牌组合存表,查询 O(1)。跟我们计划的回溯搜索法是两条路。 - 多语言实现对比:lua/c++/c#/golang/js/java/python 各一套,可以对比理解算法精髓 - 含跑胡子(一种地方牌类),说明算法设计有一定通用性 **我们可以借鉴**: - 胡牌算法对比:查表法 vs 回溯法选最优(查表快但维护表麻烦,回溯代码简单但最坏 O(3^n)) - C# 版代码风格:直接研究 `mjlib_c#` 目录,看他们怎么处理牌面编码、面子分解 - 听牌算法:查表法的听牌判断思路 - 测试数据:已有的胡牌/不胡牌的测试用例 **需要注意**:这个库只做胡牌判断,没有规则引擎、没有 DSL、没有计分。跟我们不是竞品,是底盘——可以嵌入我们的 MeldsSolver。 ### 0.2 esrrhs/majiang_algorithm ⭐478 — Java 麻将算法 + AI **地址**: https://github.com/esrrhs/majiang_algorithm 完整麻将引擎,Java 实现,含胡牌算法和 AI。MIT 协议。关键文件: | 文件 | 内容 | |------|------| | `hu.md` | 详细的胡牌算法设计文档(必读!) | | `ai.md` | AI 算法思路:评估函数 + 搜索树 | | `majiang.db` | 预计算的牌型数据表 | | `majiang_ai_feng.txt` | AI 策略配置样例 | **我们可以借鉴**: - `hu.md` 的算法设计思路——理解各种胡牌判断的坑(七对、十三幺、全不靠) - `ai.md` 的评估函数框架——手牌效率、安全度、进攻/防守系数(跟我们 Phase 3 的 Python AI 服务对接) - 番型表设计:如何组织番型数据、如何做番型叠加 ### 0.3 MahjongKit ⭐53 — Python 牌谱分析工具包 **地址**: https://github.com/erreurt/MahjongKit 麻将工具包,Python 实现。含日志爬虫、数据预处理、确定性算法。用的是日本麻将(MajSoul/天凤)的牌谱数据。 **我们可以借鉴**: - 牌谱数据分析思路——后续做回归测试时,用真实牌谱验证引擎正确性 - 番种计算的分治策略——如何把"81番种互斥"拆成可管理的子问题 ### 0.4 MahjongPantheon/riichi-ts ⭐12 — TypeScript 番种计算 **地址**: https://github.com/MahjongPantheon/riichi-ts 专注番种(役种)计算,TypeScript 实现。算番逻辑独立模块。 **我们可以借鉴**: - 番种 `excludes`/`conflicts` 的实际代码实现——跟我们的 DSL 互斥图设计对应 - TypeScript 代码可读性好,逻辑比 C# 版更容易快速理解 ### 0.5 关键发现:没有现成的 DSL 驱动引擎 以上四个项目各有侧重——胡牌算法、AI、牌谱分析、番种计算。但**没有一个项目做到了"玩法即配置"**。它们都是"一种玩法一种代码"——想支持广东麻将就得手写一个广东麻将模块。所以我们这个 YAML DSL + 能力检查 + 热切换的方向是创新的。 **借鉴清单总结**: | 借鉴方向 | 来源 | 对应我们模块 | 优先级 | |---------|------|------------|-------| | 胡牌算法对比(查表 vs 回溯) | q_algorithm | MeldsSolver | P0 | | AI 评估函数设计 | majiang_algorithm | AI Companion | P1 | | 番型表组织方式 | riichi-ts / majiang_algorithm | DSL fan_types | P1 | | 牌谱验证数据 | MahjongKit | 1000局压测 | P2 | | C# 代码风格参考 | q_algorithm/mjlib_c# | 全引擎 | P0 | --- ## 一、项目骨架 (30 min) ```bash mkdir -p ~/projects/card-game-engine cd ~/projects/card-game-engine dotnet new sln -n CardGameEngine dotnet new classlib -n RuleEngine -o RuleEngine dotnet new xunit -n RuleEngine.Tests -o RuleEngine.Tests dotnet new console -n Demo -o Demo dotnet sln add RuleEngine RuleEngine.Tests Demo cd Demo && dotnet add reference ../RuleEngine cd ../RuleEngine.Tests && dotnet add reference ../RuleEngine cd ../RuleEngine && dotnet add package YamlDotNet cd .. && dotnet build && dotnet test ``` --- ## 二、DSL 文件 — 四川麻将血战到底 (1 小时) 创建 `dsl-examples/xuezhandaodi.yaml`: ```yaml game: name: "四川麻将血战到底" type: "mahjong" engine_type: "mahjong" players: { min: 4, max: 4 } requires: - "deck.generator_mahjong" - "meldsolver.standard_win" - "meldsolver.seven_pairs" - "phase.mahjong_turn" - "phase.parallel_elimination" # 血战到底 - "phase.priority_arbitration" - "scoring.fan_exclusion" - "scoring.pre_hooks" # 查叫查花猪 deck: generator: "mahjong" suits: ["万", "条", "筒"] ranks: [1, 2, 3, 4, 5, 6, 7, 8, 9] copies_per_tile: 4 total: 108 deal: cards_per_player: 13 # 闲家13张 dealer_extra: 1 # 庄家14张(先打一张) # ─── 牌型判断由内置算法处理,DSL 不声明 patterns ─── # ─── 番型定义(只有番型互斥需要 DSL,识别由内置算法做)─── fan_types: - name: "鸡胡" # 素胡,无特殊番型 base_fan: 1 level: 1 - name: "对对胡" base_fan: 2 level: 2 conflicts: ["暗七对"] # 对对胡和七对互斥 - name: "清一色" base_fan: 4 level: 3 excludes: ["缺一门"] # 清一色必然缺一门 - name: "暗七对" base_fan: 4 level: 3 excludes: ["门清", "单钓将"] # 七对必然门清、单钓 conflicts: ["对对胡", "金钩钓"] - name: "杠上开花" base_fan: 1 level: 1 excludes: ["海底捞月"] # 杠补牌≠最后一张 - name: "杠上炮" base_fan: 1 level: 1 excludes: ["杠上开花"] - name: "抢杠胡" base_fan: 1 level: 1 - name: "海底捞月" base_fan: 1 level: 1 excludes: ["杠上开花"] - name: "金钩钓" base_fan: 2 level: 2 excludes: ["单钓将"] conflicts: ["暗七对"] - name: "带幺九" base_fan: 2 level: 2 - name: "将对" base_fan: 2 level: 2 - name: "天胡" base_fan: 6 level: 4 excludes: ["地胡"] - name: "地胡" base_fan: 6 level: 4 excludes: ["天胡"] # 以下是可能被 excludes 的低级番型(不计番,但需要存在以便互斥计算) - name: "缺一门" base_fan: 0 level: 0 - name: "门清" base_fan: 0 level: 0 - name: "单钓将" base_fan: 0 level: 0 # 番型叠加方式 fan_stacking: "add" # 四川麻将:直接加番数 # ─── 回合定义 ─── phases: - name: "deal" type: "auto" action: "deal_cards" next: "play" - name: "play" type: "mahjong_turn" # 麻将专用回合 turn_order: "counter_clockwise" first_player: "dealer" # 一个完整回合的子阶段 sub_phases: draw: # 1. 摸牌 type: "auto" action: "draw_card" on_empty_deck: "exhausted" self_action: # 2. 摸牌后自己的操作 options: - { action: "discard" } # 出牌(必选) - { action: "an_kong" } # 暗杠 - { action: "bu_kong", condition: "has_punged_pair" } # 加杠 - { action: "win", condition: "can_win_tumo" } # 自摸胡 # 如果选择了暗杠/加杠,sub_phase 回到 draw(补一张后继续) others_reaction: # 3. 出牌后他人的操作 trigger: "after_discard" options: - { action: "pung", priority: 2, condition: "has_two_same" } - { action: "ming_kong", priority: 3, condition: "has_three_same" } - { action: "win", priority: 4, condition: "can_win" } - { action: "pass", priority: 0 } # 默认:不操作 priority_policy: "highest_wins" # 胡>杠>碰 on_pung_or_kong: "skip_draw" # 碰/杠后跳过摸牌,直接出牌 on_win: "player_eliminated" # 结束条件(可多选) end_conditions: - type: "deck_exhausted" action: "check_ting_hua_zhu" # 流局 → 查叫查花猪 - name: "blood_war" # 血战到底 type: "parallel_elimination" on_player_win: "remove_from_round" # 胡牌的人退出,不结束 continue_until: "only_one_remaining" # 剩最后一人时结束 on_exhausted: "check_ting_hua_zhu" - name: "settle" type: "auto" action: "calculate_scores" next: null # ─── 结算前钩子 ─── scoring: mode: "fan_table" # 番型表模式 pre_hooks: # 结算前执行的钩子 - name: "check_hua_zhu" # 1. 查花猪 condition: "deck_exhausted OR blood_war_remaining == 2" action: | // 检查未胡玩家是否有三种花色 for each alive player: suits_in_hand = count_unique_suits(player.hand) if suits_in_hand == 3: // 花猪!赔偿所有人 penalty = total_pool / alive_count - name: "check_ting" # 2. 查叫(听牌检查) condition: "deck_exhausted OR blood_war_remaining == 2" action: | for each alive player: if not engine.IsTing(player.hand): // 没听牌,赔听牌的人 for each ting_player: pay_penalty(player, ting_player) # 番型得分计算 fan_calculation: stacking: "add" handle_exclusions: true # 启用互斥图处理 ``` --- ## 二-B、DSL 文件 — 广东麻将鸡平胡 (1 小时) 第二个 Demo 玩法。选广东麻将是因为它和四川血战在以下维度完全互补: | 维度 | 四川血战到底 | 广东鸡平胡 | |------|------------|-----------| | 牌库 | 108张(无字无花) | 136张(+ 28张字牌) | | 花牌 | ❌ 无 | ✅ 8张花牌,摸到即补 | | 吃牌 | ❌ 不能吃 | ✅ 可以吃 | | 胡牌条件 | 缺一门 | 无限制,但有番型分级 | | 结束条件 | 血战淘汰 | 有人胡就结束 | | 番型体系 | ~10种,线性叠加 | ~30种,分三级(鸡/平/爆) | | 番型分级 | 无 | 鸡胡(最低)、平胡(中等)、爆胡(8番+) | | 鬼牌 | 无 | 可选(Demo 阶段先不做) | | 多人胡 | 优先级仲裁 | 一炮三响(全胡) | | 花牌计分 | N/A | 每花1番、正花额外 | | 连庄 | 无 | 有(胡牌者连庄) | **验证点:花牌处理、吃牌、番型三级体系、一炮三响、花牌计分** 创建 `dsl-examples/guangdong_jipinghu.yaml`: ```yaml game: name: "广东麻将鸡平胡" type: "mahjong" engine_type: "mahjong" players: { min: 4, max: 4 } requires: - "deck.generator_mahjong" - "deck.flower_cards" # ← 花牌处理(四川不需要) - "meldsolver.standard_win" - "meldsolver.seven_pairs" - "meldsolver.thirteen_orphans" # ← 十三幺(四川不需要) - "phase.mahjong_turn" - "phase.priority_arbitration" - "scoring.fan_exclusion" deck: generator: "mahjong" suits: ["万", "条", "筒"] ranks: [1, 2, 3, 4, 5, 6, 7, 8, 9] copies_per_tile: 4 honors: # ← 字牌(四川没有) - { name: "东", count: 4 } - { name: "南", count: 4 } - { name: "西", count: 4 } - { name: "北", count: 4 } - { name: "中", count: 4 } - { name: "发", count: 4 } - { name: "白", count: 4 } flowers: # ← 花牌(四川没有) - { name: "春", seat: 1, count: 1 } - { name: "夏", seat: 2, count: 1 } - { name: "秋", seat: 3, count: 1 } - { name: "冬", seat: 4, count: 1 } - { name: "梅", seat: 1, count: 1 } - { name: "兰", seat: 2, count: 1 } - { name: "竹", seat: 3, count: 1 } - { name: "菊", seat: 4, count: 1 } total: 136 # 108 + 28字 = 136 deal: cards_per_player: 13 dealer_extra: 1 # ─── 花牌特殊规则 ─── flower_rules: on_draw: "replace_and_draw" # 摸到花牌→亮出→从牌墙补一张 on_deal: "replace_and_draw" # 发牌时摸到花的处理 scoring: normal: 1 # 每个花牌 1 番 matching: # 正花(座位对应)额外 value: 1 # 额外加 1 番 mapping: # seat → 花牌 1: ["春", "梅"] 2: ["夏", "兰"] 3: ["秋", "竹"] 4: ["冬", "菊"] # ─── 番型三级体系 ─── fan_levels: - name: "鸡胡" # 最低级:一番起胡,只能自摸 min_fan: 1 self_draw_only: true # ← 鸡胡只能自摸,不能吃胡 - name: "平胡" # 中级:可以吃胡 min_fan: 1 self_draw_only: false - name: "爆胡" # 高级:8番以上,可以抢胡 min_fan: 8 self_draw_only: false can_override: true # ← 爆胡优先于平胡/鸡胡 fan_types: # ── 一番 ── - name: "自摸" base_fan: 1 level: 1 condition: "self_draw" # 只有自摸时才有 - name: "无花" base_fan: 1 level: 1 condition: "no_flower_tiles" - name: "正花" base_fan: 1 level: 1 condition: "has_matching_flower" - name: "三元牌" base_fan: 1 level: 1 condition: "has_dragon_pung" # 中/发/白的刻子 - name: "门风" base_fan: 1 level: 1 condition: "has_seat_wind_pung" - name: "圈风" base_fan: 1 level: 1 condition: "has_round_wind_pung" - name: "平胡" base_fan: 1 level: 1 condition: "all_shunzi" # 全顺子无刻子 - name: "花幺" base_fan: 1 level: 1 condition: "has_1_or_9_in_all_melds" # 带幺九 - name: "海底捞月" base_fan: 1 level: 1 condition: "last_tile_win" - name: "抢杠胡" base_fan: 1 level: 1 condition: "rob_kong_win" - name: "杠上开花" base_fan: 1 level: 1 condition: "kong_bloom" # ── 两番 ── - name: "对对胡" base_fan: 2 level: 2 conflicts: ["暗七对"] excludes: ["平胡"] # 对对胡不算平胡 - name: "混一色" base_fan: 2 level: 2 excludes: ["缺一门"] - name: "半求" base_fan: 2 level: 2 # 已碰/杠三副,手中只剩一对 - name: "坎坎胡" base_fan: 2 level: 2 # 全是暗刻/暗杠,自摸 # ── 三番 ── - name: "清一色" base_fan: 3 level: 3 excludes: ["混一色", "缺一门"] - name: "混幺九" base_fan: 3 level: 3 excludes: ["带幺九"] - name: "全求人" base_fan: 3 level: 3 # 已碰/杠四副,手中只剩一张单钓 - name: "小三元" base_fan: 3 level: 3 excludes: ["三元牌"] # ── 爆胡(8番以上)── - name: "大三元" base_fan: 8 level: 8 excludes: ["小三元", "三元牌"] - name: "大四喜" base_fan: 8 level: 8 excludes: ["门风", "圈风"] - name: "十三幺" base_fan: 8 level: 8 excludes: ["五门齐", "门前清", "单钓将", "混幺九"] conflicts: ["暗七对"] - name: "暗七对" base_fan: 4 level: 4 conflicts: ["对对胡", "十三幺"] - name: "九莲宝灯" base_fan: 8 level: 8 excludes: ["清一色", "门前清"] - name: "天胡" base_fan: 8 level: 8 excludes: ["地胡"] - name: "地胡" base_fan: 8 level: 8 excludes: ["天胡"] # ── 不计番的(被 excludes 目标)── - name: "缺一门" base_fan: 0 level: 0 - name: "门清" base_fan: 0 level: 0 - name: "单钓将" base_fan: 0 level: 0 # 番型叠加方式 fan_stacking: "add" # ─── 回合定义 ─── phases: - name: "deal" type: "auto" action: "deal_cards_with_flowers" # ← 发牌时处理花牌 next: "play" - name: "play" type: "mahjong_turn" turn_order: "counter_clockwise" first_player: "dealer" sub_phases: draw: type: "auto" action: "draw_card" on_draw_flower: "replace" # ← 摸到花牌自动补 on_empty_deck: "exhausted" self_action: options: - { action: "discard" } - { action: "an_kong" } - { action: "bu_kong", condition: "has_punged_pair" } - { action: "win", condition: "can_win_tumo" } others_reaction: trigger: "after_discard" options: - { action: "chi", priority: 1, condition: "can_chi" } # ← 可以吃!(四川没有) - { action: "pung", priority: 2, condition: "has_two_same" } - { action: "ming_kong", priority: 3, condition: "has_three_same" } - { action: "win", priority: 4, condition: "can_win" } - { action: "pass", priority: 0 } priority_policy: "highest_wins" on_chi_pung_kong: "skip_draw" on_win: "game_over" # ← 有人胡就结束(不是血战!) # 关键差异:一炮三响 multi_win_policy: "all_winners" # ← 多人同时胡时,全胡(不是优先级仲裁) end_conditions: - type: "player_wins" action: "settle" - type: "deck_exhausted" action: "draw_game" # ← 流局:平局,庄家连庄 - name: "settle" type: "auto" action: "calculate_scores" next: null # ─── 结算 ─── scoring: mode: "fan_table" # 没有查花猪查叫——只有四川有 pre_hooks: [] # ← 广东无流局处理! fan_calculation: stacking: "add" handle_exclusions: true # 番型分级逻辑 fan_level_logic: type: "threshold" # 按阈值分鸡/平/爆 levels: - { name: "鸡胡", min_fan: 1, self_draw_only: true } - { name: "平胡", min_fan: 1, self_draw_only: false } - { name: "爆胡", min_fan: 8, can_override: true } # 花牌计分 flower_scoring: per_flower: 1 matching_bonus: 1 capped: false # 花牌番数无上限 ``` ### 与四川血战的 DSL 差异总结 | DSL 区域 | 四川血战 | 广东鸡平胡 | |---------|---------|-----------| | `deck.honors` | ❌ 无 | ✅ 28张字牌 | | `deck.flowers` | ❌ 无 | ✅ 8张花牌 + 座位映射 | | `flower_rules` | ❌ 无 | ✅ 摸花补牌、正花计分 | | `phases.sub_phases.others_reaction` | 碰/杠/胡 | **吃**/碰/杠/胡 | | `phases.multi_win_policy` | 优先级仲裁 | **all_winners**(一炮三响) | | `phases.on_win` | 淘汰(血战) | **game_over**(直接结束) | | `phases.end_conditions` | 牌墙耗尽+血战余一人 | **player_wins**(有人胡就结束) | | `fan_types` | ~13种,无 level | ~30种,分 **level 1/2/3/8** | | `scoring.pre_hooks` | 查花猪+查叫 | **空**(无流局处理) | | `scoring.fan_level_logic` | 无 | **threshold 三级**:鸡/平/爆 | **引擎不变——两个 DSL 共用同一套 MeldsSolver、PhaseMachine、ScoreEngine。** 引擎通过 DSL 中的 differences 自动选择不同的行为路径。 ### 新增验证测试用例 ```csharp [Fact] public void 广东麻将_花牌_摸到即补_自动继续() { // 构造牌墙:第1张是花牌春,第2张是三万 // 玩家摸牌 → 摸到春 → 自动亮出 → 补摸三万 → 进入出牌选择 var state = CreateStateWithDeck(new[] { "春", "三万" }); var events = engine.PhaseMachine.AutoPhase(state); Assert.Contains(events, e => e.Type == "flower_drawn"); // 摸到花牌 Assert.Contains(events, e => e.Type == "flower_replaced"); // 补了一张 Assert.Equal("出牌", state.SubPhase); // 进入正常出牌 Assert.Contains(state.Hand, t => t.Id == "三万"); // 补的三万在手中 } [Fact] public void 广东麻将_吃牌_上家出牌后可吃() { // AI-东 出五万 // AI-南 手中有 三万四万六万 → 可以吃(3万4万 + 6万各走一边都行) var state = CreateState(/* 东出五万,南有三万四万六万 */); var legalActions = engine.GetLegalActions(state, "AI-南"); Assert.Contains(legalActions, a => a.Type == "chi"); // 吃牌可选 } [Fact] public void 广东麻将_一炮三响_多人同时胡全算() { // AI-东 出三万,AI-南/AI-西/AI-北 都能胡 var state = CreateState(/* 三家都听三万 */); state.Phase = "play"; state.SubPhase = "others_reaction"; state.LastDiscardPlayer = "AI-东"; state.LastDiscard = Tile("三万"); var actions = engine.PhaseMachine.GetAllReactions(state); // 三家都选择胡 var huActions = actions.Where(a => a.Type == "win").ToList(); Assert.Equal(3, huActions.Count); // 执行:一炮三响 engine.PhaseMachine.ExecuteMultiWin(state, huActions); Assert.Equal(3, state.HuPlayers.Count); // 三家都算胡 Assert.False(state.AlivePlayers.Contains("AI-东")); // 被淘汰(虽然没胡,是点炮的) } [Fact] public void 广东麻将_番型分级_鸡胡只能自摸() { var state = CreateState(/* 1番的牌,非自摸 */); var action = new PlayerAction { Type = "win", IsSelfDraw = false }; var result = engine.PhaseMachine.ValidateWin(state, action); Assert.False(result.Valid); Assert.Contains("鸡胡只能自摸", result.Reason); } [Fact] public void 广东麻将_爆胡_可以抢胡() { var state = CreateState(/* 8番的牌,别人点炮 */); var action = new PlayerAction { Type = "win", IsSelfDraw = false }; var result = engine.PhaseMachine.ValidateWin(state, action); Assert.True(result.Valid); // 爆胡可以吃胡 } [Fact] public void 广东麻将_花牌正花_额外计分() { var state = CreateState(/* seat=1 的玩家有春和梅 */); state.Hands["AI-东"].Add(Tile("春")); // seat=1 的正花 state.Hands["AI-东"].Add(Tile("梅")); // seat=1 的正花 state.Hands["AI-东"].Add(Tile("夏")); // seat=2 的花,非正花 var flowerScore = engine.ScoreEngine.CalculateFlowerScore(state, "AI-东", seat: 1); Assert.Equal(5, flowerScore); // 3个花=3番 + 2个正花=2番 = 5番 } ``` ### 集成测试 ```csharp [Fact] public void 广东麻将_4AI自动打完_完整对局() { var rules = loader.Load("dsl-examples/guangdong_jipinghu.yaml"); var room = new MahjongRoom(rules, new[] { "AI-1", "AI-2", "AI-3", "AI-4" }); room.Run(); Assert.True(room.IsFinished); Assert.Equal(0, room.State.Scores.Values.Sum()); // 零和 Assert.True(CountAllTiles(room.State) == 136 || CountAllTiles(room.State) == 136 - room.State.FlowerReplaced * 1); } ``` ### 热切换验证 ```csharp [Fact] public void 热切换_四种麻将串行_同一引擎进程() { var names = new[] { "AI-1", "AI-2", "AI-3", "AI-4" }; // 四川血战 var sichuan = loader.Load("dsl-examples/xuezhandaodi.yaml"); var room1 = new MahjongRoom(sichuan, names); room1.Run(); Assert.True(room1.IsFinished); Assert.True(room1.State.HuPlayers.Count > 0 || room1.State.IsDeckExhausted); // 广东鸡平胡 var guangdong = loader.Load("dsl-examples/guangdong_jipinghu.yaml"); var room2 = new MahjongRoom(guangdong, names); room2.Run(); Assert.True(room2.IsFinished); Assert.Contains(room2.CollectedEvents, e => e.Type == "flower_drawn"); Assert.Equal(1, room2.State.HuPlayers.Count); // 国标麻将 var guobiao = loader.Load("dsl-examples/guobiao.yaml"); var room3 = new MahjongRoom(guobiao, names); room3.Run(); Assert.True(room3.IsFinished); Assert.True(CountAllTiles(room3.State) == 144 || CountAllTiles(room3.State) == 144 - room3.State.FlowerReplaced); // 武汉麻将(癞子) var wuhan = loader.Load("dsl-examples/wuhan.yaml"); Assert.Contains(wuhan.Requires, r => r == "meldsolver.wildcard"); var room4 = new MahjongRoom(wuhan, names); room4.Run(); Assert.True(room4.IsFinished); // 验证 258 将:拆开看房间事件中有 258 将的判定 Assert.Contains(room4.CollectedEvents, e => e.Type == "pair_validated_258"); } ``` --- ## 二-C、DSL 文件 — 国标麻将 (1 小时) 第三种 Demo 玩法。选国标麻将因为它是番型复杂度的天花板——81 番种 + 12 级 + 复杂互斥。 **验证点:81 番种互斥图、全不靠/一色双龙会等特殊胡型、8 番起胡、不计/不得重复规则** 创建 `dsl-examples/guobiao.yaml`: ```yaml game: name: "国标麻将" type: "mahjong" engine_type: "mahjong" players: { min: 4, max: 4 } 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" deck: # 144张牌库(万条筒108 + 字牌28 + 花牌8) generator: "mahjong" suits: ["万", "条", "筒"] ranks: [1,2,3,4,5,6,7,8,9] copies_per_tile: 4 honors: - { name: "东", count: 4 } - { name: "南", count: 4 } - { name: "西", count: 4 } - { name: "北", count: 4 } - { name: "中", count: 4 } - { name: "发", count: 4 } - { name: "白", count: 4 } flowers: - { name: "春", seat: 1, count: 1 } - { name: "夏", seat: 2, count: 1 } - { name: "秋", seat: 3, count: 1 } - { name: "冬", seat: 4, count: 1 } - { name: "梅", seat: 1, count: 1 } - { name: "兰", seat: 2, count: 1 } - { name: "竹", seat: 3, count: 1 } - { name: "菊", seat: 4, count: 1 } total: 144 deal: { cards_per_player: 13, dealer_extra: 1 } flower_rules: on_draw: "replace_and_draw" scoring: { normal: 1, matching: { value: 1, mapping: { 1: ["春","梅"], 2: ["夏","兰"], 3: ["秋","竹"], 4: ["冬","菊"] } } } # 81 番种(Demo 阶段录入关键番种,完整版需 200+ 行) fan_types: # 88番 - { name: "大四喜", base_fan: 88, level: 12, excludes: ["圈风","门风","三风"] } - { name: "大三元", base_fan: 88, level: 12, excludes: ["双箭刻"] } - { name: "十三幺", base_fan: 88, level: 12, excludes: ["五门齐","门前清","单钓将","混幺九"], conflicts: ["七对"] } - { name: "连七对", base_fan: 88, level: 12, excludes: ["七对","门前清","单钓将","清一色","无字"] } # 64番 - { name: "小四喜", base_fan: 64, level: 11, excludes: ["三风"] } - { name: "小三元", base_fan: 64, level: 11, excludes: ["双箭刻"] } - { name: "字一色", base_fan: 64, level: 11, excludes: ["碰碰和","全带幺","混幺九","缺一门"] } # 48番 - { name: "一色四同顺", base_fan: 48, level: 10, excludes: ["一色三同顺","四归一","一般高"] } # ... 其余 ~70 个番种(Demo 阶段按需录入) - { name: "清一色", base_fan: 24, level: 8, excludes: ["无字","缺一门"] } - { name: "七对", base_fan: 24, level: 8, excludes: ["门前清","单钓将"], conflicts: ["十三幺","连七对"] } # 8番起胡 win_min_fan: 8 fan_stacking: "add_max" exclusion_mode: "guobiao" phases: - { name: "deal", type: "auto", action: "deal_cards_with_flowers", next: "play" } - name: "play" type: "mahjong_turn" sub_phases: draw: { type: "auto", action: "draw_card", on_draw_flower: "replace", on_empty_deck: "exhausted" } self_action: { options: [{action:"discard"},{action:"an_kong"},{action:"bu_kong"},{action:"win",condition:"can_win_tumo AND fan>=8"}] } others_reaction: options: [{action:"chi",priority:1},{action:"pung",priority:2},{action:"ming_kong",priority:3},{action:"win",priority:4,condition:"can_win AND fan>=8"},{action:"pass",priority:0}] priority_policy: "highest_wins" on_win: "game_over" end_conditions: [{type:"player_wins",action:"settle"},{type:"deck_exhausted",action:"draw_game"}] - { name: "settle", type: "auto", action: "calculate_scores", next: null } scoring: mode: "fan_table" pre_hooks: [] fan_calculation: { stacking: "add_max", handle_exclusions: true } ``` ### 四种麻将维度对比 | DSL 区域 | 四川血战 | 广东鸡平胡 | 国标麻将 | 武汉麻将 | |---------|---------|-----------|---------|---------| | 牌库 | 108(无字无花) | 136(+字+花) | 144(+字+花) | 136(+字,红中是癞子) | | 花牌 | ❌ | ✅ | ✅ | ❌ | | 吃牌 | ❌ | ✅ | ✅ | ✅ | | 番型数 | ~13 | ~30 | 81 | ~20 | | 宝牌 | ❌ | 可选鬼牌 | ❌ | ✅ 红中固定癞子 | | 起胡条件 | 缺一门 | 鸡胡自摸 | ≥8 番 | 258 将 | | 结束 | 血战淘汰 | 一胡结束 | 一胡结束 | 一胡结束 | | 特殊胡型 | — | 十三幺 | 全不靠/双龙会 | 癞子胡 | --- ## 二-D、DSL 文件 — 武汉麻将 (1 小时) 第四种 Demo 玩法。选武汉麻将因为它是宝牌(癞子)的标准案例——红中固定为癞子,可替代任何牌。 **验证点:wildcard 缺口填充式回溯、258 将、癞子计分、封顶规则** 创建 `dsl-examples/wuhan.yaml`: ```yaml game: name: "武汉麻将" type: "mahjong" engine_type: "mahjong" players: { min: 4, max: 4 } requires: - "deck.generator_mahjong" - "meldsolver.standard_win" - "meldsolver.seven_pairs" - "meldsolver.wildcard" # ← 核心依赖:宝牌支持 - "phase.mahjong_turn" - "phase.priority_arbitration" - "scoring.fan_exclusion" deck: generator: "mahjong" suits: ["万", "条", "筒"] ranks: [1,2,3,4,5,6,7,8,9] copies_per_tile: 4 honors: - { name: "东", count: 4 } - { name: "南", count: 4 } - { name: "西", count: 4 } - { name: "北", count: 4 } - { name: "中", count: 4 } # 4张红中,均为癞子 - { name: "发", count: 4 } - { name: "白", count: 4 } total: 136 deal: { cards_per_player: 13, dealer_extra: 1 } # ─── 宝牌规则:红中固定癞子 ─── wildcard_rules: type: "fixed" tiles: ["红中"] wildcard_encoding: 50 # 红中编码 35 → 游戏中被标记为野生牌 50 behavior: "substitute" fan_calculation_policy: "optimal" # 癞子按最优番型计 scoring: per_wildcard_in_win: 1 # 胡牌时每张癞子额外1番 # ─── 258 将 ─── win_condition: pair_must_be_258: true # 将牌必须是 2/5/8 之一 # 但如果有癞子,癞子可以做258将(wildcard 替代) # ─── 番型 ─── fan_types: - { name: "碰碰胡", base_fan: 2 } - { name: "清一色", base_fan: 8, excludes: ["缺一门","无字"] } - { name: "七对", base_fan: 8, excludes: ["门前清","单钓将"] } - { name: "将一色", base_fan: 16, excludes: ["碰碰胡","缺一门"] } - { name: "全求人", base_fan: 4 } - { name: "杠上开花", base_fan: 1, excludes: ["海底捞月"] } - { name: "海底捞月", base_fan: 1 } - { name: "抢杠胡", base_fan: 1 } - { name: "天胡", base_fan: 32 } - { name: "地胡", base_fan: 16 } - { name: "癞子胡", base_fan: 1, condition: "hand_contains_wildcard" } fan_stacking: "add" max_fan: 100 # 封顶 100 番 phases: - { name: "deal", type: "auto", action: "deal_cards", next: "play" } - name: "play" type: "mahjong_turn" sub_phases: draw: { type: "auto", action: "draw_card", on_empty_deck: "exhausted" } self_action: options: - { action: "discard" } - { action: "an_kong" } - { action: "bu_kong", condition: "has_punged_pair" } - { action: "win", condition: "can_win_tumo" } others_reaction: options: - { action: "chi", priority: 1, condition: "can_chi" } - { action: "pung", priority: 2, condition: "has_two_same" } - { action: "ming_kong", priority: 3, condition: "has_three_same" } - { action: "win", priority: 4, condition: "can_win" } - { action: "pass", priority: 0 } priority_policy: "highest_wins" on_win: "game_over" end_conditions: - { type: "player_wins", action: "settle" } - { type: "deck_exhausted", action: "draw_game" } - { name: "settle", type: "auto", action: "calculate_scores", next: null } scoring: mode: "fan_table" pre_hooks: [] fan_calculation: stacking: "add" max_cap: 100 # 封顶 handle_exclusions: true ``` ### 引擎验证点 武汉麻将 DSL 加载时,CapabilityRegistry 检查 `meldsolver.wildcard` ——如果未注册直接报错。**这迫使我们在 Demo 阶段就实现 wildcard 算法。** ``` $ dotnet run -- --dsl wuhan ❌ DSL '武汉麻将' 需要: meldsolver.wildcard(宝牌/癞子支持) 引擎尚未实现! 请在 MeldsSolver.cs 中实现缺口填充式回溯后注册此能力。 ``` --- ## 三、RuleEngine 核心类 (3 天,含引擎扩展) 麻将引擎比扑克复杂——核心不是 PatternMatcher,而是 MeldsSolver(胡牌判断)。按这个顺序写,每个写完跑测试。 ### 3.1 数据结构 (30 min) #### MahjongTile.cs (60 行) — int 编码 + 静态工具类 不使用 struct 对象,直接用 `int` 编码。游戏引擎底层,性能优先。 编码规则:万1-9 = 1-9,条1-9 = 11-19,筒1-9 = 21-29。19 = 九条,28 = 八筒。 ```csharp namespace RuleEngine.Core; /// 麻将牌 int 编码工具类 public static class MahjongTile { // ── 编码 ── public static int Encode(string suit, int rank) => suit switch { "万" => rank, // 1-9 "条" => 10 + rank, // 11-19 "筒" => 20 + rank, // 21-29 _ => throw new ArgumentException($"非法花色: {suit}") }; // ── 解码 ── public static string Decode(int tile) => tile switch { >= 1 and <= 9 => $"{tile}万", >= 11 and <= 19 => $"{tile - 10}条", >= 21 and <= 29 => $"{tile - 20}筒", _ => "?" }; public static string Suit(int tile) => tile switch { >= 1 and <= 9 => "万", >= 11 and <= 19 => "条", >= 21 and <= 29 => "筒", _ => throw new ArgumentException() }; public static int Rank(int tile) => tile switch { >= 1 and <= 9 => tile, >= 11 and <= 19 => tile - 10, >= 21 and <= 29 => tile - 20, _ => throw new ArgumentException() }; public static bool SameSuit(int a, int b) => Suit(a) == Suit(b); // ── 生成所有 27 种牌 ── public static int[] AllTiles(bool includeHonors = false, bool includeFlowers = false) { int count = 27 + (includeHonors ? 7 : 0) + (includeFlowers ? 8 : 0); var tiles = new int[count]; int idx = 0; for (int i = 1; i <= 9; i++) tiles[idx++] = i; // 1-9万 for (int i = 1; i <= 9; i++) tiles[idx++] = 10 + i; // 11-19条 for (int i = 1; i <= 9; i++) tiles[idx++] = 20 + i; // 21-29筒 if (includeHonors) { tiles[idx++] = 31; tiles[idx++] = 32; tiles[idx++] = 33; tiles[idx++] = 34; tiles[idx++] = 35; tiles[idx++] = 36; tiles[idx++] = 37; } if (includeFlowers) { for (int i = 41; i <= 48; i++) tiles[idx++] = i; } return tiles; } } // ── 常量 ── public static class T { public const int 一万 = 1, 二万 = 2, 三万 = 3, 四万 = 4, 五万 = 5, 六万 = 6, 七万 = 7, 八万 = 8, 九万 = 9; public const int 一条 = 11, 二条 = 12, 三条 = 13, 四条 = 14, 五条 = 15, 六条 = 16, 七条 = 17, 八条 = 18, 九条 = 19; public const int 一筒 = 21, 二筒 = 22, 三筒 = 23, 四筒 = 24, 五筒 = 25, 六筒 = 26, 七筒 = 27, 八筒 = 28, 九筒 = 29; public const int 东 = 31, 南 = 32, 西 = 33, 北 = 34, 中 = 35, 发 = 36, 白 = 37; public const int 春 = 41, 夏 = 42, 秋 = 43, 冬 = 44, 梅 = 45, 兰 = 46, 竹 = 47, 菊 = 48; } ``` **测试用例写法对比:** ```csharp // 旧(struct,冗长) var hand = new List { new("万", 1), new("万", 1), ... }; // 新(int 编码,简洁) var hand = new List { T.一万, T.一万, T.一万, T.二万, T.三万, ... }; // 或者用 Encode var hand = new List { E("一万"), E("一万"), E("一万"), E("二万"), ... }; int E(string s) => MahjongTile.Encode(s[^1..], int.Parse(s[..^1])); ``` #### Melds.cs (50 行) — 面子分解结果 ```csharp namespace RuleEngine.Core; /// 面子分解的输出结构 public class MeldsResult { public List Melds { get; set; } // 4 组面子 public int[] Pair { get; set; } // 1 对将(2张相同,int 数组) public bool IsWin { get; set; } public List FanList { get; set; } // 满足的番型列表 } public class Meld { public string Type { get; set; } // "kezi"(刻子) | "shunzi"(顺子) | "gang"(杠) public int[] Tiles { get; set; } // int 数组 public string Suit => MahjongTile.Suit(Tiles[0]); public int BaseRank => MahjongTile.Rank(Tiles[0]); } ``` 然后更新 GameState.cs 和 Deck.cs,全部用 `int` / `List`: ```csharp // GameState.cs — 关键字段改为 int public Dictionary> Hands { get; set; } public List Deck { get; set; } public List DiscardPool { get; set; } public int? LastDiscard { get; set; } // Deck.cs — 返回 int public List Tiles { get; private set; } public int Draw() { var t = Tiles[^1]; Tiles.RemoveAt(Tiles.Count - 1); return t; } ``` #### GameState.cs (80 行) ```csharp namespace RuleEngine.Core; public class MahjongGameState { public string Phase { get; set; } public Dictionary> Hands { get; set; } // 手牌 (int 编码) public Dictionary> Exposed { get; set; } // 已碰/杠的牌 public List Deck { get; set; } // 牌墙 public List DiscardPool { get; set; } // 弃牌堆 public int? LastDiscard { get; set; } // 刚打出的牌 public string? LastDiscardPlayer { get; set; } // 出牌者 public string CurrentPlayer { get; set; } public List PlayerOrder { get; set; } public string Dealer { get; set; } public int RoundNumber { get; set; } public Dictionary Scores { get; set; } public List HuPlayers { get; set; } // 已胡玩家 public List AlivePlayers { get; set; } // 仍在打的玩家 public Dictionary FuFlags { get; set; } // 过水标记 public bool IsDeckExhausted { get; set; } public Dictionary TingCache { get; set; } // 听牌缓存 } ``` **测试**: 不需要单独测试数据结构,会在后续类中覆盖。 ### 3.2 Deck.cs (20 min, 60 行) ```csharp namespace RuleEngine.Core; public class MahjongDeck { private readonly Random _rng = new(); public List Tiles { get; private set; } // ← int public static MahjongDeck Standard108() { var tiles = new List(); for (int tile = 1; tile <= 9; tile++) // 万1-9, 各4张 for (int i = 0; i < 4; i++) tiles.Add(tile); for (int tile = 11; tile <= 19; tile++) // 条1-9, 各4张 for (int i = 0; i < 4; i++) tiles.Add(tile); for (int tile = 21; tile <= 29; tile++) // 筒1-9, 各4张 for (int i = 0; i < 4; i++) tiles.Add(tile); return new MahjongDeck { Tiles = tiles }; } public void Shuffle() { /* Fisher-Yates */ } public int Draw() { var t = Tiles[^1]; Tiles.RemoveAt(Tiles.Count - 1); return t; } public List DrawMany(int count) { /* 取多张 */ } public bool IsEmpty => Tiles.Count == 0; } ``` **测试更新:用 `T.` 常量** ```csharp [Fact] public void Standard108_HasExactly108Tiles() { ... } [Fact] public void EachTile_Has4Copies() { ... } [Fact] public void Shuffle_KeepsAll108() { ... } ``` **测试 `DeckTests.cs`**: ```csharp [Fact] public void Standard108_HasExactly108Tiles() { ... } [Fact] public void EachTile_Has4Copies() { ... } [Fact] public void Shuffle_KeepsAll108() { ... } ``` ### 3.3 MeldsSolver.cs — 核心!(4-6 小时, ~400 行) 这是整个麻将引擎最难的部分。胡牌判断 = 回溯搜索。 #### 3.3.0 胡牌算法选型:回溯 vs 查表 在开始写代码前,先决定用哪种算法(参考 q_algorithm 的查表法)。 | | 回溯搜索(我们计划) | 查表法(q_algorithm 采用) | |---|---|---| | 原理 | 14张牌递归拆解,先试刻子再试顺子 | 预计算所有胡牌组合存哈希表,O(1)查询 | | 时间复杂度 | 最坏 O(3^n),n=14 时约 1000 次递归 | O(1) 查询 + O(n) 哈希 | | 代码量 | ~100行核心逻辑 | ~80行查询 + 需要预计算工具(额外200行) | | 扩展性 | 加新胡牌条件(全不靠/一色双龙会)只需加分支 | 需要重建表 | | 调试难度 | 容易单步跟踪 | 表数据出错难定位 | | 14张牌性能 | < 0.5ms(实测足够) | < 0.01ms | | 听牌判断 | O(牌种数) × O(回溯) ≈ 34×0.5ms = 17ms | O(牌种数) × O(1) = 0.3ms | **选型结论:先做回溯搜索,后续如果听牌判断成为瓶颈再换查表法。** 理由: - Demo 阶段 4 人 AI 自动对打,听牌判断每回合调用 4 次 × 34 张可能牌 = 136 次回溯,17ms 完全不构成瓶颈 - 回溯代码可读性强,容易加"全不靠""组合龙"等特殊分支 - 等价于查表法的"验证"——如果回溯出 bug,查表法也会出错 #### 3.3.1 牌面编码方案 麻将牌在 C# 中的编码方式直接影响算法效率。两种方案: | | 对象法 | 整数编码法 | |---|---|---| | 表示 | `new MahjongTile("万", 5)` | `int tile = 5`(万5=5, 条5=15, 筒5=25) | | 排序 | 按 Suit+Rank,~10ns | 直接整数比较,~1ns | | 刻子判断 | `t1==t2 && t2==t3` | `t1==t2 && t2==t3`(值类型) | | 顺子判断 | t1.Suit==t2.Suit && t2.Rank==t1.Rank+1 | `t2==t1+1 && 同花色检查` | | 可读性 | ✅ 一眼看出是"五万" | ❌ 需要映射回字符串 | | 内存 | 每个tile 16 bytes | 每个tile 4 bytes | **选型结论:使用 int 编码。** 游戏引擎底层数据结构,性能和稳定性优先。4 倍内存优势 + 10 倍比较速度 + 整数排序不需要 Comparer。可读性通过 `MahjongTile.Decode()` 和常量 `T.一万` 解决,不影响核心算法路径。 ```csharp namespace RuleEngine.Patterns; public class MeldsSolver { private readonly FanConfig _fanConfig; public MeldsSolver(FanConfig fanConfig) { ... } /// 判断是否胡牌 + 面子分解 + 番型识别 /// hand 和 newTile 都用 int 编码 public MeldsResult CheckWin(List hand, int? newTile = null) { var tiles = new List(hand); if (newTile != null) tiles.Add(newTile.Value); if (tiles.Count != 14) return new MeldsResult { IsWin = false }; // tiles.Sort(); ← int 直接排序,不需要 Comparer tiles.Sort(); // 1. 先试七对 var sevenPairs = TrySevenPairs(tiles); if (sevenPairs != null) return sevenPairs; // 2. 回溯搜索标准胡牌 for (int i = 0; i < tiles.Count - 1; i++) { if (tiles[i] == tiles[i + 1]) // ← int 直接比较,O(1) { var remaining = new List(tiles); var pair = new[] { remaining[i], remaining[i + 1] }; remaining.RemoveAt(i + 1); remaining.RemoveAt(i); var melds = TryExtractMelds(remaining); if (melds != null) { var fans = IdentifyFans(melds, pair, tiles); return new MeldsResult { IsWin = true, Melds = melds, Pair = pair, FanList = fans }; } } } return new MeldsResult { IsWin = false }; } /// 回溯搜索:从剩余牌中提取 4 组面子 private List? TryExtractMelds(List tiles) { if (tiles.Count == 0) return new List(); if (tiles.Count % 3 != 0) return null; int first = tiles[0]; // 分支1: 尝试刻子(3张相同)— int 直接 == 比较 if (tiles.Count >= 3 && tiles[1] == first && tiles[2] == first) { var rest = new List(tiles); rest.RemoveRange(0, 3); var result = TryExtractMelds(rest); if (result != null) { result.Insert(0, new Meld { Type = "kezi", Tiles = new[] { first, first, first } }); return result; } } // 分支2: 尝试顺子(连续3张同花色) // 字数牌(万=1-9)的顺子: first+1, first+2 必须同花色 int second = first + 1; int third = first + 2; if (MahjongTile.Rank(first) <= 7 // 1-7才能起顺子 && tiles.Contains(second) && tiles.Contains(third)) { var rest = new List(tiles); rest.Remove(first); rest.Remove(second); rest.Remove(third); var result = TryExtractMelds(rest); if (result != null) { result.Insert(0, new Meld { Type = "shunzi", Tiles = new[] { first, second, third } }); return result; } } return null; } /// 七对判断 — int 直接 == 比较 private MeldsResult? TrySevenPairs(List tiles) { tiles.Sort(); for (int i = 0; i < 14; i += 2) if (tiles[i] != tiles[i + 1]) return null; return new MeldsResult { IsWin = true, Melds = new List(), Pair = new[] { tiles[0], tiles[1] }, FanList = new List { "暗七对" } }; } /// 番型识别:基于面子分解结果 private List IdentifyFans(List melds, int[] pair, List fullHand) { var fans = new List { "鸡胡" }; // 对对胡 if (melds.All(m => m.Type == "kezi")) fans.Add("对对胡"); // 清一色 — 所有牌同花色 var allTiles = melds.SelectMany(m => m.Tiles).Concat(pair); if (allTiles.Select(MahjongTile.Suit).Distinct().Count() == 1) fans.Add("清一色"); // 带幺九 if (melds.All(m => m.Tiles.Any(t => MahjongTile.Rank(t) is 1 or 9))) fans.Add("带幺九"); // 将对: 全是 2/5/8 if (melds.SelectMany(m => m.Tiles).Concat(pair) .All(t => MahjongTile.Rank(t) is 2 or 5 or 8)) fans.Add("将对"); return ApplyFanExclusions(fans); } /// 听牌判断:13 张手牌,缺一张就能胡 public List CheckTing(List hand, List? exposed = null) { var tingTiles = new List(); var possibleTiles = MahjongTile.AllTiles() // 27种牌 .Except(hand).ToList(); foreach (var tile in possibleTiles) { if (CheckWin(hand, tile).IsWin) tingTiles.Add(tile); } return tingTiles; } /// 应用番型互斥图 private List ApplyFanExclusions(List fans) { // 1. 收集所有 excludes: 如果高级番型 claimed,移除它 excludes 的低级番型 var toRemove = new HashSet(); foreach (var fan in fans) { var def = _fanConfig.Get(fan); if (def?.Excludes != null) foreach (var excluded in def.Excludes) toRemove.Add(excluded); } // 2. 处理 conflicts: 同一组互斥只保留番数最高的 foreach (var fan in fans.ToList()) { var def = _fanConfig.Get(fan); if (def?.Conflicts != null) { foreach (var conflict in def.Conflicts) { if (fans.Contains(conflict)) { // 保留番数高的 var def2 = _fanConfig.Get(conflict); if (def2 != null && def2.BaseFan > def.BaseFan) toRemove.Add(def.Name); else toRemove.Add(conflict); } } } } return fans.Where(f => !toRemove.Contains(f)).ToList(); } } ``` **测试 `MeldsSolverTests.cs`(最关键,20+ 用例)**: ```csharp // ── 标准胡牌 ── [Fact] public void 标准胡_4刻子1对() { var hand = Tiles("一万,一万,一万, 二万,三万,四万, 五条,五条,五条, 六筒,六筒,六筒, 八条,八条"); var result = solver.CheckWin(hand); Assert.True(result.IsWin); Assert.Equal(4, result.Melds.Count); Assert.Equal("八条", result.Pair[0].Id); Assert.Contains("鸡胡", result.FanList); } // ── 七对 ── [Fact] public void 七对_7个对子() { var hand = Tiles("一万,一万, 二万,二万, 三万,三万, 四条,四条, 五条,五条, 六筒,六筒, 七筒,七筒"); var result = solver.CheckWin(hand); Assert.True(result.IsWin); Assert.Contains("暗七对", result.FanList); Assert.DoesNotContain("鸡胡", result.FanList); // 七对不算鸡胡 } // ── 清一色 ── [Fact] public void 清一色_全万子() { var hand = Tiles("一万,一万,一万, 二万,三万,四万, 五万,五万,五万, 六万,七万,八万, 九万,九万"); var result = solver.CheckWin(hand); Assert.True(result.IsWin); Assert.Contains("清一色", result.FanList); } // ── 对对胡 ── [Fact] public void 对对胡_全刻子() { var hand = Tiles("一万,一万,一万, 二万,二万,二万, 五条,五条,五条, 六筒,六筒,六筒, 八条,八条"); var result = solver.CheckWin(hand); Assert.True(result.IsWin); Assert.Contains("对对胡", result.FanList); } // ── 反例:不能胡 ── [Fact] public void 不能胡_缺面子() { var hand = Tiles("一万,一万,一万, 二万,三万,五万, 五条,五条,五条, 六筒,六筒,六筒, 八条,八条"); // ^^^^^^^ 2,3,5 不成顺子 var result = solver.CheckWin(hand); Assert.False(result.IsWin); } [Fact] public void 不能胡_多一张() { var hand = Tiles("一万,一万,一万, 二万,三万,四万, 五条,五条,五条, 六筒,六筒, 八条,八条, 九筒"); Assert.Throws(() => solver.CheckWin(hand)); } // ── 番型互斥 ── [Fact] public void 对对胡和七对互斥_只保留高级() { // 全刻子但不构成七对 -> 对对胡 var hand = Tiles("一万,一万,一万, 二万,二万,二万, 三条,三条,三条, 四筒,四筒,四筒, 五条,五条"); var result = solver.CheckWin(hand); Assert.Contains("对对胡", result.FanList); Assert.DoesNotContain("暗七对", result.FanList); } [Fact] public void 清一色排除缺一门() { var hand = Tiles("一万,一万,一万, 二万,三万,四万, 五万,五万,五万, 六万,七万,八万, 九万,九万"); var result = solver.CheckWin(hand); Assert.Contains("清一色", result.FanList); Assert.DoesNotContain("缺一门", result.FanList); } // ── 听牌判断 ── [Fact] public void 听牌_单钓将() { var hand = Tiles("一万,一万,一万, 二万,三万,四万, 五条,五条,五条, 六筒,六筒,六筒, 八条"); var ting = solver.CheckTing(hand); Assert.Single(ting); Assert.Equal("八条", ting[0].Id); // 只听八条 } [Fact] public void 听牌_两面听() { var hand = Tiles("一万,一万,一万, 二万,二万,二万, 五条,五条,五条, 六筒,六筒,六筒, 七万,八万"); var ting = solver.CheckTing(hand); Assert.Equal(2, ting.Count); // 听六万和九万 } // ── 边界情况 ── [Fact] public void 胡牌判断_13张牌_应报错() { ... } [Fact] public void 空手牌_应报错() { ... } ``` ### 3.4 PhaseMachine.cs (3-4 小时, ~300 行) 麻将的状态机比扑克复杂——有嵌套子阶段(摸牌→选择→出牌→等待他人反应)。 ```csharp namespace RuleEngine.Phase; public class MahjongPhaseMachine { private readonly PhaseConfig _config; private readonly MeldsSolver _solver; public MahjongPhaseMachine(PhaseConfig config, MeldsSolver solver) { ... } /// 获取当前玩家的合法操作 public List GetLegalActions(MahjongGameState state, string playerId) { ... } /// 执行操作 → 返回事件列表 public List Execute(MahjongGameState state, PlayerAction action) { ... } /// 自动阶段(发牌、摸牌) public List AutoPhase(MahjongGameState state) { switch (state.Phase) { case "deal": return ExecuteDeal(state); case "play" when state.SubPhase == "draw": return ExecuteDraw(state); case "settle": return ExecuteSettle(state); } } } ``` **关键流程:** ``` 一个完整回合: 1. draw (auto) → 从牌墙摸一张 2. self_action → 玩家选择: 出牌 | 暗杠 | 加杠 | 自摸胡 - 如果暗杠/加杠 → 回到 draw(补牌) - 如果出牌 → 进入 others_reaction 3. others_reaction → 其他玩家选择: 碰 | 杠 | 胡 | 过 - 优先级: 胡(4) > 杠(3) > 碰(2) > 过(0) - 如果碰/杠 → skip_draw(跳过摸牌直接出牌) - 如果胡 → player_eliminated(血战中移除该玩家) - 如果全过 → next_player 4. 血战特殊: 有人胡后不结束,移除后继续 ``` **测试 `PhaseMachineTests.cs`**: ```csharp [Fact] public void 发牌_每人13张_庄家14张() { ... } [Fact] public void 摸牌_从牌墙取一张_接discard选择() { ... } [Fact] public void 出牌后_他人可选碰杠胡() { ... } [Fact] public void 优先级_胡优先于杠() { ... } [Fact] public void 碰后_跳过摸牌直接出牌() { ... } [Fact] public void 血战_有人胡后不结束_其他人继续() { ... } [Fact] public void 血战_剩最后一人自动结算() { ... } [Fact] public void 流局_查叫() { ... } [Fact] public void 流局_查花猪() { ... } [Fact] public void 过水_胡过不能立即再胡同一张() { ... } ``` ### 3.5 ScoreEngine.cs (1.5 小时, ~150 行) ```csharp namespace RuleEngine.Scoring; public class MahjongScoreEngine { private readonly ScoringConfig _config; private readonly MeldsSolver _solver; public MahjongScoreEngine(ScoringConfig config, MeldsSolver solver) { ... } /// 执行结算前钩子(查花猪、查叫) public List RunPreHooks(MahjongGameState state) { ... } /// 计算最终得分 public Dictionary Calculate(MahjongGameState state) { // 1. 先跑 pre_hooks RunPreHooks(state); // 2. 对每个已胡的玩家:番数 x 基础分 // 3. 自摸:其他三家各付,总分 x3 // 4. 点炮:点炮者付全部 // 5. 查叫/查花猪罚分 } } ``` **测试 `ScoreEngineTests.cs`**: ```csharp [Fact] public void 鸡胡自摸_得分验证() { ... } [Fact] public void 清一色对对胡_番型叠加_6番() { ... } [Fact] public void 花猪_三种花色_扣分() { ... } [Fact] public void 未听牌_赔听牌者() { ... } [Fact] public void 杠上开花_额外1番() { ... } ``` ### 3.6 DslLoader.cs (40 min, ~80 行) ```csharp namespace RuleEngine.Dsl; public class DslLoader { private readonly CapabilityRegistry _capabilities; public DslLoader(CapabilityRegistry capabilities) { ... } public RuleSet Load(string yamlPath) { var yaml = File.ReadAllText(yamlPath); var dsl = new Deserializer().Deserialize(yaml); // 能力检查 CheckCapabilities(dsl.Requires); // 构建 var deck = MahjongDeck.Standard108(); var fanConfig = BuildFanConfig(dsl.FanTypes); var solver = new MeldsSolver(fanConfig); var phaseMachine = new MahjongPhaseMachine(dsl.Phases, solver); var scoreEngine = new MahjongScoreEngine(dsl.Scoring, solver); return new RuleSet { ... }; } } ``` ### 3.7 CapabilityRegistry.cs (30 min, ~60 行) 引擎启动时注册所有已实现的算法能力(见架构计划 2.0f)。 ### 3.8 引擎扩展 — 3 个算法分支 (4-5 小时,wildcard 占 3 小时) 国标麻将和武汉麻将需要的额外算法分支。**wildcard 必须完整实现,不能再留接口。** #### wildcard — 宝牌/癞子缺口填充式回溯(武汉麻将核心)← 必须实现 **不能像之前那样只写注释。** Wildcard 是武汉麻将 DSL `requires` 中声明的硬依赖,CapabilityRegistry 加载时会检查。必须实现。 算法核心——缺口填充式回溯: ```csharp /// 缺口填充式回溯:先用非宝牌确定性分解,差一张时用宝牌补 public MeldsResult TryExtractMeldsWithWildcard( int[] tiles, int[] counts, int wildcardCount, int pairCount) { // Step 1: 找第一个非零计数的非宝牌位置 int i = FindFirstNonZero(counts); if (i == -1) { // 所有非宝牌已消耗完毕 // 剩余宝牌必须能配对或组成面子 return FinalizeWithWildcards(wildcardCount, pairCount); } int tile = tiles[i]; // Step 2: 尝试用当前牌做刻子 if (counts[i] >= 3) { counts[i] -= 3; var r = TryExtractMeldsWithWildcard(tiles, counts, wildcardCount, pairCount); if (r != null) return r; counts[i] += 3; } // Step 3: 尝试用当前牌做顺子 // ... // Step 4 (关键): 差 1 张时用宝牌补齐 if (counts[i] >= 2 && wildcardCount >= 1 && IsValidKezi(tile)) { counts[i] -= 2; var r = TryExtractMeldsWithWildcard(tiles, counts, wildcardCount - 1, pairCount); if (r != null) return FinalizeMelds(r, new Meld { Type = "kezi", Tiles = [tile,tile,-1] }); counts[i] += 2; } // Step 4b: 差 2 张时用 2 个宝牌补齐刻子 if (counts[i] >= 1 && wildcardCount >= 2 && IsValidKezi(tile)) { counts[i] -= 1; var r = TryExtractMeldsWithWildcard(tiles, counts, wildcardCount - 2, pairCount); if (r != null) return FinalizeMelds(r, new Meld { Type = "kezi", Tiles = [tile,-1,-1] }); counts[i] += 1; } // Step 4c: 顺子缺中间张用宝牌补齐 // ... // Step 5: 宝牌做将(需 258 检查) if (counts[i] >= 1 && wildcardCount >= 1 && pairCount == 0) { // 如果规则要求 258 将 → 检查 tile 是否是 258 if (IsValidPair(tile, allowWildcard: true)) { counts[i] -= 1; var r = TryExtractMeldsWithWildcard(tiles, counts, wildcardCount - 1, pairCount + 1); if (r != null) return r with { PairTiles = [tile, -1] }; counts[i] += 1; } } return null; } ``` 关键改动点: 1. **`counts` 索引扩展**:万1-9 → 0-8, 条1-9 → 9-17, 筒1-9 → 18-26, 字31-37 → 27-33。宝牌不进入 counts,单独用 `wildcardCount` 追踪。 2. **`FinalizeWithWildcards`**:处理"非宝牌已全部消耗完,只剩宝牌"的情况。剩余宝牌数 ≥ 2w 时表示可能有 w 个宝牌对子/面子。 3. **`IsValidPair` 扩展**:武汉麻将需检查 258 将(tile 的 rank 是 2/5/8,或者传递 `allowWildcard`)。 4. **宝牌计数**:胡牌结果中需标记每张宝牌被用来替代了什么牌,供 ScoreEngine 计算癞子番。 **预计代码量:~200 行。** #### wildcard 的 Capability 注册 ```csharp // CapabilityRegistry — 初始化时注册 registry.Register(new Capability { Id = "meldsolver.wildcard", Name = "宝牌/癞子支持(缺口填充式回溯)", Category = "mahjong", Since = "1.0.0", Description = "支持任意替代型宝牌:固定癞子(武汉)、翻鬼(广东)、百搭(台湾)" }); ``` 注册后,武汉麻将 DSL 加载时不会再报"能力缺失"。广东麻将的可选鬼牌模式也可以复用同一套算法。 #### 武汉麻将 258 将检查 ```csharp /// 用于 MeldsSolver 的将牌验证 private bool IsValid258Pair(int tile, bool allowWildcard) { if (allowWildcard) return true; // 宝牌可以做任何将 int rank = MahjongTile.Rank(tile); return rank == 2 || rank == 5 || rank == 8; } // 集成到 CheckWin: public MeldsResult CheckWin(List hand, int? wildcardTile = null, bool require258Pair = false) { // ... 先检查 wildcard 数量 int wildcardCount = wildcardTile.HasValue ? hand.Count(t => t == wildcardTile.Value) : hand.Count(MahjongTile.IsWildcard); // 进入缺口填充式回溯 var result = TryExtractMeldsWithWildcard(tiles, counts, wildcardCount, 0); if (result != null && require258Pair) { // 验证将牌是 258 或由宝牌组成 if (!result.PairTiles.All(t => IsValid258Pair(t, isWildcard: t == -1))) return new MeldsResult { IsWin = false }; } return result; } ``` ``` #### all_orphans — 全不靠(国标麻将) ```csharp /// 全不靠判断:14张牌之间无任何关联 /// 三种花色各自按 1-4-7 / 2-5-8 / 3-6-9 排列 /// 加上东南西北中发白各一张,再加任意一对 public MeldsResult? CheckAllOrphans(List tiles) { // 1. 检查是否所有牌都是幺九牌或字牌 // 2. 检查三种花色是否按 147/258/369 分布 // 3. 检查字牌是否齐全 // 约 60 行 } ``` #### double_dragon — 一色双龙会(国标麻将) ```csharp /// 一色双龙会:同花色1-9各两张,14张从18张中取 /// 实质是面子分解的特殊变体 public MeldsResult? CheckDoubleDragon(List tiles) { // 1. 检查是否全部同花色 // 2. 检查是否1-9各有至少2张 // 3. 尝试拆分成 2组龙(123/456/789)+ 2组龙 + 任意一对 // 约 50 行 } ``` **在 CheckWin 中集成:** ```csharp public MeldsResult CheckWin(List hand, int? newTile = null) { var tiles = new List(hand); if (newTile != null) tiles.Add(newTile.Value); if (tiles.Count != 14) return new MeldsResult { IsWin = false }; tiles.Sort(); // 1. 七对 var sevenPairs = TrySevenPairs(tiles); if (sevenPairs != null) return sevenPairs; // 2. 十三幺 if (TryThirteenOrphans(tiles, out var orphansResult)) return orphansResult; // 3. 全不靠(国标) ← 新增 var allOrphans = CheckAllOrphans(tiles); if (allOrphans != null) return allOrphans; // 4. 一色双龙会(国标) ← 新增 var doubleDragon = CheckDoubleDragon(tiles); if (doubleDragon != null) return doubleDragon; // 5. 标准胡牌(回溯搜索)+ 可选 wildcard 模式 // ... 原有逻辑 } ``` **新增测试用例:** ```csharp [Fact] public void 鬼牌_1wildcard补刻子_ShouldWin() { ... } [Fact] public void 鬼牌_2wildcard补齐刻子_ShouldWin() { ... } [Fact] public void 鬼牌_wildcard补顺子中间张_ShouldWin() { ... } [Fact] public void 鬼牌_3wildcard_1做将2补面子_ShouldWin() { ... } [Fact] public void 武汉麻将_258将_非258不能胡() { ... } [Fact] public void 武汉麻将_癞子做258将_ShouldWin() { ... } [Fact] public void 武汉麻将_癞子胡_额外1番() { ... } [Fact] public void 全不靠_147万_258条_369筒_ShouldWin() { ... } [Fact] public void 全不靠_缺字牌_ShouldNotWin() { ... } [Fact] public void 一色双龙会_1到9各两张_ShouldWin() { ... } [Fact] public void 鬼牌_wildcard替代刻子_ShouldWin() { ... } [Fact] public void 鬼牌_wildcard替代顺子_ShouldWin() { ... } ``` --- ## 四、AI 陪打 (2 小时) ```csharp namespace Demo.AI; public class RandomMahjongAI { public string Name { get; } private readonly Random _rng = new(); public RandomMahjongAI(string name) { Name = name; } public PlayerAction Decide(MahjongGameState state, List legalActions) { // 1. 能胡就胡(最高优先级) var huAction = legalActions.FirstOrDefault(a => a.Type == "win"); if (huAction != null) return huAction; // 2. 有杠就杠(简单启发式) var kongAction = legalActions.FirstOrDefault(a => a.Type is "an_kong" or "ming_kong" or "bu_kong"); if (kongAction != null && _rng.Next(4) > 0) // 75%概率杠 return kongAction; // 3. 排除"出危险牌"(靠近危险区的牌——简化:随机) var discardActions = legalActions.Where(a => a.Type == "discard").ToList(); if (discardActions.Count > 0) return discardActions[_rng.Next(discardActions.Count)]; // 4. 不碰(随机碰) var pungActions = legalActions.Where(a => a.Type == "pung").ToList(); if (pungActions.Count > 0 && _rng.Next(3) == 0) // 33%概率碰 return pungActions[_rng.Next(pungActions.Count)]; // 5. 过 return legalActions.First(a => a.Type == "pass"); } } ``` --- ## 五、Demo 控制台程序 (1.5 小时) ### 5.1 Room.cs (~200 行) ```csharp namespace Demo; public class MahjongRoom { private readonly RuleSet _rules; private readonly MahjongGameState _state; private readonly List _players; public MahjongRoom(RuleSet rules, string[] playerNames) { ... } public bool IsFinished => _state.Phase == null; public void Run() { // 洗牌发牌 var deck = MahjongDeck.Standard108(); deck.Shuffle(); // ... 每人13张,庄家14张 // 游戏主循环 while (!IsFinished) { // 自动阶段(摸牌) var events = _rules.PhaseMachine.AutoPhase(_state); RenderEvents(events); // 玩家操作 var player = GetCurrentPlayer(); var legalActions = _rules.PhaseMachine.GetLegalActions(_state, player.Name); var action = player.Decide(_state, legalActions); events = _rules.PhaseMachine.Execute(_state, action); RenderEvents(events); } // 结算 _rules.ScoreEngine.RunPreHooks(_state); var scores = _rules.ScoreEngine.Calculate(_state); RenderScores(scores); } } ``` ### 5.2 Program.cs — 交互+自动双模式 默认交互模式(每步暂停),`--auto` 切换为自动模式(压测用)。 ```csharp var caps = new CapabilityRegistry(); caps.Register("meldsolver.standard_win"); caps.Register("meldsolver.seven_pairs"); caps.Register("phase.mahjong_turn"); caps.Register("phase.parallel_elimination"); caps.Register("phase.priority_arbitration"); caps.Register("scoring.fan_exclusion"); caps.Register("scoring.pre_hooks"); var loader = new DslLoader(caps); var rules = loader.Load("dsl-examples/xuezhandaodi.yaml"); bool autoMode = args.Contains("--auto"); Console.WriteLine($"=== 麻将规则引擎 Demo — 四川血战到底 === ({(autoMode ? "自动模式" : "交互模式")})"); Console.WriteLine(); var room = new MahjongRoom(rules, new[] { "AI-东", "AI-南", "AI-西", "AI-北" }, autoMode); room.Run(); ``` ### 5.3 交互模式输出示例(默认) 每一步暂停,显示所有玩家的完整手牌。按任意键继续下一步。 ``` === 麻将规则引擎 Demo — 四川血战到底 === (交互模式) ══════════════════════════════════════════════ [发牌] AI-东(庄): 一万,二万,三万,四万,五万,六万,七万,八万,九万,一条,二条,三条,二筒,三筒 AI-南: 一筒,二筒,三筒,四筒,五筒,六筒,七筒,八筒,九筒,五条,六条,七条,一万 AI-西: 三条,四条,五条,六条,七条,八条,九条,三筒,四筒,五筒,六筒,二万,三万 AI-北: 一筒,三筒,五筒,七筒,九筒,一条,三条,五条,七条,九条,四万,六万,八万 牌墙剩余: 94 张 ────────────────────────────────────────────── 按任意键开始游戏... ══════════════════════════════════════════════ [第1轮] 庄家 AI-东 摸牌: 四筒 手牌: 一万,二万,三万,四万,五万,六万,七万,八万,九万,一条,二条,三条,二筒,三筒,四筒 → 出牌: 一万 ────────────────────────────────────────────── 按任意键继续... [第1轮] AI-南 摸牌: 八条 手牌: 一筒,二筒,三筒,四筒,五筒,六筒,七筒,八筒,九筒,五条,六条,七条,一万,八条 → 出牌: 一筒 ────────────────────────────────────────────── 按任意键继续... [第1轮] AI-西 摸牌: 七筒 手牌: 三条,四条,五条,六条,七条,八条,九条,三筒,四筒,五筒,六筒,二万,三万,七筒 → 出牌: 二万 ────────────────────────────────────────────── AI-北 可以操作: 碰(二万) AI-北: ✅ 碰!(二万) 已碰: [二万,二万,二万] 手牌: 一筒,三筒,五筒,七筒,九筒,一条,三条,五条,七条,九条,四万,六万,八万 跳过摸牌,→ 出牌: 一条 ────────────────────────────────────────────── 按任意键继续... ══════════════════════════════════════════════ [第2轮] AI-东 摸牌: 五筒 手牌: 二万,三万,四万,五万,六万,七万,八万,九万,一条,二条,三条,二筒,三筒,四筒,五筒 → 出牌: 九万 ────────────────────────────────────────────── AI-南 可以操作: 碰(九万) AI-南: 不碰 ────────────────────────────────────────────── AI-西 可以操作: 碰(九万) AI-西: 不碰 ────────────────────────────────────────────── 按任意键继续... ══════════════════════════════════════════════ [第5轮] AI-东 摸牌: 一万 手牌: 二万,三万,四万,五万,六万,七万,八万,三条,二条,一条,二筒,三筒,四筒,五筒,一万 ✅ 自摸!番型: 清一色(4番) + 对对胡(2番) = 6番 → AI-东 已胡,退出本轮。血战继续! ────────────────────────────────────────────── 按任意键继续... ══════════════════════════════════════════════ [第8轮] 只剩 AI-南 和 AI-北 AI-南 手牌: 四筒,五筒,六筒,七筒,八筒,九筒,五条,六条,八条 已碰: [九万,九万,九万] AI-北 手牌: 三筒,五筒,七筒,九筒,三条,五条,七条,九条 已碰: [二万,二万,二万] 牌墙耗尽! ────────────────────────────────────────────── [查花猪] AI-南: ✓ 两种花色(筒+条),合格 AI-北: ✓ 两种花色(筒+条),合格 [查叫] AI-南: ✓ 听牌(听 7条) AI-北: ❌ 未听牌!(差 2 张) 按任意键查看结算... ══════════════════════════════════════════════ [最终结算] 牌局类型: 自摸 + 清一色对对胡 (6番) ──────────────────────────── AI-东: +18分 (6番 × 3家) AI-南: +3分 (收 AI-北 罚分 +1, 收 AI-西 罚分 +2) AI-北: -7分 (付 AI-东 6分 + 未听牌罚 1分) ──────────────────────────── 总分: +18 -4 -7 -7 = 0 ✓ ══════════════════════════════════════════════ 一局结束。按 Enter 重来,q 退出: ``` ### 5.4 自动模式(压测用 `--auto`) 自动模式跳过所有交互,AI 之间全自动对打,只在结算时输出一行结果: ``` $ dotnet run -- --auto === 麻将规则引擎 Demo — 四川血战到底 === (自动模式) [局 1/1000] ✅ AI-东 自摸胡 清一色对对胡(6番) | 耗时 234ms [局 2/1000] ✅ AI-北 胡 AI-南点炮 鸡胡(1番) | 耗时 189ms [局 3/1000] ✅ 流局 | 耗时 312ms ... [局 1000/1000] ✅ AI-西 自摸胡 暗七对(4番) | 耗时 267ms ================================ 统计: 总对局: 1000 出错: 0 平均耗时: 245ms/局 胡牌率: AI-东 28% | AI-南 24% | AI-西 26% | AI-北 22% 流局率: 18% ================================ ``` Room.Run() 根据 `autoMode` 参数决定是否在每步后等待按键: ```csharp public void Run() { // ... 游戏主循环 while (!IsFinished) { var events = Step(); // 执行一步(摸牌→决策→出牌→反应) RenderEvents(events); if (!_autoMode) { RenderFullHands(); // 显示所有玩家的完整手牌 Console.ReadKey(true); // 等待按键 } } Settle(); } ``` --- ## 六、完整测试清单 ### 6.1 单元测试 (35+ 用例) | 类 | 用例数 | 关键覆盖 | |---|-------|---------| | MahjongTileTests | 3 | Encode/Decode、Suit/Rank、AllTiles | | DeckTests | 3 | 108张、每张4份、洗牌不变 | | MeldsSolverTests | 28 | 标准胡×3、七对×2、十三幺×2、清一色×2、wildcard×4、258将×2、癞子计分×1、全不靠×2、双龙会×1、不能胡×3、番型互斥×3、听牌×3 | | PhaseMachineTests | 12 | 发牌、摸牌、出牌流转、碰、杠、优先级、血战淘汰×2、流局查叫×2、过水 | | ScoreEngineTests | 8 | 鸡胡自摸、番型叠加、花猪扣分、听牌罚分、杠分、总分守恒 | | DslLoaderTests | 3 | 加载DSL、缺能力报错、缺文件 | ### 6.2 集成测试 ```csharp [Fact] public void 四川血战_4AI自动打完_完整对局() { var rules = loader.Load("dsl-examples/xuezhandaodi.yaml"); var room = new MahjongRoom(rules, new[] { "AI-1", "AI-2", "AI-3", "AI-4" }); room.Run(); Assert.True(room.IsFinished); // 总分应为零(零和游戏) Assert.Equal(0, room.State.Scores.Values.Sum()); // 108 张牌守恒 Assert.Equal(108, CountAllTiles(room.State)); } ``` ### 6.3 压力测试 ```csharp [Fact] public void 四川血战_连续1000局_零报错() { var rules = loader.Load("dsl-examples/xuezhandaodi.yaml"); for (int i = 0; i < 1000; i++) { var room = new MahjongRoom(rules, new[] { $"AI-{i}-1", $"AI-{i}-2", $"AI-{i}-3", $"AI-{i}-4" }); try { room.Run(); // 不变量1: 牌数守恒 Assert.Equal(108, CountAllTiles(room.State)); // 不变量2: 零和游戏 Assert.Equal(0, room.State.Scores.Values.Sum()); // 不变量3: 没有人同时胡和未胡 Assert.Empty(room.State.HuPlayers.Intersect(room.State.AlivePlayers)); } catch (Exception ex) { Assert.Fail($"第 {i} 局出错:\n{ex}"); } } } ``` ### 6.4 番型互斥专项测试 这是 Demo 阶段最容易被跳过的测试,但也是最容易出 bug 的地方。 ```csharp [Fact] public void 番型互斥_清一色_不计算缺一门() { var hand = Tiles("一万,一万,一万, 二万,三万,四万, 五万,五万,五万, 六万,七万,八万, 九万,九万"); var result = solver.CheckWin(hand); Assert.Contains("清一色", result.FanList); Assert.DoesNotContain("缺一门", result.FanList); // 被 excludes Assert.Equal(4, CalculateTotalFan(result)); // 只计清一色4番 } [Fact] public void 番型互斥_七对和对对胡_只保留高级() { // 全刻子但不构成七对 → 对对胡(2番) var hand = Tiles("一万,一万,一万, 二万,二万,二万, 三条,三条,三条, 四筒,四筒,四筒, 五条,五条"); var result = solver.CheckWin(hand); Assert.Contains("对对胡", result.FanList); Assert.DoesNotContain("暗七对", result.FanList); // conflicts 互斥 } [Fact] public void 番型叠加_清一色对对胡_6番() { var hand = Tiles("一万,一万,一万, 二万,二万,二万, 三万,三万,三万, 四万,四万,四万, 五万,五万"); var result = solver.CheckWin(hand); Assert.Contains("清一色", result.FanList); Assert.Contains("对对胡", result.FanList); Assert.Equal(6, CalculateTotalFan(result)); // 4+2 } [Fact] public void 番型叠加_金钩钓不打单钓将() { // 金钩钓(2番) excludes 单钓将(0番) // 需要构造"已碰3副,只剩1张"的状态(从GameState判断,不在MeldsSolver) } ``` ### 6.5 听牌判断专项测试 ```csharp [Fact] public void 听牌_双面听_1万和4万() { var hand = Tiles("二万,三万, 五条,五条,五条, 六筒,六筒,六筒, 七万,八万,九万, 一条,一条"); var ting = solver.CheckTing(hand); Assert.Equal(2, ting.Count); Assert.Contains(ting, t => t.Rank == 1 && t.Suit == "万"); Assert.Contains(ting, t => t.Rank == 4 && t.Suit == "万"); } [Fact] public void 听牌_三面听() { var hand = Tiles("四万,五万,六万,七万,八万, 二筒,二筒,二筒, 三条,四条,五条, 六条,六条"); // 听 三万/六万/九万(三面听) var ting = solver.CheckTing(hand); Assert.Equal(3, ting.Count); } [Fact] public void 听牌_不听_差两张() { var hand = Tiles("一万,三万, 五条,五条,五条, 六筒,六筒,六筒, 七万,八万,九万, 一条,一条"); // 缺面子结构,怎么摸都不能胡 var ting = solver.CheckTing(hand); Assert.Empty(ting); } [Fact] public void 听牌_手牌含已碰_听牌判断应忽略已碰牌() { var hand = Tiles("五条,五条, 六筒,六筒,六筒, 七万,八万"); // 只有8张在手 var exposed = new List { new() { Type = "kezi", Tiles = TilesArray("三万,三万,三万") }, new() { Type = "shunzi", Tiles = TilesArray("一万,二万,三万") } }; // 听 六万/九万(双面听,已碰不影响) var ting = solver.CheckTing(hand, exposed); Assert.Equal(2, ting.Count); } ``` ### 6.6 错误处理测试 ```csharp [Fact] public void DSL加载_文件不存在_抛明确异常() { var ex = Assert.Throws( () => loader.Load("dsl-examples/not_exist.yaml")); Assert.Contains("not_exist.yaml", ex.Message); } [Fact] public void DSL加载_YAML格式错误_抛明确异常() { // 构造一个格式损坏的yaml var ex = Assert.Throws( () => loader.LoadString("game: { name: 四川麻将\n type: [broken")); Assert.Contains("syntax error", ex.Message.ToLower()); } [Fact] public void DSL加载_番型excludes指向不存在_形式化验证报错() { // 构造一个 excludes 指向不存在番型的 DSL // fan_types: [{ name: "清一色", excludes: ["不存在的番型"] }] var ex = Assert.Throws( () => loader.Load(yamlWithInvalidExcludes)); Assert.Contains("不存在的番型", ex.Message); Assert.Contains("excludes", ex.Message); } [Fact] public void Card_非法花色_抛异常() { Assert.Throws(() => MahjongTile.Encode("火星", 5)); } [Fact] public void Deck_从空牌墙抽牌_抛异常() { var deck = new MahjongDeck { Tiles = new List() }; Assert.Throws(() => deck.Draw()); } [Fact] public void Phase_非法操作_Settle阶段不能出牌() { var state = CreateState(phase: "settle"); var action = new PlayerAction { Type = "discard", PlayerId = "AI-东" }; var ex = Assert.Throws( () => engine.PhaseMachine.Execute(state, action)); Assert.Contains("settle", ex.Message); Assert.Contains("discard", ex.Message); } ``` ### 6.7 MeldsSolver 性能测试 ```csharp [Fact] public void 回溯搜索_全顺子材料_14张全连续_最坏情况() { // 这是回溯搜索的最坏输入:全是可组成顺子的牌 // 1万×4 + 2万×4 + 3万×4 + 4万×2 = 14张 // 回溯分支: 刻子分支(1万3张) + 顺子分支(1,2,3万) var hand = new List(); for (int i = 0; i < 4; i++) hand.Add(Tile("一万")); for (int i = 0; i < 4; i++) hand.Add(Tile("二万")); for (int i = 0; i < 4; i++) hand.Add(Tile("三万")); for (int i = 0; i < 2; i++) hand.Add(Tile("四万")); var sw = Stopwatch.StartNew(); for (int i = 0; i < 1000; i++) solver.CheckWin(hand); sw.Stop(); Assert.True(sw.ElapsedMilliseconds < 500, $"1000次胡牌判断应在500ms内,实际{sw.ElapsedMilliseconds}ms"); } [Fact] public void 听牌判断_34种牌_完整检查_应在20ms内() { var hand = Tiles("一万,二万,三万, 五条,五条,五条, 六筒,六筒,六筒, 七万,八万,九万, 一条"); var sw = Stopwatch.StartNew(); for (int i = 0; i < 100; i++) solver.CheckTing(hand); sw.Stop(); Assert.True(sw.ElapsedMilliseconds < 2000, $"100次听牌判断应在2000ms内,实际{sw.ElapsedMilliseconds}ms"); } ``` ### 6.8 测试文件总数预计 新增 4 个测试类别后: | 类别 | 用例数 | |------|-------| | 数据结构 (Tile/Deck/GameState) | 6 | | MeldsSolver (胡牌+番型+听牌) | 24 | | PhaseMachine | 12 | | ScoreEngine | 8 | | DslLoader (含错误处理) | 7 | | 番型互斥专项 | 5 | | 听牌判断专项 | 4 | | 错误处理 | 6 | | 性能 | 2 | | 集成测试 | 3 | | 压力测试 | 1 | | **总计** | **92** | --- ## 七、Demo 完成标准 - [ ] `dotnet test` — 92+ 个测试用例全部绿色(含 wildcard 缺口填充、258将、全不靠、双龙会、国标测试) - [ ] `dotnet run --project Demo` — 交互模式:四川血战(108张,缺一门+血战+查叫) - [ ] `dotnet run --project Demo -- --dsl guangdong` — 广东鸡平胡(136张,花牌+吃+番型三级) - [ ] `dotnet run --project Demo -- --dsl guobiao` — 国标麻将(144张,81番种+≥8番起胡) - [ ] `dotnet run --project Demo -- --dsl wuhan` — 武汉麻将(136张,红中癞子+258将+缺口填充回溯) - [ ] **热切换验证**:同一进程,四川→广东→国标→武汉串行跑,不需要重新编译、不需要重启 - [ ] 结算验证:四种麻将各自总分 = 0(零和)、牌数守恒(108/136/144/136) - [ ] 番型互斥正确:四川(清一色⊃缺一门)、广东(七对 vs 对对胡)、国标(81 番种互斥图形式化验证通过) - [ ] **wildcard 验证**:1张癞子补刻子、2张补齐、3张补面子+将、258将正确、癞子计分正确 - [ ] `dotnet run --project Demo -- --auto --count 1000` — 自动模式连续 1000 局零报错 ## 八、Demo 之后的路 ``` Demo ✅ 三种麻将 + 引擎扩展 + 热切换 ↓ Unity 前端: 麻将牌面渲染 + 出牌操作 UI ↓ Python AI 服务: MCTS 搜索 + LLM Agent ↓ CSharpScript 沙盒: 自定义计分/比较函数 ↓ 10+ 麻将玩法 DSL + CI 压测 ```