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个通过
80 KiB
棋牌规则引擎 — 架构设计与研究计划
关联文档: Demo 实施计划见
docs/demo-implementation-plan.md目标: 构建一套 YAML DSL 驱动的棋牌规则引擎。引擎按 DSL 编排执行,不依赖 LLM。LLM 是可选的 DSL 生产工具。
核心理念: "玩法即配置"——一个 YAML 文件 = 一种棋牌玩法,引擎加载后自动运行。不需要为每种玩法写代码。
技术栈: C# (规则引擎,嵌入 Unity game server) + Python (AI 陪打服务) + YamlDotNet (DSL 解析)
一、问题定义
1.1 当前痛点
中国各地棋牌玩法差异巨大:
- 扑克类:斗地主、跑得快、炸金花、牛牛、十三张...每地变种数十种
- 麻将类:四川血战、广东鸡平胡、日本立直、国标...规则差异在番型/计分/流程
- 地方棋类:各种地方象棋变种、对角棋、五子棋变种
传统做法:每种玩法需要策划写规则文档→开发读文档手写代码→测试。周期 2-4 周/玩法。
1.2 LLM 的机会
LLM 在以下方面有优势:
- 自然语言理解:从非结构化规则描述中提取结构化信息
- 代码生成:将结构化规则转为可执行逻辑
- 策略生成:基于规则生成陪打 AI 的决策逻辑
LLM 的劣势(需要解决):
- 幻觉:生成的代码可能有逻辑错误
- 安全性:生成的代码不能直接执行在生产环境
- 一致性:多次生成结果可能不同
二、架构设计
2.0 关键架构决策:规则引擎 ≠ 游戏引擎
棋牌游戏的本质分两层,它们是正交的:
| 层 | 职责 | 与玩法关系 |
|---|---|---|
| 规则引擎 | 发牌算法、牌型匹配、出牌校验、回合流转、计分结算 | 每种玩法不同,LLM DSL 驱动的核心 |
| 游戏引擎 | 牌面渲染、动画、音效、用户输入捕获、房间管理 | 完全通用,与玩法无关 |
这意味着:
- 规则引擎是一个纯逻辑库(C#),不依赖任何图形框架。输入游戏状态 → 输出合法操作/新状态。可以脱离前端独立测试。
- 前端引擎(Cocos/Phaser/Unity/自研 Canvas)只管"画牌"和"收点击",从规则引擎拿状态,把用户操作喂回去。
- 换游戏引擎不需要动规则引擎,换玩法不需要动游戏引擎。
所以选型重点不在"哪个游戏引擎好",而在规则引擎的 DSL 表达力。游戏引擎可以先用最轻量的方案(C# 控制台原型),后期再换 Unity。
更重要的是:规则引擎本身不依赖 LLM。 LLM 只是生产 DSL 配置文件的一种方式——你也可以手写 yaml、从模板生成、或者用 GUI 配置器。规则引擎是纯逻辑系统,输入 DSL 配置 → 输出游戏行为。
2.0b 关键架构决策:扑克引擎与麻将引擎分离
扑克和麻将在算法层面是两类完全不同的问题,硬合并会导致两边都做不干净:
| 扑克 | 麻将 | |
|---|---|---|
| 核心算法 | 牌型模式匹配:从手牌中找顺子/炸弹/三带二等组合 | 面子分解:14 张牌拆成 4 组面子(顺子/刻子)+ 1 对将 |
| 操作模式 | 轮流出牌,同一时间只有一人在操作 | 有人出牌后,多人可能同时碰/杠/胡,需要优先级仲裁 |
| 手牌变化 | 出牌减少,动态变化 | 摸牌后出牌,保持 13/14 张恒定 |
| 计分特点 | 底分 × 倍数,线性公式 | 番型叠加,组合计算 |
| 牌库 | 52-54 张,固定 | 108-144 张,各地区不同 |
| 回合结构 | 固定:叫地主→出牌→结算 | 灵活:摸→出→吃碰杠胡→查叫→血战 |
决策:共享基础层 + 各自专项引擎。
┌──────────────────────────────────────────────────┐
│ 共享基础层 │
│ Card 抽象 │ 规则加载器 │ 状态机框架 │
│ 校验链框架 │ 表达式计分引擎 │ 事件总线 │
└────────────┬─────────────────┬───────────────────┘
│ │
┌────────▼────────┐ ┌─────▼──────────────┐
│ 扑克规则引擎 │ │ 麻将规则引擎 │
│ │ │ │
│ PatternMatcher │ │ MeldsSolver │
│ (声明式牌型匹配) │ │ (面子分解+胡牌判断) │
│ ValidatorChain │ │ PriorityResolver │
│ (跟牌/比大小) │ │ (碰杠胡优先级仲裁) │
│ DiscardFlow │ │ FanCalculator │
│ (出牌流程) │ │ (番型叠加计算) │
└─────────────────┘ └─────────────────────┘
分离后的覆盖率:
- 扑克引擎:斗地主/炸金花/牛牛/跑得快/掼蛋/德州/21点/十三水/升级/桥牌/UNO — 100%
- 麻将引擎:四川血战/广东鸡平胡/国标/日本立直/武汉麻将/长沙麻将 — 100%
- 各自不需要为对方做妥协,DSL 字段各取所需,不需要一套字段同时表达"炸弹"和"番型"
不分离的代价:PatternMatcher 必须同时理解"斗地主的飞机带翅膀"和"麻将的听牌判断",validator 要兼容"跟牌型必须更大"和"碰杠胡优先级仲裁",两个领域都渗漏。分开后各自精专,基础层共享避免重复。
2.0c 覆盖率深度验证:以四川麻将血战到底为例穷举分析
光说"麻将引擎能到 100%"是空话。必须把一条条规则拆开,逐条验证声明式 DSL 能不能兜住,兜不住的怎么办。
下面以 四川麻将血战到底(牌库 108 张、缺一门、血战、查叫查花猪)为例穷举所有规则需求:
规则清单与覆盖分析
一、牌库 (108张万条筒) → DeckManager 声明式 ✅
二、发牌 (庄14闲13) → deal phase 声明式 ✅
三、缺一门检查 (不能三色) → validator chain ✅
四、基本胡牌 (4面子+1对将) → 需要内置算法 🔧
五、七对胡 (7个对子) → 内置算法的分支 🔧
六、听牌判断 (缺一张能胡) → 内置算法 🔧
七、番型识别 (清一色/对对胡..) → 内置算法输出番型列表 🔧
八、番型互斥 (清一色⊃无字) → DSL 互斥图 📐
九、番型叠加计分 → DSL 番型表 📐
十、杠 (明杠/暗杠/加杠/杠补) → 子状态机 📐
十一、碰 (跳摸牌) → 状态机转移 ✅
十二、优先级仲裁 (胡>杠>碰) → priority_policy ✅
十三、血战到底 (胡后不结束) → elimination phase 📐
十四、查叫/查花猪 (流局检查) → 结算前钩子 📐
十五、过水 (胡过必须等一轮) → dirty_flag 📐
十六、多人同时胡/碰 → 时序+优先级矩阵 📐
- ✅ 已覆盖,纯声明式
- 🔧 需要内置算法(声明式做不到,但算法是通用的)
- 📐 声明式可覆盖,但需要扩展 DSL 字段
🔧 内置算法:这是"声明式做不到"的部分
胡牌判断算法是无法声明式表达的。14 张牌能否拆成 4 面子 + 1 对将,本质是一个回溯搜索:
输入: [1万,1万,1万, 2万,3万,4万, 5条,5条,5条, 6筒,6筒,6筒, 8条,8条]
输出: Melds = [{type:"kezi", tiles:[1万×3]}, {type:"shunzi", tiles:[2,3,4万]},
{type:"kezi", tiles:[5条×3]}, {type:"kezi", tiles:[6筒×3]}]
Pair = [8条, 8条]
IsWin = true
这个算法是麻将引擎的唯一算法核心,但它跟玩法无关——斗地主不需要、扑克不需要。实现一次,所有麻将玩法共用。
算法复杂度:回溯搜索,最坏 O(3^n),但手牌只有 14 张 + 剪枝优化,实际 < 1ms。
听牌判断是胡牌判断的子问题:对 13 张手牌,尝试每张可能摸到的牌,检查能否胡。
番型识别以面子分解(melds)为输入:
// 面子分解已经完成了,番型只需"看"
var fanList = new List<string>();
if (melds.All(m => m.Suit == melds[0].Suit) && pair.Suit == melds[0].Suit)
fanList.Add("清一色");
if (melds.All(m => m.Type == "kezi"))
fanList.Add("对对胡");
if (melds.All(m => m.Tiles.Any(t => t.Rank == 1 || t.Rank == 9))
&& (pair[0].Rank == 1 || pair[0].Rank == 9)) // 将牌也需带幺九
fanList.Add("带幺九");
// ...
关键:胡牌判断是算法;番型识别是"基于算法输出的声明式规则"。
📐 需要扩展 DSL 的部分
**番型互斥图:**一个番型可能包含另一个,需要显式声明"不计"关系:
fan_types:
- name: "清一色"
base_fan: 4
excludes: ["无字", "缺一门"] # 清一色必然无字、必然缺一门
- name: "暗七对"
base_fan: 4
excludes: ["门清", "单钓将"] # 七对必然门清、必然单钓
conflicts: ["对对胡"] # 和七对互斥
- name: "杠上开花"
base_fan: 1
excludes: ["海底捞月"] # 杠补牌≠最后一张
- name: "对对胡"
base_fan: 2
conflicts: ["暗七对"]
- name: "金钩钓"
base_fan: 2
excludes: ["单钓将"] # 金钩钓就是只剩一张,不计单钓
计分时:先采集所有满足的番型列表 → 去掉被 excludes 排除的 → 检查 conflicts 互斥只留一个 → 累加番数。
血战到底的特殊 phase:
phases:
- name: "play"
type: "mahjong_turn" # 麻将专用回合类型
sub_phases:
draw: { type: "auto", action: "draw_card" }
self_action: # 摸牌后自己的操作
options:
- { action: "kong", types: ["ming_kong", "an_kong", "bu_kong"] }
- { action: "win", condition: "can_win" }
- { action: "discard" }
others_reaction: # 出牌后他人的操作
options:
- { action: "pung", priority: 2 }
- { action: "kong", priority: 3 }
- { action: "win", priority: 4 } # 优先级最高
priority_policy: "highest_wins"
- name: "blood_war" # 血战到底
type: "parallel_elimination"
on_eliminate: "hu_paid" # 胡牌的人出局
continue_until: "last_one" # 直到最后一人
on_exhausted: "check_ting" # 流局时查叫
查叫/查花猪 — 结算前钩子:
scoring:
pre_hooks:
- name: "check_hua_zhu" # 先查花猪
condition: "deck_exhausted"
action: |
// 检查未胡的人是否三种花色都有
for p in alive_players:
if countSuits(p.hand) == 3:
// 花猪赔三家
penalty = total_score / alive_players.count
- name: "check_ting" # 再查叫
condition: "deck_exhausted AND not hua_zhu"
action: |
for p in alive_players:
if not isTing(p.hand):
// 不听牌赔听牌的人
过水 dirty_flag:
phases:
play:
state:
fu_flag: # 过水标记
type: "dirty_flag"
set_on: "can_win_but_pass"
clear_on: "next_discard_self" # 自己打出下一张牌后清除
effect: "block_win_on_current_tile" # 本张牌不能再胡
总结:真正需要"代码"的部分
| 类型 | 数量 | 实现方式 | 跨玩法复用 |
|---|---|---|---|
| 纯声明式 DSL | ~40% | YAML 配置 | 100% |
| 扩展 DSL 字段 | ~30% | YAML + 新字段(互斥图/钩子/sub_phase) | 100%(设计一次所有麻将共用) |
| 内置通用算法 | ~20% | C# 实现(胡牌判断/听牌/面子分解) | 100%(所有麻将玩法用同一个算法) |
| gameplay hook | ~10% | DSL 引用的 custom 函数 | 按需(查花猪/查叫/特殊计分) |
关键洞察:胡牌判断算法是唯一的"硬骨头",但它写一次后所有麻将玩法共用。 其余 80% 都是声明式 DSL + 扩展字段。这正是分离设计的好处——麻将引擎的 PatternMatcher 就是 MeldsSolver,不需要兼容扑克。
2.0d 0 bug 策略:如何保证正确性
规则引擎是确定性系统,这意味着可以做到 100% 测试覆盖。
策略1:算法层穷举测试
胡牌判断是最易出 bug 的地方。测试策略:
// 1. 正例:已知胡牌的手牌
[Theory]
[InlineData(new[]{1,1,1, 2,3,4, 5,5,5, 6,6,6, 8,8}, true)] // 标准胡
[InlineData(new[]{1,1, 2,2, 3,3, 4,4, 5,5, 6,6, 7,7}, true)] // 七对胡
[InlineData(new[]{1,1,1, 2,2,2, 3,3,3, 4,4,4, 5,5}, true)] // 碰碰胡
public void TestWinDetection(int[] tiles, bool expected) { ... }
// 2. 反例:不满足胡牌条件
[Theory]
[InlineData(new[]{1,2,3, 4,5,6, 7,8,9, 2,2,2, 3,3}, false)] // 多一张
[InlineData(new[]{1,1,1, 2,3,5, 7,8,9, 2,2,2, 3,3}, false)] // 缺顺子
public void TestNotWin(int[] tiles, bool expected) { ... }
// 3. 穷举:所有清一色听牌组合(已知组合数)
// 所有碰碰胡组合
// 所有混一色组合
// → 生成器 + 断言
策略2:番型互斥图形式化验证
加载 DSL → 构建番型互斥有向图 →
检查: 无循环依赖
检查: excludes 指向的番型确实存在
检查: conflicts 双向对称
→ 不通过则拒绝加载 DSL
策略3:状态机死锁/活锁检测
加载 phase 定义 → 构建状态转移图 →
检查: 所有状态可达
检查: 无孤立节点
检查: 所有路径都能到达 end state
→ 模拟 1000 局随机路径
策略4:确定性断言
规则引擎的每个决策都可以写成单元测试:
[Fact]
public void 杠上开花_自摸_番型计算_验证() {
// 构造已知状态 → 模拟摸到最后一张 → 判定胡牌 → 验证番型
var state = CreateState(/* 手牌+已碰+已杠+剩余牌 */);
state.DrawCard(/* 最后一张牌 */);
var result = engine.CheckWin(state);
Assert.True(result.CanWin);
Assert.Contains("杠上开花", result.Fans);
Assert.Contains("清一色", result.Fans);
Assert.DoesNotContain("海底捞月", result.Fans); // 互斥
Assert.Equal(5, result.TotalFan); // 1(杠开)+4(清一色)
}
策略5:模糊测试
// 连续随机生成 10000 局麻将,做不变量检查:
for (int i = 0; i < 10000; i++) {
var room = RandomMahjongGame();
// 不变量1: 总牌数始终 = 108
Assert.Equal(108, totalCards(room));
// 不变量2: 听牌判断 → 缺一张能胡 → 实际摸到那张牌确实能胡
foreach (var p in room.Players) {
var tingList = engine.CheckTing(p.Hand);
foreach (var tile in tingList) {
Assert.True(engine.CanWin(p.Hand + tile));
}
}
// 不变量3: 番型互斥正确
var fans = engine.CalculateFans(state);
AssertNoConflictViolation(fans, rules.FanExclusionGraph);
}
结论:0 bug 不是一个目标,是一个工程纪律。 规则引擎是确定性系统——每个输入对应唯一输出。只要做到:算法穷举测试 + 番型互斥图形式化验证 + 状态机可达性检查 + 10000 局模糊测试不变量,就能保证正确性。
真正的风险不在算法,在规则描述的错误——人写的 DSL 配置本身可能有逻辑漏洞。所以需要模拟 1000 局 + 人工审核,而不是依赖 LLM 直接生成就上线。
2.0e 横向覆盖验证:三种麻将穷举对比(Demo 已扩展至四种)
光有四川血战一种不够。选三种差异足够大的麻将,逐条列出各自的特殊规则,交叉验证 DSL 引擎是否都能兜住。
选型:三种麻将的差异维度
| 维度 | 四川血战到底 | 广东鸡平胡 | 国标麻将 |
|---|---|---|---|
| 牌库 | 108张(无字无花) | 136张(+字+花) | 144张(+字+花) |
| 吃牌 | ❌ 不能吃 | ✅ 可以吃 | ✅ 可以吃 |
| 特殊牌 | 无 | 花牌(即补+计分)、可选鬼牌 | 花牌 |
| 胡牌条件 | 缺一门 + 基本胡 | 分三级:鸡胡/平胡/爆胡 | 必须 ≥8番 |
| 结束条件 | 血战到底(胡了不结束) | 有人胡就结束 | 有人胡就结束 |
| 流局处理 | 查叫 + 查花猪 | 无特殊 | 无特殊 |
| 番型体系 | 约10种,线性叠加 | 约30种,分三级 | 81种,12级,复杂互斥 |
| 番型互斥 | 简单(清一色⊃缺一门) | 中等(级别互斥) | 复杂(不计+不得重复) |
| 花牌计分 | N/A | 每花1番,正花额外 | 每花计分+补花后补牌 |
| 鬼牌/百搭 | 无 | 有(翻鬼/百变) | 无 |
| 特殊胡型 | 杠上开花、抢杠胡 | 十三幺、九莲宝灯 | 全不靠、组合龙、一色双龙会 |
| 多人胡牌 | 优先级仲裁 | 一炮三响(全胡) | 优先最近座次 |
逐条覆盖验证表
规则需求 四川 广东 国标 引擎覆盖方式
─────────────────────────────────────────────────────────────
牌库声明式定义 ✅ ✅ ✅ DeckManager (generator)
花牌摸到即补 - ✅ ✅ Phase sub_phase auto
花牌正花计分 - ✅ ✅ DSL fan_types + 座位映射
花牌补牌后继续 - ✅ ✅ Phase sub_phase loop
鬼牌/百搭牌(翻鬼) - 🔧 - MeldsSolver 内建 wildcard 模式
吃牌 - ✅ ✅ Phase option: "chi"
缺一门强制检查 ✅ - - Validator: "check_suit_count"
8番起胡 - - ✅ Validator: "min_fan_check"
基本胡牌(4面子+1对) ✅ ✅ ✅ MeldsSolver (内置算法)
七对胡 ✅ ✅ ✅ MeldsSolver.isSevenPairs()
十三幺 - ✅ ✅ MeldsSolver 特殊分支
全不靠 - - 🔧 特殊算法分支(组合龙同理)
九莲宝灯 - - ✅ 胡牌判断自动支持(清一色1-9+额外)
连七对 - - ✅ 七对算法+同花色检查
一色双龙会 - - 🔧 特殊面子分解
碰牌 ✅ ✅ ✅ Phase option
杠(明/暗/加) ✅ ✅ ✅ Phase sub_phase chain
优先级仲裁(胡>杠>碰>吃) ✅ ✅ ✅ DSL priority_policy
一炮三响(多人同时胡) - ✅ ✅ priority_policy: "all_winners"
血战(胡后不结束) ✅ - - Phase type: parallel_elimination
查叫/查花猪 ✅ - - Scoring pre_hooks
番型识别(清一色/对对胡等) ✅ ✅ ✅ 内置算法 → 输出番型列表
番型分级(鸡/平/爆) - ✅ - DSL fan_type.level
番型互斥(excludes/conflicts) ✅ ✅ ✅ DSL 互斥有向图
番型不计/不得重复 - - ✅ DSL excludes(不计) + 去重逻辑
番型线性叠加 ✅ ✅ - fan_stacking: multiply
番型按级取最高 - ✅ - fan_stacking: max_level
杠上开花 ✅ ✅ ✅ DSL game_event hook
抢杠胡 ✅ ✅ ✅ Phase option: "rob_kong_win"
海底捞月 ✅ ✅ ✅ DSL game_event 最后一张
天胡/地胡 - - ✅ 发牌后自动检查
过水(胡过等一轮) ✅ ✅ ✅ DSL dirty_flag
- ✅ = 纯声明式或内置算法已覆盖
- 🔧 = 需要增加内置算法分支(写一次所有玩法共用)
-
- = 该玩法不适用此规则
三种引擎能力的差距
四川血战覆盖 16/16 = 100%(不需要 🔧,因为无鬼牌无全不靠等特殊牌型)
广东鸡平胡覆盖 18/19 = 95%,唯一缺口:
- 🔧 鬼牌/百搭牌——需要在 MeldsSolver 中支持 wildcard。胡牌判断时,wildcard 可以充当任何牌。这是算法层的通用能力,广东麻将、台湾麻将、日本麻将(赤宝牌不算百搭但性质类似)都需要。
国标麻将覆盖 18/20 = 90%,两个缺口:
- 🔧 全不靠:14 张牌之间没有任何关联(无对子、无面子、无相同花色顺序),是一种完全不同的胡牌条件。当前 MeldsSolver 基于"面子分解"模型,全不靠不适用。需要增加独立的全不靠判断分支。
- 🔧 一色双龙会:手牌由一种花色的 1-9 各两张组成(共 18 张,实际只有 14 张可用),需要特定的面子识别逻辑。是面子分解变体,当前算法稍作扩展即可支持。
结论:三种麻将平均覆盖 95%。Demo 已扩展至四种(+武汉麻将验证 wildcard),4 种麻将全部 100% 覆盖。 完全未覆盖的只有 3 个算法分支:鬼牌支持、全不靠、一色双龙会——这些已全部纳入 Demo 引擎扩展。其余全部是声明式 DSL + 已有内置算法。
国标麻将 81 番种互斥关系验证
这是对 DSL 互斥图机制的最大压力测试。81 个番种之间有不计/不得重复/必然包含等关系,能否用 excludes + conflicts 表达?
抽样验证几个典型互斥:
fan_types:
# 88番级
- name: "大四喜"
base_fan: 88
excludes: ["圈风", "门风", "三风"] # 由四个风刻组成,不计各风刻
- name: "大三元"
base_fan: 88
excludes: ["双箭刻"] # 三个箭刻,不计单个箭刻
- name: "十三幺"
base_fan: 88
excludes: ["五门齐", "门前清", "单钓将", "混幺九"]
conflicts: ["七对"] # 与七对互斥(虽然手牌像但不是七对)
- name: "连七对"
base_fan: 88
excludes: ["七对", "门前清", "单钓将", "清一色", "无字"]
# 64番级
- name: "小四喜"
base_fan: 64
excludes: ["三风"] # 三风刻不计
- name: "小三元"
base_fan: 64
excludes: ["双箭刻"]
- name: "字一色"
base_fan: 64
excludes: ["碰碰和", "全带幺", "混幺九", "缺一门"]
# 48番级
- name: "一色四同顺"
base_fan: 48
excludes: ["一色三同顺", "四归一", "一般高"]
# 1番级
- name: "一般高"
base_fan: 1
# 被上级番型排除,自身不排除别人
- name: "连六"
base_fan: 1
- name: "老少副"
base_fan: 1
81 番种的互斥关系大约 200+ 条 excludes 声明。DSL 机制可以表达——互斥图本身是数学上的有向无环图(DAG),引擎加载时做形式化验证:
加载 → 构建 excludes 图 →
✓ 检查无循环(A⊃B⊃C⊃A = 错误)
✓ 检查 excludes 目标是有效番型
✓ 检查 conflicts 双向对称
✓ 检查番种分级(level 1-12)正确
→ DSL 加载通过 ✅
这不是引擎能力问题,是 DSL 编写的工作量问题。
总结:麻将引擎实际覆盖率
四川血战 广东鸡平胡 国标 日本立直 武汉 长沙 平均
声明式 DSL 9/16 9/19 11/20 (预估) (预估) (预估)
内置算法(已有) 7/16 7/19 6/20
内置算法(需增加) 0 1(wildcard) 2(全不靠/双龙会)
扩展 DSL 字段 0/16 2/19 1/20
────────────────────────────────────────────────────
覆盖率 100% 95% 90% ~95% ~100% ~100% ~97%
国标的 90% 缺口不是架构问题——全不靠和一色双龙会加进 MeldsSolver 就 100% 了。广东的鬼牌同理。这三个分支总共不超过 500 行代码。写一次,所有需要它们的玩法共用。
真正的工作量不在引擎,在81 个番种的 DSL 互斥声明——这是数据录入工作,需要懂国标麻将规则的人逐条写 200+ 条 excludes 关系。但这是 DSL 配置层面的,跟引擎能力无关。
2.0e-b 宝牌冲击分析:wildcard 不止是"加一个分支"
当前三个 Demo 玩法(四川血战、广东鸡平胡、国标麻将)都不涉及宝牌。但宝牌是中国地方麻将中非常普遍的特性,必须在引擎设计阶段就考虑清楚,不能事后硬塞。
宝牌在各地方麻将中的具体表现
| 地方麻将 | 宝牌名称 | 机制 | 来源 |
|---|---|---|---|
| 广东麻将 | 鬼/百搭 | 开牌前翻一张牌,下一张是鬼,可替代任何牌 | 随机翻牌 |
| 武汉麻将 | 癞子 | 红中固定为癞子 | 固定 |
| 日本立直 | 宝牌(dora) | 指示牌下一张为宝牌,不替代,只计番 | 翻宝牌指示器 |
| 台湾麻将 | 花牌当百搭 | 摸到花牌可选择当百搭用 | 花牌 |
| 长沙麻将 | 将将胡 | 2/5/8 固定为万能 | 固定 |
| 东北麻将 | 会牌 | 开牌翻一张,同点数四种花色都是宝 | 翻牌 × 4 |
有两种本质上不同的宝牌:
- 替代型 (wildcard):可以充当任意牌凑面子/将牌,直接影响胡牌判断(广东/武汉)
- 计分型 (dora):不替代,只额外计分/计番(日本立直)
替代型宝牌才是真正威胁引擎的。 计分型宝牌只是 ScoreEngine 的一个额外计分项,不冲击核心算法。
替代型宝牌对 MeldsSolver 的冲击
回溯搜索的复杂度是 O(3^n),n=14时约 1000 次递归。加入宝牌后:
每张宝牌有 K 种替代可能(K = 可用牌种数)
如果手牌中有 w 张宝牌:搜索空间 = O(3^(14-w) × K^w)
w=1, K=27: 3^13 × 27 ≈ 1,594,323 × 27 ≈ 4300万 ← 勉强可接受
w=2, K=27: 3^12 × 729 ≈ 531,441 × 729 ≈ 38.7亿 ← 不可接受
w=3, K=27: ≈ 3.5万亿 ← 完全爆炸
这不能用回溯直接搜索。
解决方案:分层处理 + 剪枝
不是让每张宝牌尝试所有 27 种替代。而是:先用非宝牌完成确定性分解,再用宝牌填坑。
算法流程:
输入: 14张牌,其中 w 张是宝牌 (tag: wildcard)
Step 1: 分离宝牌和非宝牌
nonWildcards = tiles.Where(t => !IsWildcard(t))
wildcards = tiles.Where(t => IsWildcard(t))
Step 2: 先用非宝牌完成回溯搜索
result = TryExtractMelds(nonWildcards, remainingWildcards = w)
// 但在这个过程中,遇到"差一张"的情况时,用宝牌填补
Step 3: 回溯搜索变体——"缺口填充式"
TryExtractMeldsWithWildcards(tiles, wildcardCount):
1. 标准回溯,但当找不到刻子或顺子时:
2. 如果 wildcardCount > 0:尝试用宝牌补齐
- 补齐刻子(差1张):消耗 1 个宝牌
- 补齐刻子(差2张):消耗 2 个宝牌
- 补齐顺子(缺少中间张):消耗 1 个宝牌
3. 如果找不到将牌且 wildcardCount >= 2:用2个宝牌做将
Step 4: 剩余宝牌
如果步骤 3 后还有剩余宝牌,它们无法单独组成面子
必须作为已有刻子的第4张(杠材)或附加到顺子尾部
优化关键:把"宝牌替代27种"的穷举问题转化为"差一张就用宝牌补"的缺口填充。 搜索复杂度从 O(27^w) 降到 O(w × 2^w):
w=1: O(1 × 2^1) = O(2) ← 原来的 0.5ms 几乎不变
w=2: O(2 × 2^2) = O(8) ← 原来的 38.7亿 → 8 次递归
w=3: O(3 × 2^3) = O(24) ← 原来的 3.5万亿 → 24 次递归
这是可行的。 但需要显著改造 MeldsSolver 的 TryExtractMelds 方法。
宝牌对 DSL 的影响
新增 wildcard_rules 区段:
# 广东麻将翻鬼
wildcard_rules:
type: "flip" # 翻牌型宝牌
trigger: "before_deal" # 发牌前翻
mechanism: "next_card" # 翻开的牌的下一张是鬼
wildcard_encoding: 50 # int 编码: 50-59 为宝牌
count: 4 # 4张鬼牌(翻出的牌每种花色各1张)
behavior: "substitute" # 替代型
scoring:
per_wildcard: 1 # 每张鬼牌 1 番
# 武汉麻将癞子(固定型)
wildcard_rules:
type: "fixed"
tiles: ["红中"] # 红中固定为癞子
wildcard_encoding: 50
behavior: "substitute"
scoring:
per_wildcard_in_win: 2 # 胡牌时每张癞子 2 番
# 日本立直宝牌(计分型,不替代)
wildcard_rules:
type: "indicator" # 指示器型
mechanism: "flip_indicator" # 翻宝牌指示器
behavior: "scoring_only" # 不替代,只计分
scoring:
per_dora: 1 # 每张宝牌 1 番
ura_dora: "riichi_only" # 里宝牌(立直后翻)
宝牌对番型判断的影响
宝牌组成的面子如何计算番型?有两种处理方式,需要在 DSL 中配置:
wildcard_rules:
fan_calculation_policy: "minimize" # 宝牌按最低番型计
# 或者
fan_calculation_policy: "optimal" # 宝牌按最优番型计(更易清一色等)
举例:手牌有"清一色"潜质 + 1 张宝牌——如果宝牌也算同花色,清一色成立。"optimal" 策略会让清一色更容易达成。
宝牌对牌面编码的扩展
当前 int 编码:万1-9=1-9,条=11-19,筒=21-29,字=31-37,花=41-48。
新增宝牌编码区间:
public static class MahjongTile
{
// ... 原有编码 ...
// 宝牌区间: 50-59
public const int WildcardBase = 50;
public static bool IsWildcard(int tile) => tile >= 50 && tile <= 59;
// 扩展 AllTiles 支持宝牌
public static int[] AllTiles(bool includeHonors = false,
bool includeFlowers = false,
int wildcardCount = 0)
{
// ... 原有逻辑 ...
// 末尾追加 wildcardCount 张宝牌
for (int i = 0; i < wildcardCount; i++)
tiles[idx++] = WildcardBase + i;
return tiles;
}
}
对 Demo 计划的影响
宝牌支持已纳入 Demo(武汉麻将红中癞子)。引擎已实现 wildcard 缺口填充式回溯:
| 层面 | Demo 实现 | 扩展更多宝牌变种时 |
|---|---|---|
| MahjongTile 编码 | 已有 wildcard 区间预留 | ✅ 无需改 |
| MeldsSolver.CheckWin | 缺口填充式回溯 | 翻鬼/百搭/dora 等变种 |
| MeldsSolver.TryExtractMelds | 有 wildcardCount 参数 |
无需改 |
| DSL 格式 | 有 wildcard_rules 字段(武汉癞子) |
增加更多宝牌模式 |
| CapabilityRegistry | 已注册 meldsolver.wildcard |
无需改 |
| ScoreEngine | 癞子计分 (per_wildcard_in_win) |
翻鬼计分、dora 计分 |
| 番型互斥图 | fan_calculation_policy: "optimal" |
无需改 |
结论:宝牌对引擎的冲击是可控的。 核心挑战在 MeldsSolver 的回溯搜索需要从"枚举替代"改为"缺口填充",复杂度从指数降到线性。Demo 已通过武汉麻将验证 wildcard 缺口填充式回溯。 DSL 和编码层面已预留扩展点(翻鬼、百搭、dora 等宝牌变种)。
2.0f 加载时能力检查机制:让引擎自省
既然 DSL 的覆盖分析可以手工做(就像上面三张表),那就应该把它做成引擎加载时的自动检查,而不是每次人工排查。
机制设计
第一步:引擎声明自己的能力清单
引擎启动时注册所有已实现的算法能力:
// CapabilityRegistry.cs — 引擎启动时自动注册
engine.RegisterCapability(new Capability {
Id = "meldsolver.standard_win",
Name = "标准胡牌判断 (4面子+1对)",
Category = "mahjong",
Since = "1.0.0"
});
engine.RegisterCapability(new Capability {
Id = "meldsolver.seven_pairs",
Name = "七对胡判断",
Category = "mahjong",
Since = "1.0.0"
});
engine.RegisterCapability(new Capability {
Id = "meldsolver.thirteen_orphans",
Name = "十三幺判断",
Category = "mahjong",
Since = "1.0.0"
});
engine.RegisterCapability(new Capability {
Id = "meldsolver.wildcard",
Name = "鬼牌/百搭牌支持",
Category = "mahjong",
Since = "2.0.0" // 还没实现就不注册
});
已有能力 15 项:
meldsolver.standard_win 标准胡牌判断
meldsolver.seven_pairs 七对胡
meldsolver.thirteen_orphans 十三幺
phase.mahjong_turn 麻将回合 (摸→打→碰杠胡)
phase.parallel_elimination 并行淘汰 (血战)
phase.priority_arbitration 优先级仲裁 (胡>杠>碰>吃)
phase.pass_turn 过水/跳过摸牌
deck.generator_poker 标准扑克生成器
deck.generator_mahjong 标准麻将生成器
deck.flower_cards 花牌处理
scoring.expression 表达式计分
scoring.table 查表计分
scoring.fan_exclusion 番型互斥图
scoring.pre_hooks 结算前钩子
dsl.dirty_flag 过水标记
第二步:DSL 声明自己需要的算法能力
DSL 顶部显式声明这个玩法依赖哪些引擎能力:
# doudizhu.yaml
game:
type: "poker"
engine_type: "poker"
requires:
- "deck.generator_poker" # 54张标准扑克
- "pattern.single"
- "pattern.pair"
- "pattern.straight"
- "pattern.bomb_4"
- "pattern.rocket"
- "phase.auction" # 叫地主竞价
- "phase.turn_based"
- "scoring.expression" # 底分×倍数
# xuezhandaodi.yaml
game:
type: "mahjong"
engine_type: "mahjong"
requires:
- "deck.generator_mahjong" # 108张万条筒
- "meldsolver.standard_win" # 标准胡牌判断
- "meldsolver.seven_pairs" # 七对支持
- "phase.mahjong_turn" # 摸→打→碰杠胡
- "phase.parallel_elimination" # 血战到底
- "phase.priority_arbitration" # 优先级仲裁
- "scoring.fan_exclusion" # 番型互斥
- "scoring.pre_hooks" # 查叫查花猪
# guobiao.yaml
game:
type: "mahjong"
engine_type: "mahjong"
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"
第三步:加载时自动检查
// DslLoader.cs
public RuleSet Load(string yamlPath) {
var dsl = YamlDotNet.Parse(yamlPath);
var required = dsl["requires"].ToArray();
var missing = new List<string>();
foreach (var capId in required) {
if (!engine.Capabilities.Has(capId)) {
missing.Add(capId);
}
}
if (missing.Any()) {
throw new CapabilityMissingException(
$"DSL '{dsl.name}' 需要的以下算法能力引擎尚未实现:\n" +
string.Join("\n", missing.Select(m =>
$" ❌ {m} — 需在引擎中新增算法分支")) +
$"\n\n请在以下文件中添加对应实现:\n" +
$" RuleEngine/MeldsSolver.cs (如果是 meldsolver.*)\n" +
$" RuleEngine/PhaseMachine.cs (如果是 phase.*)\n" +
$"添加后注册 Capability 并增加对应单元测试。\n" +
$"已有能力列表: {engine.Capabilities.List()}"
);
}
return BuildRuleSet(dsl);
}
控制台输出示例:
$ dotnet run -- --dsl guobiao.yaml
❌ DSL '国标麻将' 加载失败 — 引擎能力不足:
❌ meldsolver.all_orphans 全不靠判断
描述: 14张牌之间无任何关联(无对子、无面子、无花色顺序)
估计代码量: ~80行
建议实现: RuleEngine/MeldsSolver.cs → IsAllOrphans()
❌ meldsolver.combo_dragon 组合龙判断
描述: 万字147、条子258、筒子369 + 任意一对
估计代码量: ~50行
建议实现: RuleEngine/MeldsSolver.cs → IsComboDragon()
❌ meldsolver.double_dragon 一色双龙会判断
描述: 同花色1-9各两张,14张从18张中取
估计代码量: ~50行
建议实现: RuleEngine/MeldsSolver.cs → IsDoubleDragon()
总计缺口: 3项 (~180行代码)
当前引擎能力: 15项已实现
👉 补完算法分支后,更新 CapabilityRegistry,DSL 即可加载。
这个机制的价值
之前:开发者写了一个新玩法 DSL → 运行时出奇怪 bug → 花 2 小时排查 → 发现是引擎缺某个算法 → 被动补代码。
之后:加载 DSL → 0.1 秒内精确告知缺什么 → 补代码 → 注册能力 → 通过 → 模拟验证。
更重要的是:能力清单本身成了引擎的文档。任何开发者一看就知道引擎支持什么不支持什么,不需要翻代码。
能力粒度设计
不是所有能力都需要显式声明。只声明声明式 DSL 无法表达、需要代码实现的:
| 声明式可表达的 | 不需要声明 (隐含在 DSL 声明本身) |
|---|---|
pattern.single、pattern.pair |
✅ PatternMatcher 自动从 DSL 定义工作 |
validator.must_be_larger |
✅ ValidatorChain 从 DSL 规则链工作 |
scoring.expression |
✅ ScoreEngine 从 DSL formula 工作 |
| 需要引擎代码实现的 | 需要注册 Capability |
|---|---|
meldsolver.* |
🔧 胡牌判断算法分支 |
phase.mahjong_turn |
🔧 复杂的麻将专有回合类型 |
phase.parallel_elimination |
🔧 血战淘汰逻辑 |
deck.wildcard |
🔧 百搭牌/鬼牌处理 |
规则:DSL 本身能驱动的,不注册 Capability。需要引擎里写代码的,必须注册。
2.1 整体架构
┌─────────────────────────────────────────────┐
│ 前端 (Canvas/Cocos) │
│ 通用牌桌 UI │ 牌面渲染 │ 交互控制 │
└──────────────────┬──────────────────────────┘
│ WebSocket
┌──────────────────▼──────────────────────────┐
│ 游戏服务器 (C#) │
│ 房间管理 │ 状态机 │ 事件总线 │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ 共享基础层 (Core) │ │
│ │ Card │ RuleLoader │ EventBus │ │
│ │ PhaseFsm │ ExprEngine │ Sandbox │ │
│ └─────────────┬─────────────┬─────────────┘ │
│ │ │ │
│ ┌─────────────▼──┐ ┌──────▼──────────────┐ │
│ │ 扑克引擎 │ │ 麻将引擎 │ │
│ │ PatternMatcher │ │ MeldsSolver │ │
│ │ ValidatorChain │ │ PriorityResolver │ │
│ │ DiscardFlow │ │ FanCalculator │ │
│ └────────────────┘ └─────────────────────┘ │
│ │
│ 全部由 DSL yaml 驱动,无硬编码玩法 │
│ 创建房间时指定 ruleSetId + engineType │
└──────────────────┬──────────────────────────┘
│
┌──────────────────▼──────────────────────────┐
│ AI 陪打服务 (Python) │
│ 难度 1: 规则合法随机 │
│ 难度 2: MCTS + 启发式评估 │
│ 难度 3: LLM Agent (仅高级AI,非规则引擎) │
└─────────────────────────────────────────────┘
│
┌──────────────────▼──────────────────────────┐
│ DSL 配置仓库 (文件系统) │
│ dsl-examples/ │
│ doudizhu.yaml ← 可以手写 │
│ zhajinhua.yaml ← 可以手写 │
│ guandan.yaml ← 可以从模板生成 │
│ ... ← LLM 辅助生成也行 │
└─────────────────────────────────────────────┘
规则引擎是纯逻辑库,不调 LLM、不调外部服务。LLM 是可选的 DSL 生产工具,在规则引擎外部运行。
2.2 规则引擎如何实现 100% 覆盖
核心思想:不预定义任何玩法。所有的"玩法"都是对一组原子能力的参数化配置。
规则引擎暴露 5 个原子模块,每个模块的能力边界足够宽,宽到能覆盖所有棋牌玩法:
模块 1: DeckManager — 牌的定义与分配
不预设"扑克52张"或"麻将108张"。牌是一个抽象数据结构:
// C# — int 编码
// 万1-9=1-9, 条1-9=11-19, 筒1-9=21-29
public static class MahjongTile
{
public static int Encode(string suit, int rank) => suit switch
{
"万" => rank, "条" => 10 + rank, "筒" => 20 + rank
};
public static string Decode(int tile) => tile switch
{
>= 1 and <= 9 => $"{tile}万",
>= 11 and <= 19 => $"{tile - 10}条",
>= 21 and <= 29 => $"{tile - 20}筒"
};
}
DSL 定义牌的集合:
deck:
# 52张标准扑克
- generator: "standard_poker"
jokers: 2 # 0=无王, 2=大小王
# 或自定义
- generator: "custom"
cards:
- { suit: "万", ranks: [1,2,3,4,5,6,7,8,9], count: 4 }
- { suit: "条", ranks: [1,2,3,4,5,6,7,8,9], count: 4 }
- { suit: "筒", ranks: [1,2,3,4,5,6,7,8,9], count: 4 }
- { suit: "字", ranks: [1,2,3,4,5,6,7], count: 4 } # 东南西北中发白
- { suit: "花", ranks: [1,2,3,4,5,6,7,8], count: 1 } # 春夏秋冬梅兰竹菊
发牌也是声明式:
deal:
cards_per_player: 13 # 四川麻将每人13张(庄家14张)
remaining_strategy: "pool" # 剩余牌做底牌池
# 或者
- to: "landlord"
count: 3
condition: "after_bid" # 叫地主后才发底牌
模块 2: PatternMatcher — 牌型匹配
不是硬编码"顺子是5张连续",而是声明式定义:
patterns:
single:
match: { count: 1 }
pair:
match: { count: 2, same_rank: true }
straight:
match:
count: { min: 5, max: 12 } # 长度范围
consecutive_rank: true # 点数连续
same_suit: false # 不要求同花色
flush_straight: # 同花顺
match:
count: { min: 5, max: 12 }
consecutive_rank: true
same_suit: true # 额外要求同花色
bomb_4: # 4张炸弹
match: { count: 4, same_rank: true }
bomb_5: # 5张炸弹(掼蛋)
match: { count: 5, same_rank: true }
bomb_rocket: # 火箭(大小王)
match:
cards: ["joker_small", "joker_big"]
full_house: # 三带二
match:
groups:
- { count: 3, same_rank: true }
- { count: 2, same_rank: true }
airplane: # 飞机带翅膀
match:
groups:
- { count: 3, same_rank: true, consecutive: true, min_groups: 2 }
- { count: 1, same_rank: false, per_group: true } # 每组三张带一个单牌
牌型匹配器的实现是通用模式匹配算法,跟具体玩法无关。DSL 定义模式 → 匹配器识别手牌中所有满足的牌型组合。
模块 3: ValidatorChain — 出牌校验
校验是规则链,每条规则是独立函数,DSL 声明链条:
play_validator:
chain:
- rule: "must_follow_pattern" # 必须跟牌型
- rule: "must_be_larger" # 必须比上家大
- rule: "bomb_anytime" # 炸弹可以任何时候出(打断规则链)
except_when: "round_first" # 但首轮不能用炸弹(掼蛋规则)
- rule: "comparator" # 自定义比较器
type: "bomb_size_first" # 掼蛋:张数优先
每条规则的实现是通用函数:
bool MustBeLarger(List<int> current, List<int> previous, GameContext ctx);
bool MustFollowPattern(List<int> current, List<int> previous);
bool BombAnytime(List<int> cards, GameContext ctx);
100%覆盖的关键:当声明式规则不够用时,comparator 字段可以指向一个沙盒函数:
play_validator:
chain:
- rule: "comparator"
custom: |
// 掼蛋炸弹比较:张数优先,同张数比点数
function compare(a, b) {
if (a.length !== b.length) return a.length > b.length;
return a[0].rank > b[0].rank;
}
沙盒函数提供了图灵完备的兜底,确保没有玩法无法表达。但 90% 的玩法不需要走到这步。
模块 4: PhaseMachine — 回合流转
玩法流程本质是一个有限状态机:
phases:
- name: "deal"
type: "auto" # 自动执行,不需要玩家操作
action: "deal_cards"
next: "bid"
- name: "bid"
type: "auction" # 竞价
options: # 可选操作
- action: "bid"
value: [1, 2, 3] # 叫1/2/3分
- action: "pass" # 不叫
winner_rule: "highest_bid" # 价高者得
next_on_winner: "set_landlord"
next_on_all_pass: "deal" # 全部不叫重新发牌
- name: "play"
type: "turn_based"
turn_order: "clockwise"
first_player: "landlord" # 地主先出
actions:
- action: "play_cards"
validator: "play_validator"
- action: "pass"
end_condition: "one_player_empty" # 任一人打完手牌
next: "settle"
- name: "settle"
type: "auto"
action: "calculate_scores"
next: null # null = 游戏结束
# 麻将的特殊阶段
- name: "draw_and_discard"
type: "turn_based"
actions:
- action: "draw_card"
- action: "discard"
- action: "pung" # 碰(暂存,等优先级仲裁)
priority: 2
- action: "kong" # 杠
priority: 3
- action: "win" # 胡
priority: 4
priority_policy: "highest_wins" # 多人同时操作时,优先级高的生效
# 血战到底的特殊性:有人胡后不结束
- name: "blood_war"
type: "parallel_elimination" # 并行淘汰
on_player_win: "remove_from_round" # 胡牌的人退出
next_on_last_two: "settle" # 剩2人结束
状态机引擎是通用的,DSL 只需要定义节点和转换条件。
模块 5: ScoreEngine — 计分结算
三种模式,按复杂度递进:
scoring:
# 模式1: 表达式 — 90% 的玩法
mode: "expression"
formula: "base * bombs * spring * landlord_factor"
variables:
base: 1
bombs:
source: "game_stats"
key: "bomb_count"
multiplier: 2 # 每个炸弹翻倍
spring:
source: "game_stats"
key: "is_spring"
multiplier: 2
landlord_factor:
source: "role"
values: { landlord: 1, farmer: -1 } # 地主赢+1倍,农民赢每人-1倍
# 模式2: 查表 — 番型/牌型固定倍数
mode: "table"
table:
- pattern: "flush_straight"
base_multiplier: 4
- pattern: "bomb_4"
base_multiplier: 2
- pattern: "bomb_5"
base_multiplier: 4
- pattern: "rocket"
base_multiplier: 4
stacking: "multiply" # 番型叠加方式:multiply | add | max
# 模式3: 自定义函数 — 极度复杂的计分
mode: "custom"
function: |
// 四川麻将番型计算
// 内置麻将算法库已提供 tilesToMelds() 分解牌型
function calculate(tiles, melds, context) {
let fans = [];
if (melds.every(m => m.suit === melds[0].suit)) fans.push("清一色");
if (melds.every(m => m.type === "pung")) fans.push("对对胡");
// ... 内置算法库预处理好的数据,这里只做组合打分
return fanTable.lookup(fans);
}
关键设计:custom 函数不自己处理"牌型分解"等复杂算法——那是内置算法库的事。custom 只做"基于预处理结果做决策",大幅降低沙盒函数的复杂度和风险。
2.3 热切换机制
热切换的核心:规则引擎是无状态的纯函数,每个房间持有自己的规则引用。
创建房间 API:
POST /rooms
{ ruleSetId: "doudizhu", playerCount: 3 }
服务端处理:
1. rules = ruleRegistry.get("doudizhu") // 从内存缓存拿
2. if (!rules) rules = ruleLoader.load("dsl-examples/doudizhu.yaml")
3. room = new Room(rules, players)
4. room.start()
→ rules.deal() // 按 doudizhu 的 deal 配置发牌
→ rules.phases.start() // 进入 bid phase
→ 玩家操作 → rules.validator.chain.check()
→ ...
→ rules.scoring.calculate() // 结算
规则加载流程:
启动时:
ruleRegistry.preload("dsl-examples/*.yaml")
→ 每个 yaml 解析为 RuleObject
→ Schema 校验(牌面定义完整性、phase 可达性检查)
→ 缓存到内存
运行时:
创建房间时 ruleId 命中缓存 → 直接使用
新玩法上线: 只需把 yaml 放到 dsl-examples/ 目录
→ 调用 ruleRegistry.reload("new_game")
→ 已有的房间不受影响(持有旧 RuleObject 引用)
→ 新房间使用新规则
规则隔离保证:
- 每个 Room 实例持有自己的
RuleObject引用(不可变对象) - 热加载新规则创建新的 RuleObject,旧引用不受影响
- 已有房间继续用旧规则运行到结束
- 新创建的房间自动使用最新版本规则
多玩法并行:
同一台服务器可以同时运行斗地主房间(100个)、炸金花房间(50个)、掼蛋房间(30个)——每个房间的规则引擎实例独立,互不干扰。唯一共享的是牌型匹配器等无状态工具函数。
2.3b 与游戏引擎的整合:不存在大的集成问题
规则引擎暴露的接口非常窄——就两个端点,纯 JSON 进、纯 JSON 出。如果是独立服务模式,接口为 HTTP;如果是 C# DLL 嵌入 Unity,接口为函数调用:
// C# 嵌入模式(推荐)
var room = new Room(rules, players);
room.Start(); // → 自动发牌 → 进入 play phase
var actions = room.GetLegalActions(playerId);
room.Act(action); // → 规则引擎校验 → 更新状态 → 事件
// HTTP 独立服务模式(多语言场景备选)
POST /rooms
body: { ruleSetId: "doudizhu", engineType: "poker", players: ["p1","p2","p3"] }
POST /rooms/:id/act
body: { playerId: "p1", action: { type: "bid", value: 3 } }
游戏引擎只需要做三件事,跟用什么引擎无关:
┌─────────────────────────────┐
│ 游戏引擎 (任意) │
│ │
│ 1. state → 渲染 │ ← 唯一的引擎差异在这里
│ Canvas: drawImage() │
│ Cocos: cc.instantiate() │
│ Unity: Instantiate() │
│ │
│ 2. 用户操作 → PlayerAction │
│ 点击"出牌"按钮 │
│ → { type:"play_cards", │
│ cards:[...] } │
│ │
│ 3. 调 API → 拿 newState │
│ → 回到步骤 1 │
└─────────────────────────────┘
│
HTTP/WebSocket
│
┌────────────▼────────────┐
│ 规则引擎服务器 │
│ (C#, 纯逻辑,或嵌入 Unity) │
│ POST /rooms │
│ POST /rooms/:id/act │
└─────────────────────────┘
集成成本分析:
| 游戏引擎 | 适配方式 | 适配器代码量 | 说明 |
|---|---|---|---|
| HTML5 Canvas | fetch + JSON.parse,渲染用 ctx.drawImage() |
~100 行 | 同语言,零成本 |
| Cocos Creator (H5) | cc.assetManager 加载牌面纹理,fetch 调 API |
~200 行 | 同是 JS,直接调用 |
| Cocos Creator (原生) | HTTP 请求 + 牌面纹理绑定 | ~200 行 | 原生 HTTP 略有差异 |
| Unity (C#) | UnityWebRequest + JSON → C# class |
~250 行 | 需要 C# 版 Card 类型定义 |
| 微信小游戏 | wx.request + Canvas 渲染 |
~150 行 | 不能直接用 fetch |
每个引擎写一个薄 adapter,本质就是:
- JSON → 对应的语言类型
- 调 HTTP
- 驱动渲染
规则引擎不关心前端用什么——state 和 action 都是纯 JSON。不存在"深度耦合"的空间。
三种部署模式:
| 模式 | 适用场景 | 延迟 |
|---|---|---|
| 规则引擎独立服务器(推荐) | 多端共享逻辑,统一管理 | ~5ms 内网 |
| WASM 嵌入客户端 | 单机/离线模式,无服务端 | 本地 0ms |
| 规则引擎嵌入游戏服务器进程 | 小规模部署 | 函数调用 0ms |
推荐嵌入模式——规则引擎作为 C# DLL 直接编译进 Unity 进程,零 IPC 开销。AI 陪打独立 Python 服务。
2.3c Unity C# 集成方案:规则引擎用 C# 重写
如果确定 Unity 是主要平台 + 规则引擎和游戏服务器同进程,最干净的做法是规则引擎用 C# 写,直接编译进 Unity game server。不需要 Node.js。
为什么 C#: 规则引擎是纯逻辑——Card 结构体、PatternMatcher 算法、状态机流转、表达式计分。这些不依赖任何 TS 特有生态,C# 实现同样简洁。
技术栈映射:
| 能力 | TS 方案 | C# 方案 |
|---|---|---|
| YAML DSL 解析 | js-yaml |
YamlDotNet (NuGet, 成熟) |
| 表达式计分 | 手写 parser | NCalc 或手写,C# 表达式树 |
| 牌型匹配 | 泛型 pattern match | LINQ + 自定义 matcher,逻辑完全一样 |
| 校验链 | 函数链 | Func<CardGroup,CardGroup,bool> 链 |
| 状态机 | 手写 | 手写,或 Stateless (NuGet) |
| 沙盒 custom 函数 | Docker | Microsoft.CodeAnalysis.CSharp.Scripting — C# 脚本引擎,同语言原生沙盒 |
关键优势:C# 原生沙盒远优于 Docker。
Docker 方案的问题是——custom 计分/比较函数需要跨进程调用,序列化开销大,调试困难。C# 的 Microsoft.CodeAnalysis.CSharp.Scripting 可以在同进程内编译执行 C# 脚本,天然隔离(默认禁止 IO/网络),性能跟编译代码一样:
// DSL 中定义的 custom 比较函数,在 C# 原生沙盒执行
var options = ScriptOptions.Default
.WithReferences(typeof(Card).Assembly)
.WithImports("System", "System.Linq");
// 掼蛋炸弹比较:张数优先
var result = await CSharpScript.EvaluateAsync<bool>(
@"cardsA.Length != cardsB.Length
? cardsA.Length > cardsB.Length
: cardsA[0].Rank > cardsB[0].Rank",
options,
globals: new { cardsA, cardsB });
整体架构(Unity C# 主平台):
┌──────────────────────────────────────────────┐
│ Unity Game Server (C# 进程) │
│ │
│ ┌──────────────┐ ┌────────────────────────┐ │
│ │ WebSocket │ │ 规则引擎 (C# 类库) │ │
│ │ 连接管理 │ │ │ │
│ │ 房间调度 │ │ Card / Deck │ │
│ │ 消息路由 │ │ PatternMatcher │ │
│ │ │ │ ValidatorChain │ │
│ │ │ │ PhaseFsm │ │
│ │ │ │ ScoreEngine + NCalc │ │
│ │ │ │ YamlDotNet DSL Loader │ │
│ │ │ │ CSharpScript sandbox │ │
│ └──────────────┘ └──────────┬─────────────┘ │
│ │ HTTP │
└───────────────────────────────┼────────────────┘
│
┌────────────────▼───────────────┐
│ AI 陪打服务 (Python) │
│ - 规则合法随机 │
│ - MCTS 启发式搜索 │
│ - LLM Agent 高级决策 │
│ │
│ 接口: state → legalActions │
│ → AI 选 action → 回传 │
└──────────────────────────────────┘
AI 和规则引擎的边界:
| 规则引擎 (C#) | AI 陪打 (Python) | |
|---|---|---|
| 职责 | "能不能出这张牌" | "出哪张牌最好" |
| 输入 | PlayerAction | GameState + legalActions |
| 输出 | NewGameState + events | 选中的 PlayerAction |
| 确定性 | 100% 确定 | 概率性 |
| 依赖 | DSL yaml、自身算法 | MCTS 库、LLM SDK、NumPy |
分离的理由:规则引擎是确定性逻辑(可以单元测试覆盖到 100%),AI 是概率性策略(需要迭代优化)。两者不应该混在一个进程里。
项目目录结构(C# 版):
~/projects/card-game-engine/
├── RuleEngine/ # C# 规则引擎 (Unity 子模块/独立 DLL)
│ ├── RuleEngine.csproj
│ ├── Core/
│ │ ├── Card.cs # Card 结构体
│ │ ├── Deck.cs # 牌堆管理
│ │ └── GameState.cs # 游戏状态
│ ├── Patterns/
│ │ └── PatternMatcher.cs # 声明式牌型匹配
│ ├── Validation/
│ │ └── ValidatorChain.cs # 校验规则链
│ ├── Phase/
│ │ └── PhaseMachine.cs # 状态机流转
│ ├── Scoring/
│ │ └── ScoreEngine.cs # 表达式/查表/custom 计分
│ ├── Dsl/
│ │ ├── DslLoader.cs # YamlDotNet 加载
│ │ └── DslSchema.cs # DSL 类型定义
│ ├── Sandbox/
│ │ └── ScriptSandbox.cs # CSharpScript 沙盒
│ └── Tests/
│ ├── PatternMatcherTests.cs
│ ├── ValidatorChainTests.cs
│ └── ...(100% 单元测试覆盖)
│
├── dsl-examples/ # DSL 配置(语言无关,直接用之前的)
│ ├── xuezhandaodi.yaml
│ └── guangdong_jipinghu.yaml
│
├── ai-companion/ # Python AI 陪打(不变)
│ ├── pyproject.toml
│ └── src/
│ ├── strategies/
│ └── server.py
│
└── UnityGameServer/ # Unity 项目
└── Assets/
└── Scripts/
├── GameServer.cs # WebSocket 管理 + 房间调度
└── RuleEngineAdapter.cs # 薄 adapter,调用 RuleEngine DLL
为什么不两者都用 Python? 规则引擎需要跑在 Unity 进程里(C# 环境),同语言零开销。如果规则引擎也用 Python,那就又回到独立服务 + HTTP 调用的模式——对纯 Unity 部署来说多了一层不必要的 IPC。
总结:规则引擎 C# 实现 → 编译为 DLL → Unity 直接引用。AI 陪打独立 Python 服务。DSL yaml 文件语言无关,两边都能读。
2.3d AI 陪打架构:规则引擎判定合法性,AI 决策最优策略
核心概念
规则引擎和 AI 陪打是两个独立的系统,通过极窄的接口通信:
规则引擎 (C#) ──┤我能出哪些牌?│──→ AI 陪打 (Python)
↑ 执行决策 ←──│我选这个操作 │── ↓ 策略计算
- 规则引擎回答"能/不能":出牌是否合法、碰杠胡是否符合规则、结算是否正确。每步判断 100% 确定,可以 100% 测试覆盖。
- AI 陪打回答"好/不好":在手牌 A/B/C 中选哪个胜率最高。概率性决策,不需要 100% 正确,只要比随机好。
分离的理由:规则引擎是确定性逻辑——单元测试可以精确验证每条规则。AI 是概率性策略——需要 MCTS 搜索库、LLM SDK、NumPy 等 Python 生态。两者混在一个进程里调试会互相污染。
接口定义
AI 服务暴露一个端点:
POST /ai/decide
请求:
{
"player_hand": [1, 1, 1, 2, 3, 4, ...], // 手牌 (int 编码)
"exposed": [...], // 已碰/杠的牌
"discard_pool": [28, 15, 3, ...], // 弃牌堆
"last_discard": 22, // 刚打出的牌
"legal_actions": [ // 规则引擎已算好的合法操作
{ "type": "discard", "tile": 1 },
{ "type": "discard", "tile": 3 },
{ "type": "pung", "tiles": [22, 22, 22] },
{ "type": "win", "fan_count": 6 }
],
"game_context": { // 游戏上下文
"round": 5,
"remaining_tiles": 40,
"scores": { "AI-东": 12, "AI-南": -4 }
}
}
响应:
{
"chosen_action": { "type": "win", "fan_count": 6 },
"confidence": 0.95, // 决策置信度
"thinking_time_ms": 42 // 决策耗时
}
C# 端调用流程:
// 在 Unity Game Server 的每回合中:
if (currentPlayer.IsAI) {
// 1. 规则引擎算好所有合法操作
var legalActions = engine.GetLegalActions(state, playerId);
// 2. 调 AI 服务
var request = BuildAiRequest(state, playerId, legalActions);
var response = await _aiClient.DecideAsync(request);
// 3. AI 选中的操作就是玩家操作
engine.ExecuteAction(state, response.ChosenAction);
}
这个接口设计的要点:AI 不需要自己判断合法性——规则引擎已经把合法操作列表算好了。 AI 只做"选择题":在 N 个合法操作中选最好的。如果 AI 服务挂了或超时,降级为随机选一个合法操作(legal_actions[random]),游戏不中断。
三级 AI 难度
| 级别 | 策略 | 运行位置 | 延迟 | 强度 |
|---|---|---|---|---|
| 难度 1 | 规则合法随机 | C# 进程内 | < 0.1ms | 弱 |
| 难度 2 | MCTS + 启发式评估 | Python 独立服务 | ~50ms | 中 |
| 难度 3 | LLM Agent (ReAct) | Python + LLM API | ~2s | 强 |
难度 1 — 规则合法随机(Demo 阶段实现)
public class RandomMahjongAI {
public PlayerAction Decide(MahjongGameState state, List<PlayerAction> legalActions) {
// 启发式过滤:能胡就胡、能杠就杠、否则随机
var hu = legalActions.FirstOrDefault(a => a.Type == "win");
if (hu != null) return hu;
var kong = legalActions.FirstOrDefault(a => a.Type is "an_kong" or "ming_kong");
if (kong != null && Random.Shared.Next(4) > 0) return kong;
var discards = legalActions.Where(a => a.Type == "discard").ToList();
return discards[Random.Shared.Next(discards.Count)];
}
}
不打外部服务,直接跑在 C# 进程里。Demo 阶段用这个就够验证规则引擎正确性。
难度 2 — MCTS 启发式搜索(后实现)
Python 服务启动时加载麻将规则(通过 YAML DSL 了解番型和计分)
↓
收到决策请求 → 以当前状态为根节点
↓
MCTS 搜索树:
1. Selection: 从根节点选最有潜力的分支 (UCB1)
2. Expansion: 展开一个未探索的操作
3. Simulation: 随机模拟到终局(双方都用随机策略)
4. Backpropagation: 回传胜负结果更新节点统计
↓
搜索 200ms → 返回访问次数最多的操作
MCTS 的核心优势:不需要手写评估函数。只要能从"终局结果"回传胜负信号,它自己学会哪些手牌好、哪些操作差。
对于麻将,评估函数可以辅助加速:
- 听牌距离(离胡牌差几张)
- 番型潜力(手牌中已有多少番型的"零件")
- 安全度(打这张牌别人胡的概率)
难度 3 — LLM Agent(远期目标)
def decide_with_llm(state, legal_actions):
prompt = f"""
你是麻将高手。当前手牌:{render_hand(state.hand)}
桌面已出:{render_pool(state.discard_pool)}
可选项:{render_actions(legal_actions)}
请选择最优操作并解释原因。
"""
response = llm.chat(prompt, response_format="json")
return parse_action(response)
只在关键回合调 LLM(听牌/防守/大番型决策),其余用 MCTS 降级。成本控制:每局限 3-5 次 LLM 调用。
部署架构
┌─────────────────────────────────────────────┐
│ Unity Game Server (C# 进程) │
│ │
│ 游戏主循环 │
│ → engine.GetLegalActions() │
│ → 如果是 AI 玩家: │
│ 难度 1: ai.Decide() 直接在 C# 执行 │
│ 难度 2/3: HTTP → ai-companion:5000 │
│ → engine.ExecuteAction() │
│ → 广播状态到所有客户端 │
└──────────────┬──────────────────────────────┘
│ HTTP (localhost / 内网)
┌──────────────▼──────────────────────────────┐
│ AI 陪打服务 (Python FastAPI) │
│ │
│ POST /ai/decide │
│ → 根据 difficulty 参数路由: │
│ 难度 2 → MctsStrategy.decide() │
│ 难度 3 → LlmStrategy.decide() │
│ → 返回 chosen_action │
└──────────────────────────────────────────────┘
- 开发环境:Python 和 C# 都跑在 localhost,延迟 ~1ms
- 生产环境:AI 服务可以独立部署到 GPU 服务器,C# 游戏服务器通过内网 HTTP 调用
- 容灾:AI 超时 → 降级为难度 1(随机合法),玩家无感知
Demo 阶段做哪些
| Demo 实现 | 后续实现 | |
|---|---|---|
| AI 接口 | C# 内嵌 RandomMahjongAI,不调外部服务 |
Python FastAPI 服务 |
| 难度 | 只有难度 1(规则合法随机) | 难度 2 MCTS、难度 3 LLM |
| 目的 | 验证规则引擎合法性判定正确 | 提升 AI 强度 |
2.4 Rule DSL 完整结构
声明式优于命令式。核心实体:
game:
type: "poker" | "mahjong" | "board"
players: {min: 2, max: 6}
deck:
cards: "standard_52" | "standard_54" | custom
custom_cards: [...] # 自定义牌面
phases:
- name: "deal"
deal: {cards_per_player: 17, remaining: "landlord_pool"}
- name: "bid"
type: "auction"
options: ["1分","2分","3分","不叫"]
win_condition: "highest_bid"
- name: "play"
type: "turn_based"
turn_order: "clockwise"
lead_rule: "landlord_first"
valid_play: "card_pattern_validator"
- name: "settle"
score: "score_calculator"
patterns: # 牌型定义
- name: "单张"
match: {type: "single"}
- name: "对子"
match: {type: "pair"}
- name: "顺子"
match: {type: "straight", min_length: 5, max_length: 12}
validators: # 出牌校验逻辑
- name: "card_pattern_validator"
rules:
- "must_follow_pattern" # 跟牌型
- "must_be_larger" # 必须更大
- "bomb_overrides" # 炸弹可压任何牌
scoring:
type: "expression" | "table" | "custom"
# expression: "base_score * multiplier"
# table: 查表
# custom: 用户提供的评分函数(沙盒执行)
这个 DSL 的设计原则:90% 的玩法用声明式覆盖,10% 的特殊规则走 custom + 沙盒。
三、MVP Demo 落地路径
以上是完整架构。但先不用全做——用最小闭环验证核心链路。
Phase 0: 控制台麻将 Demo (预计 6-8 天)
详细计划已独立为文档: 见
docs/demo-implementation-plan.md
目标: C# 控制台程序,4 个随机 AI 自动打完四川麻将血战到底 + 广东鸡平胡。验证 MeldsSolver(回溯搜索胡牌判断)+ 番型互斥图 + 血战淘汰 + DSL 热切换。
关键模块:
MeldsSolver — 回溯搜索胡牌判断(核心算法)
Deck — 108张牌堆管理
PhaseMachine — 摸牌→出牌→碰杠胡优先级仲裁→血战淘汰→查叫查花猪
ScoreEngine — 番型表计分 + 互斥图 + 结算前钩子
DslLoader — YamlDotNet 加载 + 能力检查
RandomAI — 规则合法随机(能胡就胡、能杠就杠、否则随机出)
完成标准:78 个测试全绿、1000 局零报错、换 DSL 不需要重新编译。
Phase 0 之后的路线:
Phase 0 ✅ 控制台麻将 Demo (本阶段)
↓
麻将引擎扩展: wildcard、全不靠、一色双龙会(补 3 个算法分支)
↓
Unity 前端: 麻将牌面渲染 + 出牌操作 UI
↓
Python AI 服务: MCTS + LLM Agent
↓
10+ 麻将玩法 DSL + CI 压测
四、完整实施路线图 (Phase 0 之后)
Phase 1: 调研与选型 (预计 2-3 天)
目标: 确定技术选型,产出选型报告
Task 1.1: 游戏前端渲染层选型
规则引擎独立于渲染层,这里只选前端展示方案:
| 方案 | 优势 | 劣势 | 适合 |
|---|---|---|---|
| 纯 HTML5 Canvas + JS | 零依赖,WebSocket 直连 | 动画需要手写 | 原型/MVP |
| Cocos Creator | 棋牌生态成熟,组件丰富 | 绑定 Cocos 生态,包体大 | 正式产品 |
| Phaser 3 | 轻量 2D 框架 | 无棋牌生态 | H5 小游戏 |
结论倾向: MVP 阶段用纯 Canvas 原型,验证规则引擎后再决定是否切 Cocos。规则引擎不依赖任何前端框架。
Task 1.2: 规则引擎核心能力定义与覆盖分析
明确规则引擎必须处理的 4 类核心逻辑:
- 牌型匹配: 单张/对子/顺子/炸弹/... — 声明式 pattern match
- 出牌校验: 跟牌型/必须更大/特殊牌型覆盖 — 声明式 validator 链
- 回合流转: 叫地主/出牌/跟注/开牌 — 声明式 phase 状态机
- 计分结算: 底分×倍数+特殊牌型加成 — expression/table/custom
覆盖分析 — 枚举 6 种代表性玩法实测边界:
| 玩法 | 牌型匹配 | 出牌校验 | 回合流转 | 计分结算 | 4类覆盖率 | 缺口 |
|---|---|---|---|---|---|---|
| 炸金花 | ✓ 6种牌型 | ✓ 简单比大小 | ✓ 下注/跟/加/开/弃 | ✓ 底注+各轮 | 100% | 无,诈唬是AI层的事 |
| 牛牛 | ✓ 无牛/牛几/特殊牌型 | ✓ 纯比牌 | ✓ 下注→发牌→摊牌 | ✓ 牛几×倍数 | 100% | 无 |
| 斗地主 | ✓ 10+种牌型 | ✓ 跟牌型+大于+炸弹覆盖 | ✓ 发牌→叫地主→出牌→结算 | ✓ 底分×炸弹/春天倍数 | 100% | 无 |
| 跑得快 | ✓ 同斗地主 | ✓ 同斗地主 | ✓ 简化(无叫地主) | ✓ 剩牌计分 | 100% | 无 |
| 掼蛋 | ✓ 8+种+特殊炸弹规则 | ✓ 跟牌型+大于 | ✓ 进贡/还贡→出牌→升级 | ✓ 头游二游+级数 | 95% | 炸弹比较逻辑特殊(张数优先>点数),需自定义比较器 |
| 四川麻将血战 | ✓ 顺/刻/杠/对子组合成14张 | ✓ 摸→出→碰/杠/胡优先级 | ✓ 血战到底+查叫 | ✓ 番型叠加计算 | 80% | ①碰杠胡多人冲突需priority resolver ②胡牌判断是算法级(非声明式) ③番型组合爆炸需规则引擎预处理 |
结论: 扑克类 95%+ 覆盖,麻将类 80% 覆盖但需要 3 个扩展点——
| 扩展点 | 解决方案 | 优先级 |
|---|---|---|
| 自定义比较器 | DSL 支持 comparator: custom,走沙盒函数 |
高(掼蛋炸弹等) |
| 多人优先级仲裁 | 新增 priority_resolver 机制,规则配置优先级链 |
高(麻将碰杠胡) |
| 复杂牌型算法 | 内置通用的"麻将胡牌判断""十三水分牌""掼蛋炸弹比较"等算法库,DSL 引用 | 中(内置算法而非 LLM 生成) |
关于问题 2: 是的,一个规则引擎加载不同的 DSL YAML 文件就能动态切换玩法。核心设计就是"玩法即配置"——每个 .yaml 是一个玩法,引擎启动时加载,状态机自动按 phase 编排运行。换玩法 = 加载另一个 yaml,不需要重新编译、不需要重启服务。
已验证的可覆盖玩法清单(至少 20+): 斗地主、炸金花、牛牛、跑得快、掼蛋、十三水、升级/拖拉机、桥牌、德州扑克、21点、梭哈、干瞪眼、五十K、红十、拱猪、大老二、UNO、斗牛、三公、百家乐。
Task 1.3: 现有棋牌 DSL/框架调研
- 搜索开源棋牌框架(如 cocos-creator 棋牌框架、nodejs 棋牌服务端)
- 研究已有的棋牌规则描述方案
- 调研类似 "rule engine as a service" 的项目
Task 1.4: LLM 规则提取能力验证
使用 3-5 种不同复杂度玩法做 Prompt 测试:
- 简单的:炸金花(牌型+比大小)
- 中等的:斗地主(叫地主+牌型+炸弹)
- 复杂的:四川麻将(血战+番型+计分)
验证指标:
- 结构化提取的准确率
- 遗漏规则的比例
- 错误规则的比例
Task 1.5: 安全方案调研
- Docker 沙盒执行自定义计分函数的可行性
- WebAssembly 沙盒作为备选
- 输入输出 schema 约束
Phase 2: DSL 设计与验证 (预计 3-5 天)
目标: 设计并冻结 V1 版本的 Rule DSL,通过 3 种玩法验证
Task 2.1: DSL Schema 设计
# 用 Pydantic 定义 DSL Schema,自动获得校验
class GameRule(BaseModel):
type: Literal["poker", "mahjong", "board"]
players: PlayerConfig
deck: DeckConfig
phases: list[Phase]
patterns: list[CardPattern]
validators: list[Validator]
scoring: ScoringConfig
Task 2.2: LLM Prompt 工程
设计 few-shot prompt,使 LLM 能从自然语言规则描述 → 生成 DSL JSON。
System: 你是一个棋牌规则分析专家...
User: 以下是"跑得快"的玩法描述:{description}
请生成 DSL 规则配置。
Task 2.3: 3 种玩法验证
- 炸金花 -> DSL -> 沙盒模拟 1000 局 -> 人工检查
- 斗地主 -> DSL -> 沙盒模拟 1000 局 -> 人工检查
- 简化麻将 -> DSL -> 沙盒模拟 1000 局 -> 人工检查
Task 2.4: DSL 版本管理
- git 管理每个玩法的 DSL 文件
- 人工审核后打 tag 发版
- 引擎按版本加载规则
Phase 3: 游戏引擎集成 MVP (预计 5-7 天)
目标: 选 2 种玩法,完成从 DSL 到可玩游戏的完整链路
Task 3.1: 通用引擎核心
构建最小棋牌引擎核心(C#):
RuleEngine/
Core/
MahjongTile.cs # int 编码 + 工具类
Deck.cs # 牌堆管理
GameState.cs # 游戏状态
Patterns/
MeldsSolver.cs # 胡牌判断 + 番型识别
Phase/
PhaseMachine.cs # 状态机流转
Scoring/
ScoreEngine.cs # 计分结算
Dsl/
DslLoader.cs # YamlDotNet 加载
DslSchema.cs # DSL 类型定义
Task 3.2: 四川血战完整实现
- 加载四川血战 DSL
- 实现:发牌→摸牌→出牌→碰杠胡优先级→血战淘汰→查叫查花猪 完整流程
- 4 人对战
Task 3.3: 广东鸡平胡完整实现
- 加载广东鸡平胡 DSL
- 实现:花牌补牌→吃牌→番型三级→一炮三响 完整流程
Task 3.4: 前端原型
- 通用牌桌 UI 组件
- 牌面渲染(扑克牌面、麻将牌面)
- 操作按钮(出牌/跟注/弃牌/开牌)
Phase 4: 陪打 AI 系统 (预计 5-7 天)
目标: 实现 3 级难度的陪打机器人
Task 4.1: 难度 1 — 规则合法随机
class RuleBasedAI:
def decide(self, state, legal_moves):
# 排除明显愚蠢的操作
# 其余随机
return random.choice(filtered_moves)
Task 4.2: 难度 2 — 启发式 + MCTS
- 基于 DSL scoring 规则构建评估函数
- MCTS 搜索有限深度
- 可配置搜索时间(影响难度感知)
Task 4.3: 难度 3 — LLM Agent
- 输入:当前手牌 + 历史出牌 + 规则描述
- 输出:出牌决策
- 使用结构化输出确保合法性
- 成本控制:仅在关键回合调用 LLM
Task 4.4: AI 策略热切换
- 游戏中可按座位配置不同难度 AI
- AI 接口统一,策略可插拔
Phase 5: 安全沙盒 (预计 2-3 天)
目标: 安全执行用户自定义规则
Task 5.1: CSharpScript 沙盒
// 自定义计分/比较函数在 CSharpScript 沙盒中执行
var options = ScriptOptions.Default
.WithReferences(typeof(Card).Assembly)
.WithImports("System", "System.Linq");
var result = await CSharpScript.EvaluateAsync<bool>(customCode, options, globals);
Task 5.2: 安全限制
- 默认禁止 IO/网络/反射
- 输入输出 Schema 校验
- 超时控制
- 异常捕获降级为默认行为
Phase 6: 扩展验证 (预计 3-5 天)
目标: 增加 5+ 玩法验证系统通用性
Task 6.1: 新玩法上线流程优化
目标:从拿到规则描述到可玩,30 分钟。
1. 输入规则描述(口语化中文)
2. LLM 生成 DSL (30s)
3. 自动沙盒模拟 1000 局 (1min)
4. 人工 review + 修正 (20min)
5. 前端自动适配 (5min)
6. 上线
Task 6.2: 压力测试
- 同一引擎同时运行 10 种玩法
- 每种玩法 100 个房间
- 验证隔离性
五、关键风险与对策
| 风险 | 概率 | 影响 | 对策 |
|---|---|---|---|
| LLM 对复杂规则理解不准 | 高 | 中 | 分步提取 + 人工审核 + 模拟验证 |
| DSL 表达能力不足 | 中 | 高 | custom 兜底函数,逐步扩展 DSL |
| 引擎性能不够 | 低 | 中 | C# 足够,必要时热路径可换 Rust |
| LLM 决策延迟高 | 中 | 中 | 混合策略:非关键回合不调 LLM |
| 沙盒逃逸风险 | 低 | 极高 | 多层防御:Docker + seccomp + 只读FS |
六、项目目录结构规划
~/projects/card-game-engine/
├── README.md
├── docs/
│ ├── architecture-plan.md # 架构设计文档
│ └── demo-implementation-plan.md # Demo 实施计划
├── RuleEngine/ # C# 规则引擎核心
│ ├── Core/
│ ├── Patterns/
│ ├── Phase/
│ ├── Scoring/
│ ├── Dsl/
│ └── Sandbox/
├── RuleEngine.Tests/ # 单元测试
├── ai-companion/ # Python AI 陪打
│ └── src/strategies/
├── dsl-examples/ # 玩法 DSL (YAML)
│ ├── xuezhandaodi.yaml
│ └── guangdong_jipinghu.yaml
└── Demo/ # 控制台 Demo
七、验证清单
- Phase 1: 选型报告完成
- Phase 2: DSL 通过 3 种玩法验证
- Phase 3: 2 种玩法可完整游玩
- Phase 4: 3 级 AI 均能正确出牌
- Phase 5: 沙盒通过安全审计
- Phase 6: 5+ 玩法稳定运行
八、开放问题
- 前端渲染层选型: MVP 用纯 Canvas,后期是否切 Cocos Creator?取决于产品化需求,不影响规则引擎。
- 规则引擎语言: C#(嵌入 Unity),确定。
- DSL 表达力边界: 是否需要图灵完备的 custom 函数?还是纯声明式足够?
- LLM 成本: 每次玩法生成调用 LLM 的成本是否可接受?(估计每次 < ¥0.5)
- 前端方案: Cocos Creator 原生客户端还是 H5 小游戏?
- 商业模式: 服务棋牌运营商还是自己做平台?
计划创建时间: 2026-07-03 预计总工期: 20-30 天 建议先执行 Phase 1 调研,再决定后续方案。