可插拔 13 款实时棋牌对战平台实践:房间容器、Plugin SDK 与幂等账本
说明:本文介绍的是开源式技术实践 / 学习项目「Chess & Cards」。平台使用虚拟筹码记分,不提供充值、提现、兑换或任何资金闭环;文中涉及的牌类/棋类玩法均为规则与状态机演练,不构成博彩服务。
写在前面
多人实时棋牌系统常见的工程痛点很集中:
- 规则膨胀:每加一款游戏就改房间服,耦合越来越重;
- 状态不一致:客户端本地推演与服务端结果对不上;
- 账务重复入账:重连、重试、重复结算把余额打穿;
- 回归成本高:改一处规则,十几款玩法都要手工点一遍。
「Chess & Cards」用一套 大厅 + 房间容器 + 游戏插件 的结构把这几件事拆开:大厅只负责账号/任务/榜单/钱包,房间服只负责座位、快照、重连与结算上报,具体玩法全部下沉到独立插件。当前内置 13 款游戏(扑克、记分竞技、棋类),新增一款游戏通常只需要实现 GamePlugin、注册一行、补一个前端渲染组件。

图 0:整体架构——大厅前端通过 HTTP 访问 API,通过 WebSocket 进入房间容器;游戏规则以插件形式挂载。
一、Monorepo 怎么切
仓库用 pnpm-workspace 管理:
packages/protocol # 前后端共享协议与类型(唯一来源)
packages/game-sdk # GamePlugin 契约、牌型引擎
packages/auth # HS256 JWT
apps/api-server # 大厅 API(Fastify)
apps/lobby-web # 大厅前端(React 18 + Vite 6)
services/room-server # WebSocket 房间容器
services/games/* # 13 个独立游戏插件
infra/ # systemd + nginx 片段 + deploy.sh
scripts/ # e2e-smoke / play-all-games
tools/gm-cli # 本地 GM 运维工具
根 package.json 提供 dev:api / dev:room / dev:web / e2e。未配置数据库时 API 自动降级为内存仓储,本地可以零依赖跑通全流程。

图 1:大厅。左侧按「全部热门 / 记分竞技 / 经典扑克 / 智谋对弈」分类;中部为大卡入口;顶栏显示虚拟筹码余额与福利、榜单、助手入口。
选台页把「极速匹配」和「创建专属房间」拆开,空台状态也有明确引导:

图 2:斗地主选台。活跃台为 0 时提示可极速匹配或创建专属房间。
二、统一协议:客户端只发意图
协议集中在 @cc/protocol。WebSocket 消息统一信封:
export interface Envelope<T = unknown> {
t: string; // 消息类型
d: T; // 负载
seq?: number;
ts: number;
}
客户端消息类型包括 client:hello、client:join_room、client:ready、client:intent、client:reconnect、client:add_bot 等;服务端对应下发 server:room_view、server:snapshot、server:settlement、server:wallet、server:error。
关键约束是:客户端只上报意图(intent),不擅自改本地权威状态。合法性校验、发牌、比牌、结算全部在服务端完成;快照按观察者裁剪(例如只把自己的手牌发给自己)。
三、房间容器:与玩法无关的那一层
GameRoom 是通用容器:座位、准备、开局、200ms tick、断线宽限、快照脏标记、结算一次性上报。它不包含任何具体游戏规则。
插件通过注入的 RoomContext 做事,禁止直接碰存储:
export interface RoomContext {
readonly config: RoomConfig;
readonly players: readonly PlayerContext[];
broadcast(t: string, d: unknown): void;
sendTo(playerId: string, t: string, d: unknown): void;
markDirty(): void;
settle(input: SettlementInput): void; // 每局只允许一次
charge(playerId: string, amount: number): Promise<string>;
refund(playerId: string, ref: string): Promise<void>;
beginRound(): void;
}
对「21 点 / 骰宝」这类常驻记分玩法,容器还会维护平台台(persistent room):无人时也按时间轴开局,大厅列表可展示近期走势。
四、GamePlugin:加一款游戏要写什么
契约在 @cc/game-sdk:
export interface GamePlugin {
readonly apiVersion: number;
readonly meta: GameMeta;
readonly commands: readonly string[]; // 指令白名单
onRoomCreate(ctx: RoomContext): void;
onRoundStart?(ctx: RoomContext): void;
onPlayerJoin(ctx: RoomContext, player: PlayerContext): void;
onIntent(ctx: RoomContext, playerId: string, intent: GameIntent): void | Promise<void>;
getSnapshot(ctx: RoomContext, viewerId: string | null): GameSnapshot;
getCurrentActor?(ctx: RoomContext): string | null;
botIntent?(ctx: RoomContext, playerId: string): GameIntent | null;
}
BaseGamePlugin 提供空实现;容器加载时校验 apiVersion,不兼容直接拒绝,避免协议漂移。
注册表集中在一行一行挂载:
export function registerBuiltinGames(manager: RoomManager): void {
manager.register(() => new GomokuPlugin());
manager.register(() => new DoudizhuPlugin());
manager.register(() => new XiangqiPlugin());
// ... 其余插件
for (const gameId of PERSISTENT_GAMES) {
manager.ensurePlatformRooms(gameId, PLATFORM_ROOMS_INITIAL);
}
}
以斗地主为例:onRoundStart 用房间 seed 洗牌发牌,onIntent 只接受 play / pass,牌型比较复用 SDK 的 parseCombo / canBeat,终局调用 ctx.settle。

图 3:斗地主对局。三人桌、手牌扇形排布,机器人陪玩入座;操作区提供提示与出牌。
棋类插件走同一套生命周期,只是快照变成棋盘矩阵与走子合法性:

图 4:中国象棋。棋盘按可用高度等比缩放;顶栏统一展示局数、在线人数与虚拟筹码。
记分竞技类(如 21 点)强调「下注阶段倒计时 + 发牌区 + 走势侧栏」三栏布局,用来验证常驻台的时间轴与即时扣款:

图 5:21 点常驻记分台。下注阶段倒计时、虚拟筹码档位、右侧历史局走势。
五、虚拟筹码账本:只追加、可幂等
筹码在本项目中只是记分单位(界面里也称「欢乐豆」),没有购买、提现或实物兑换路径。
账务设计要点:
- 下注即时扣款:插件调
charge,大厅侧原子校验余额并记账,返回幂等凭证;撤注走refund(凭证)。 - 结算净盈亏:本金已扣时,插件上报的 delta 按净结果计算;容器把
settlementId报到大厅。 - 幂等键:任务奖励、助手救济、结算入账都带唯一业务键,重复请求不会多发。
- 失败补偿:结算上报最多重试 3 次,仍失败记日志等待人工补偿(生产可换成 MQ + outbox)。
钱包客户端示意:
export function createWalletClient(config: RoomServerConfig): WalletClient {
return async (input) => {
const res = await fetch(`${config.apiBaseUrl}/internal/wallet`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-internal-key': config.internalKey,
},
body: JSON.stringify(input), // playerId / amount / action / bizId
});
// ... 解析 balance
};
}
排行榜按虚拟筹码余额排序,前若干名挂演示称号(每日结算);任务中心用签到、完成局数、体验多款游戏等条件发记分奖励:

图 6:财富排行榜。按虚拟筹码余额排序,当前用户行高亮。

图 7:福利中心。每日签到 / 完成对局 / 救济金等任务,奖励写入幂等账本。
六、机器人与牌桌助手
机器人陪玩:房主可添加 bot,人满自动开局。决策链路是:
回合到达 →(可选)大模型建议 → 房间统一合法性校验 → 失败则回退插件 botIntent
模型只输出 JSON 建议动作,非法动作会被丢弃;未配置模型密钥时纯用规则 AI,对局不会卡住。
牌桌助手:大厅侧聊天问答,可解释规则、任务、排行榜。涉及「救济筹码」时,模型只能给意向标记,真正发放由服务端校验余额门槛、每日次数,并用幂等键入账——模型无权直接改余额。

图 8:牌桌助手。快捷问题 + 对话窗口;救济发放逻辑固定在服务端。
鉴权方面,@cc/auth 实现极简 HS256 JWT;注册/登录带图形验证码,并做 IP / 账号维度的尝试限流(细节见 apps/api-server/src/security.ts)。
七、测试与部署
测试分层:
| 脚本 | 作用 |
|---|---|
game-smoke.ts | 不依赖网络,直接驱动插件规则 |
e2e-smoke.ts | 注册 → 匹配 → 对局 → 结算 → 任务 → 榜单 |
play-all-games.ts | 逐款建房、加机器人、验证开局 |
部署目标形态是 Ubuntu + Node 20 + PostgreSQL + nginx。infra/nginx-lobby.conf 是可 include 的片段:静态资源走 /lobby/,API 走 /lobby/api/,WebSocket 走 /lobby/ws,方便与同机其他站点共存。cc-api / cc-room 两个 systemd 单元由 deploy.sh 幂等构建并重启。日常运维可通过本地 GM工具(打包上传、服务状态、筹码补偿、公告),GM 接口单独鉴权,Web 端不暴露后台页。
八、新增一款游戏的最小路径
- 复制
services/games/gomoku为新目录,实现GamePlugin; - 在
plugins.ts注册,并在room-server/package.json加依赖; - 前端
apps/lobby-web/src/games/增加渲染组件,在Room.tsx分发; - 跑
game-smoke/play-all-games冒烟后部署。
牌型解析、比较、洗牌种子等通用能力放在 packages/game-sdk/src/cards.ts,多数扑克类可直接复用。
小结
这套实现验证了几条可复用的工程选择:
- 容器与规则分离:房间生命周期稳定,玩法可独立发版;
- 意图 + 快照:服务端权威,客户端只渲染观察者视图;
- 只追加账本 + 幂等键:重试与重连不破坏余额;
- 规则 AI 兜底:机器人与 LLM 建议都不得绕过合法性校验;
- 脚本化回归:13 款玩法靠自动化冒烟而不是纯手工点点点。
若你也在做实时对战或多玩法大厅,可以把「协议包 + 插件契约 + 幂等账本」这三块先立住,再往上堆 UI 与运营功能。项目定位始终是技术学习与架构演示,虚拟筹码仅用于记分。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐


所有评论(0)