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

2479 lines
84 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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