- 这篇文章开始一个新的长期系列:从 0 到 1 实现一个 Balatro 风格的游戏后端系统,并逐步将其工程化为可扩展的实时服务项目。
- GitHub地址: https://github.com/RainbowZhou93/balatro-realtime-backend
- 📌 本文对应代码版本:commit:
init project with poker hand evaluator and unit test(2026年3月12日 10:57)
- 📌 本文对应代码版本:commit:
- ⚠️ 注意:由于项目持续迭代,当前仓库代码可能已发生变化,本文内容基于该 commit 版本进行说明。
本项目不仅仅是算法练习,也不是简单的游戏复刻,而是希望通过完整实现一个卡牌游戏后端,逐步实践后端工程中常见的核心能力,包括:
- 状态机设计
- WebSocket 实时通信
- Redis 缓存分层
- MySQL 持久化
- 游戏状态恢复
- Docker 化部署
- 单元测试与日志系统
- 可扩展的规则与效果系统
选择 Balatro 作为实现对象,是因为它同时具备:
- 明确的回合制状态流转
- 复杂但可拆分的规则系统
- 可扩展的卡牌效果机制
- 适合逐步工程化演进
这使得它非常适合作为一个长期的后端工程实践项目。
本系列将按照阶段逐步推进,并在每一步记录设计思路与实现过程。
本文是第一篇,主要完成:
✔ 明确项目阶段目标
✔ 实现基础牌型判断逻辑
文章目录
一、技术栈规划
本项目后端主要使用 Node.js + TypeScript + NestJS构建,并逐步引入完整的工程化组件。
当前计划使用的技术栈:
- Node.js v22
- TypeScript 5.x
- NestJS(后端框架)
- WebSocket(实时通信)
- Jest(单元测试)
- Redis(缓存与会话状态)
- MySQL(持久化存储)
- Docker / Docker Compose(环境管理)
前期先实现核心规则逻辑,随后逐步加入接口层、状态管理、缓存层与持久化层。
二、项目阶段规划
本项目不会一次性实现全部功能,而是按照从核心逻辑 → 工程化 → 扩展系统 → 高级特性的顺序逐步推进。
第一阶段:单局游戏流程完整
目标:做出可运行的一局游戏后端
- 目标包括
- 可运行的 Game Engine
- 支持回合状态
- 支持目标分数
- 支持发牌 / 出牌 / 弃牌 / 补牌
- 支持得分计算
- 支持回合结算
- 支持 WebSocket 主流程调用,并预留 HTTP 查询接口
- 需要实现
- NestJS框架搭建
- GameState设计
- 洗牌
- 发牌(初始8张)
- 出牌(最多5次)
- 弃牌(最多3次)
- 补牌
- 牌型判断
- 得分计算
- 判断是否达标
- 回合结算
- 当前进度
✔牌型判断
第二阶段:关卡与盲注系统
目标:让项目从"一局逻辑"变成"有进度的游戏"
包括:
- 小盲
- 大盲
- Boss
- 目标分数递增
- 允许跳过盲注
- 回合推进
- 局结束判断
第三阶段:持久化与缓存分层
包括:
- MySQL
- Redis
- 游戏恢复
- 用户数据
- 对局记录
- 状态分层设计
第四阶段:工程化与部署
包括:
- Docker Compose
- 配置化
- 日志系统
- 单元测试
- 历史查询接口
- README 初版
- 架构图初版
第五阶段:卡牌效果与Modifier系统
包括:
- Joker小丑牌
- 增强卡
- 封蜡
- 得分Modifier
- MultiplierModifier
- CardEffect
- JokerEffect
- Trigger
- EventBus
第六阶段:奖励池与商店系统
包括:
- 商店
- 补充包
- 标签
- 刷新
- 购买
- 奖励池
- 权重随机
第七阶段:特殊卡牌系统
包括:
- 塔罗牌
- 星球牌
- 幻灵牌
- 特殊事件
第八阶段:扩展玩法与AI系统
包括:
- 房间系统
- 排行榜
- 自动对局 AI
- Go 子服务预研
- Agent 扩展构想
三、牌型判断实现
牌型判断是整个游戏规则系统的基础,也是后续得分计算、效果系统、Modifier 系统的核心依赖,因此先从纯逻辑层实现。
1. 目标
输入为 1~5 张牌,根据当前出牌组合判断牌型。
- 支持牌型:
- royalFlush > 皇家同花顺 > 10
- straightFlush > 同花顺 > 9
- fourOfAKind > 金刚 > 8
- fullHouse > 葫芦 > 7
- flush > 同花 > 6
- straight > 顺子 > 5
- threeOfAKind > 三条 > 4
- twoPair > 两对 > 3
- onePair > 一对 > 2
- highCard > 杂牌 > 1
2. 规则
-
点数:2~10 J Q K A
-
A = 14
-
如其余牌是2、3、4、5,则A = 1,牌型为顺子
-
-
花色:
- H(Hearts): 红桃♥️
- S(Spades):黑桃♠️
- D(Diamonds): 方片♦️
- C(Clubs): 梅花♣️
-
输入
- 10H JH QH KH AH
-
输出
- 10
3. 代码实现
ts-node xx.ts运行
typescript
/**
* 输入最多5张牌,根据牌的点数和花色,判断是否是皇家同花顺,同花顺、四条、葫芦、同花、顺子、三条、两对、一对、高牌、杂牌等牌型。
* 牌的点数从2到10,J、Q、K、A分别对应11、12、13、14。
* 花色有四种:红桃(H)、黑桃(S)、方块(D)、梅花(C)。
* 输出牌型的名称,例如:皇家同花顺royalFlush,同花顺straightFlush、四条fourOfAKind、葫芦fullHouse、同花flush、顺子straight、三条threeOfAKind、两对twoPair、一对onePair、高牌highCard、杂牌junk。
*
* 输入格式:
* 输入最多5张牌,牌的格式为点数加花色,例如:10H、JD、QS、KC、AD。
* 输入结束后,输出对应的牌型名称。
*
* 示例输入:
* 10H JD QS KC AD
* 2H 3D 4S 5C 6H
* 2H 2D 2S 5C 6H
*
* 示例输出:
* 10
*/
type Suit = 'H' | 'S' | 'D' | 'C';
type HandType = 'royalFlush' | 'straightFlush' | 'fourOfAKind' | 'fullHouse' | 'flush' | 'straight' | 'threeOfAKind' | 'twoPair' | 'onePair' | 'highCard';
type Card = {
rank: number,
suit: Suit
}
const cardType: Record<HandType, number> = {
royalFlush: 10,
straightFlush: 9,
fourOfAKind: 8,
fullHouse: 7,
flush: 6,
straight: 5,
threeOfAKind: 4,
twoPair: 3,
onePair: 2,
highCard: 1,
}
const rankMap: Record<string, number> = {
A: 14,
K: 13,
Q: 12,
J: 11
}
function getCardType(cards: string[]): number {
const userCard = parseCard(cards)
const suitCount = Object.values(checkSuitCount(userCard));
const isFlush = suitCount.includes(5);
const sortedRanks = userCard.map(card => card.rank).sort((a, b) => a - b);
let isStraight = false;
if (userCard.length === 5) {
isStraight = true;
const uniqueRanks = new Set(sortedRanks);
if (uniqueRanks.size !== 5) {
isStraight = false;
} else {
for (let i = 1; i < sortedRanks.length; i++) {
// 这里使用非空断言,是因为当前循环边界已经保证 sortedRanks[i] 与 sortedRanks[i - 1] 一定存在。
if (sortedRanks[i]! - 1 != sortedRanks[i - 1]!) {
isStraight = false;
break;
}
}
// sortedRanks.join() 判断sortedRanks中的元素是否是2、3、4、5、14(A)。如果是的话,说明这是一个特殊的顺子,A在这里被当作1来使用。
if (sortedRanks.join() === "2,3,4,5,14") isStraight = true;
}
}
const rankCount = checkRankCount(userCard);
const rankCounts = Object.values(rankCount);
if (isStraight && isFlush && sortedRanks[0] === 10) return cardType.royalFlush;
if (isStraight && isFlush) return cardType.straightFlush;
if (rankCounts.includes(4)) return cardType.fourOfAKind;
if (rankCounts.includes(3) && rankCounts.includes(2)) return cardType.fullHouse;
if (isFlush) return cardType.flush;
if (isStraight) return cardType.straight;
if (rankCounts.includes(3)) return cardType.threeOfAKind;
/**
* count => count === 2 是一个回调函数,判断每个元素是否等于2。filter方法会返回一个新数组,包含所有满足条件的元素。
* 例如,如果rankCounts是[1, 2, 2, 1],那么rankCounts.filter(count => count === 2)会返回[2, 2],因为有两个元素等于2。
* 然后我们检查这个新数组的长度是否等于2,如果是的话,说明我们有两对牌。
*/
if (rankCounts.filter(count => count === 2).length === 2) return cardType.twoPair;
if (rankCounts.includes(2)) return cardType.onePair;
return cardType.highCard;
}
/**
*
* @param cards ['10H', 'JD', 'KS', '9C']
* @returns: [{rank:10,suit:'H'},{rank:11,suit:'D'},{rank:12,suit:'S'},{rank:13,suit:'C'},{rank:14,suit:'D'}]
*/
function parseCard(cards: string[]): Card[] {
const validSuits: Set<Suit> = new Set(['H', 'S', 'D', 'C']);
return cards.map((card) => {
const suit = card.slice(-1);
const rankStr = card.slice(0, -1) || "0";
const rank = rankMap[rankStr] ?? Number(rankStr);
//这里使用 as Suit 进行类型断言,用于通过 Set<Suit> 的类型检查。这类断言只影响 TypeScript 编译期,不会在运行时做额外校验。
if (!validSuits.has(suit as Suit) || Number.isNaN(rank)) {
throw new Error(`Invalid card format rank: ${card}`);
}
return { rank, suit: suit as Suit }
});
}
/**
*
* @param cards: [{rank:10,suit:'H'},{rank:11,suit:'D'},{rank:12,suit:'S'},{rank:13,suit:'C'},{rank:14,suit:'D'}]
* @returns: { '3': 1, '5': 1, '8': 1, '10': 1, '11': 1 }
*/
function checkRankCount(cards: Card[]): Record<number, number> {
const rankCount: Record<number, number> = {};
for (let card of cards) {
rankCount[card.rank] = (rankCount[card.rank] || 0) + 1;
}
return rankCount;
}
/**
*
* @param cards [{rank:10,suit:'H'},{rank:11,suit:'D'},{rank:12,suit:'S'},{rank:13,suit:'C'},{rank:14,suit:'D'}]
* @returns: { H: 1, S: 1, D: 2, C: 1 }
*/
function checkSuitCount(cards: Card[]): Record<Suit, number> {
const suitCount: Record<Suit, number> = { H: 0, S: 0, D: 0, C: 0 };
for (let card of cards) {
suitCount[card.suit]++;
}
return suitCount;
}
// console.log(getCardType(['10H', 'JD', 'QS', 'KC', 'AD']));
export { getCardType }
4. 单元测试
使用Jest做单元测试
1) Jest安装
bash
//安装 安装要求{ node: '^20.19.0 || ^22.13.0 || >=24' }
npm install --save-dev jest ts-jest @types/jest
//初始化
npx ts-jest config:init
//package.json 添加 (有tsconfig.json,也要有package.json 不冲突)
"scripts": {
"test": "jest"
}
"type": "module"
//如果没有package.json,就在项目主目录下init
npm init -y
//如果没有tsconfig.json
npx tsc --init
2) 需注意
- 生成的
jest.config.js中,还在用module.exports = { ... }(CommonJS写法),就会有冲突,因为package.json中是"type":"module",所以需要把文件jest.config.js修改后缀为jest.config.cjs
3) 单元测试代码
运行:npm test
typescript
import { getCardType } from "../src/games/hand/handEvaluator";
describe("Poker Hand Type Test", () => {
test("Royal Flush", () => {
const result = getCardType(['QH', '10H', 'AH', 'JH', 'KH']);
expect(result).toBe(10);
});
test("Straight Flush", () => {
const result = getCardType(['9S', '6S', '10S', '7S', '8S']);
expect(result).toBe(9);
});
test("Four of a kind", () => {
const result = getCardType(['9C', '9H', '9D', '9S']);
expect(result).toBe(8);
});
test("Full House", () => {
const result = getCardType(['6H', '3D', '3H', '6C', '3S']);
expect(result).toBe(7);
});
test("Flush", () => {
const result = getCardType(['KH', '2H', '9H', '5H', '7H']);
expect(result).toBe(6);
});
test("Straight", () => {
const result = getCardType(['7C', '4H', '8H', '6D', '5S']);
expect(result).toBe(5);
});
test("Straight A2345", () => {
const result = getCardType(['3D', 'AH', '5H', '2S', '4C']);
expect(result).toBe(5);
});
test("Three of a kind", () => {
const result = getCardType(['KH', '7D', '9H', '7H', '7S']);
expect(result).toBe(4);
});
test("Two Pair", () => {
const result = getCardType(['9C', '5H', '9D', '5S']);
expect(result).toBe(3);
});
test("One Pair", () => {
const result = getCardType(['2D', 'KH', '8H', '5C', '8S']);
expect(result).toBe(2);
});
test("High Card", () => {
const result = getCardType(['JC', '2H', 'KH', '9D', '5S']);
expect(result).toBe(1);
});
test("Invalid card", () => {
expect(() => {
getCardType(['9D', 'KH', 'XX', '5S', 'JC']);
}).toThrow();
});
});
- 输出
bash
rainbow@bogon tests % npm test
> balatro@1.0.0 test
> jest
PASS tests/poker.test.ts
Poker Hand Type Test
✓ Royal Flush (4 ms)
✓ Straight Flush (1 ms)
✓ Four of a kind
✓ Full House (1 ms)
✓ Flush (2 ms)
✓ Straight (1 ms)
✓ Straight A2345 (1 ms)
✓ Three of a kind (2 ms)
✓ Two Pair (2 ms)
✓ One Pair (1 ms)
✓ High Card (1 ms)
✓ Invalid card (16 ms)
Test Suites: 1 passed, 1 total
Tests: 12 passed, 12 total
Snapshots: 0 total
Time: 1.743 s, estimated 2 s
Ran all test suites.
四、下一步计划
下一篇将开始搭建 NestJS 项目结构,并将当前的纯逻辑代码迁移到后端框架中,同时设计游戏状态对象 GameState,为后续回合流程和 WebSocket 接口做准备。
本系列将持续记录从规则实现到工程化落地的全过程。