简介:这份资源是面向日本麻将(立直麻将)爱好者的开源助手工具,使用 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 && melds+partials < 5 { // 有雀头时向听数可能减一 } return shanten } // 尝试组成刻子 if counts[i] >= 3 { counts[i] -= 3 s := calcShanten(counts, melds+1, 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 局,对比和牌率和放铳率。如果和牌率提升、放铳率下降,说明引擎确实有效。
从那以后我每次评估一个麻将辅助工具,都会先看它的向听数计算有没有剪枝、有效牌统计有没有遗漏副露、防守模型有没有区分手切和摸切。这三个点只要有一个翻车,后面的决策就全是错的。希望帮到你。
本文还有配套的精品资源,点击获取