日本麻将(Riichi Mahjong)游戏引擎与 Gymnasium 环境,用于训练强化学习智能体。
- C++ 核心引擎(
src/):高性能游戏逻辑,实现摸打流程、手牌评估、向听计算、役种判定、得点结算以及完整对局流程。 - Python 绑定(
src/bindings.cpp):通过 pybind11 将 C++ 引擎导出为 Python 模块mahjong_cpp。 - Gymnasium 环境(
mahjong/env.py):封装为标准 Gymnasium 接口,提供观测编码与动作掩码。 - 训练流水线(
mahjong/train.py):基于自博弈的 PPO 训练与评估脚本。
- 完整的 4 人立直麻将规则实现
- 40+ 役/役满检测
- 标准符数 / 番数得点系统
- 东南战(半庄),带庄家轮转与本场数
- 包含荒牌流局、九种九牌等流局处理
- 支持立直、一发、Dora / Ura Dora
- 支持赤宝牌(Akadora)
- 动作掩码保证只输出合法动作
- 约 161 维离散动作空间,约 7000+ 维观测空间
这一节简要说明当前 C++ 核心引擎实现的主要规则与已知简化点,方便和现有日麻客户端 / 规则书对照。
-
基本设置
- 人数与起始分:4 人对战,每人起始分 25000 点。
- 场次结构:固定为一东一南半庄(Hanchan),风场顺序为 东场 → 南场,共 8 局(含连庄局数)。
- 庄家与连庄:
- 当前局的庄家为
round_number % 4。 - 和了后如果 庄家和了 或特定流局条件满足,则庄家连庄,本场数
honba+1。
- 当前局的庄家为
- 游戏结束条件:
round_wind >= 2(南场打一圈结束);- 或任意一名玩家分数 < 0。
-
牌与牌山
- 牌组:使用 34 种牌、共 136 张(每种 4 张),含 3 张赤宝牌(赤5万 / 赤5筒 / 赤5索)。
- 赤宝牌(赤ドラ):
- 使用
tile_id = tile_type * 4 + tile_index表示单张牌; - 当
tile_index == 0且tile_type ∈ {5m, 5p, 5s}(即t == 4, 13, 22)时视为赤宝牌。
- 使用
- 牌山生成与布局:
- 初始牌山为
[0, 1, ..., 135],经std::shuffle随机洗牌。 - 可摸牌山:
tiles[0..121](共 122 张),按顺序自前向后摸牌。 - 王牌(Dead wall):
tiles[122..135](共 14 张),布局为:- 表 Dora 指示牌:
tiles[130], 128, 126, 124, 122(最多 5 张); - 里 Dora 指示牌:
tiles[131], 129, 127, 125, 123(最多 5 张,对应表 Dora 张数); - 岭上牌:
tiles[135], 134, 133, 132,从 135 向前依次摸。
- 表 Dora 指示牌:
- 初始牌山为
- 起手配牌:
- 庄家开始,按 4 张一组轮流发 3 圈 + 每人补 1 张,庄家最终 14 张,其余 13 张。
-
立直与一发
- 立直条件(
can_riichi):- 未立直、门前清、分数 ≥ 1000,牌山剩余牌数 ≥ 4;
- 自家手牌张数满足听牌结构(去任意一张后向听数为 0)。
- 立直流程:
- 选择一张手牌打出并声宣立直,当次打出的牌记录为立直舍牌;
- 即时支付 1000 点立直棒,
riichi_sticks+1; - 若立直发生在 东 1 局前 1 巡内(含起手和第一轮摸打),则记作 双立直。
- 一发(Ippatsu):
- 立直后自家获得
ippatsu标记; - 当发生任何吃 / 碰 / 杠 时,全员
ippatsu标记被清除; - 引擎中未对 “自家下一次摸打后自动失效” 做完全精细化处理,逻辑有一定简化。
- 立直后自家获得
- 立直条件(
-
杠相关规则
- 支持 暗杠(Ankan) / 大明杠(Minkan) / 加杠(Kakan),并在杠后:
- 翻开新 Dora 指示牌(最多 5 张);
- 从岭上摸 1 张牌(岭上没牌则直接荒牌流局)。
- 四开杠途中流局(Suukaikan,四杠流局):
- 引擎维护全场所有副露中的杠数(暗 / 明 / 加杠均计入)。
- 当场上累计杠数 达到或超过 4 个 时:
- 若 仅有 1 名玩家 拥有全部 4 个以上的杠(即可以冲「四槓子」役满),则 本局继续;
- 否则立即触发途中流局,
draw_type = "四開槓",不再进行岭上补牌。
- 未实现 / 简化点:
- 抢杠荣和(槓放銃 / 槍槓)当前未实现:加杠时不会触发他家“抢杠和”,逻辑中直接跳过该检查。
- 暗杠目前不会取消他家一发标记(与正式规则有出入)。
- 支持 暗杠(Ankan) / 大明杠(Minkan) / 加杠(Kakan),并在杠后:
-
流局与途中流局
- 荒牌流局(墙牌摸完):
- 当可摸牌山或岭上牌耗尽时触发;
- 进行听牌 / 不听罚分(3000 点在听者与非听者之间流动);
- 庄家听牌则连庄,本场数 +1,否则换庄、本场数按规则更新。
- 九种九牌流局(Kyuushukyuhai):
- 起手 14 张中有 9 种及以上幺九牌(含字牌与断幺外数字端张),且尚未进行鸣牌;
- 玩家可主动宣告“九种九牌”,触发途中流局,
draw_type = "九种九牌",庄家连庄,本场数 +1。
- 其他常见途中流局(四风连打、四家立直、三家和等)目前未实现。
- 荒牌流局(墙牌摸完):
-
役种与计分
- 役种检测由
yaku.cpp完成,包含 40+ 个役 / 役满,包括但不限于:- 一般役:立直、一发、门前清自摸和、平和、断幺九、一杯口、三色同顺、三色同刻、一气通贯、对对和、三暗刻、三杠子、小三元等;
- 门前限定役:两杯口、七对子等;
- 役满:国士无双、国士无双十三面、四暗刻、四暗刻单骑、字一色、大三元、小四喜、大四喜、清老头、绿一色、九莲宝灯、纯正九莲宝灯、天和、地和、四槓子 等。
- ** Dora 计分**:
- 根据当前 表 Dora 指示牌 计算 Dora 种类;
- 里 Dora 指示牌数量与表 Dora 数量一致,在立直和了时生效;
- 赤宝牌根据
is_red_dora(tile_id)单独+1 翻,与普通 Dora 累加。
- 点棒结算:
- 采用标准符数 / 番数计算方式,区分庄家 / 闲家的自摸与荣和支付;
- 本场数影响放铳方 / 非和方额外支付(每本 300 点总计拆分)。
- 役种检测由
-
其他注意事项
- 振听(Furiten)判定有简化:通过“把自己舍牌视作摸进来再加一张看能否和牌”的近似方式来判定,有可能与正式规则边界情况略有差异。
- 客户端 / 强化学习侧 目前并不区分赤宝牌的 tile_type(仍为 5m/5p/5s),赤宝信息只在 C++ 引擎中通过
tile_id和is_red_dora体现,用于计役与计分。
以上为当前实现状态的概要说明;如需严格对齐某一特定赛事 / 平台的详细规则,可在此基础上进一步调整 src/game.cpp、src/yaku.cpp 等文件中的逻辑。
这一节简要描述当前用于强化学习的模型结构 MahjongNet 以及 PPO 训练流程。
-
观测与动作空间
- 观测(Observation) 来源于
MahjongEnv,由三部分组成:spatial:形状为(40, 34, 4)的张量,40 个通道分别编码自家手牌、4 家河牌、4 家副露以及 dora 指示牌位置;global_features:长度为 18 的向量,包含场风 / 自风、墙牌剩余、本场数、立直棒数、4 家点数与立直状态、巡目、当前是否摸牌阶段、摸入牌种、自家是否门前、有无副露等信息;action_mask:长度为 161 的 0/1 向量,标记当前时刻哪些离散动作是 合法 的。
- 动作空间(Action space):长度为 161 的离散动作:
- 0–33:按牌种打出对应牌(弃牌);
- 34–54:吃(不同花色与起点的顺子模板);
- 55–88:碰(按牌种);
- 89–122:杠(暗杠 / 大明杠 / 加杠,按牌种编码);
- 123–156:立直并打出对应牌种;
- 157:自摸;158:荣和;159:过;160:九种九牌流局宣言。
- 观测(Observation) 来源于
-
网络结构:
MahjongNet- 使用一个共享干路的 策略‑价值网络(policy‑value network):
- 空间编码器
spatial_encoder:- 两层 2D 卷积:
Conv2d(OBS_CHANNELS, 64, kernel_size=(3,1), padding=(1,0))×2,均接 ReLU; - 展平后接
Linear(64 * NUM_TILE_TYPES * 4, hidden=256)+ ReLU; - 负责从 40×34×4 的局面平面中抽取牌型与结构化信息。
- 两层 2D 卷积:
- 全局特征编码器
global_encoder:Linear(OBS_GLOBAL_SIZE, 64)+ ReLU,将 18 维全局特征压缩到 64 维。
- 共享层
shared:- 将空间特征(256 维)与全局特征(64 维)拼接成 320 维;
- 经
Linear(320, 256)+ ReLU 得到共享隐藏表示。
- 策略头
policy_head:Linear(256, ACTION_SPACE_N)输出每个动作的 logits;- 前向时若提供
action_mask,会对非法动作位置填充-inf,从而在 softmax / Categorical 中强制屏蔽非法动作。
- 价值头
value_head:Linear(256, 1)输出标量状态价值 (V(s)),形状为(batch,)。
- 空间编码器
- 使用一个共享干路的 策略‑价值网络(policy‑value network):
-
训练算法:PPO 自博弈
- 自博弈设置:
- 训练时,自家(玩家 0)使用可学习的
MahjongNet; - 其余 3 家使用规则驱动的
RuleAgent,形成固定对手环境。
- 训练时,自家(玩家 0)使用可学习的
- 数据收集
self_play_collect:- 在一个环境中从起局打到局终:
- 轮到自家时,用模型前向得到带掩码的策略分布
Categorical(logits),采样动作、执行一步环境,并记录观测、动作、即时回报、价值估计、log‑prob、动作掩码与终止标志; - 轮到对手时,由
RuleAgent选择动作,不记录到训练缓冲区。
- 轮到自家时,用模型前向得到带掩码的策略分布
- 样本缓存在
PPOBuffer中,直至达到设定容量(默认buffer_size=4096)。
- 在一个环境中从起局打到局终:
- 优势估计与损失:
- 使用 GAE‑Lambda(
gamma=0.99, lambda=0.95)计算优势advantages与回报returns; - 训练前对优势做标准化(零均值、单位方差);
- 优化目标为标准 PPO:
- 策略损失:裁剪的 PPO 目标,裁剪阈值
clip_eps=0.2; - 价值损失:((V(s)-R)^2) 的均方误差,系数
vf_coef=0.5; - 熵正则:鼓励策略多样性,系数
ent_coef=0.01。
- 策略损失:裁剪的 PPO 目标,裁剪阈值
- 使用 GAE‑Lambda(
- 优化细节:
- 每次更新时按最多 256 的 batch size 打乱取 mini‑batch,迭代
epochs=4轮; - 优化器为
Adam(lr=3e-4)(可通过命令行参数--lr调整); - 使用
clip_grad_norm_(model.parameters(), 0.5)做梯度裁剪。
- 每次更新时按最多 256 的 batch size 打乱取 mini‑batch,迭代
- 奖励设计:
- 在单局结束时,将该局点数变化(相对本局开始时)除以 1000 作为局内累积 reward;
- 在整场对战结束时,以最终点数相对 25000 的增减比值(除以 25000)作为终局奖励;
- 中间步骤 reward 默认为 0。
- 自博弈设置:
-
评估与模型保存
- 每隔
--eval-interval轮(默认 50)会对当前模型进行评估:- 与同样的规则对手打
--eval-games(默认 20)局,统计平均回报avg_reward和自家第一名率win_rate; - 若新的
win_rate超过历史最佳,则保存为checkpoints/best_model.pt。
- 与同样的规则对手打
- 训练过程中还会周期性写入 checkpoint(每 100 轮一次,含模型与优化器状态),训练结束时额外保存
final_model.pt。
- 每隔
-
uv(用于创建虚拟环境与安装依赖)。安装示例:
curl -LsSf https://astral.sh/uv/install.sh | sh
# 使用 uv 创建虚拟环境并安装依赖(含训练与开发依赖)
uv venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
uv sync --extra train --extra dev
# 编译 C++ 引擎
cmake -S . -B build -DBUILD_PYTHON=ON -DBUILD_TESTS=ON \
-Dpybind11_DIR=$(python -c "import pybind11; print(pybind11.get_cmake_dir())")
cmake --build build -j$(nproc)
# 复制编译好的模块
cp build/src/mahjong_cpp*.so .# C++ tests
./build/tests/cpp/mahjong_tests
# Python tests
PYTHONPATH=. pytest tests/python/ -vPYTHONPATH=. python -m mahjong.train \
--iterations 1000 \
--games-per-iter 10 \
--eval-interval 50 \
--device cpu# 从项目根目录运行
uv run maj-game --seed 42
# 使用固定牌山(便于复现)
uv run maj-game --seed 42 --wall-file apps/cli_game/examples/wall.txtfrom mahjong.env import MahjongEnv
from mahjong.agents.random_agent import RandomAgent
env = MahjongEnv(seed=42)
agent = RandomAgent()
obs, info = env.reset()
done = False
while not done:
action = agent.act(obs)
obs, reward, terminated, truncated, info = env.step(action)
done = terminated or truncated
print(f"Final scores: {info['scores']}")maj/
├── CMakeLists.txt # Top-level CMake
├── pyproject.toml # Python package config
├── src/ # C++ core engine
│ ├── tile.h/cpp # Tile representation
│ ├── hand.h/cpp # Hand management + shanten
│ ├── meld.h/cpp # Open melds (chi/pon/kan)
│ ├── wall.h/cpp # Tile wall + dead wall
│ ├── player.h/cpp # Player state
│ ├── action.h/cpp # Action types
│ ├── yaku.h/cpp # Yaku evaluation (40+ yaku)
│ ├── score.h/cpp # Scoring calculation
│ ├── game.h/cpp # Game engine
│ └── bindings.cpp # pybind11 bindings
├── mahjong/ # Python package
│ ├── env.py # Gymnasium environment
│ ├── train.py # PPO training pipeline
│ └── agents/ # Agent implementations
│ ├── random_agent.py
│ └── rule_agent.py
├── apps/ # Application entry modules
│ └── cli_game/ # CLI game app (migrated from maj-game)
│ ├── main.py
│ ├── client/
│ └── examples/wall.txt
└── tests/
├── cpp/ # C++ unit tests (Google Test)
└── python/ # Python tests (pytest)