情怀源代码工程实践(加长版 1/3):确定性内核、事件回放与最小可运行骨架

花了半年时间把这套情怀棋牌源代码修到能稳定跑起来,最大的感受是:要想后面 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. 实战坑位与修复手记(血的教训)

  1. 浮点参与演算

    不同平台/编译器细节差异导致终态不同。解决:统一定点(见 fixed.ts),关键路径全部 |0 截断,禁止 Math.round 在判定里直接出现。

  2. 随机源散落代码

    任何模块里私自 Math.random() 都会把回放搞崩。解决:RNG 只允许从 shared/rng.ts 拿,代码审查 + ESLint 规则拦截。

  3. 多进程并发写

    逻辑状态发生并发写,事件排序不唯一。解决:写路径收敛到单线程逻辑环;跨进程用队列串行化,或统一进程内事件总线。

  4. 事件日志过大

    JSONL 久了会膨胀。解决:按天滚动文件 + 定期生成快照;回放时先从最新快照开始,减少重建成本。

  5. "偶发现象"复现不了

    没有输入录制,再怎么猜都徒劳。解决:客户端统一录制输入与时间戳,必要时附加种子与版本信息,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...

把"确定性 + 回放 + 证据链"打通后,修复与扩展就会从"玄学调参"变成"可验证的工程",这在体量巨大的互动项目里尤其关键。接下来两篇继续把大厅容器化与热更新、再到观测与灰度回滚的闭环补齐。

纯技术交流,请勿熵用!

相关推荐
笑我归无处2 小时前
强引用、软引用、弱引用、虚引用详解
java·开发语言·jvm
02苏_2 小时前
秋招Java面
java·开发语言
ytttr8732 小时前
64QAM信号的数字预失真处理(MATLAB实现)
开发语言·matlab
Nebula_g2 小时前
C语言应用实例:硕鼠游戏,田忌赛马,搬桌子,活动选择(贪心算法)
c语言·开发语言·学习·算法·游戏·贪心算法·初学者
爱吃甜品的糯米团子2 小时前
详解 JavaScript 内置对象与包装类型:方法、案例与实战
java·开发语言·javascript
华仔啊2 小时前
JavaScript + Web Audio API 打造炫酷音乐可视化效果,让你的网页跟随音乐跳起来
前端·javascript
郝学胜-神的一滴2 小时前
Linux下,获取子进程退出值和异常终止信号
linux·服务器·开发语言·c++·程序人生
AI科技星2 小时前
张祥前统一场论动量公式P=m(C-V)误解解答
开发语言·数据结构·人工智能·经验分享·python·线性代数·算法
是你的小橘呀3 小时前
深入理解 JavaScript 预编译:从原理到实践
前端·javascript