简介:这是一份基于Java开发的Gin Rummy(杜松子酒接龙)纸牌游戏项目,面向对Java游戏开发、面向对象设计与AI博弈算法感兴趣的开发者。项目完整实现了双人对战规则,玩家可与计算机对手进行回合制对战,并通过智能策略模拟人类决策,适合作为游戏编程入门或课程设计的实战参考。压缩包共163个文件,约1.44MB,其中包含15个Java源码、18个class编译文件、11个XML配置,以及108个GIF运行演示,另有少量jar库与properties配置文件,目录结构清晰。已有214人学习下载。从项目中可深入理解牌型组合判断、发牌流程、得分计算等核心逻辑,并学习如何在Java中构建机器人对手、设计GUI界面,以及运用JUnit等工具进行测试。无论是希望掌握Java游戏开发全流程,还是想研究纸牌AI策略,这份资源都能提供可运行的示例与完整的工程结构。
1. 从「会玩金拉米」到「能跑的金拉米对局」:Gin-Rummy-Card-Game 到底在交付什么
多数人看到 Gin-Rummy-Card-Game 这个标题,第一反应是「做一个纸牌界面」,真正做进去才发现,金拉米这个看似简单的两人纸牌游戏,坑全在规则引擎和出牌策略里:meld 怎么拆、deadwood 怎么算、knock 阈值怎么定,每一处都能让对局结果对不上。这篇笔记围绕「一个能跑起来的金拉米对局引擎」展开,从卡牌建模、回合状态机、AI 出牌策略到高频翻车点逐一拆开,适合想把规则吃透再动手、或者已经被自家 AI 蠢哭过的开发者。你会看到完整可复现的数据结构与算法思路,按步骤走就能在本地把一局金拉米从发牌跑到算分。
2. 把牌桌规则翻译成数据模型:点数、花色与 meld 判定
金拉米的规则听起来一句话能说完:每人 10 张牌,尽量凑成 meld(顺子或同点数组),凑不上的牌点数之和叫 deadwood,deadwood 越小越好,小于等于 10 就能 knock。但「一句话说完」和「能跑」之间,隔着三个必须提前定死的设计决策:牌的表示、meld 的搜索、最优保留牌的求解。这一章把这三个决策全部落成代码。
2.1 卡牌建模与点数表:A 是 1 点不是 11 点,必须在第一行定死
我一般用数字 rank 而不是字符串表示点数,这样顺子判定只需要比较连续整数。花色用四个单字符即可,不需要枚举类,因为金拉米里花色只影响 run 的合法性,没有桥牌那种花色大小。标准 52 张牌,两人各 10 张,剩余 32 张作为牌堆,翻开一张作为弃牌堆顶。
type Suit = 'S' | 'H' | 'D' | 'C'; interface Card { suit: Suit; rank: number; // A=1, 2-10 按面值, J=11, Q=12, K=13 } function pointValue(card: Card): number { if (card.rank === 1) return 1; // A 只算 1 点 if (card.rank >= 10) return 10; // J/Q/K 都是 10 点 return card.rank; // 2-9 按面值 } function createDeck(): Card[] { const suits: Suit[] = ['S', 'H', 'D', 'C']; const deck: Card[] = []; for (const suit of suits) { for (let rank = 1; rank <= 13; rank++) { deck.push({ suit, rank }); } } return deck; }rank 从 1 到 13 的映射是我反复确认过的:A=1,J/Q/K=10 点,这是金拉米 deadwood 计分的通行约定。最容易翻车的写法是把 A 设成 14,这样 A-2-3 的 run 判定会失败,而 Q-K-A 反而会被误判成合法 run。金拉米里 A 只能当 1,Q-K-A 不是顺子。
点数值单独抽成pointValue函数而不是写死在业务代码里,是因为后面算 deadwood、算 undercut 分差都要用它。如果直接把点数塞进 Card 对象,牌一旦被复制或序列化,两处点数不一致的 bug 非常难查。Card 对象保持纯数据,所有规则都走函数,这是我做过几个牌类项目后固定的习惯。
2.2 从手牌里找出所有 meld 候选:set 与 run 的判定算法
meld 分两类:set 是至少 3 张同 rank 不同花色的牌,run 是至少 3 张同花色连续 rank 的牌。找候选不难,难的是不重复、不遗漏,尤其 run 要处理 A-2-3 这种边界。我先把 set 和 run 的候选全部找出来,存成「牌的集合列表」,供下一步组合搜索使用。
function findMeldCandidates(hand: Card[]): Card[][] { const melds: Card[][] = []; // 找 set:按 rank 分组,同 rank 至少 3 张 const byRank = new Map<number, Card[]>(); for (const card of hand) { const list = byRank.get(card.rank) ?? []; list.push(card); byRank.set(card.rank, list); } for (const [, cards] of byRank) { if (cards.length >= 3) melds.push(cards); } // 找 run:按花色分组,rank 排序后找连续段 const bySuit = new Map<Suit, Card[]>(); for (const suit of ['S', 'H', 'D', 'C'] as Suit[]) { bySuit.set(suit, hand.filter(c => c.suit === suit)); } for (const [, cards] of bySuit) { const sorted = cards.slice().sort((a, b) => a.rank - b.rank); let start = 0; for (let i = 1; i <= sorted.length; i++) { const isEnd = i === sorted.length; const isBroken = !isEnd && sorted[i].rank !== sorted[i - 1].rank + 1; if (isEnd || isBroken) { if (i - start >= 3) melds.push(sorted.slice(start, i)); start = i; } } } return melds; }这个实现的关键在 run 的连续段扫描:排序后从左到右,只要当前 rank 不等于前一张的 rank+1,就断一段,段长达到 3 才记为候选。这样 A-2-3 会被正确捕获,因为 A=1、2、3 恰好连续。重复 rank 的情况在金拉米里不会出现——同一花色同一 rank 只有一张牌,所以不需要处理同 rank 并列。
set 候选直接整组加入,不需要再做子集枚举。run 候选也只取最长连续段,因为如果 3-4-5-6 是一个合法段,那么 3-4-5 也是,但最优组合搜索时保留最长段就够了,短段可以由组合搜索自然产生。这里需要注意:同一个 run 里如果恰好有 4 张,就只能算一个 meld,不能拆成两个,金拉米没有「重复使用牌」的概念。
2.3 用最小 deadwood 选择保留牌:为什么不能只取最大 meld
很多初版实现会犯一个错误:找到手牌里最大的一个 meld 扣掉,剩下的算 deadwood。这在 10 张牌里几乎必然算错。举个常见场景:手牌有 3-4-5-6 同花,还有三张 7,单独看 3-4-5-6 是 4 张 run,三张 7 是 set,两个都保留 deadwood 是 0,但如果只挑 4 张 run,剩下的三张 7 会拆成 7、7、7 共 30 点。所以选择保留哪些 meld 是一个组合优化问题,我直接对候选做递归搜索,10 张牌规模下性能完全够。
interface MeldResult { deadwood: number; usedMelds: Card[][]; } function bestDeadwood(hand: Card[], candidates: Card[][]): MeldResult { let best: MeldResult = { deadwood: hand.reduce((sum, c) => sum + pointValue(c), 0), usedMelds: [], }; const used = new Set<Card>(); function dfs(startIdx: number) { // 先用当前未使用的牌算一次 deadwood,作为当前方案的基准 const remaining = hand.filter(c => !used.has(c)); const dw = remaining.reduce((sum, c) => sum + pointValue(c), 0); if (dw < best.deadwood) { best = { deadwood: dw, usedMelds: [] }; // 这里仅记录数值,meld 列表另行回溯 } for (let i = startIdx; i < candidates.length; i++) { const meld = candidates[i]; // 候选内部可能与其他候选共用牌,必须全部未使用才可加入 if (meld.some(c => used.has(c))) continue; meld.forEach(c => used.add(c)); dfs(i + 1); meld.forEach(c => used.delete(c)); } } dfs(0); return best; }递归里每次进入都计算一次剩余 deadwood,这意味着「一个 meld 都不选」的基准也被覆盖了。dfs(0)从第一个候选开始,后面的dfs(i+1)保证每个候选只被考虑一次,不会出现同一组 meld 重复组合。候选之间共用牌的冲突通过used集合拦截,比如手牌里 3-4-5-6 同花,3 可能在另一个 3-4-5 候选里,二者不能同时选。
这个搜索在 10 张牌、候选数量不超过 20 个的情况下,递归深度有限,实测一局 40 轮以内毫无压力。如果你把代码接到前端 UI 里每次都要实时算,可以加一层缓存:以手牌的字符串指纹(牌排序后拼接)为 key 缓存结果。但注意缓存键必须包含花色,只拼 rank 会串味。
3. 回合流程用状态机管理:发牌、摸牌、出牌到摊牌
规则引擎的第二大块不是算分,而是回合流转。金拉米一局里,玩家要决定从牌堆摸还是从弃牌堆拿,拿到牌后必须弃一张,然后决定要不要 knock。这些动作如果散落在 UI 回调里,很快会变成一坨 goto 式代码。我把整局拆成有限状态集合,每个状态只允许有限的合法转移。
3.1 把一局拆成状态:DRAW、DISCARD、KNOCK、SHOWDOWN 的职责边界
我设计的状态集合是:AWAIT_DRAW(当前玩家选择摸牌来源)、AWAIT_DISCARD(当前玩家已摸牌,必须弃一张)、KNOCK_CHECK(弃牌后判断是否触发摊牌)、SHOWDOWN(双方亮牌算分)、ROUND_END(本局结束)。这里没把「发牌」设成状态,因为发牌是一局开始前的准备工作,放到构造函数里执行即可。
type Phase = | 'AWAIT_DRAW' | 'AWAIT_DISCARD' | 'KNOCK_CHECK' | 'SHOWDOWN' | 'ROUND_END'; interface GameState { stock: Card[]; discardPile: Card[]; // 数组末尾是顶牌 hands: Card[][]; // hands[0] 与 hands[1] turn: number; // 0 或 1 phase: Phase; drawnFromDiscard: boolean; // 本轮是否从弃牌堆摸牌 drawnCard: Card | null; // 本轮摸到的牌 }drawnFromDiscard和drawnCard是专门为一条规则加的:从弃牌堆顶拿牌后,不允许把同一张牌立刻弃回。如果从牌堆摸牌,则可以弃刚摸的牌。这个细节在 UI 层很容易被忽略,但引擎层必须拦死,否则会出现「A 从弃牌堆拿 7H 又弃 7H,等于什么都没发生」的死循环。
3.2 状态机落地:一个最小可跑的回合循环示例
状态转移的核心函数是advance,每次玩家完成一个动作后调用它,根据当前 phase 决定下一状态。下面是一个最小实现,去掉了 UI,只保留纯逻辑,方便单测。
function advance(state: GameState): void { if (state.phase === 'AWAIT_DRAW') { // 玩家动作:由外部调用 drawFromStock / drawFromDiscard return; } if (state.phase === 'AWAIT_DISCARD') { // 玩家动作:由外部调用 discardCard return; } if (state.phase === 'KNOCK_CHECK') { const dw = bestDeadwood(state.hands[state.turn], findMeldCandidates(state.hands[state.turn])).deadwood; if (dw <= KNOCK_THRESHOLD) { state.phase = 'SHOWDOWN'; } else { state.turn = 1 - state.turn; state.phase = 'AWAIT_DRAW'; state.drawnCard = null; state.drawnFromDiscard = false; } return; } if (state.phase === 'SHOWDOWN') { resolveShowdown(state); // 算分,见下文 state.phase = 'ROUND_END'; } }KNOCK_CHECK是回合流转的关键:玩家弃牌后,如果自己 deadwood 已经小于等于阈值,可以选择 knock;不想 knock 也可以继续。真实游戏里玩家可以主动选择不 knock,所以这个状态应该留一个forceContinue分支,上面的代码默认「达到条件就强制摊牌」,这是最简单的一版,AI 对手会选它,真人局需要在 UI 层给按钮。KNOCK_THRESHOLD常量我用 10,这是金拉米最常见的约定,部分变体会用 15 或 0(仅允许 gin),实现时把常量抽出来即可。
3.3 玩家动作校验:为什么非法动作要返回原因而不是直接拒绝
摸牌和弃牌两个动作的校验看起来简单,但错误信息必须具体。比如弃牌时,如果玩家尝试弃掉刚摸的那张牌,而这张牌来自弃牌堆,正确的响应不是「非法」,而是「你刚拿了这张牌,不能立刻弃回它」。仔细想想:从牌堆摸的牌可以立刻弃掉,从这个角度看「非法」的描述要区分来源。
type ActionError = { ok: false; reason: string }; function canDiscard(state: GameState, card: Card): { ok: boolean; reason?: string } { const hand = state.hands[state.turn]; if (state.phase !== 'AWAIT_DISCARD') { return { ok: false, reason: '当前不在弃牌阶段' }; } if (!hand.some(c => c.suit === card.suit && c.rank === card.rank)) { return { ok: false, reason: '手牌中没有这张牌' }; } if (state.drawnFromDiscard && state.drawnCard && state.drawnCard.suit === card.suit && state.drawnCard.rank === card.rank) { return { ok: false, reason: '不能把刚从弃牌堆拿的牌立刻弃回' }; } return { ok: true }; }返回reason而不是布尔值,最大的好处是 UI 层可以直接把原因展示给玩家,测试代码也能断言具体错误文案。很多对局引擎的 bug 就是死在「只知道不合法,不知道为什么」,一旦规则变体调整(比如允许弃回),你找不到要改哪一行。这个函数的另一个隐含作用:它让 AI 决策模块也能复用同一套校验逻辑,AI 的模拟搜索永远不会走出非法步。
4. 给电脑对手一个能看的策略:贪心组牌与防御性出牌
金拉米是两人游戏,想让一个引擎又称得上「游戏」,至少要有一个能跑的 AI。AI 不需要顶尖,但必须让玩家觉得「它在玩」,而不是随机抽牌。我的实现分三层:最底层的目标函数是「最小化自己的 deadwood」,中间层是「避免喂牌给对手」,最外层是难度调节。
4.1 最少 deadwood 贪心:把「尽量早 knock」落成可计算的目标函数
第一步是让 AI 在任何局面下都能回答两个问题:摸这张牌值不值、弃这张牌后自己还剩多少 deadwood。这两个问题共用bestDeadwood。摸牌决策可以贪心:比较摸牌前后 deadwood 的变化,如果从弃牌堆顶摸能把 deadwood 降低超过一个阈值,就拿;否则摸牌堆。
function decideDraw(state: GameState): 'stock' | 'discard' { const hand = state.hands[state.turn]; const discardTop = state.discardPile[state.discardPile.length - 1]; if (!discardTop) return 'stock'; const currentDW = bestDeadwood(hand, findMeldCandidates(hand)).deadwood; const candidateHand = [...hand, discardTop]; const candidateDW = bestDeadwood(candidateHand, findMeldCandidates(candidateHand)).deadwood; // 只有明显改善才拿弃牌,否则摸牌堆 const improvement = currentDW - candidateDW; return improvement >= 3 ? 'discard' : 'stock'; }improvement >= 3这个阈值是经验值,它防止 AI 为了拿一张只减少 1 点 deadwood 的牌而暴露意图。在规则上,从弃牌堆拿牌是向对手暴露信息的——对手能根据你拿走的牌推断你在凑什么。这个阈值越大,AI 越保守。注意这里bestDeadwood没有把新拿的牌和手牌分组一起找 meld,因为candidateHand已经是完整集合,findMeldCandidates会重新扫描全部牌,没有问题。
弃牌决策则反过来:遍历手牌中每一张,模拟弃掉它之后计算 deadwood,选择让自己 deadwood 最小的那张。这个「最小 regret」策略是贪心组牌的基础。
4.2 防御性出牌:观察对手拿过的牌,避免喂 meld
纯贪心 AI 有个明显缺陷:它会为了自己减少 1 点 deadwood 而弃掉一张对手正好需要的牌。人类玩家几局下来就会利用这点。我给 AI 加了一个轻量的对手模型:记录对手从弃牌堆拿过的所有牌,统计对手需要的花色和点数区域。
interface OpponentModel { takenFromDiscard: Card[]; // 对手从弃牌堆拿走的牌 } function dangerScore(card: Card, model: OpponentModel): number { let score = 0; for (const t of model.takenFromDiscard) { if (t.suit === card.suit) score += 1; // 同花色可能凑 run if (Math.abs(t.rank - card.rank) <= 1) score += 1; // 相邻点数可能凑 meld } return score; }这个模型非常粗糙,但够用。真正实现时,decideDiscard会先按「弃后 deadwood」排序候选弃牌,然后从低危到高危扫描:如果弃掉某张后 deadwood 增加不超过 2 点,而它的危险分数低,就弃它;如果最贪心的那张牌危险分数过高,就改弃次优牌。这里有个权衡:过度防御会让 AI 自己迟迟无法 knock,我的经验是「deadwood 增加不超过 2 点」这个容忍度比较平衡。
4.3 难度调节:从纯贪心到带随机性的风险决策
同一套引擎要做三个难度,不需要换算法,加两个参数即可。简单难度下,摸牌阈值从 3 降到 1,弃牌时直接忽略 dangerScore,纯随机选一张候选;普通难度用默认参数;困难难度把摸牌阈值提到 4,弃牌时把「低危优先」的权重提高,并且在 deadwood <= 12 时就尝试 knock(阈值 10 是规则允许,12 是提前冒险,前提是你们约定的规则允许提前 knock)。
interface AIConfig { drawImprovement: number; // 摸弃牌的最小改善 dangerTolerance: number; // 弃牌时允许的最大 dangerScore knockThreshold: number; // AI 主动 knock 的阈值 randomMoveRate: number; // 随机动作概率,制造失误 } const EASY: AIConfig = { drawImprovement: 1, dangerTolerance: 99, knockThreshold: 10, randomMoveRate: 0.3 }; const NORMAL: AIConfig = { drawImprovement: 3, dangerTolerance: 3, knockThreshold: 10, randomMoveRate: 0.1 }; const HARD: AIConfig = { drawImprovement: 4, dangerTolerance: 1, knockThreshold: 10, randomMoveRate: 0.02 };randomMoveRate是实现「新手感」最简单的手段:在动作选择前 roll 一个随机数,小于该概率就完全随机走一步。注意随机弃牌也要走canDiscard校验,否则会把刚拿的牌弃回去。难度调节不外露给玩家没意义,我在对局初始化时把难度参数传进 AI 构造函数,整局不变。
5. 常见问题与避坑:金拉米引擎最容易翻车的六个细节
写规则引擎最怕的不是算法难,而是规则边界摸不透。这个项目我实际踩过、也见别人踩过的坑集中在这六个地方,每一条都是先给现象再给修法,你可以直接对照自己的代码排查。
5.1 洗牌后连续几局牌型一模一样
现象:调试时连续开新人机对局,发牌结果完全相同,甚至第一局和第三局的牌序一致。原因:初始化随机数时用了固定种子,或者每次创建牌局都重建随机数生成器而种子来源是同一时钟值。修法:洗牌用 Fisher-Yates,随机种子在对局创建时注入,测试时才用固定种子,生产环境必须用不可预测的种子。
function shuffle<T>(deck: T[], rng: () => number): T[] { for (let i = deck.length - 1; i > 0; i--) { const j = Math.floor(rng() * (i + 1)); [deck[i], deck[j]] = [deck[j], deck[i]]; } return deck; }rng设计成函数注入而不是直接用全局 Math.random,是为了可测试性。你可以在测试里传一个假 RNG 构造出想要的牌序,生产环境传Math.random。如果有玩家投诉「连续两局拿到同样的牌」,优先检查是不是忘了给 RNG 换种子。
5.2 A 在 run 判定里被当成 14,导致 Q-K-A 合法
现象:手牌是 Q、K、A 同花色,UI 显示它是合法 run,但规则上金拉米里 A 只能当 1,Q-K-A 不是顺子。原因:有的实现为了方便桥牌或其他游戏,把 A 定义为 14,然后 run 判定只检查「连续递增加 1」。修法:rank 固定 A=1,run 判定只认 rank 连续。如果你还想做 A-2-3,这套映射天然支持。
这个坑的隐蔽性在于:单测如果没有覆盖 Q-K-A 和 A-2-3 两个方向,很容易放过去。我建议测试用例里专门加四个:A-2-3 合法、Q-K-A 非法、K-A-2 非法、10-J-Q-K 合法。K-A-2 在某些扑克变体里有特殊规则,金拉米没有。
5.3 knock 算分时没先让对手 laying off,导致分差算错
现象:AI 显示自己 knock 赢了 15 分,但手动按规则算只有 6 分。原因:resolveShowdown直接把双方剩余手牌的点数相减,没有允许对手把自己能配的牌搭到 knock 方的 meld 上。金拉米规则里,knock 方亮出 meld 后,对手可以把手牌中的牌加到这些 meld 上,减少自己的 deadwood,这叫 laying off。修法:算分时先让对手的牌尝试加入已有 meld,重新计算双方 deadwood,再做差值。
function resolveShowdown(state: GameState): void { const knocker = state.turn; const opponent = 1 - state.turn; const knockerMelds = findMeldCandidates(state.hands[knocker]); // 对手的 deadwood:先把能搭进 knocker meld 的牌扣掉 const oppHand = [...state.hands[opponent]]; for (const meld of knockerMelds) { for (let i = oppHand.length - 1; i >= 0; i--) { if (canAddToMeld(oppHand[i], meld)) oppHand.splice(i, 1); } } const oppDeadwood = oppHand.reduce((s, c) => s + pointValue(c), 0); // knocker 的 deadwood 用自身 bestDeadwood 计算 }canAddToMeld判断一张牌能放进某个 set 或 run 里,逻辑与 2.2 节的 meld 判定规则一致。注意这里每个 meld 只能搭一次牌,搭进去的牌不能再给另一个 meld 用,实现时要从数组里移除处理过的牌。很多实现用「数量统计」代替「移除」,会导致同一张牌被搭给多个 meld,分差进一步算错。
5.4 牌堆摸完了,游戏既不结束也不报错
现象:对局进行到一半,AI 一直摸牌堆但牌堆已经空了,然后界面卡死。原因:没处理 stock 耗尽的终端条件。金拉米规则里,牌堆摸完且无人 knock 时,一般算平局重新发牌,部分变体规定如果弃牌堆顶牌被拿走后就地翻转弃牌堆作为新牌堆,但这会让策略失去意义。修法:在drawFromStock前检查库存,空则直接进入ROUND_END并标记平局。
function drawFromStock(state: GameState): { card?: Card; error?: string } { if (state.stock.length === 0) { state.phase = 'ROUND_END'; return { error: 'stock emptied - draw game' }; } const card = state.stock.pop()!; // ... 摸牌后的状态更新 return { card }; }平局在算总分时怎么记录,取决于你前台的总分规则,我建议把平局和胜利作为两种终局事件分别记录,而不是都当成「0 分」。否则胜率统计会把平局算进某一方的失败里,AI 调参时数据就脏了。
5.5 UI 层持有同一张牌对象引用,改一张牌导致引擎状态脏
现象:调试时发现某张牌同时出现在两个玩家手里,或出现在弃牌堆顶和手牌里。原因:发牌时没有复制对象,UI 为了显示拖拽效果直接改了牌对象上的字段,而引擎里同一个对象被多处引用。修法:引擎内部一律用不可变 Card,需要修改就新建对象;UI 层拿到的牌做一层序列化深拷贝,引擎与 UI 之间不共享同一个引用。
function copyCard(card: Card): Card { return { suit: card.suit, rank: card.rank }; }不要小看这条。牌类游戏的对象引用共享,是「看着没问题、一上对局就灵异」的头号来源。我的习惯是引擎对外暴露的所有数据都只读,任何写操作必须走引擎的 action 函数,UI 无法直接改状态。这样虽然多写几个方法,但排错成本直线下降。
6. 用对局回放日志验证 AI 与规则:唯一可靠的复盘手段
AI 调参最怕什么?不是胜率低,而是不知道某一步为什么输。我养成的习惯是:每个对局引擎必然带一个结构化回放日志,每步一个事件,对局结束整个数组输出。这个日志不仅用于调参,还是规则 bug 的「后悔药」——出了诡异结果,把日志回滚到出错那一步,逐步重放即可定位。
interface LogEvent { seq: number; turn: number; action: string; // 'draw_stock' | 'draw_discard' | 'discard' | 'knock' | 'gin' | 'showdown' extra: Record<string, unknown>; deadwoodAfter: number; // action 发生后当前玩家 deadwood stockSize: number; }字段设计有三个注意点。deadwoodAfter必须记录,它是复盘时核对 AI 决策是否合理的快速指标。stockSize用于校验牌堆消耗速度,如果某局牌堆在 20 步内就空,说明摸牌逻辑有问题。extra里我习惯塞「弃牌后对手的 dangerScore」这类 AI 内部指标,方便确认 AI 是因为防御才弃了某张牌,而不是随机失误。
有了日志,验证引擎正确性就有了锚点。我常用的做法是写一个回放脚本:按日志里的seq顺序重放所有 action,每一步重新调用一次bestDeadwood和canDiscard,把计算结果与日志记录比对,不一致就是规则或 AI 的 bug。这个脚本跑起来很快,每局几千步,毫秒级完成,建议每次改动规则后全量回放历史日志。如果你保存了足够多历史对局,等于给引擎建了一个回归测试集,比手写单测覆盖得更全面。
我做金拉米引擎最深的教训是:AI 的「蠢」往往不是算法问题,而是规则引擎给它的输入已经错了——比如 deadwood 算错 2 点,AI 就会做出完全不同的弃牌决策。先修日志,再修规则,最后才调 AI 参数,这个顺序别反过来。希望这篇笔记能帮你少走几趟弯路,把 Gin-Rummy-Card-Game 从「能发牌」推进到「能玩、能调、能跟人打」。
本文还有配套的精品资源,点击获取