Python 实现 Gin Rummy:状态机、回溯判分与 AI 决策算法实战
发布时间:2026/10/2 16:06:13来源:尧图网络
简介杜松子酒接龙纸牌游戏项目基于 Java 开发为玩家提供与电脑对战体验适合有 Java 基础并希望学习游戏逻辑、面向对象设计与简单 AI 的开发者项目遵循经典规则覆盖牌型组合、计分、发牌与回合流程等完整环节。压缩包共 163 个文件、约 1.44MB其中 15 个 Java 源码构成项目主体108 个 gif 动图可用于预览界面交互18 个 class 文件便于对比编译结果另有 XML 配置、JAR 依赖库和工程描述文件可清晰查看依赖关系目前已获 214 人学习下载。通过分析 Card、Deck、Hand、AllCards 等核心类以及 TestAutoMatch 测试代码能够梳理牌组管理、手牌匹配、胜负判定与 AI 出牌策略的落地实现整体涵盖面向对象建模、事件驱动交互和基础博弈算法有助于加深对 Java 游戏项目结构、测试方法和工程组织的理解对课程设计或游戏开发入门具有扎实的参考价值。1. Gin-Rummy-Card-Game把牌桌规则变成可判分的状态机难点在判分和 AI 决策Gin-Rummy-Card-Game 看起来像个扑克练手项目真上手会发现它是那种规则三句话能说完、实现起来到处是边界的双人纸牌游戏摸牌、弃牌、凑 Melds、算 Deadwood、敲牌每一环都要先把规则钉死再写代码。最耗时间的不是发牌和界面而是从 10 张牌里找出最优 Melds 组合的搜索算法以及让 AI 判断哪张牌能安全丢出去。这篇笔记沿着这条路线把牌型建模、回合状态机、判分结算、AI 决策和排错方式一次讲透适合想用一个小项目吃透状态机设计和回溯搜索的开发者。2. 先把牌桌翻译成代码Gin Rummy 的牌型模型与回合状态机做任何牌类项目我第一件事都不是写界面而是把牌和回合这两层数据结构定下来。Gin Rummy 虽然是两个人玩一局的流转却比看起来复杂先发 10 张翻一张作为弃牌堆起点之后每个回合要经历摸牌、判定是否敲牌、弃牌、换人四个环节。如果不用状态机把这些环节显式建模后面加 AI、加回放日志时一定会漏判。这一章先讲两层地基牌怎么表示回合怎么切。2.1 一张牌怎么表达花色、点数与 52 张牌初始化牌的定义我一般用两个枚举加一个不可变的数据类。不用字符串的好处是排序和哈希都稳定后面回溯搜索要反复把牌放进 set 里判重可变对象会直接翻车——第 5 章专门讲这个坑。from enum import IntEnum from dataclasses import dataclass class Suit(IntEnum): CLUB 0 DIAMOND 1 HEART 2 SPADE 3 class Rank(IntEnum): ACE 1 TWO 2 THREE 3 FOUR 4 FIVE 5 SIX 6 SEVEN 7 EIGHT 8 NINE 9 TEN 10 JACK 11 QUEEN 12 KING 13 dataclass(frozenTrue) class Card: suit: Suit rank: Rank def deadwood_value(self) - int: # 人头牌 10 分A 记 1 分其余按面值 if self.rank Rank.JACK: return 10 return int(self.rank) def build_deck() - list[Card]: return [Card(s, r) for s in Suit for r in Rank]逻辑说明deadwood_value是后面所有判分和 AI 决策都会复用的函数所以必须放在 Card 上而不是散落在判分模块里。注意 Ace 在 Gin Rummy 里固定按 1 分算不存在 11 或 14 的变体Rank从 1 开始编号还有一个原因就是判断顺子时可以直接算点数差A 在最小端天然和 2、3 连续。参数说明两个枚举的顺序不是随便排的。Suit的数值顺序会影响花色之间的比较结果我习惯固定为梅花、方块、红心、黑桃至少团队讨论时不会因为顺序起争议。Rank里把JACK 11而不是10是为了让人头牌的判断写成rank Rank.JACK语义清楚。牌堆初始化后发牌逻辑就一行一行来了。我一般在deal里完成洗牌、切 20 张给两边、剩下的进牌堆、翻一张做弃牌起点import random def deal(state: GameState) - None: deck build_deck() random.shuffle(deck) state.hands[0] deck[:10] state.hands[1] deck[10:20] state.stock deck[20:] state.discard [deck[20]] # 翻开的第一张作为弃牌堆底 state.turn random.randint(0, 1) state.phase GamePhase.WAITING_DRAW逻辑说明注意deck[20]是stock列表里的第一张。如果先state.stock deck[20:]再pop会改变牌序所以我直接用索引取同一张牌避免洗牌结果被二次扰动。turn用随机数决定谁先手先手在第一回合是否允许直接敲牌不同规则有分歧后面做成配置项。2.2 回合状态机摸牌、弃牌、敲牌、结算四态切换回合流转我一般直接枚举阶段把非法操作挡在断言层。这个设计在纯逻辑项目里看着有点重但一旦要加 AI、加联机、加回放状态机的价值立刻体现出来每个操作都能断言当前状态出问题时对局日志能精确指出是哪一步断了。class GamePhase(IntEnum): WAITING_DRAW 0 WAITING_DISCARD 1 SETTLING 2 class GameState: def __init__(self): self.stock: list[Card] [] self.discard: list[Card] [] self.hands: dict[int, list[Card]] {0: [], 1: []} self.turn: int 0 self.phase: GamePhase GamePhase.WAITING_DRAW self.knocker: int | None None self.is_gin: bool False self.is_big_gin: bool False def draw_from_stock(self) - None: assert self.phase GamePhase.WAITING_DRAW assert self.stock, 牌堆空了应该在上一层处理重发 self.hands[self.turn].append(self.stock.pop()) def draw_from_discard(self) - None: assert self.phase GamePhase.WAITING_DRAW card self.discard.pop() self.hands[self.turn].append(card) def discard(self, card: Card) - None: assert self.phase GamePhase.WAITING_DISCARD self.hands[self.turn].remove(card) self.discard.append(card) self.turn 1 - self.turn # 换对手 self.phase GamePhase.WAITING_DRAW def knock(self, declares_gin: bool False) - None: assert self.phase GamePhase.WAITING_DISCARD self.knocker self.turn self.is_gin declares_gin if declares_gin: # Gin 不需要弃牌直接结算 self.phase GamePhase.SETTLING逻辑说明knock设计成从WAITING_DISCARD进入是因为玩家摸完牌后要先计算 deadwood满足条件才敲普通敲牌必须弃一张再进结算Gin 则省掉弃牌直接结算。draw_from_discard和draw_from_stock分开写是为了让 AI 决策层能明确知道自己捡的是哪一张——弃牌堆顶牌是公开信息这个信息在风险评分里很关键。参数说明turn用 0/1 表示两个玩家切换时1 - self.turn比(self.turn 1) % 2可读性更好。assert self.stock这行不是装饰牌堆中途摸完是真实会发生的事常见处理是这局作废重发或者按部分规则直接进入结算。我一般选择重发并在对局日志里打一条RE_DEAL标记避免死循环。开发期断言别关等性能 profiling 时再考虑用-O去掉。提示best_melds的返回结果不仅决定判分还会被 AI 决策复用。如果以后要接 UI 展示记得把 Meld 结构也暴露出来而不是只暴露 deadwood 值。3. 核心判分逻辑Melds 回溯搜索、Deadwood 计算与敲牌门槛规则上Gin Rummy 的目标是把手里 10 张牌尽量组成 Melds——三张以上同点数的 Set或三张以上同花色且点数连续的 Run——剩下的牌叫 Deadwood点数加起来小于等于 10 就能敲牌。这段描述看起来简单但尽量组成四个字背后是一个组合优化问题。3.1 用回溯找出最优 Melds为什么贪心会算错先看反例。你手里有红心 7、8、9、10还有方块 8、9、10外加一张黑桃 8。贪心算法如果先把黑桃 8 配进方块三张的 Set剩下红心 7、8、9、10 还是能组成 Run结果不差但如果先配的是红心 8、9、10黑桃 8 就只能算进方块三张里两种分法 deadwood 完全不同。更隐蔽的情况是同一张牌同时能进两个候选 Meld不把所有组合试一遍就不知道哪个方案把死牌压得最少。所以判分核心我直接用回溯先枚举手牌里所有合法的三张以上 Meld再搜索互不重叠的组合def is_valid_set(cards: list[Card]) - bool: # 3~4 张同点数花色互不重复 if len(cards) not in (3, 4): return False return len({c.rank for c in cards}) 1 and len({c.suit for c in cards}) len(cards) def is_valid_run(cards: list[Card]) - bool: # 同花色、点数连续、至少 3 张A 只能当 1禁止把它抬高到 14 if len(cards) 3: return False if len({c.suit for c in cards}) ! 1: return False ranks sorted(c.rank for c in cards) if ranks[-1] - ranks[0] ! len(ranks) - 1: return False if ranks[0] Rank.ACE and ranks[-1] Rank.KING: return False return True def generate_all_melds(cards: list[Card]) - list[list[Card]]: melds [] n len(cards) for i in range(n): for j in range(i 1, n): for k in range(j 1, n): base [cards[i], cards[j], cards[k]] if is_valid_set(base) or is_valid_run(base): melds.append(base) return melds逻辑说明generate_all_melds先生成所有三张组合因为任何 4 张或 5 张的合法 Meld 一定包含某个合法的三张子集搜索阶段用三张组合做拼接就能覆盖更长顺子。is_valid_set要求花色互不重复其实是单副牌天然满足的条件但写成显式判断可以在混入测试牌时立刻发现问题。回溯搜索部分我按当前牌要么进 Deadwood要么作为某个 Meld 的一员继续组来分支def best_melds(cards: list[Card]) - tuple[list[list[Card]], int]: melds generate_all_melds(cards) n len(cards) best_deadwood sum(c.deadwood_value() for c in cards) best_meld_set: list[list[Card]] [] def backtrack(idx: int, used: frozenset, chosen: list) - None: nonlocal best_deadwood, best_meld_set while idx n and cards[idx] in used: idx 1 if idx n: dead sum(c.deadwood_value() for c in cards if c not in used) if dead best_deadwood: best_deadwood dead best_meld_set chosen.copy() return # 分支 1这张牌不进任何 Meld直接算死牌 backtrack(idx 1, used, chosen) # 分支 2这张牌作为某个 Meld 的成员且该 Meld 与已选组合不重叠 for m in melds: if cards[idx] in m and frozenset(m).isdisjoint(used): backtrack(idx 1, used | frozenset(m), chosen [list(m)]) backtrack(0, frozenset(), []) return best_meld_set, best_deadwood逻辑说明used用不可变集合传入是为了让回溯分支之间不共享可变状态否则一个分支改过的 used 会污染另一个分支。while idx n and cards[idx] in used跳过已经被 Meld 占用的牌避免重复处理。复杂度上10 张牌的候选 Meld 数量一般不超过几十个搜索空间很小实测单次判分在毫秒级当前代码里重复分支会造成少量冗余计算量级可忽略真要优化可以按Meld 内最小牌索引排序后去重但 Gin Rummy 用不着。参数说明best_deadwood的初始值我直接用所有牌都是死牌的分数而不是float(inf)。这样在手牌全是死牌、一个 Meld 都没有时也能返回正确结果避免出现找不到最优解的边界。3.2 Deadwood 分值与敲牌阈值10 分规则和两种常见变体敲牌门槛是规则层最核心的参数手牌 deadwood 小于等于 10 时当前玩家可以选择敲牌。这个 10 是 Gin Rummy 的标准规则不是拍脑袋定的但不同地区在附加分上有分歧所以我一般把分数全部收进一个配置字典而不是散落在判分函数里SCORE_RULES { knock_deadwood_limit: 10, # 敲牌门槛 gin_bonus: 25, # Gin 奖励分 big_gin_bonus: 31, # Big Gin 奖励分 undercut_bonus: 25, # 被截胡Undercut奖励分 target_score: 100, # 一局游戏的目标分数 first_turn_knock_allowed: False, # 首回合是否允许直接敲 opponent_layoff: True, # 敲牌后对手能否贴牌 }常见变体的参数差距在这张表里规则项标准值常见变体影响gin_bonus2520影响 Gin 局的单次收益undercut_bonus2520影响防守策略target_score100150影响长局里的弃牌冒险程度first_turn_knock_allowedFalseTrue影响先手优势参数说明first_turn_knock_allowed是最容易被新手忽略的。标准规则里先手第一回合摸完牌不能立刻敲必须先弃一张不遵守这条会让先手胜率明显偏高。我的做法是把这个开关放在SCORE_RULES里跑批量模拟时改配置对比先手胜率比在代码里埋 if 分支干净得多。判分公式本身不复杂敲牌方和对手各自算完最优 Meld 和 deadwood 后对手还能把自己手里没组成的牌往敲牌方的 Meld 上贴layoff之后比较双方 deadwood。差值就是敲牌方得分但如果对手贴完后 deadwood 小于等于敲牌方对手反过来得undercut_bonus 差值。举例敲牌方死牌 4对手贴完后死牌 7敲牌方得 3 分如果对手贴完后死牌也是 4对手得 25 分而不是平局。3.3 Gin 与 Big Gin两个容易漏掉的结算分支Gin 是 deadwood 恰好为 0 的敲牌此时不需要弃牌直接结算并加 25 奖励分。Big Gin 更特殊玩家摸完牌后手里有 11 张如果 11 张全部能组成 Meld则触发 Big Gin奖励 31 分。这两个分支如果放在普通敲牌流程里会出错——Gin 少一张弃牌Big Gin 多一张手牌。def settle_hand(state: GameState) - dict: knocker state.knocker opponent 1 - knocker k_melds, k_dead best_melds(state.hands[knocker]) o_melds, o_dead best_melds(state.hands[opponent]) # 对手 layoff把能贴进敲牌方 Meld 的死牌贴上去减少自己的 deadwood o_dead_after apply_layoffs(state.hands[opponent], o_melds, o_dead, k_melds) delta o_dead_after - k_dead if state.is_big_gin: score SCORE_RULES[big_gin_bonus] o_dead_after elif state.is_gin: score SCORE_RULES[gin_bonus] o_dead_after elif delta 0: score delta else: # undercut对手 deadwood 小于等于敲牌方 score SCORE_RULES[undercut_bonus] (k_dead - o_dead_after) return {knocker: knocker, opponent: opponent, knocker_dead: k_dead, opp_dead: o_dead_after, score: score}逻辑说明结算顺序是死的——先双方摆 Meld再 layoff最后比较。apply_layoffs的实现就是对每张死牌尝试加入敲牌方的某个 Set 或 Run如果加入后该 Meld 仍然合法就贴上去并从对手 deadwood 里扣掉对应分值。注意 undercut 的判断条件是o_dead_after k_dead时触发等号不算敲牌方赢这是规则里最容易写反的地方。参数说明Big Gin 的 31 分并非全行业统一网上很多实现写成 25 分以SCORE_RULES为准规则变体只改配置不改逻辑测试用例可以针对两种分值各写一组断言。opponent_layoff如果被置为 False那么apply_layoffs直接返回原o_dead这是少数简化版规则的处理方式。4. 给 AI 装上决策脑弃牌风险评分与摸牌/敲牌的判断参数AI 对手是这个项目里最能拉开差距的部分。Gin Rummy 是双人不完全信息游戏AI 看不到对手手牌只能从对手的摸牌来源和弃牌行为反推。一个堪用的 AI 不需要多高深的算法把弃牌安全性和捡牌收益用参数化评分函数表达出来就已经能打过只会随机出牌的玩家。4.1 弃牌风险评分从对手手里剩下的牌反推哪张能丢弃牌的核心矛盾是手里的死牌必须丢但丢出去的牌可能正好帮对手凑成 Meld。我一般维护两条对手可见信息——对手弃过的牌集合、对手从弃牌堆捡走的牌列表然后用加权评分函数估计单张牌的危险度def risk_score(card: Card, opp_discards: set[Card], opp_pickups: list[Card]) - float: # 对手自己弃过这张具体牌说明大概率不需要直接视为安全 if any(c.rank card.rank and c.suit card.suit for c in opp_discards): return 0.0 score 0.0 for picked in opp_pickups: # 对手捡过同点数这张牌可能凑成 Set风险高 if picked.rank card.rank: score 2.5 # 对手捡过相邻点数且同花色这张可能补 Run elif abs(picked.rank - card.rank) 1 and picked.suit card.suit: score 1.5 # 中张7~10是顺子枢纽比边张更容易被利用 if 7 card.rank 10: score 0.6 # deadwood 分值越高的牌越该早丢避免最后卡手里 score card.deadwood_value() / 10.0 return score逻辑说明这条评分把危险拆成三个可解释的因素同点数被 Set 的风险、相邻同花色被 Run 的风险、以及中张的枢纽价值。opp_discards里出现过同张牌直接给 0是因为对手主动弃掉说明他不缺这张但注意只匹配具体牌而不是点数因为对手可能弃过方块 7、却留着黑桃 7 等不同花色。这组权重的调参是典型的玄学部分没有规则推导只靠批量模拟说话。参数说明2.5、1.5、0.6、0.1 这组权重是经验值我建议暴露成模块级变量别写死在函数里否则后面做参数对比时要改几十处。如果想升级可以加一个对手接近 Gin 时收紧风险阈值的动态逻辑但基础版先把权重固定住跑通。4.2 AI 决策流程捡不捡、敲不敲、丢哪张的三个参数AI 每回合的决策顺序是固定的先决定摸哪堆再检查能不能敲最后决定丢哪张。摸牌决策我用一个收益门槛控制只有当弃牌堆顶牌能让手牌 deadwood 至少减少 2 分时才捡否则摸牌堆。这个 2 分门槛如果设成 0AI 会频繁暴露意图设成 5AI 又太保守几乎只摸牌堆。def ai_turn(state: GameState, player: int) - None: top state.discard[-1] # 模拟两个摸牌来源后的 deadwood with_stock state.hands[player] [state.stock[-1]] with_discard state.hands[player] [top] dead_stock best_melds(with_stock)[1] dead_discard best_melds(with_discard)[1] gain dead_stock - dead_discard # 正数代表捡弃牌堆更赚 if gain 2.0 and risk_score(top, state.opp_discards[player], state.opp_pickups[player]) 1.2: state.draw_from_discard() else: state.draw_from_stock() # 摸完后检查能不能敲 melds, dead best_melds(state.hands[player]) if dead SCORE_RULES[knock_deadwood_limit]: state.knock(declares_gin(dead 0)) return # 丢牌优先丢不参与最优 Meld 的牌风险分最低的先丢 in_melds set(c for m in melds for c in m) candidates [c for c in state.hands[player] if c not in in_melds] if not candidates: # 全部牌都在 Meld 里说明有冗余配置随便丢一张分值最低的 candidates state.hands[player][:] candidates.sort(keylambda c: risk_score( c, state.opp_discards[player], state.opp_pickups[player])) state.discard(candidates[0])逻辑说明gain 2.0和risk_score 1.2是三个关键参数里的两个。第一个管值不值得捡第二个管捡了会不会暴露太多。best_melds 在 AI 回合里被调用两次用来模拟它足够快所以可以直接这么用如果以后换更重的判分算法这里需要加缓存。还有一个容易被忽略的参数目标分数。领先局里 AI 应该更保守因为对手被迫冒险追分落后局里 AI 应该在 deadwood 接近 8 时就敲赌对手 deadwood 更高。这个动态调整我会在ai_turn开头根据累计分数修改 gain 和敲牌阈值代码量不大但对胜率影响明显。参数说明knock(declares_gin(dead 0))把普通敲牌和 Gin 用一个布尔区分避免两个入口。一个微妙的点是 dead 正好等于 10 时敲不敲我的经验是看目标分数领先时不敲、落后时果断敲这个策略用一行条件就能实现但先把基础版跑通再优化。捡牌还有一层信息泄露代价从弃牌堆捡走一张牌等于告诉对手你的 Meld 方向所以 gain 阈值本身就是在手牌进步和意图泄露之间做权衡。5. 避坑清单Gin Rummy 实现中最容易翻车的 5 个边界这章是血泪经验汇总。Gin Rummy 的坑基本集中在规则边界和数据结构上下面 5 条按实际踩到的频率排序每一条都能对应到前面的代码。5.1 现象A-2-3 组不上 RunK-A-2 却被当成合法顺子现象手牌 A、2、3 同花色is_valid_run返回 False反过来 K、A、2 这种跨边界组合反而通过了校验。原因点数用了字符串表示时排序会把 10 排在 2 前面连续区间判断全乱或者 Ace 在某些实现里被同时映射成 1 和 14A-2-3 反而连不上。解决点数用 IntEnum 而不用字符串A 固定等于 1顺子判断里保留ranks[0] Rank.ACE and ranks[-1] Rank.KING的显式排除当作给后来者的注释——Gin Rummy 里 A 永远只能当 1不能当 14。这条坑在换语言重写时最容易复发我踩过两次。5.2 现象敲牌结算后对手分数出现负数现象敲牌方 deadwood 是 4对手算完 deadwood 是 2按差值敲牌方应该赢 2 分但代码输出 -21。原因结算时没有先做 layoff 就比较双方 deadwood或者 undercut 判断写成了严格小于把等于的情况漏掉了。解决结算顺序固定为先双方摆最优 Meld再让对手把能贴的牌贴到敲牌方 Meld 上最后才比较。undercut 条件写成o_dead_after k_dead等号也要算对手赢。这个小于等于的边界我当年找了一晚上最后翻规则原文才确认强烈建议把这条规则用注释写在settle_hand上方。5.3 现象AI 永远摸牌堆一局几百个回合结束不了现象AI 从不去捡弃牌堆顶牌两个人各摸各的牌堆很快摸空对局被强制重发。原因gain 2.0的门槛设得太严或者 risk_score 把弃牌堆顶的公开牌误判成很危险。解决先用固定牌序跑 1000 局统计平均回合数。正常 Gin Rummy 一局在 20 到 40 回合之间超过 60 回合说明 AI 太保守把 gain 门槛从 2.0 降到 1.0同时让风险只计算捡牌当回合对手的下一步不要跨回合累加。这条属于调参玄学没有标准答案只能靠模拟数据说话。5.4 现象判分结果时好时坏同样的手牌两次调用返回不同 Meld现象best_melds对同一手牌第一次返回 3 张死牌第二次返回 5 张死牌而且传入的是同一个 Card 对象。原因Card 如果是可变 dataclass放进 set 后字段被别处修改哈希值会变used集合里找不到原对象回溯直接错乱。这是 Python 里典型的黑匣子问题报错不在判分逻辑里而在数据结构上。解决把 Card 声明成dataclass(frozenTrue)所有字段只读需要临时改牌时就新建一个 Card而不是修改原对象。我还会在__post_init__里加一层assert 1 self.rank 13把非法牌挡在构造阶段省得查半天才发现是测试数据拼错了。5.5 现象Big Gin 永远触发不了牌越多反而报错现象摸完牌后 11 张全部成 Meld代码却在这里抛丢弃的牌不在手牌里异常。原因Big Gin 的判分入口和普通 Gin 共用而普通 Gin 流程会走弃一张牌的逻辑11 张手牌必然被误拆。解决摸牌动作完成后先检查 Big Gin——11 张全 Meld 直接走 31 分分支再检查普通 Gin——10 张零 deadwood 走 25 分分支最后才判断普通敲牌。这个顺序我写成了一段注释放在ai_turn的敲牌判断前面提醒后面接手的同事不要挪动三个分支的顺序。注意以上 5 个坑按出现频率排序5.1 和 5.4 在团队协作项目里几乎必踩建议直接把 Card 的 frozen 属性和顺子校验写进代码评审检查单。6. 从能玩到能测对局回放、批量模拟与 AI 参数验证有了能跑通的逻辑和 AI下一步不是加界面而是让对局可复现。我一般给 GameState 加一个log列表每个动作记一条 JSON回合号、摸牌来源、弃了哪张、敲牌时的 deadwood 值。出问题时用同一个随机种子重放一遍能精确看到 AI 在哪一步做了错误决策。验证分三层。第一层是固定牌序的单元测试手写 5 组已知答案的 10 张牌直接断言best_melds返回的 deadwood 值比如一组预期的 0全 Meld和一组预期的 4。第二层是批量模拟两个 AI 参数不同时跑 10 万局统计胜率和平均回合数用胜率差判断哪组参数更优。第三层是边界断言例如故意构造 Big Gin 手牌确认走的是 31 分分支而不是 25 分。def test_best_melds_known_hand(): hand [Card(Suit.HEART, Rank.TWO), Card(Suit.HEART, Rank.THREE), Card(Suit.HEART, Rank.FOUR), Card(Suit.CLUB, Rank.SEVEN), Card(Suit.CLUB, Rank.EIGHT), Card(Suit.CLUB, Rank.NINE)] _, dead best_melds(hand) assert dead 0, fexpected 0 deadwood, got {dead}这套测试帮我发现过最隐蔽的问题AI 参数在 100 局里胜率不错拉到 10 万局后某组参数在先手场景下会系统性漏掉捡牌机会只有大样本才暴露得出来。我现在养成的习惯是所有判分函数和 AI 决策函数都输出一条结构化日志先写测试断言再写新功能这个习惯省下的排错时间比写 AI 本身还多。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网