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个通过
84 KiB
麻将规则引擎 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;
}
关键改动点:
counts索引扩展:万1-9 → 0-8, 条1-9 → 9-17, 筒1-9 → 18-26, 字31-37 → 27-33。宝牌不进入 counts,单独用wildcardCount追踪。FinalizeWithWildcards:处理"非宝牌已全部消耗完,只剩宝牌"的情况。剩余宝牌数 ≥ 2w 时表示可能有 w 个宝牌对子/面子。IsValidPair扩展:武汉麻将需检查 258 将(tile 的 rank 是 2/5/8,或者传递allowWildcard)。- 宝牌计数:胡牌结果中需标记每张宝牌被用来替代了什么牌,供 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 压测