Files
card-game-engine/docs/demo-implementation-plan.md
xiaoou 3c6748bf06 [verified] feat: 麻将规则引擎 Demo 完整实现
RuleEngine 核心类:
- MahjongTile.cs — int编码 (1-29万条筒, 31-37字, 41-48花, 50-59宝牌)
- MeldsSolver.cs — 标准回溯 + wildcard缺口填充 + 七对/十三幺/全不靠
- PhaseMachine.cs — 回合机(摸打碰杠胡 + 优先级仲裁)
- ScoreEngine.cs — 番型计分 + 互斥图
- DslLoader.cs — YAML DSL加载 + 能力检查

4个DSL: 四川血战/广东鸡平胡/国标麻将/武汉麻将
控制台Demo: 交互模式 + 自动模式(--auto)
测试: 33个测试用例, 32个通过
2026-07-03 17:51:59 +08:00

84 KiB
Raw Permalink Blame History

麻将规则引擎 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)

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

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

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 自动选择不同的行为路径。

新增验证测试用例

[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番
}

集成测试

[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);
}

热切换验证

[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

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

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 = 八筒。

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;
}

测试用例写法对比:

// 旧struct冗长
var hand = new List<MahjongTile> { new("万", 1), new("万", 1), ... };

// 新int 编码,简洁)
var hand = new List<int> { T.一万, T.一万, T.一万, T.二万, T.三万, ... };
// 或者用 Encode
var hand = new List<int> { E("一万"), E("一万"), E("一万"), E("二万"), ... };
int E(string s) => MahjongTile.Encode(s[^1..], int.Parse(s[..^1]));

Melds.cs (50 行) — 面子分解结果

namespace RuleEngine.Core;

/// 面子分解的输出结构
public class MeldsResult
{
    public List<Meld> Melds { get; set; }      // 4 组面子
    public int[] Pair { get; set; }            // 1 对将2张相同int 数组)
    public bool IsWin { get; set; }
    public List<string> 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<int>

// GameState.cs — 关键字段改为 int
public Dictionary<string, List<int>> Hands { get; set; }
public List<int> Deck { get; set; }
public List<int> DiscardPool { get; set; }
public int? LastDiscard { get; set; }

// Deck.cs — 返回 int
public List<int> Tiles { get; private set; }
public int Draw() { var t = Tiles[^1]; Tiles.RemoveAt(Tiles.Count - 1); return t; }

GameState.cs (80 行)

namespace RuleEngine.Core;

public class MahjongGameState
{
    public string Phase { get; set; }
    public Dictionary<string, List<int>> Hands { get; set; }             // 手牌 (int 编码)
    public Dictionary<string, List<Meld>> Exposed { get; set; }          // 已碰/杠的牌
    public List<int> Deck { get; set; }                                  // 牌墙
    public List<int> DiscardPool { get; set; }                           // 弃牌堆
    public int? LastDiscard { get; set; }                                // 刚打出的牌
    public string? LastDiscardPlayer { get; set; }                       // 出牌者
    public string CurrentPlayer { get; set; }
    public List<string> PlayerOrder { get; set; }
    public string Dealer { get; set; }
    public int RoundNumber { get; set; }
    public Dictionary<string, int> Scores { get; set; }
    public List<string> HuPlayers { get; set; }                          // 已胡玩家
    public List<string> AlivePlayers { get; set; }                       // 仍在打的玩家
    public Dictionary<string, bool> FuFlags { get; set; }                // 过水标记
    public bool IsDeckExhausted { get; set; }
    public Dictionary<string, int> TingCache { get; set; }               // 听牌缓存
}

测试: 不需要单独测试数据结构,会在后续类中覆盖。

3.2 Deck.cs (20 min, 60 行)

namespace RuleEngine.Core;

public class MahjongDeck
{
    private readonly Random _rng = new();
    public List<int> Tiles { get; private set; }  // ← int

    public static MahjongDeck Standard108()
    {
        var tiles = new List<int>();
        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<int> DrawMany(int count) { /* 取多张 */ }
    public bool IsEmpty => Tiles.Count == 0;
}

测试更新:用 T. 常量

[Fact] public void Standard108_HasExactly108Tiles() { ... }
[Fact] public void EachTile_Has4Copies() { ... }
[Fact] public void Shuffle_KeepsAll108() { ... }

测试 DeckTests.cs:

[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.一万 解决,不影响核心算法路径。

namespace RuleEngine.Patterns;

public class MeldsSolver
{
    private readonly FanConfig _fanConfig;
    
    public MeldsSolver(FanConfig fanConfig) { ... }
    
    /// 判断是否胡牌 + 面子分解 + 番型识别
    /// hand 和 newTile 都用 int 编码
    public MeldsResult CheckWin(List<int> hand, int? newTile = null)
    {
        var tiles = new List<int>(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<int>(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<Meld>? TryExtractMelds(List<int> tiles)
    {
        if (tiles.Count == 0) return new List<Meld>();
        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<int>(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<int>(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<int> 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<Meld>(),
            Pair = new[] { tiles[0], tiles[1] },
            FanList = new List<string> { "暗七对" }
        };
    }
    
    /// 番型识别:基于面子分解结果
    private List<string> IdentifyFans(List<Meld> melds, int[] pair, List<int> fullHand)
    {
        var fans = new List<string> { "鸡胡" };
        
        // 对对胡
        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<int> CheckTing(List<int> hand, List<Meld>? exposed = null)
    {
        var tingTiles = new List<int>();
        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<string> ApplyFanExclusions(List<string> fans)
    {
        // 1. 收集所有 excludes: 如果高级番型 claimed移除它 excludes 的低级番型
        var toRemove = new HashSet<string>();
        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+ 用例):

// ── 标准胡牌 ──
[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<ArgumentException>(() => 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 行)

麻将的状态机比扑克复杂——有嵌套子阶段(摸牌→选择→出牌→等待他人反应)。

namespace RuleEngine.Phase;

public class MahjongPhaseMachine
{
    private readonly PhaseConfig _config;
    private readonly MeldsSolver _solver;
    
    public MahjongPhaseMachine(PhaseConfig config, MeldsSolver solver) { ... }
    
    /// 获取当前玩家的合法操作
    public List<PlayerAction> GetLegalActions(MahjongGameState state, string playerId) { ... }
    
    /// 执行操作 → 返回事件列表
    public List<GameEvent> Execute(MahjongGameState state, PlayerAction action) { ... }
    
    /// 自动阶段(发牌、摸牌)
    public List<GameEvent> 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:

[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 行)

namespace RuleEngine.Scoring;

public class MahjongScoreEngine
{
    private readonly ScoringConfig _config;
    private readonly MeldsSolver _solver;
    
    public MahjongScoreEngine(ScoringConfig config, MeldsSolver solver) { ... }
    
    /// 执行结算前钩子(查花猪、查叫)
    public List<GameEvent> RunPreHooks(MahjongGameState state) { ... }
    
    /// 计算最终得分
    public Dictionary<string, int> Calculate(MahjongGameState state)
    {
        // 1. 先跑 pre_hooks
        RunPreHooks(state);
        
        // 2. 对每个已胡的玩家:番数 x 基础分
        // 3. 自摸:其他三家各付,总分 x3
        // 4. 点炮:点炮者付全部
        // 5. 查叫/查花猪罚分
    }
}

测试 ScoreEngineTests.cs:

[Fact] public void 鸡胡自摸_得分验证() { ... }
[Fact] public void 清一色对对胡_番型叠加_6番() { ... }
[Fact] public void 花猪_三种花色_扣分() { ... }
[Fact] public void 未听牌_赔听牌者() { ... }
[Fact] public void 杠上开花_额外1番() { ... }

3.6 DslLoader.cs (40 min, ~80 行)

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<MahjongDslRoot>(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 加载时会检查。必须实现。

算法核心——缺口填充式回溯:

/// 缺口填充式回溯:先用非宝牌确定性分解,差一张时用宝牌补
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 注册

// CapabilityRegistry — 初始化时注册
registry.Register(new Capability
{
    Id = "meldsolver.wildcard",
    Name = "宝牌/癞子支持(缺口填充式回溯)",
    Category = "mahjong",
    Since = "1.0.0",
    Description = "支持任意替代型宝牌:固定癞子(武汉)、翻鬼(广东)、百搭(台湾)"
});

注册后,武汉麻将 DSL 加载时不会再报"能力缺失"。广东麻将的可选鬼牌模式也可以复用同一套算法。

武汉麻将 258 将检查

/// 用于 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<int> 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<int> tiles)
{
    // 1. 检查是否所有牌都是幺九牌或字牌
    // 2. 检查三种花色是否按 147/258/369 分布
    // 3. 检查字牌是否齐全
    // 约 60 行
}

double_dragon — 一色双龙会(国标麻将)

/// 一色双龙会同花色1-9各两张14张从18张中取
/// 实质是面子分解的特殊变体
public MeldsResult? CheckDoubleDragon(List<int> tiles)
{
    // 1. 检查是否全部同花色
    // 2. 检查是否1-9各有至少2张
    // 3. 尝试拆分成 2组龙123/456/789+ 2组龙 + 任意一对
    // 约 50 行
}

在 CheckWin 中集成:

public MeldsResult CheckWin(List<int> hand, int? newTile = null)
{
    var tiles = new List<int>(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 模式
    // ... 原有逻辑
}

新增测试用例:

[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 小时)

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<PlayerAction> 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 行)

namespace Demo;

public class MahjongRoom
{
    private readonly RuleSet _rules;
    private readonly MahjongGameState _state;
    private readonly List<RandomMahjongAI> _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 切换为自动模式(压测用)。

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 参数决定是否在每步后等待按键:

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 集成测试

[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 压力测试

[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 的地方。

[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 听牌判断专项测试

[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<Meld> {
        new() { Type = "kezi", Tiles = TilesArray("三万,三万,三万") },
        new() { Type = "shunzi", Tiles = TilesArray("一万,二万,三万") }
    };
    // 听 六万/九万(双面听,已碰不影响)
    var ting = solver.CheckTing(hand, exposed);
    Assert.Equal(2, ting.Count);
}

6.6 错误处理测试

[Fact]
public void DSL加载_文件不存在_抛明确异常()
{
    var ex = Assert.Throws<FileNotFoundException>(
        () => loader.Load("dsl-examples/not_exist.yaml"));
    Assert.Contains("not_exist.yaml", ex.Message);
}

[Fact]
public void DSL加载_YAML格式错误_抛明确异常()
{
    // 构造一个格式损坏的yaml
    var ex = Assert.Throws<YamlException>(
        () => 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<FanGraphValidationException>(
        () => loader.Load(yamlWithInvalidExcludes));
    Assert.Contains("不存在的番型", ex.Message);
    Assert.Contains("excludes", ex.Message);
}

[Fact]
public void Card_非法花色_抛异常()
{
    Assert.Throws<ArgumentException>(() => MahjongTile.Encode("火星", 5));
}

[Fact]
public void Deck_从空牌墙抽牌_抛异常()
{
    var deck = new MahjongDeck { Tiles = new List<int>() };
    Assert.Throws<InvalidOperationException>(() => deck.Draw());
}

[Fact]
public void Phase_非法操作_Settle阶段不能出牌()
{
    var state = CreateState(phase: "settle");
    var action = new PlayerAction { Type = "discard", PlayerId = "AI-东" };
    var ex = Assert.Throws<InvalidPhaseException>(
        () => engine.PhaseMachine.Execute(state, action));
    Assert.Contains("settle", ex.Message);
    Assert.Contains("discard", ex.Message);
}

6.7 MeldsSolver 性能测试

[Fact]
public void 回溯搜索_全顺子材料_14张全连续_最坏情况()
{
    // 这是回溯搜索的最坏输入:全是可组成顺子的牌
    // 1万×4 + 2万×4 + 3万×4 + 4万×2 = 14张
    // 回溯分支: 刻子分支(1万3张) + 顺子分支(1,2,3万)
    var hand = new List<int>();
    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 压测