Java实现桥牌计分系统:定约建模与计分引擎实战
简介一款基于SpringBoot的桥牌计分系统Java源码围绕桥牌比赛计分与成绩管理需求设计主要面向计算机、电子信息等专业学生可用于毕业设计、课程设计或期末大作业。系统采用B/S架构与MVC模式前后端分离技术栈涵盖SpringBoot、MyBatis、MySQL、Vue、Ajax等支持Windows/Mac环境可在IDEA/Eclipse中配合JDK1.8、Maven3.6、MySQL5.7与Tomcat 8/9完成部署整个项目包含763个文件压缩包大小15.85MB其中Java源码与MyBatis映射文件实现后端业务逻辑与接口Vue页面、JS、CSS负责前端交互和界面展示png、svg、jpg等图片与图标资源用于页面美化XML文件支撑数据持久化配置bat脚本提供了快捷安装与启动入口yml、json等配置则方便了解SpringBoot项目的常用写法数据库脚本和项目配置均已整理目录划分清楚便于按模块学习和二次开发。目前已有39人学习代码经过严格测试适合需要快速获得完整桥牌计分系统参考实现的学习者。1. 桥牌计分系统代码本质上是把规则组合翻译成可测试的逻辑桥牌计分看着是一张罚分表加几句乘法真正写代码时才发现坑全在组合上3NT 加倍超两墩、有局方小满贯再加倍刚好完成、无局方 5 阶加倍宕三墩——这些场景不是一条公式能算完的。做桥牌计分系统代码核心不是把五个数字塞进 Math而是先拆解定约、局况、加倍状态再分层算出墩分、奖分、罚分。这篇文章按我平时落地这类系统的顺序来讲先建模型再写计分引擎接着接持久化和校验。适合要做桥牌教学工具、比赛编排软件或者正在刷 Java 面试题想找真实业务练手的人。看完你手里应该有一套能跑的 Java 计分核心以及一套测它没算错的办法。2. 定约与局况建模让桥牌计分系统的输入先稳定下来2.1 用 record 表达定约阶数、花色、加倍状态桥牌计分系统的输入是一份定约比如“南家主打 3NT 加倍”。定约包含三个不可拆的属性阶数1 到 7、花色C/D/H/S/NT、加倍状态未加倍/加倍/再加倍。Java 里最合适的载体是 record不变性天然匹配“一副牌的结果一旦确认就不该被修改”这个约束。public record Contract(int level, Strain strain, Doubling doubling) { public Contract { if (level 1 || level 7) { throw new IllegalArgumentException(定约阶数必须在1到7之间: level); } } public enum Strain { CLUB(20, C), DIAMOND(20, D), HEART(30, H), SPADE(30, S), NOTRUMP(40, NT); private final int baseTrickScore; private final String code; Strain(int baseTrickScore, String code) { this.baseTrickScore baseTrickScore; this.code code; } } public enum Doubling { UNDOUBLED, DOUBLED, REDOUBLED } }baseTrickScore存的是该花色每墩的基本分低级花色每墩 20高级花色每墩 30无将首墩 40 后续每墩 30。注意无将的 40 不能直接当乘法系数因为它的计分不是线性的。把基分挂在枚举上是桥牌计分系统代码里值得养成的小习惯——以后要支持新的计分变体比如米切尔赛的局况设置只需动枚举不动计分逻辑。解析外部输入时我常用静态工厂方法把3NTx、4SXX这类比赛常用缩写转成 Contractpublic static Contract parse(String input) { String s input.trim().toUpperCase(); Doubling doubling Doubling.UNDOUBLED; if (s.endsWith(XX)) { doubling Doubling.REDOUBLED; s s.substring(0, s.length() - 2); } else if (s.endsWith(X)) { doubling Doubling.DOUBLED; s s.substring(0, s.length() - 1); } // 前面是阶数最后一位是花色 int level Integer.parseInt(s.substring(0, s.length() - 1)); Strain strain switch (s.charAt(s.length() - 1)) { case C - Strain.CLUB; case D - Strain.DIAMOND; case H - Strain.HEART; case S - Strain.SPADE; case N - Strain.NOTRUMP; default - throw new IllegalArgumentException(无法识别的花色: s); }; return new Contract(level, strain, doubling); }解析顺序是先处理后缀再取阶数因为4SXX去掉XX后剩4S而如果先取最后一个字符会拿到X导致花色解析失败。这种防御式解析在对接比赛记分软件导出的文本时很有用。2.2 局况与庄家方位影响奖分和罚分的两个隐式参数桥牌计分系统代码里最容易漏的不是定约本身而是局况Vulnerability。同一个 4S 加倍宕一有局方输 200无局方只输 100差一倍。局况按牌桌方位组合定义标准规则是四组双方无局None南北有局NS东西有局EW双方有局Both一副牌落在哪一组由牌号和比赛类型决定最常见的是按 16 副一轮的固定轮转。代码里用枚举就够了同时要记录庄家方位因为判断“防守方是谁”“谁有局”都依赖它。public enum Vulnerability { NONE(false, false), NS(true, false), EW(false, true), BOTH(true, true); private final boolean northSouth; private final boolean eastWest; Vulnerability(boolean northSouth, boolean eastWest) { this.northSouth northSouth; this.eastWest eastWest; } public boolean isVulnerable(Seat declarer) { return switch (declarer) { case NORTH, SOUTH - northSouth; case EAST, WEST - eastWest; }; } public static Vulnerability fromBoardNumber(int boardNo) { return switch ((boardNo - 1) % 16 / 4) { case 0 - NONE; case 1 - NS; case 2 - EW; default - BOTH; }; } } public enum Seat { NORTH, EAST, SOUTH, WEST }fromBoardNumber是比赛实战里最容易验证的逻辑按桥牌竞赛规则第 1 副双方无局第 5 副南北有局第 9 副东西有局第 13 副双方有局之后每 16 副循环一次。我在多个桥牌计分相关的 Java 项目里都见过手写 if 判断boardNo % 16 1 || ...导致第 17 副算错的 bug用分组取模至少把逻辑收敛到一个方法里方便做参数化测试。有了 Contract、Vulnerability、Seat 三个模型计分引擎的输入就完整了一副牌输进来的是“庄家在哪个方位、主打什么定约、实际拿了多少墩、这副牌是什么局况”。接下来才轮到真正的算术。3. 核心计分引擎按桥牌规则分层计算3.1 基础墩分与成局判断把线性规则先算清进入计分引擎的第一步是算定约的基础分trick score它是后续所有奖分的基准。算法分三步先算实际拿到的墩数对应的基础分再判断是否成局最后根据成局与否和局况加奖分。public class ScoreEngine { public static int trickScore(Contract contract) { Strain strain contract.strain(); if (strain Strain.NOTRUMP) { return 40 (contract.level() - 1) * 30; } return contract.level() * strain.baseTrickScore(); } public static boolean isGame(Contract contract) { return trickScore(contract) 100; } public static int contractBonus(Contract contract, boolean vulnerable) { if (contract.level() 6) { if (contract.level() 7) { return vulnerable ? 1500 : 1000; } return vulnerable ? 750 : 500; } return isGame(contract) ? (vulnerable ? 500 : 300) : 50; } }trickScore把无将单独处理是因为无将的墩分曲线是 40、70、100……而花色定约是 20 或 30 的线性累加。这里有个桥牌计分代码常见的边界问题一个5C加倍完成基础墩分按规则是5 * 20 * 2 200但成局判断必须基于未加倍的基础分也就是5 * 20 100。所以isGame一定要在加倍修正之前调用。我见过的错误实现多半是把加倍后的分数直接拿来跟 100 比较导致2NT 加倍实际基础分 140但未加倍只有 70被误判为成局。加倍对基础分的影响规则是基础墩分翻倍再加倍翻四倍。这在桥牌计分系统代码里可以用一个乘数表表达后面算超墩奖和宕墩罚也要复用。public static int multiplier(Contract contract) { return switch (contract.doubling()) { case UNDOUBLED - 1; case DOUBLED - 2; case REDOUBLED - 4; }; }3.2 满贯奖分与加倍超墩桥牌计分代码里最容易算错的区域满贯奖分是独立于成局奖的两者取一个还是叠加是新手最常问的点。答案是叠加完成一个小满贯定约除了成局奖还要再加小满贯奖。所以计算顺序必须是先判断是否满贯再判断是否成局最后统一加部分定约奖。超墩的计分是另一个高频出错点。未加倍时超一墩拿该花色的每墩基础分加倍后无局方每墩 100有局方每墩 200再加倍再翻倍。这段逻辑放在一个私有方法里对外只暴露总分便于分层测试。public static int overtrickScore(Contract contract, int declarerTricks, boolean vulnerable) { int extra declarerTricks - contract.level() - 6; if (extra 0) { return 0; } return switch (contract.doubling()) { case UNDOUBLED - extra * contract.strain().baseTrickScore(); case DOUBLED - extra * (vulnerable ? 200 : 100); case REDOUBLED - extra * (vulnerable ? 400 : 200); }; }declarerTricks是庄家一方实际拿到的总墩数减 6 之后才是超了多少墩。这里有个约定需要统一整个计分系统内部一律使用“庄家拿到的墩数”不做“南北拿几墩”的换算。因为在双人赛记分里输入通常是 Tricket 文件或手工录入的最终墩数统一视角能让代码少一层转换。3.3 宕墩罚分用查找表代替 if 堆叠宕墩的罚分是桥牌计分规则里最不适合用 if 硬编码的部分。我一般直接做一张二维查找表行是局况列是加倍状态值是该状态下的罚分序列。这里放的是无局方和有局方各三种状态的标准罚分单位是每墩。宕墩数无局方未加倍无局方加倍无局方再加倍有局方未加倍有局方加倍有局方再加倍1-50-100-200-100-200-4002-100-300-600-200-500-10003-150-500-1000-300-800-16004-200-800-1600-400-1100-2200注意无局方加倍从第三墩起每墩 300有局方加倍从第二墩起每墩 300这个不规则增长正是查找表的价值。用代码实现表驱动后续如果遇到本地桥牌俱乐部的自定义罚分规则改表即可。private static final int[][] PENALTY_TABLE { // 未加倍无局 加倍无局 再加倍无局 未加倍有局 加倍有局 再加倍有局 { -50, -100, -200, -100, -200, -400 }, // 宕1 { -100, -300, -600, -200, -500, -1000 }, // 宕2 { -150, -500, -1000, -300, -800, -1600 }, // 宕3 { -200, -800, -1600, -400, -1100, -2200 } // 宕4 }; public static int penaltyForUndertricks(int undertricks, Contract contract, boolean vulnerable) { if (undertricks PENALTY_TABLE.length) { throw new IllegalArgumentException(暂不支持的宕数: undertricks); } int col switch (contract.doubling()) { case UNDOUBLED - vulnerable ? 3 : 0; case DOUBLED - vulnerable ? 4 : 1; case REDOUBLED - vulnerable ? 5 : 2; }; return PENALTY_TABLE[undertricks - 1][col]; }列号通过加倍状态和局况双重切换省去了三个嵌套 if。这里有一个设计取舍罚分表只做到宕 4实际桥牌比赛极少出现宕 5 以上的情况但为了健壮性抛异常明确告诉调用方当前系统支持的上限好过返回一个错得离谱的分数。最后把三部分组装起来统一入口暴露给上层public static int calculateScore(Contract contract, int declarerTricks, boolean vulnerable) { int tricksTakenByDeclarer declarerTricks; if (tricksTakenByDeclarer contract.level() 6) { int undertricks contract.level() 6 - tricksTakenByDeclarer; return penaltyForUndertricks(undertricks, contract, vulnerable); } int score multiplier(contract) * trickScore(contract); score contractBonus(contract, vulnerable); score overtrickScore(contract, tricksTakenByDeclarer, vulnerable); return score; }这个方法返回的是庄家视角的分数正数表示定约方得分负数表示防守方得分。比赛表格里通常记的是完成方口号相反视角在展示层转换即可核心引擎保持单一语义这也是桥牌计分系统 Java 代码里分层的意义所在。4. 比赛计分表与持久化让桥牌计分系统真正跑起来4.1 一副牌的完整记分记录统一为一行数据单副牌算完只是第一步桥牌计分系统面向的是整场比赛。常见做法是把每桌每副牌的结果归一化成一条记录字段包含场次编号、桌号、牌号、庄家方位、定约描述、实际墩数、局况冗余存储一份方便查询和核对。public record BoardResult(int sessionId, int tableNo, int boardNo, Seat declarer, Contract contract, int tricksTaken, int nsScore, int ewScore) { public static BoardResult of(int sessionId, int tableNo, int boardNo, Seat declarer, String contractText, int tricks) { Contract contract Contract.parse(contractText); boolean vulnerable Vulnerability.fromBoardNumber(boardNo) .isVulnerable(declarer); int declarerScore ScoreEngine.calculateScore(contract, tricks, vulnerable); int nsScore (declarer Seat.NORTH || declarer Seat.SOUTH) ? declarerScore : -declarerScore; return new BoardResult(sessionId, tableNo, boardNo, declarer, contract, tricks, nsScore, ewScore); } }nsScore和ewScore永远是相反数因为桥牌每一副牌的总分是零和的。既然零和为什么还要同时存两列因为比赛软件需要直接按南北视角和东西视角分别出报表存冗余字段比每行现算快得多也方便排查“记分是否记反”的争议。局况不单独存而是通过boardNo实时算因为局况规则是固定的冗余存储反而可能在导入外部数据时带进不一致。4.2 用 JDBC 持久化适合计分系统的最小方案比赛规模一般在几十副牌用不上重量级 ORM。我通常直接用 JDBC 加一个简单连接池表结构设计以副次board为主键维度。CREATE TABLE board_results ( session_id INT NOT NULL, table_no INT NOT NULL, board_no INT NOT NULL, declarer VARCHAR(4) NOT NULL, contract VARCHAR(8) NOT NULL, tricks_taken INT NOT NULL, ns_score INT NOT NULL, ew_score INT NOT NULL, PRIMARY KEY (session_id, table_no, board_no) );写库采用 upsert 语义保证同一桌同一副牌的结果重复提交时是覆盖而不是报错。这在现场比赛中很常见裁判发现录入错误后要重新提交。public void saveResult(Connection conn, BoardResult r) throws SQLException { String sql INSERT INTO board_results (session_id, table_no, board_no, declarer, contract, tricks_taken, ns_score, ew_score) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE declarer VALUES(declarer), contract VALUES(contract), tricks_taken VALUES(tricks_taken), ns_score VALUES(ns_score), ew_score VALUES(ew_score) ; try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, r.sessionId()); ps.setInt(2, r.tableNo()); ps.setInt(3, r.boardNo()); ps.setString(4, r.declarer().name()); ps.setString(5, r.contract().toString()); ps.setInt(6, r.tricksTaken()); ps.setInt(7, r.nsScore()); ps.setInt(8, r.ewScore()); ps.executeUpdate(); } }文本协议里桥牌常用3NT*这种带星号表示加倍而我内部用X后缀所以写入前要把记录里的 contract 统一成Contract.toString()的规范格式。数据库里的 contract 列存的是纯文本但读取时一定要经过Contract.parse再使用不要直接拿文本拼接 SQL既防注入又保证格式校验只在一处做。4.3 出错时先看这三处桥牌计分系统排错定位法写桥牌计分系统代码时线上报错通常集中在三个位置第一是Contract.parse抛异常多半是外部导入的文件用了*、这些非标符号解析前先规范化字符串。第二是calculateScore返回的分数和手算不一致优先怀疑局况判断特别是从第 17 副牌开始的循环。第三是数据库层的主键冲突说明同一副牌的重复提交没有走 upsert 而是走了 insert。定位手段是给BoardResult.of增加一个校验断言在入参阶段就把明显错误拦住public static BoardResult of(int sessionId, int tableNo, int boardNo, Seat declarer, String contractText, int tricks) { if (tricks 0 || tricks 13) { throw new IllegalArgumentException(墩数必须在0到13之间: tricks); } // ... 后续逻辑不变 }这个断言成本极低但能挡住一大类“录入时把 3 和 8 看反”的脏数据。正规一点的做法是录入手工结果时做二次确认弹窗把系统计算出的庄家得分展示给操作员核对。桥牌比赛的争议大多来自人工录入而不是计分规则本身。5. 用对照表批量验证计分引擎确认桥牌计分代码没算错计分引擎写完之后验证环节决定这套桥牌计分系统能不能真正用于比赛。手工逐副验算太慢我一般准备一张从权威规则整理来的对照表然后跑参数化测试。这些测试用例专挑边界组合加倍的成局判断、满贯叠加、宕墩表的跨列取值。public class ScoreEngineTest { ParameterizedTest CsvSource({ // 定约, 实际墩数, 是否无局方, 庄家得分 3NT, 9, true, 400, // 有局方成局300基础成局奖 100墩分 3NT, 9, false, 300, // 无局方成局 3NTX, 9, false, 550, // 无局方3NT加倍3*40*224050?错成局奖300超墩1无局加倍100 7NT, 13, true, 2220, // 有局大满贯墩分406*30220成局500满贯1500 6S, 12, false, 980, // 无局小满贯6*30180成局300满贯500 2HXX, 8, true, 640 // 有局2H再加倍完成2*30*4240成局500无超墩 }) void testCompletedContracts(String contractText, int tricks, boolean vulnerable, int expected) { Contract contract Contract.parse(contractText); int actual ScoreEngine.calculateScore(contract, tricks, vulnerable); assertEquals(expected, actual, contractText 计分错误); } }用 JUnit 的CsvSource把用例集中在一张表里新增规则变体时只需加一行。写这些用例时很自然地暴露出一个常见误解3NTX无局方完成时基础分是 240成局奖应该是 300 而不是部分定约的 50因为成局判断来自未加倍的基础分 100。这类瑕疵如果不做参数化测试很难在手工测试里覆盖到。除了正确的完成定约宕墩的测试也要补上尤其是无局方加倍宕三墩这个经典边界Test void testUndoubledUndertricksVulnerable() { Contract contract Contract.parse(4S); assertEquals(-300, ScoreEngine.calculateScore(contract, 6, true)); } Test void testDoubledUndertricksNonVulnerable() { Contract contract Contract.parse(5CX); assertEquals(-500, ScoreEngine.calculateScore(contract, 8, false)); }第二个用例的期望值来自罚分表无局方加倍宕三墩三墩分别是 100、200、200合计 500。之所以单独写两条不带参数化的测试是为了在报错时能直接看到具体是哪条规则不满足而不是从表格里十几行中猜。最后一招是对拍验证。把计分引擎接入一个随机生成器随机生成定约、局况和实际墩数批量调用calculateScore后把结果导出成 CSV跟网上桥牌计分器的输出或规则书的计分表做抽样比对。这一招不能证明逻辑正确但能筛掉大部分手滑写错的常量。我习惯把随机种子固定下来避免每次回归测试结果不一致。做完这四类验证桥牌计分系统代码才敢接正式比赛的实时录入。本文还有配套的精品资源点击获取