花了半年时间把这套情怀棋牌源代码修到能稳定跑起来,最大的感受是:要想后面 600+ 互动模块不失控,先把确定性 、可回放 、可追踪三件事打穿。下面不闲聊,直接给出一套能落在仓库里的"骨架工程",脚手架、代码、脚本一应俱全------把它跑起来,就能复现我这半年踩过的大坑与最终的解法。

仓库结构与基础脚手架
采用 mono-repo,前后端与共享内核在同一棵树,协议与确定性逻辑只实现一份(shared),避免"看起来一样其实不一样"。
nostalgia-suite/
├─ client/ # Cocos Creator/TS
├─ server/ # Node.js 服务
├─ shared/ # 确定性内核 + 协议 + 工具
├─ tests/ # 回放/基线/对拍
└─ tools/ # 构建、清单、诊断脚本
// package.json(根)
{
"name": "nostalgia-suite",
"private": true,
"workspaces": ["client", "server", "shared", "tests", "tools"],
"scripts": {
"build": "pnpm -F shared build && pnpm -F server build",
"dev:server": "pnpm -F server dev",
"test": "pnpm -F tests test",
"manifest": "node tools/gen-manifest.js build dist/manifest.json"
}
}
# pnpm-workspace.yaml
packages:
- "client"
- "server"
- "shared"
- "tests"
- "tools"
1. 共享确定性内核(shared)
1.1 固定点与 RNG(同种子可重放)
// shared/src/fixed.ts ------ 统一"定点小数"工具,避免浮点漂移
export type Fx = number; // 以 1e4 放大
const SCALE = 10000;
export function fx(n: number): Fx { return (n * SCALE) | 0; }
export function add(a: Fx, b: Fx): Fx { return (a + b) | 0; }
export function sub(a: Fx, b: Fx): Fx { return (a - b) | 0; }
export function mul(a: Fx, b: Fx): Fx { return ((a * b) / SCALE) | 0; }
export function div(a: Fx, b: Fx): Fx { return ((a * SCALE) / b) | 0; }
export function toNumber(a: Fx): number { return a / SCALE; }
// shared/src/rng.ts ------ 统一随机源(LCG / 可换 PCG)
export class Lcg {
private x: number;
constructor(seed: number){ this.x = seed | 0; }
next(): number { // 32-bit 无符号
this.x = (1103515245 * this.x + 12345) | 0;
return this.x >>> 0;
}
int(max: number){ return this.next() % max; } // [0, max)
range(lo: number, hi: number){ return lo + (this.next() % (hi - lo)); }
}
1.2 纯函数状态机(同输入→同结果)
// shared/src/state.ts
export type Tick = number;
// 示例:仅保留必要字段,真实项目可扩展 room/seat/stack 等子状态
export interface WorldState {
tick: Tick;
score: number; // 全整数/定点
}
export function initState(): WorldState {
return { tick: 0, score: 0 };
}
// 所有演算写进这里:无副作用、只依赖入参
export function advance(s: WorldState, inputs: readonly number[]): WorldState {
let v = s.score | 0;
for (const u of inputs) v = (v + (u | 0)) | 0; // 示例:聚合输入
return { tick: (s.tick + 1) as Tick, score: v };
}
1.3 协议与能力协商(共享)
// shared/src/protocol.ts
export interface Msg<T extends string, P> { t: T; v: number; p: P; } // v=版本
export type Join = Msg<'join', { uid: string; token: string; caps: string[] }>;
export type TickIn = Msg<'tick', { seq: number; input: number[] }>;
export type TickAck = Msg<'tick_ack', { seq: number; stateHash: string }>;
export type Snapshot = Msg<'snap', { tick: number; state: unknown }>;
export const Caps = {
DET_CORE_173: 'det-core>=1.7.3',
BUNDLE_V2: 'bundle-v2'
} as const;
1.4 哈希与签名(建立证据链)
// shared/src/hash.ts
import { createHash, createHmac } from 'crypto';
export function md5(obj: unknown){
return createHash('md5').update(JSON.stringify(obj)).digest('hex');
}
export function hmac(content: string, key: string){
return createHmac('sha256', key).update(content).digest('hex');
}
2. 服务端:网关轻,逻辑环稳(Node.js)
2.1 WebSocket 网关(仅鉴权/限流/路由)
// server/src/gateway.ts
import http from 'http';
import WebSocket, { WebSocketServer } from 'ws';
import { randomUUID } from 'crypto';
type Session = { sid: string; ws: WebSocket; uid: string };
const sessions = new Map<string, Session>();
const server = http.createServer();
const wss = new WebSocketServer({ server });
function verifyToken(token: string){ return token && token.length > 6; } // 示例
wss.on('connection', (ws) => {
const sid = randomUUID();
let uid = 'guest-' + sid.slice(0, 6);
ws.on('message', (buf) => {
let msg: any;
try { msg = JSON.parse(buf.toString()); } catch { return; }
if (msg.t === 'join') {
if (!verifyToken(msg.p?.token)) return ws.close();
uid = msg.p.uid || uid;
sessions.set(sid, { sid, ws, uid });
return ws.send(JSON.stringify({ t:'join_ack', v:1, p:{ sid, uid }}));
}
// 转发到逻辑进程(同机用 Node IPC 或本进程事件总线)
process.emit('to-room', { sid, uid, msg });
});
ws.on('close', () => sessions.delete(sid));
});
server.listen(7001, () => console.log('gateway on :7001'));
2.2 逻辑环(单线程 + 事件溯源 + 快照)
// server/src/room.ts
import fs from 'fs';
import path from 'path';
import { WorldState, initState, advance } from '../../shared/dist/state';
import { md5 } from '../../shared/dist/hash';
type Evt = { t:'tick'; uid:string; input:number[]; at:number };
const DATA_DIR = path.resolve('./data');
const EVT_FILE = path.join(DATA_DIR, 'events.jsonl');
const SNAP_FILE = path.join(DATA_DIR, 'snap.json');
fs.mkdirSync(DATA_DIR, { recursive: true });
let S: WorldState = fs.existsSync(SNAP_FILE)
? JSON.parse(fs.readFileSync(SNAP_FILE,'utf8'))
: initState();
const Q: Evt[] = [];
// 接入网关事件
process.on('to-room', (payload: any) => {
const m = payload.msg;
if (m.t === 'tick') Q.push({ t:'tick', uid: payload.uid, input: m.p.input, at: Date.now() });
});
// 50Hz 逻辑环
setInterval(() => {
if (!Q.length) return;
const batch = Q.splice(0, Q.length);
fs.appendFileSync(EVT_FILE, batch.map(e => JSON.stringify(e)).join('\n') + '\n');
const inputs = batch.flatMap(e => e.input);
S = advance(S, inputs);
}, 20);
// 5s 快照一次
setInterval(() => {
fs.writeFileSync(SNAP_FILE, JSON.stringify(S));
}, 5000);
// 提供一个简单状态接口(用于测试/巡检)
import express from 'express';
const app = express();
app.get('/state', (_, res) => res.json({ tick: S.tick, score: S.score, hash: md5(S) }));
app.listen(7101);
3. 客户端:输入录制与统一播种(Creator/TS)
3.1 输入跟踪与上行
// client/assets/scripts/trace.ts
export type InputFrame = { seq: number; at: number; input: number[] };
export class TraceRecorder {
private seq = 0; private frames: InputFrame[] = [];
push(input: number[]) { this.frames.push({ seq: ++this.seq, at: Date.now(), input }); }
dump(): string { return this.frames.map(f => JSON.stringify({ t:'tick', ...f })).join('\n'); }
clear(){ this.seq = 0; this.frames.length = 0; }
}
// client/assets/scripts/net.ts
import { TraceRecorder } from './trace';
const recorder = new TraceRecorder();
export function sendInput(ws: WebSocket, input: number[]) {
recorder.push(input);
ws.send(JSON.stringify({ t:'tick', v:1, p:{ seq: Date.now(), input }}));
}
// 导出日志以供回放(调试页一个按钮即可)
(window as any).__dumpTrace = () => {
const txt = recorder.dump();
console.log('[TRACE]\n' + txt);
}
3.2 统一 RNG(与服务端同算法)
// client/assets/scripts/rng.ts
import { Lcg } from '../../../shared/dist/rng';
let rng = new Lcg(0);
export function setSeed(seed: number){ rng = new Lcg(seed); }
export function nextInt(max: number){ return rng.int(max); }
4. 回放工具与基线对拍(tests)
把线上或压测录到的 JSONL 事件流喂给回放器;新旧构建在同一份输入下必须产出一致哈希。这件事我在 CI 里强制执行。
// tests/replay.ts
import fs from 'fs';
import { WorldState, initState, advance } from '../shared/dist/state';
import { md5 } from '../shared/dist/hash';
export function replay(file: string, fromSnap?: string) {
const lines = fs.readFileSync(file,'utf8').trim().split('\n');
let s: WorldState = fromSnap ? JSON.parse(fs.readFileSync(fromSnap,'utf8')) : initState();
for (const ln of lines) {
const e = JSON.parse(ln);
if (e.t === 'tick') s = advance(s, e.input);
}
return { state: s, hash: md5(s) };
}
// tests/replay.spec.ts(Vitest/Jest 均可)
import { expect, test } from 'vitest';
import { replay } from './replay';
test('trace-000 bit-identical', () => {
const { hash } = replay('tests/cases/trace-000.jsonl', 'tests/snap.json');
expect(hash).toBe('6b7a8d3b8c5b0f3e4b2c...');
});
首帧差异二分定位:
// tests/bisect.ts
import fs from 'fs';
import { initState, advance, WorldState } from '../shared/dist/state';
import { md5 } from '../shared/dist/hash';
const lines = fs.readFileSync(process.argv[2], 'utf8').trim().split('\n');
const expectHash = process.argv[3];
function apply(n: number){
let s: WorldState = initState();
for (let i=0;i<n;i++){
const e = JSON.parse(lines[i]); if (e.t === 'tick') s = advance(s, e.input);
}
return md5(s);
}
let l=0, r=lines.length;
while(l<r){
const m=(l+r)>>1;
const h = apply(m);
if (h === expectHash) l = m+1; else r = m;
}
console.log('first mismatch frame:', l);
5. 资源清单与内容寻址(热更新的前置)
虽然热更新细节留到第二篇深讲,但第一篇先把"清单与签名"打好底座,这样任何状态/资源都能通过哈希定位。
// tools/gen-manifest.js
const fs = require('fs'), path = require('path'), crypto = require('crypto');
function walk(dir){ return fs.readdirSync(dir,{withFileTypes:true}).flatMap(e=> e.isDirectory()? walk(path.join(dir,e.name)) : [path.join(dir,e.name)]); }
function md5(fp){ return crypto.createHash('md5').update(fs.readFileSync(fp)).digest('hex'); }
function hmac(s, key){ return crypto.createHmac('sha256', key).update(s).digest('hex'); }
const base = path.resolve(process.argv[2] || './build');
const out = path.resolve(process.argv[3] || './dist/manifest.json');
const key = process.env.MANIFEST_KEY || 'dev-key';
const files = walk(base).map(f => f.replace(base + path.sep, '').replace(/\\/g,'/'));
const table = Object.fromEntries(files.map(f => [f, md5(path.join(base,f))]));
const manifest = { version: Date.now(), files: table, signature: hmac(JSON.stringify(table), key) };
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, JSON.stringify(manifest, null, 2));
console.log('manifest saved:', out);
客户端增量校验示例(H5 版本,原生可落地到沙箱):
// client/assets/scripts/hot-update.ts
type Manifest = { version:number; files:Record<string,string>; signature:string };
export async function hotUpdate(baseUrl: string){
const remote: Manifest = await fetch(`${baseUrl}/manifest.json`).then(r=>r.json());
const local: Manifest = JSON.parse(localStorage.getItem('manifest') || '{"version":0,"files":{}}');
const need = Object.entries(remote.files).filter(([f,h]) => local.files[f] !== h).map(([f]) => f);
for(const f of need){
await fetch(`${baseUrl}/${f}`, { cache:'no-store' }); // 存储到 IndexedDB/FS,略
}
localStorage.setItem('manifest', JSON.stringify(remote));
}
6. 实战坑位与修复手记(血的教训)
-
浮点参与演算
不同平台/编译器细节差异导致终态不同。解决:统一定点(见
fixed.ts),关键路径全部|0截断,禁止Math.round在判定里直接出现。 -
随机源散落代码
任何模块里私自
Math.random()都会把回放搞崩。解决:RNG 只允许从shared/rng.ts拿,代码审查 + ESLint 规则拦截。 -
多进程并发写
逻辑状态发生并发写,事件排序不唯一。解决:写路径收敛到单线程逻辑环;跨进程用队列串行化,或统一进程内事件总线。
-
事件日志过大
JSONL 久了会膨胀。解决:按天滚动文件 + 定期生成快照;回放时先从最新快照开始,减少重建成本。
-
"偶发现象"复现不了
没有输入录制,再怎么猜都徒劳。解决:客户端统一录制输入与时间戳,必要时附加种子与版本信息,CI 对拍保证新旧一致。
7. 今日可直接提交的内容
-
shared/:定点运算、统一 RNG、纯函数状态机、协议、哈希/签名。 -
server/:WebSocket 网关、单线程逻辑环、事件溯源与快照、状态巡检接口。 -
client/:输入录制与统一播种、基础热更新校验。 -
tests/:回放器、基线对拍、二分定位首帧差异。 -
tools/:生成资源清单脚本。
把这些塞进你的仓库,同输入必定同结果这条底线就立住了。第二篇我会把大厅容器化、模块注册、资源切包与按需热更新展开到"能上线的程度",并补上 Creator 工程的 Prefab/Widget 约束与网格虚拟化实现。
8. 常用命令与最小运行说明
# 安装依赖
pnpm i
# 构建共享与服务
pnpm build
# 启动网关与房间逻辑
node dist/server/src/gateway.js
node dist/server/src/room.js
# 生成资源清单(示例)
node tools/gen-manifest.js ./build ./dist/manifest.json
# 回放对拍
pnpm test
# 或单独执行二分定位
node tests/bisect.js tests/cases/trace-000.jsonl 6b7a8d3b8c5b0f3e4b2c...
把"确定性 + 回放 + 证据链"打通后,修复与扩展就会从"玄学调参"变成"可验证的工程",这在体量巨大的互动项目里尤其关键。接下来两篇继续把大厅容器化与热更新、再到观测与灰度回滚的闭环补齐。
纯技术交流,请勿熵用!