Java Gin Rummy纸牌游戏源码解析:melds、deadwood与回合状态机

发布时间:2026/10/28 11:06:05
Java Gin Rummy纸牌游戏源码解析:melds、deadwood与回合状态机

简介:Gin-Rummy-Card-Game 是一份基于 Java 的杜松子酒接龙(Gin Rummy)纸牌对战项目源码包,面向希望练习面向对象设计、游戏逻辑实现与简单 AI 策略的 Java 学习者及课程实践者。项目围绕两人对战的经典规则展开,涵盖发牌、组牌、出牌、计分与机器人对手决策等环节,适合用作课程设计、毕业项目参考或 Java 游戏开发入门案例。

压缩包共 163 个文件,约 1.44MB,以 108 个 gif 牌面与界面素材、18 个编译产物 class、15 个 Java 源文件为主,并包含 11 个 xml 配置、3 个 jar 依赖及 properties、checkstyle 等工程文件,可从中观察资源组织、构建配置与代码结构。已有 215 人学习,说明其具备一定参考价值。

读者可据此梳理牌型判定、回合流程、得分结算与机器人策略的实现思路,也能借鉴 Java 面向对象建模、GUI 素材组织及测试调试方法。工程中保留的 checkstyle 规范与 classpath 配置,还有助于理解项目构建与代码风格约定,适合按模块拆解学习并用于二次开发。

1. 拿到 Gin-Rummy-Card-Game 源码先别急着跑界面

Gin-Rummy-Card-Game 这类 Java 纸牌项目,第一眼看上去像课后作业,真正拆开才发现难点全在规则层:十张手牌怎么拆成最优的顺子和刻子、deadwood 点数怎么算、knock 之后对手还能 layoff 反杀。这份源码把这几块都落了地——Suit/Rank 双枚举建模、回溯搜 meld、带种子可复现洗牌、回合状态机,外加控制台和 Swing 两个入口。它解决的不是「能不能发牌」这种问题,而是「一局打完分数对不上」这种把人按在椅子上查半宿的问题。适合正在找 Java 综合练手项目的人,也适合想把一手牌最优拆分算法吃透的开发者。下面按从建牌到结算的顺序,把可复现的部分一段段抠出来。


2. 牌的建模与牌堆:两个枚举比 0 到 51 的整型编码强在哪

2.1 用 int 编码扑克牌,调试时会先崩在日志上

几乎所有纸牌项目的第一个决策都是「一张牌怎么表示」。最省事的写法是 int 0 到 51,除以 13 得花色、取模得点数,存进数组一把梭。这个方案跑得通,但会让你在后面每一步都付出代价:日志里打印出来是37,你得心算半天才知道是黑桃 K;断点调试时看着一屏整数,完全判断不出这手牌能不能组成顺子;更要命的是 equals 和 hashCode 得自己写,写成suit * 13 + rank还容易和别的编码约定打架,等到单元测试里断言失败,你会发现根本分不清是算法错了还是编码错了。

我在实际项目里一般会先花二十分钟把枚举写死,后面能省下好几个小时。花色四选一、点数十三选一,两个枚举天然把非法状态挡在编译期——你没法构造出一张「花色是 5」的牌。代价是初始化牌堆时要两层循环,但那是唯一一次。

public enum Suit { CLUBS, DIAMONDS, HEARTS, SPADES } public enum Rank { ACE(1), TWO(2), THREE(3), FOUR(4), FIVE(5), SIX(6), SEVEN(7), EIGHT(8), NINE(9), TEN(10), JACK(10), QUEEN(10), KING(10); private final int points; Rank(int points) { this.points = points; } /** Gin Rummy 计分:A 记 1,J/Q/K 记 10,其余按面值 */ public int points() { return points; } /** 用于判断顺子连续性,取值 1..13 */ public int order() { return ordinal() + 1; } }

这里有个容易被忽略的设计:points()和order()是两个完全不同的概念,必须拆成两个方法。如果偷懒只留ordinal(),写计分时你会下意识用ordinal() + 1,J 就会算成 11 分而不是 10 分,一局下来分数全错,而且错得很隐蔽——因为大部分牌点数和序数恰好相等,只有 J/Q/K 三张会露馅。

2.2 Card 做成不可变对象,手牌集合才不会自己变

第二层是 Card 本身。纸牌在游戏过程中不会改变花色点数,所以它天生理应是不可变对象:字段 final、不提供 setter、equals 和 hashCode 基于花色和点数。这一点看着像教科书说教,实际影响很直接——手牌用List<Card>存,你在做 meld 搜索时会反复remove和add试探,如果 Card 可变,某次试探改了对象状态又没回滚,后面所有计算都是脏数据,排查起来就是黑匣子。

public final class Card implements Comparable<Card> { private final Suit suit; private final Rank rank; public Card(Suit suit, Rank rank) { this.suit = Objects.requireNonNull(suit, "suit"); this.rank = Objects.requireNonNull(rank, "rank"); } public Suit suit() { return suit; } public Rank rank() { return rank; } public int points() { return rank.points(); } public int order() { return rank.order(); } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Card other)) return false; return suit == other.suit && rank == other.rank; } @Override public int hashCode() { return Objects.hash(suit, rank); } @Override public int compareTo(Card other) { // 先按花色分组,同花色内按点数排序,方便生成顺子候选 int bySuit = this.suit.compareTo(other.suit); return bySuit != 0 ? bySuit : Integer.compare(this.order(), other.order()); } @Override public String toString() { return rank.name().charAt(0) + suit.name().substring(0, 1); } }

compareTo的排序口径值得单独说一句:先花色后点数。这个顺序是给后面「按花色分组找顺子」用的。如果反过来先点数后花色,你也能排序,但找顺子时要额外做一次分组,白白多一层循环。这类小决策单看无所谓,叠十几个之后就是可读性的差距。

2.3 牌堆用双端队列,洗牌必须能塞种子

牌堆这块,很多实现会写成一个List<Card>,抽牌用remove(0)。牌少的时候没感觉,但ArrayList的头部删除是 O(n),而且语义上你其实想要的是「从牌堆顶拿一张」和「把弃牌压到弃牌堆顶」这两个动作。用ArrayDeque当 stock,addLast压底、pollFirst抽顶,弃牌堆单独用一个List只保留顶牌可见,逻辑更贴近实体牌桌。

public final class Deck { private final Deque<Card> stock = new ArrayDeque<>(); private final List<Card> discardPile = new ArrayList<>(); public static Deck standard() { Deck deck = new Deck(); for (Suit s : Suit.values()) { for (Rank r : Rank.values()) { deck.stock.addLast(new Card(s, r)); } } return deck; // 52 张 } /** 传入固定种子的 Random,整局牌序可复现,测试才有意义 */ public void shuffle(Random rng) { List<Card> all = new ArrayList<>(stock); Collections.shuffle(all, rng); stock.clear(); all.forEach(stock::addLast); } public Card drawFromStock() { Card c = stock.pollFirst(); if (c == null) { throw new IllegalStateException("牌堆已空,检查是否缺少重洗规则"); } return c; } public Card peekDiscard() { if (discardPile.isEmpty()) { throw new IllegalStateException("弃牌堆为空,首回合不得取明牌"); } return discardPile.get(discardPile.size() - 1); } public Card takeDiscard() { Card c = peekDiscard(); discardPile.remove(discardPile.size() - 1); return c; } public void pushDiscard(Card c) { discardPile.add(c); } public int stockSize() { return stock.size(); } }

Collections.shuffle内部就是 Fisher-Yates,传Random实例是为了可控。这一步是整个项目可测试性的地基:JUnit 里new Random(20240501L),每次跑出来的初始手牌完全一致,你写死的断言才站得住。反过来,如果类里直接new Random()或者用Math.random(),同一份代码两次运行结果不同,测试只能断言「手牌有 10 张」这种废话,算法对不对根本验不出来。

2.4 发牌与首张明牌的顺序

一局开始的动作序列是固定的:洗牌、给两家各发 10 张、翻开第 21 张作为弃牌堆起始顶牌、非发牌方先手。顺序写错就会闹出「发牌方先手」或者「明牌塞回牌堆」这种事。

public void startRound(long seed) { Deck deck = Deck.standard(); deck.shuffle(new Random(seed)); for (int i = 0; i < 10; i++) { human.hand().add(deck.drawFromStock()); computer.hand().add(deck.drawFromStock()); } deck.pushDiscard(deck.drawFromStock()); // 第 21 张作为明牌 // 先手方不能取明牌,只能从牌堆抽 this.phase = Phase.AWAIT_DRAW; this.currentPlayer = computer; this.deck = deck; }

这里面暗含一个规则点:先手玩家的第一次抽牌只能抽暗牌,因为明牌是刚翻出来的,规则上它属于「上一轮的弃牌」。如果引擎不区分「首回合」和「常规回合」,先手直接摸走明牌,第一轮就少了一张公开信息,对局节奏就变了。这类细节在规则文档里往往只有一句话,但实现时漏掉就是实打实的 bug。


3. 手牌拆分算法:melds 与 deadwood 的搜索怎么落地

3.1 先把计分规则钉死,算法才有目标函数

Gin Rummy 的核心机制是:手里 10 张牌,能组成顺子(同花色连续三张以上)和刻子(同点数三张或四张)的部分叫 meld,剩下不能成组的牌叫 deadwood,按点数累加。每回合抽一张、弃一张,当你的 deadwood 点数 ≤ 10 时可以 knock 宣布结束,但对手还有一次机会把自己的 deadwood 贴到你的 meld 上(layoff),贴完再比点数。

这段规则决定了算法要解决的是一个最优化问题:给定 10 张(抽牌后 11 张)手牌,找出一种 meld 划分方式,使剩余 deadwood 点数最小。注意是「最小」而不是「melds 数量最多」——四张 K 组成的刻子算一个 meld 拿 4 张牌,但如果拆成两对半分到别处可能反而让别的牌成组。用「melds 个数最多」当目标函数,会在某些牌型上给出错误答案。

举个具体例子:手牌是 ♠A ♠2 ♠3 ♠4 ♥4 ♦4 ♣4 ♠9 ♦9 ♥9。表面上 ♥4 ♦4 ♣4 是个刻子,♠A♠2♠3♠4 是个四张顺子,♠9 ♦9 ♥9 是第三个刻子,剩下零张 deadwood,一眼就是 Gin。但如果代码贪心地先抓点数最多的组,可能先拿走四张 4,剩下 ♠A ♠2 ♠3 ♠9 ♦9 ♥9 也还行。真正出问题的是更微妙的牌型,比如同一张牌既能进顺子又能进刻子,贪心选错就丢掉最优解。

3.2 回溯枚举所有 meld 组合,10 张牌的量级完全扛得住

既然目标是最小化 deadwood,最稳的办法就是把所有可能的 meld 组合枚举出来,逐个评估。手牌最多 11 张,候选 meld 数量有限(比如五个刻子候选加三个顺子候选),组合数在几千量级,回溯加剪枝跑完不到一毫秒。这就是为什么这个场景下可以放心用暴力搜索:它不需要什么玄学启发式,正确性还容易证明。

先写候选 meld 的生成。刻子按点数分组,同点数 ≥3 张就是一个候选;顺子按花色分组,同花色内按点数排序后枚举所有长度 ≥3 的连续段。

public final class MeldFinder { /** 生成全部候选 meld,每个 meld 是一个 Card 列表 */ public static List<List<Card>> candidates(List<Card> hand) { List<List<Card>> result = new ArrayList<>(); // 刻子:同点数、不同花色,3 张或 4 张都算候选 Map<Rank, List<Card>> byRank = hand.stream() .collect(Collectors.groupingBy(Card::rank)); for (List<Card> group : byRank.values()) { if (group.size() >= 3) { result.add(new ArrayList<>(group.subList(0, 3))); if (group.size() == 4) { result.add(new ArrayList<>(group)); } } } // 顺子:同花色连续,长度 3 到 5,A 只作低位,禁止 K-A-2 环绕 Map<Suit, List<Card>> bySuit = hand.stream() .collect(Collectors.groupingBy(Card::suit)); for (List<Card> group : bySuit.values()) { group.sort(Comparator.comparingInt(Card::order)); for (int i = 0; i < group.size(); i++) { for (int j = i + 2; j < group.size(); j++) { if (group.get(j).order() - group.get(i).order() == j - i) { result.add(new ArrayList<>(group.subList(i, j + 1))); } else { break; // 断了,后面的起点不用再试 } } } } return result; } }

这里group.get(j).order() - group.get(i).order() == j - i是判断连续的关键:如果从 i 到 j 每一张都比前一张大 1,那么首尾差值必然等于下标差。写成这个形式比逐张比较省一层循环,也顺手把「重复点数」的情况排除了——同花色不可能有两张点数相同的牌,但如果是别的数据结构混进了重复,这个判断会直接失效。

接下来是回溯主体。用一个布尔数组标记手牌里哪些牌已经被选进 meld,每层递归决定「跳过当前候选」还是「选用当前候选」,走到叶子时统计剩余点数。

public record Melding(List<List<Card>> melds, List<Card> deadwood) { public int deadwoodPoints() { return deadwood.stream().mapToInt(Card::points).sum(); } public boolean isGin() { return deadwood.isEmpty(); } } public static Melding best(List<Card> hand) { List<List<Card>> cands = candidates(hand); Melding[] best = { new Melding(List.of(), new ArrayList<>(hand)) }; search(hand, cands, 0, new boolean[hand.size()], new ArrayList<>(), best); return best[0]; } private static void search(List<Card> hand, List<List<Card>> cands, int idx, boolean[] used, List<List<Card>> chosen, Melding[] best) { if (idx == cands.size()) { List<Card> dead = new ArrayList<>(); for (int i = 0; i < hand.size(); i++) { if (!used[i]) dead.add(hand.get(i)); } int points = dead.stream().mapToInt(Card::points).sum(); if (points < best[0].deadwoodPoints()) { best[0] = new Melding(deepCopy(chosen), dead); } return; } // 分支一:不采用当前候选 search(hand, cands, idx + 1, used, chosen, best); // 分支二:采用当前候选,前提是牌还没被占用 List<Card> meld = cands.get(idx); int[] pos = new int[meld.size()]; boolean free = true; for (int k = 0; k < meld.size(); k++) { pos[k] = hand.indexOf(meld.get(k)); if (pos[k] < 0 || used[pos[k]]) { free = false; break; } } if (free) { for (int p : pos) used[p] = true; chosen.add(meld); search(hand, cands, idx + 1, used, chosen, best); chosen.remove(chosen.size() - 1); for (int p : pos) used[p] = false; } }

参数上,used数组和hand同长度,靠下标绑定;chosen是当前路径选中的 meld 列表,回溯时必须按对称顺序移除。best用单元素数组是因为 Java 的 lambda 和递归里没法直接改外层局部变量,包一层数组是最省事的写法。

这里有个可优化点:进入递归前先算一个「剩余牌点数下界」,如果已经不可能比当前最优更小就直接返回。但对 10 张牌来说剪不剪枝差别在毫秒级,我一般先不写,等真做 20 张牌变体再补。

3.3 knock 之后还有 layoff,结算顺序不能乱

这是最容易翻车的地方。很多实现算完自己的 deadwood ≤ 10 就直接判赢了,忘了对手的 layoff。正确流程是:knocker 亮出 meld,defender 把手上所有 deadwood 牌尝试贴到 knocker 的 meld 上——顺子可以往两端延伸,刻子可以补到四张——贴不上去的才算有效点数。

/** 返回 defender 贴牌之后真正剩下的 deadwood 点数 */ public static int applyLayoff(List<List<Card>> knockerMelds, List<Card> defenderDead) { List<Card> leftover = new ArrayList<>(); for (Card c : defenderDead) { boolean laid = false; for (List<Card> meld : knockerMelds) { if (canExtend(meld, c)) { meld.add(c); laid = true; break; } } if (!laid) leftover.add(c); } return leftover.stream().mapToInt(Card::points).sum(); } private static boolean canExtend(List<Card> meld, Card c) { // 刻子:同点数且当前不足 4 张 if (meld.size() <= 4 && meld.stream().allMatch(m -> m.rank() == c.rank())) { return meld.size() < 4; } // 顺子:同花色,且能接在最小或最大端 if (meld.stream().allMatch(m -> m.suit() == c.suit())) { int min = meld.stream().mapToInt(Card::order).min().orElse(0); int max = meld.stream().mapToInt(Card::order).max().orElse(0); return c.order() == min - 1 || c.order() == max + 1; } return false; }

注意canExtend里刻子判断先于顺子判断,因为四张同点数的 meld 既满足刻子条件也满足「同花色」条件(如果它们恰好同花色,实际不可能,但代码不能依赖这个巧合)。结算顺序必须是先 layoff 再比点数,Gin(deadwood 为 0)是唯一例外,规则上 defender 不能贴牌。

分数计算按常见规则:knock 成功得「对手剩余点数 − 自己点数」;如果对手剩余点数比你低(undercut),对手得差值加 25 分奖励;Gin 得 25 分加对手全部点数。累计到 100 分结束一局,再加 100 分游戏奖励。不同变体在奖励分上略有出入,代码里把这几个常数抽成static final放在一处,改规则时不用满项目找。

3.4 为什么这套暴力搜索不用换算法

看到递归就担心性能是常见的条件反射,但这里得算笔账:10 张手牌最多能生成的候选 meld 通常在 6 到 12 个之间,回溯树是每个候选选或不选,最坏 2 的 12 次方约 4000 个叶子,每个叶子做一次求和,现代 JVM 上耗时在微秒级。AI 每回合要评估 11 张弃牌选择,也就四万次叶子访问,人眼完全感知不到。

真正会拖慢的是把搜索放进 UI 线程的每帧刷新里。常见做法是把 AI 决策放在回合切换时算一次并缓存结果,而不是鼠标移动一次算一次。这一点比换算法重要得多。


4. 回合状态机:抽牌、弃牌、knock 的顺序不能被搅乱

4.1 用枚举表达阶段,不用一堆布尔标志

纸牌游戏最容易写崩的地方不是算法,是流程。抽牌前不能弃牌、弃牌前必须已经抽过、knock 只能在弃牌阶段发起、回合结束后不能再摸牌——这些约束如果靠boolean hasDrawn、boolean roundOver之类的标志组合来表达,很快就会变成 if 嵌套地狱,而且组合爆炸:两个布尔能表达四种状态,其中两种是非法的,你得靠额外判断去挡。

把阶段做成枚举,非法状态在类型层面就不存在。

public enum Phase { DEAL, // 发牌中 AWAIT_DRAW, // 等待当前玩家抽牌 AWAIT_DISCARD, // 已抽牌,等待弃牌 ROUND_OVER, // 本局结束,等待结算 GAME_OVER // 有人累计到 100 分 }

每次操作进来先做requirePhase,不匹配直接抛IllegalStateException。这样你在联调时会立刻发现「谁在错误的时机调了弃牌」,而不是默默把牌丢进弃牌堆、过几轮才发现手牌数量对不上。

4.2 一次合法回合的驱动代码

引擎类持有牌堆、两名玩家、当前玩家和阶段,对外只暴露四个动作:抽暗牌、取明牌、弃牌、knock。

public void drawFromStock() { requirePhase(Phase.AWAIT_DRAW); if (deck.stockSize() == 0) { reshuffleDiscardIntoStock(); // 变体规则,见第 5 章 } currentPlayer.hand().add(deck.drawFromStock()); phase = Phase.AWAIT_DISCARD; } public void takeDiscard() { requirePhase(Phase.AWAIT_DRAW); if (isFirstTurnOfRound()) { throw new IllegalStateException("首回合不得取明牌"); } currentPlayer.hand().add(deck.takeDiscard()); phase = Phase.AWAIT_DISCARD; } public void discard(Card c) { requirePhase(Phase.AWAIT_DISCARD); if (!currentPlayer.hand().remove(c)) { throw new IllegalArgumentException("手牌中没有 " + c); } if (currentPlayer.hand().size() != 10) { throw new IllegalStateException("弃牌后手牌必须剩 10 张,实际 " + currentPlayer.hand().size()); } deck.pushDiscard(c); currentPlayer = currentPlayer.opponent(); phase = Phase.AWAIT_DRAW; } public void knock() { requirePhase(Phase.AWAIT_DISCARD); Melding m = HandAnalyzer.best(currentPlayer.hand()); if (m.deadwoodPoints() > 10) { throw new IllegalStateException("deadwood 超过 10 点,不能 knock"); } settle(currentPlayer, m); phase = Phase.ROUND_OVER; }

三个细节值得单独拎出来。第一,discard里对「弃完必须剩 10 张」做了硬校验,这是防止 AI 或界面积累出第 11 张的兜底;第二,takeDiscard挡住首回合取明牌,避免前面提到的节奏问题;第三,knock 的校验放在引擎层而不是 AI 层,因为人类玩家也可能点错按钮。

4.3 电脑对手的抽牌与弃牌启发式

AI 不需要多聪明,能把每张牌的弃牌收益算清楚就足够打得像人。抽牌阶段的判断逻辑是:分别模拟「抽暗牌」和「取明牌」两种情况,各算一次最优 meld,选 deadwood 更小的那一边。

public Action decideDraw(List<Card> hand, Card topDiscard, boolean canTakeDiscard) { int stockBase = HandAnalyzer.best(hand).deadwoodPoints(); // 模拟抽暗牌:未知,用当前 deadwood 估值 int stockScore = stockBase; // 模拟取明牌:直接把明牌加进手牌重算 int discardScore = Integer.MAX_VALUE; if (canTakeDiscard && topDiscard != null) { List<Card> trial = new ArrayList<>(hand); trial.add(topDiscard); discardScore = HandAnalyzer.best(trial).deadwoodPoints(); } return discardScore < stockScore ? Action.TAKE_DISCARD : Action.DRAW_STOCK; } public Card decideDiscard(List<Card> hand) { return hand.stream() .min(Comparator.comparingInt(c -> { List<Card> trial = new ArrayList<>(hand); trial.remove(c); return HandAnalyzer.best(trial).deadwoodPoints(); })) .orElseThrow(() -> new IllegalStateException("手牌为空")); }

decideDraw里那个stockScore = stockBase是一种近似:摸暗牌摸到什么你不知道,拿当前 deadwood 当期望值。更精细的做法是按剩余牌堆做蒙特卡洛采样,抽几十次求平均,效果会好一些但耗时也上去。对课程设计级别够用了,写上注释说明这是估算而不是精确值,比假装精确要诚实。

decideDiscard的复杂度是 11 次全量 meld 搜索,前面算过总耗时可控。这里有个隐含假设:每张牌最多出现一次,所以List.remove(Object)不会删错。手牌里确实不会有重复牌,但如果你把弃牌堆也混进来做候选,就得改用下标删除。

4.4 计分与多局累计

单局结算完要落到总分上。玩家对象持有累计分,一局结束后判断是否有人到 100 分。

private void settle(Player knocker, Melding knockerMelding) { Player defender = knocker.opponent(); int knockerPoints = knockerMelding.deadwoodPoints(); int defenderPoints; if (knockerPoints == 0) { // Gin:defender 无权 layoff,直接按原始 deadwood 计 defenderPoints = HandAnalyzer.best(defender.hand()).deadwoodPoints(); knocker.addScore(25 + defenderPoints); } else { Melding dm = HandAnalyzer.best(defender.hand()); defenderPoints = HandAnalyzer.applyLayoff(knockerMelding.melds(), dm.deadwood()); int diff = defenderPoints - knockerPoints; if (diff > 0) { knocker.addScore(diff); } else { defender.addScore(-diff + 25); // undercut } } // 牌局累计到 100 分后加 100 分奖励并结束 for (Player p : List.of(knocker, defender)) { if (p.score() >= 100) { p.addScore(100); } } }

applyLayoff会修改knockerMelding.melds()里的列表内容,所以这个 Melding 必须是深拷贝过的。如果直接拿HandAnalyzer.best()返回的对象改,缓存里那份就被污染了,下一局复用同一个对象时会带着上一局的贴牌结果——这种 bug 找不到源头时非常折磨人。


5. 避坑与排查:五个把我按在椅子上的现场

5.1 洗牌每次结果不同,单元测试写成摆设

现象:写了assertEquals(expected10Cards, player.hand()),本地跑绿,换台机器跑红,重跑一次又绿了。

原因:Deck.standard()里面直接调了Collections.shuffle(list)无参版本,内部用的是ThreadLocalRandom,种子不可控。更要命的是有的实现每个方法里都new Random()一次,连续抽两张牌用的都不是同一个随机源。

解决:把随机源提到类外部注入,shuffle(Random rng)显式收参数;测试固定种子,生产环境用new Random(clock.millis())。同时把「发牌结果」抽成一个纯函数,输入种子输出初始手牌,这样测试和实际运行走同一段代码。

5.2 knock 算出来 8 点却被判负,layoff 被整个跳过

现象:玩家自信 knock,自己 deadwood 8 点,对手手里剩 9 点,按理赢 1 分,结算却显示输了 24 分。

原因:结算代码里漏了 layoff 步骤,直接拿双方原始 deadwood 比对。对手那 9 点里有两张能贴到 knocker 的顺子上,贴完只剩 2 点,触发了 undercut。

解决:把结算拆成三个明确阶段并加日志——knocker 亮 meld、defender 贴牌、比点数。Gin 分支单独走,不走 layoff。日志里打印贴牌前后的手牌,出问题一眼能看出哪张没被识别成可延伸。

5.3 A 在顺子里既能当 1 又能当 14,K-A-2 被误判合法

现象:手牌 ♠K ♠A ♠2 被判成一个顺子,deadwood 直接少 10 点。

原因:判断连续性时用了Math.abs(a.order() - b.order()) == 1这类循环差值判断,或者把 A 同时赋了两个序数。Gin Rummy 里 A 固定为低位,K-A-2 不是合法顺子。

解决:Rank.order()只返回 1 到 13,A 恒为 1,绝不提供「14」这个值;顺子判断统一用「排序后相邻元素 order 差为 1」。这样 K(13)和 A(1)差 12,天然被判不连续,不需要额外特判。

5.4 牌堆抽空直接抛空指针,重洗规则没人写

现象:一局打了很久,某回合点抽牌,界面卡死,控制台一片红。

原因:ArrayDeque.pollFirst()在空的时候返回 null,代码直接hand.add(null),后续所有card.points()全炸。规则上牌堆耗尽时应该把弃牌堆除顶牌外重新洗回牌堆,这个分支根本没实现。

解决:drawFromStock里显式判空并抛带业务信息的异常;同时补上重洗逻辑——保留弃牌堆顶牌,其余洗回 stock 并清空计数。重洗是变体规则,一定要在 README 里写清采用的是哪一种,否则测试和实际玩法对不上。

5.5 读档之后手牌一样,deadwood 却变了

现象:保存对局、加载、同一手牌算出来的最优 meld 不一样,分数对不上。

原因:存档用HashSet<Card>序列化,反序列化后遍历顺序和原来不同;meldfinder 里的hand.indexOf依赖列表顺序,顺序变了,候选 meld 的下标映射全乱,浮点无关但结果相关。

解决:手牌一律用List<Card>且保持稳定顺序;存档只存原始牌列表,melding 结果在读档后重新计算,不存计算结果。存中间结果看着省事,实际是把一致性责任外包给了序列化格式,早晚要还。


6. 用固定牌局做回归:一套能反复跑的验证套路

算法类的纸牌项目,最容易陷入的循环是「改一处规则、手动玩三局、感觉没问题、上线后发现某个边角牌型算错」。我现在的习惯是把发牌做成可注入的,然后用几组写死的牌局当回归样本,每次改完规则全跑一遍。

具体做法是把「从牌堆取牌」抽成一个接口:

public interface CardSource { Card next(); } public final class StackedSource implements CardSource { private final Deque<Card> preset; public StackedSource(List<Card> cards) { this.preset = new ArrayDeque<>(cards); } @Override public Card next() { Card c = preset.pollFirst(); if (c == null) throw new IllegalStateException("预设牌不够"); return c; } }

测试里构造StackedSource,把想要的牌按发牌顺序排好塞进去,就能精确控制两家的初始手牌。这比调种子碰运气靠谱得多——固定种子只能保证「每次一样」,但你不能指定「这一局给我发一手接近 Gin 的牌」。

三组必须有断言的场景:

场景输入手牌期望结果
纯 Gin3 个完整 meld 加 1 张多余牌弃掉后的 10 张deadwood 为 0,isGin 为 true
边界 knockdeadwood 恰好 10 点允许 knock;改成 11 点则抛异常
undercutknocker 6 点,defender 原始 12 点但可贴 8 点defender 得 25 + 2 分

第一组用来验证 meldfinder 不会把能成组的牌漏掉;第二组钉住 knock 的阈值边界,> 10和>= 10差一个等号,写反了线上就是「10 点不能 knock」这种投诉;第三组专门验 layoff 和 undercut 分支,这条路径平时跑不到,一旦出错就是分数全乱。

再补一层性质测试:随机生成一万手 10 张牌,断言best()返回的 meld 列表里所有牌互不重复、所有 meld 长度合法、deadwood 加点数之和等于 10 张牌的总点数。这三条不依赖具体算法实现,换一种搜索策略也照样成立,是真正的后悔药。

最后说个我自己的教训。早些年做这类项目,我总觉得「规则这么简单,跑两眼就行」,结果每次加一条新变体规则——比如允许 A 在顺子两端、或者 knock 之后必须弃一张——都会在某处把之前的边界悄悄改掉,而且改的时候毫无知觉,等到几天后手动试玩才发现分数不对,回头定位又要花半天。从那以后我每加一条规则,都先把固定牌局那几组测试跑一遍,红了的先修再往下写,不让问题过夜。希望帮到你。

本文还有配套的精品资源,点击获取