简介这份资源是面向日本麻将立直麻将爱好者的开源助手工具使用 Go 语言开发适合希望提升牌效判断与防守能力的进阶玩家也适合对麻将算法感兴趣的开发者研究。核心功能包括对战时自动分析手牌并综合进张与打点给出推荐舍牌有人立直或多副露时显示各张牌的危险度记录他家手切摸切并支持牌谱复盘逐打推荐进攻与防守选择兼容四人麻将与三人麻将可对接雀魂网页版与天凤平台。资源包共138个文件以87个go源码为主体辅以33个png界面截图、6个js脚本及proto、json、html、makefile等配置与构建文件压缩包约1.47MB结构紧凑。目前已有14172人学习下载读者可从中获取完整的牌效与防守算法实现、平台对接逻辑及可运行的助手程序用于实战辅助或二次开发参考。1. 日麻助手背后的决策引擎为什么“牌效”不是玄学而是可计算的很多人第一次接触日麻辅助工具时都会有一个直觉判断麻将运气成分那么大AI 能算准吗这个疑问本身就问错了方向。日麻助手真正解决的不是“预测下一张摸什么”而是在信息不完全的博弈中把每一手的期望收益算清楚。mahjong-helper 这个项目用 Go 语言实现了一套完整的牌效计算、防守判断和记牌推理引擎支持雀魂和天凤两个平台的对局数据接入。它的核心价值在于把人类牌手靠经验和直觉做的决策拆解成可量化、可复现的计算过程。牌效不是玄学向听数、有效牌枚数、打点期望、放铳概率这些全都有明确的数学模型。搞懂这套引擎怎么运转比记住一百个牌谱定式有用得多。2. 向听数与有效牌牌效计算的底层数据结构2.1 手牌怎么表示从 14 张牌到计数数组任何牌效计算的第一步都是把手牌变成计算机能高效处理的形式。最直觉的做法是用一个长度为 34 的整数数组索引 0 到 33 分别对应日麻的 34 种牌万子 1-9、筒子 1-9、索子 1-9、东南西北白发中。数组的值就是该种牌在手牌中的张数。// 34 种牌的计数表示 // 0-8: 一万到九万 // 9-17: 一筒到九筒 // 18-26: 一索到九索 // 27-33: 东南西北白发中 type Hand struct { Counts [34]int } // 从牌 ID 转换到计数数组 func NewHand(tiles []int) *Hand { h : Hand{} for _, t : range tiles { h.Counts[t] } return h }这种表示方式的好处是判断面子顺子、刻子时只需要做数组索引运算不需要排序或查找。比如判断“一万二万三万”是否构成顺子只需要检查Counts[0] 0 Counts[1] 0 Counts[2] 0。常见的实现思路是配合递归回溯来枚举所有可能的面子拆分但纯递归在 14 张牌时可能产生大量重复计算所以实际引擎里通常会加记忆化或剪枝。2.2 向听数计算递归拆解与动态规划向听数shanten是牌效的核心指标表示“还差几张有效牌能听牌”。标准型手牌的向听数计算可以转化为在 14 张牌中找出最多能组成多少个面子3 张一组和搭子2 张一组然后套公式shanten 8 - 2*melds - partials其中 melds 是面子数partials 是搭子数但需要满足melds partials 4且手牌中有雀头时额外修正。// 递归枚举面子拆分的核心逻辑简化版 func calcShanten(counts [34]int, melds, partials, pairs int) int { // 找到第一个非零的牌 i : 0 for i 34 counts[i] 0 { i } if i 34 { // 所有牌处理完毕计算向听数 total : melds partials if total 4 { total 4 } shanten : 8 - 2*melds - total // 雀头修正 if pairs 0 meldspartials 5 { // 有雀头时向听数可能减一 } return shanten } // 尝试组成刻子 if counts[i] 3 { counts[i] - 3 s : calcShanten(counts, melds1, partials, pairs) counts[i] 3 // 取最小值... } // 尝试组成顺子需要 i 在万筒索范围内且不跨花色 // 尝试组成对子 // 尝试组成搭子两面、嵌张、边张 // 跳过当前牌 // ... }这段代码的关键在于每次递归都尝试把当前牌归入某种组合然后取所有可能性中向听数最小的结果。参数melds记录已确定的面子数partials记录搭子数pairs记录对子数。实际引擎里会加一个剪枝条件如果当前已经算出的向听数不可能比已知最优解更小就直接返回。这个剪枝能把计算时间从毫秒级压到微秒级是实时辅助的关键。2.3 有效牌枚举从“差什么”到“摸到什么能进张”知道向听数之后下一步是算有效牌——也就是摸到哪些牌能让向听数减少。做法很直接对 34 种牌逐一尝试加入手牌重新计算向听数如果减少了就说明是有效牌。但这里有个容易翻车的地方有效牌要排除手牌里已经用掉的牌和场上可见的牌。// 计算有效牌及其剩余枚数 func calcUsefulTiles(hand *Hand, visible [34]int) map[int]int { currentShanten : calcShanten(hand.Counts, 0, 0, 0) useful : make(map[int]int) for i : 0; i 34; i { if hand.Counts[i] 4 { continue // 手牌里已经四张不可能再摸到 } hand.Counts[i] newShanten : calcShanten(hand.Counts, 0, 0, 0) hand.Counts[i]-- if newShanten currentShanten { // 剩余枚数 4 - 手牌中已有 - 场上可见 remaining : 4 - hand.Counts[i] - visible[i] if remaining 0 { useful[i] remaining } } } return useful }参数visible是场上已经出现的牌包括自家副露、对手舍牌、宝牌指示牌等。这个函数的返回值直接决定了“打哪张牌进张最广”。比如手牌是两面听牌有效牌有 8 枚但其中 3 枚已经在对手舍牌里实际只剩 5 枚这个信息对决策至关重要。很多新手只看“听牌了没有”不看“剩余枚数”结果就是听了个死牌还浑然不知。3. 防守判断对手听牌概率与放铳风险的量化3.1 对手听牌推断从舍牌序列反推手牌结构防守的核心问题是对手听牌了吗听什么mahjong-helper 的做法是基于对手的舍牌序列和副露情况用统计模型推断其手牌范围和听牌概率。常见的实现思路是维护一个“危险度”评分综合以下几个因素对手的舍牌中是否出现了明显的“手切”手牌中打出的牌而非摸切、副露的次数和类型、对局进行到第几巡、对手是否立直。// 对手危险度评估的简化模型 type OpponentModel struct { Discards []int // 舍牌序列 Melds []Meld // 副露 Riichi bool // 是否立直 Turn int // 当前巡目 } func (o *OpponentModel) DangerScore(tile int) float64 { score : 0.0 // 立直后危险度大幅提升 if o.Riichi { score 0.5 } // 副露次数越多听牌概率越高 score float64(len(o.Melds)) * 0.1 // 巡目越晚听牌概率越高 score float64(o.Turn) * 0.02 // 舍牌中是否有该牌附近的牌筋牌逻辑 // 比如对手打过 4 万那么 1 万和 7 万相对安全 // ... return score }这个模型不是精确的概率计算而是一个启发式评分。实际引擎里会结合“筋牌”“壁牌”“字牌安全度”等日麻特有的防守逻辑。比如对手立直后打过 4 万和 6 万那么 5 万相对安全因为 4 万和 6 万附近的顺子可能性降低。这些规则在代码里表现为一系列条件判断和权重叠加。3.2 放铳概率估算贝叶斯推断的工程化近似更精细的做法是用贝叶斯推断给定对手的舍牌序列反推其手牌分布然后计算打出某张牌时对手和牌的概率。但完整的贝叶斯计算量太大实时辅助里通常用查表法或蒙特卡洛模拟的近似。// 蒙特卡洛模拟估算放铳概率 func estimateDealInProbability(opp *OpponentModel, tile int, trials int) float64 { dealIn : 0 for i : 0; i trials; i { // 根据对手舍牌和副露随机生成一个符合条件的手牌 simulatedHand : generateHandFromDiscards(opp) // 检查这张牌是否点炮 if isWinningTile(simulatedHand, tile) { dealIn } } return float64(dealIn) / float64(trials) }trials是模拟次数通常几千次就能收敛到可用的精度。generateHandFromDiscards是关键它要根据对手的舍牌序列排除掉那些“如果手牌里有就不会打出来”的牌从而缩小可能的手牌范围。这个函数的实现质量直接决定了防守判断的准确率。我一般会这么理解这就像是在玩一个“猜牌”游戏对手每打一张牌就排除掉一批可能性剩下的可能性越少猜得越准。3.3 攻守切换期望值比较的决策框架有了进攻端的牌效数据和防守端的放铳概率下一步就是做攻守决策。核心逻辑是比较“进攻的期望收益”和“防守的期望损失”。如果手牌打点高、听牌概率大即使有放铳风险也值得进攻如果手牌打点低、对手立直且危险度高就应该弃和防守。// 攻守决策的简化框架 func decideAttackOrDefense(hand *Hand, opp *OpponentModel, turn int) string { // 计算进攻期望向听数、有效牌枚数、预估打点 shanten : calcShanten(hand.Counts, 0, 0, 0) useful : calcUsefulTiles(hand, getVisibleTiles()) attackEV : estimateAttackEV(shanten, len(useful), estimateHandValue(hand)) // 计算防守期望放铳概率、对手打点预估 dangerTiles : getDangerousTiles(hand, opp) defenseEV : 0.0 for _, t : range dangerTiles { p : estimateDealInProbability(opp, t, 1000) defenseEV p * estimateOpponentHandValue(opp) } if attackEV defenseEV { return attack } return defense }这个框架的关键参数是estimateHandValue和estimateOpponentHandValue它们分别估算自家手牌和对手手牌的打点。日麻的打点计算涉及翻数、符数、宝牌等代码里通常用查表法实现。攻守决策没有绝对正确的答案但有了量化框架至少能避免“明明该弃和却硬冲”的低级失误。4. 记牌与推理场上信息如何转化为决策依据4.1 可见牌统计舍牌、副露、宝牌指示牌的全量追踪记牌的本质是维护一个“可见牌”数组记录每种牌在场上出现了多少张。数据来源包括自家手牌、自家舍牌、对手舍牌、所有副露、宝牌指示牌、里宝牌指示牌如果可见。这个数组是有效牌计算和防守判断的基础输入。// 可见牌追踪器 type VisibleTracker struct { Counts [34]int } func (v *VisibleTracker) AddDiscard(tile int) { v.Counts[tile] } func (v *VisibleTracker) AddMeld(meld Meld) { for _, t : range meld.Tiles { v.Counts[t] } } func (v *VisibleTracker) AddDoraIndicator(tile int) { v.Counts[tile] } // 获取某张牌的剩余枚数 func (v *VisibleTracker) Remaining(tile int) int { return 4 - v.Counts[tile] }这个追踪器需要在每一巡更新确保数据实时准确。常见的翻车点是忘记统计副露中的牌或者把宝牌指示牌和里宝牌指示牌搞混。一旦可见牌统计出错后续所有计算都会跟着错。4.2 牌山剩余推断从可见牌到剩余牌的概率分布知道可见牌之后可以进一步推断牌山的剩余构成。虽然不能精确知道下一张摸什么但可以计算“某张牌还在牌山里的概率”。这个概率等于剩余枚数 / 牌山总剩余张数。牌山总剩余张数 136 - 所有可见牌 - 自家手牌。// 计算某张牌还在牌山里的概率 func tileInWallProbability(tile int, visible *VisibleTracker, hand *Hand) float64 { remaining : visible.Remaining(tile) - hand.Counts[tile] if remaining 0 { return 0 } totalWall : 136 - visible.Total() - hand.Total() if totalWall 0 { return 0 } return float64(remaining) / float64(totalWall) }这个概率在决策时很有用。比如两面听牌的有效牌有 8 枚但其中 5 枚已经可见实际只剩 3 枚在牌山里那么和牌概率就大幅下降。结合巡目信息可以估算“在剩余巡目内和牌的概率”从而决定是否要继续进攻。4.3 对手手牌范围收窄舍牌序列的约束满足对手每打一张牌就排除掉一批可能的手牌组合。比如对手开局打了一张中那么他手里大概率没有中的刻子或对子。如果对手中盘打了一张 3 万而之前打过 1 万和 2 万那么他手里大概率没有 1-2-3 万的顺子。这些约束条件逐步收窄对手的手牌范围。// 根据舍牌序列过滤可能的手牌 func filterPossibleHands(discards []int, allHands [][34]int) [][34]int { var result [][34]int for _, hand : range allHands { valid : true for _, d : range discards { // 如果手牌里有这张牌且不是摸切则排除 // 简化逻辑假设所有舍牌都是手切 if hand[d] 0 { valid false break } } if valid { result append(result, hand) } } return result }实际引擎里不会枚举所有可能的手牌数量太大而是用统计模型或启发式规则来近似。比如“对手打过 4 万和 6 万那么 5 万附近的顺子可能性降低”就是一条常用的启发式规则。这些规则在代码里表现为一系列条件判断和权重调整。5. 避坑与常见问题实时辅助工具踩过的那些坑5.1 向听数计算溢出递归深度过大导致栈溢出现象手牌接近和牌型时向听数计算偶尔会卡死或崩溃。原因递归枚举面子拆分时如果没有加剪枝条件14 张牌的递归深度可能达到几十层导致栈溢出或计算时间过长。解决在递归函数里加剪枝如果当前已确定的面子数加上剩余牌能组成的最大面子数仍然无法超过已知最优解就直接返回。另外可以用迭代代替递归或者用记忆化缓存已计算过的子问题。5.2 有效牌统计遗漏副露导致进张数虚高现象计算出的有效牌枚数比实际多导致决策过于乐观。原因可见牌追踪器没有统计对手副露中的牌或者统计了自家副露但忘记统计对手副露。解决在每一巡更新可见牌时遍历所有玩家的副露区域把每张副露牌都加入统计。同时注意宝牌指示牌和里宝牌指示牌也要计入。建议写一个统一的updateVisibleTiles函数所有数据来源都在这里汇总避免遗漏。5.3 防守判断过于保守对手未听牌时提前弃和现象对手只是普通舍牌没有立直也没有明显副露但防守模块给出了很高的危险度评分导致过早弃和。原因危险度评分模型里巡目权重设置过高或者副露次数的权重过大导致中盘阶段所有对手都被判定为“高危险”。解决调整权重参数巡目权重建议在 0.01 到 0.03 之间副露次数权重在 0.05 到 0.15 之间。同时加入“对手舍牌中是否有明显手切”的判断如果对手连续摸切说明手牌没有进展危险度应该降低。5.4 平台数据接入延迟舍牌信息更新不及时现象辅助工具显示的牌效建议比实际对局慢了一拍导致错过最佳打牌时机。原因平台数据接入层没有做增量更新每次都是全量拉取对局状态导致延迟。解决改成增量更新只拉取自上次更新以来发生变化的舍牌和副露信息。同时用 WebSocket 或长轮询代替定时轮询减少延迟。如果平台提供了事件推送接口优先使用事件推送。5.5 打点估算偏差宝牌和里宝牌漏算现象手牌打点估算比实际和牌时低很多导致攻守决策偏向防守。原因打点估算函数没有统计宝牌和里宝牌或者只统计了明宝牌没有统计暗宝牌。解决在打点估算时遍历手牌和副露中的每一张牌检查是否是宝牌或里宝牌。宝牌指示牌的下一张是宝牌里宝牌指示牌的下一张是里宝牌。注意宝牌和里宝牌可以叠加一张牌可能既是宝牌又是里宝牌。6. 设计取舍为什么用 Go 而不是 Python为什么用启发式而不是精确解mahjong-helper 选择 Go 语言实现这个决策本身就值得琢磨。日麻辅助工具需要实时响应从对局数据更新到给出建议延迟必须控制在几百毫秒以内。Python 的牌效计算如果用纯递归14 张牌的全枚举可能要几十毫秒甚至上百毫秒加上网络延迟和界面渲染很容易超过可接受的范围。Go 的编译型特性和原生并发支持让计算密集型的牌效枚举可以跑在独立 goroutine 里不阻塞数据接入和界面更新。而且 Go 的数组和切片操作效率高34 长度的计数数组在栈上分配没有 GC 压力。另一个关键取舍是为什么用启发式规则而不是完整的贝叶斯推断完整的贝叶斯推断需要枚举所有可能的手牌组合数量级是 C(136,14) 级别根本算不完。工程化的做法是用蒙特卡洛模拟加启发式剪枝在精度和速度之间找平衡。比如防守判断里不是精确计算“对手和牌概率”而是用“危险度评分”来近似。这个评分模型可能不够精确但胜在快而且可以通过调整权重来适配不同平台的玩家风格。还有一个容易被忽略的取舍记牌模块要不要追踪里宝牌指示牌里宝牌在立直和牌后才会翻开对局中不可见。如果追踪里宝牌就需要在立直发生时更新可见牌数组增加了复杂度。但如果不追踪打点估算就会偏低。mahjong-helper 的做法是里宝牌指示牌在立直时记录但不计入可见牌统计只在打点估算时使用。这样既保证了打点估算的准确性又不影响有效牌计算。验证这套引擎是否靠谱最直接的方法是对比人类高手的牌谱。拿一局已知结果的对局把每个决策点的牌效建议和人类高手的选择做对比统计一致率。如果一致率在 80% 以上说明引擎的决策逻辑基本合理。另一个方法是做 A/B 测试用引擎的建议打 100 局和不用引擎打 100 局对比和牌率和放铳率。如果和牌率提升、放铳率下降说明引擎确实有效。从那以后我每次评估一个麻将辅助工具都会先看它的向听数计算有没有剪枝、有效牌统计有没有遗漏副露、防守模型有没有区分手切和摸切。这三个点只要有一个翻车后面的决策就全是错的。希望帮到你。本文还有配套的精品资源点击获取