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个通过
2479 lines
84 KiB
Markdown
2479 lines
84 KiB
Markdown
# 麻将规则引擎 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/Decode、Suit/Rank、AllTiles |
|
||
| DeckTests | 3 | 108张、每张4份、洗牌不变 |
|
||
| MeldsSolverTests | 28 | 标准胡×3、七对×2、十三幺×2、清一色×2、wildcard×4、258将×2、癞子计分×1、全不靠×2、双龙会×1、不能胡×3、番型互斥×3、听牌×3 |
|
||
| PhaseMachineTests | 12 | 发牌、摸牌、出牌流转、碰、杠、优先级、血战淘汰×2、流局查叫×2、过水 |
|
||
| ScoreEngineTests | 8 | 鸡胡自摸、番型叠加、花猪扣分、听牌罚分、杠分、总分守恒 |
|
||
| DslLoaderTests | 3 | 加载DSL、缺能力报错、缺文件 |
|
||
|
||
### 6.2 集成测试
|
||
|
||
```csharp
|
||
[Fact]
|
||
public void 四川血战_4AI自动打完_完整对局()
|
||
{
|
||
var rules = loader.Load("dsl-examples/xuezhandaodi.yaml");
|
||
var room = new MahjongRoom(rules, new[] { "AI-1", "AI-2", "AI-3", "AI-4" });
|
||
room.Run();
|
||
|
||
Assert.True(room.IsFinished);
|
||
// 总分应为零(零和游戏)
|
||
Assert.Equal(0, room.State.Scores.Values.Sum());
|
||
// 108 张牌守恒
|
||
Assert.Equal(108, CountAllTiles(room.State));
|
||
}
|
||
```
|
||
|
||
### 6.3 压力测试
|
||
|
||
```csharp
|
||
[Fact]
|
||
public void 四川血战_连续1000局_零报错()
|
||
{
|
||
var rules = loader.Load("dsl-examples/xuezhandaodi.yaml");
|
||
|
||
for (int i = 0; i < 1000; i++)
|
||
{
|
||
var room = new MahjongRoom(rules,
|
||
new[] { $"AI-{i}-1", $"AI-{i}-2", $"AI-{i}-3", $"AI-{i}-4" });
|
||
try
|
||
{
|
||
room.Run();
|
||
|
||
// 不变量1: 牌数守恒
|
||
Assert.Equal(108, CountAllTiles(room.State));
|
||
|
||
// 不变量2: 零和游戏
|
||
Assert.Equal(0, room.State.Scores.Values.Sum());
|
||
|
||
// 不变量3: 没有人同时胡和未胡
|
||
Assert.Empty(room.State.HuPlayers.Intersect(room.State.AlivePlayers));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Assert.Fail($"第 {i} 局出错:\n{ex}");
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 6.4 番型互斥专项测试
|
||
|
||
这是 Demo 阶段最容易被跳过的测试,但也是最容易出 bug 的地方。
|
||
|
||
```csharp
|
||
[Fact]
|
||
public void 番型互斥_清一色_不计算缺一门()
|
||
{
|
||
var hand = Tiles("一万,一万,一万, 二万,三万,四万, 五万,五万,五万, 六万,七万,八万, 九万,九万");
|
||
var result = solver.CheckWin(hand);
|
||
|
||
Assert.Contains("清一色", result.FanList);
|
||
Assert.DoesNotContain("缺一门", result.FanList); // 被 excludes
|
||
Assert.Equal(4, CalculateTotalFan(result)); // 只计清一色4番
|
||
}
|
||
|
||
[Fact]
|
||
public void 番型互斥_七对和对对胡_只保留高级()
|
||
{
|
||
// 全刻子但不构成七对 → 对对胡(2番)
|
||
var hand = Tiles("一万,一万,一万, 二万,二万,二万, 三条,三条,三条, 四筒,四筒,四筒, 五条,五条");
|
||
var result = solver.CheckWin(hand);
|
||
Assert.Contains("对对胡", result.FanList);
|
||
Assert.DoesNotContain("暗七对", result.FanList); // conflicts 互斥
|
||
}
|
||
|
||
[Fact]
|
||
public void 番型叠加_清一色对对胡_6番()
|
||
{
|
||
var hand = Tiles("一万,一万,一万, 二万,二万,二万, 三万,三万,三万, 四万,四万,四万, 五万,五万");
|
||
var result = solver.CheckWin(hand);
|
||
Assert.Contains("清一色", result.FanList);
|
||
Assert.Contains("对对胡", result.FanList);
|
||
Assert.Equal(6, CalculateTotalFan(result)); // 4+2
|
||
}
|
||
|
||
[Fact]
|
||
public void 番型叠加_金钩钓不打单钓将()
|
||
{
|
||
// 金钩钓(2番) excludes 单钓将(0番)
|
||
// 需要构造"已碰3副,只剩1张"的状态(从GameState判断,不在MeldsSolver)
|
||
}
|
||
```
|
||
|
||
### 6.5 听牌判断专项测试
|
||
|
||
```csharp
|
||
[Fact]
|
||
public void 听牌_双面听_1万和4万()
|
||
{
|
||
var hand = Tiles("二万,三万, 五条,五条,五条, 六筒,六筒,六筒, 七万,八万,九万, 一条,一条");
|
||
var ting = solver.CheckTing(hand);
|
||
Assert.Equal(2, ting.Count);
|
||
Assert.Contains(ting, t => t.Rank == 1 && t.Suit == "万");
|
||
Assert.Contains(ting, t => t.Rank == 4 && t.Suit == "万");
|
||
}
|
||
|
||
[Fact]
|
||
public void 听牌_三面听()
|
||
{
|
||
var hand = Tiles("四万,五万,六万,七万,八万, 二筒,二筒,二筒, 三条,四条,五条, 六条,六条");
|
||
// 听 三万/六万/九万(三面听)
|
||
var ting = solver.CheckTing(hand);
|
||
Assert.Equal(3, ting.Count);
|
||
}
|
||
|
||
[Fact]
|
||
public void 听牌_不听_差两张()
|
||
{
|
||
var hand = Tiles("一万,三万, 五条,五条,五条, 六筒,六筒,六筒, 七万,八万,九万, 一条,一条");
|
||
// 缺面子结构,怎么摸都不能胡
|
||
var ting = solver.CheckTing(hand);
|
||
Assert.Empty(ting);
|
||
}
|
||
|
||
[Fact]
|
||
public void 听牌_手牌含已碰_听牌判断应忽略已碰牌()
|
||
{
|
||
var hand = Tiles("五条,五条, 六筒,六筒,六筒, 七万,八万"); // 只有8张在手
|
||
var exposed = new List<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 压测
|
||
```
|