// session.js - 会话重放应答器:把真实官方服会话里「上行 → 下行」的对应关系搬到我们服务器上 // // 为什么要有它:车库空间(100073070)在真实服里是**一问一答**的—— // 模板 names/space-init-.json 只装得下"进空间时服务器主动推的那几帧"(车库 5 帧), // 而客户端进车库后发的 G.q1wd / ba.lcr / M_.hcn / ls.xbl / M_.fcn / Qh.ycw 换来的 s2c // (对象数据、资源、容器增量)原来一条都没回,于是武器列表/切换全都不动。 // 本模块读的是 names/space-session-.json(由 tools/extract_space_session.js 从真实抓包抽出): // 每条 s2c 帧带一个 After = 「换它出来的上行清单」({MethodId, Target})。 // // 约定(与产物文件一一对应): // After 为空 → 纯推送帧(真实服在客户端进空间后无人请求就推的那几帧); // DelayMs=0 的进空间立刻发,DelayMs>0 的按真实时延定时发 // (实测车库那 2 帧:s2c_0000 立刻,s2c_0040 在 +8.7s) // After 非空 → 等客户端发到清单里的上行时发(同一帧只发一次) // 命中判定 → MethodId 相同,且(After 项没有 Target 或 Target 相同) // // 顺序推进(关键,别再改回「任一命中即发」): // 真实服是一问一答、且**成批**——s2c_0003 要等 28 条 ba.lcr 齐了才发一条含 28 条 // LoadDependencies 的帧,s2c_0004 要等 28 条 G.q1wd 齐了才发。 // 所以每次上行**只推进会话顺序里最靠前的那个「还没发的被触发帧」**,一帧一上行。 // 早期实现用「任一 After 项命中就发」:因为会话里 30 多帧的 After 都含 `G.q1wd @100073070`, // 客户端第一个 G.q1wd(1) 就把 21 帧全扑了出去(server-logs/session-02/session_007/log.txt:59), // 客户端拿到乱序数据当场抛错重载,第二轮起车库再也进不去 —— 务必保持顺序推进。 // Frame.Hex → 形态表达不了(Short 命令号 / 解不出 objectId)时整帧原样的兜底: // 直接按帧体重组,一个字节都不改(extract_space_session.js 里写明原因) // // 发送走的是现成的组帧路径(Space.BuildInitFrames → space.js → Link.EncodeFrame), // 与 space-init-*.json 那条路完全同源,所以「模板里存的记录能逐字节重建原帧」这条保证直接继承过来。 // // 用法: // const Session = require('./session'); // const Doc = Session.Load(namesDir, spaceId); // 没有产物文件 → null // const Replay = Session.MakeReplay(Doc.Frames, { Container, Semantic, Log }); // Replay.OnEnter(SendFrames); // 进空间时 // const Hits = Replay.OnUplink({ MethodId, Target }, SendFrames); // 每条上行都问一次;返回本次发出的帧数 // const Hits2 = Replay.OnUplink({ MethodId, Target }, SendFrames, { Suppress: true }); // // 该上行已由查库定向应答接管 → 命中的帧只标记已发、不发字节 // SendFrames(Frame, Bytes) 返回 true 表示**真的发出去了**(连接已断就返回 false,这时不标记已发) 'use strict'; const fs = require('fs'); const path = require('path'); const Link = require('./link'); const Space = require('./space'); const SemanticModule = require('./semantic'); const NAMES_DIR = path.resolve(__dirname, '..', '..', 'names'); // 卡住看门狗阈值:连续这么多条上行都匹配不上「会话顺序里最靠前的待触发帧」就跳过它并大声记日志。 // 取值偏大(我们的客户端与抓包那份是同一个构建,正常不该触发),只是为了不把整条会话卡死。 const STALL_LIMIT = 40; // 读 names/space-session-.json;不存在返回 null(调用方回落到 space-init-.json) function Load(NamesDir, SpaceId) { const File = path.join(NamesDir || NAMES_DIR, 'space-session-' + SpaceId + '.json'); if (!fs.existsSync(File)) return null; return JSON.parse(fs.readFileSync(File, 'utf8')); } // After 清单里有没有命中这条上行记录 function Match(After, Record) { for (const A of After || []) { if (String(A.MethodId) !== String(Record.MethodId)) continue; if (A.Target === undefined || A.Target === null || A.Target === '') return true; if (String(A.Target) === String(Record.Target)) return true; } return false; } function MakeReplay(Frames, Deps) { const Opts = Deps || {}; const Log = typeof Opts.Log === 'function' ? Opts.Log : () => {}; // 没给 Container / Semantic 时自己加载一份(用法上 index.js 会传现成的实例,避免重复读表) const Semantic = Opts.Semantic || SemanticModule.Load(NAMES_DIR); const Container = Opts.Container || Semantic.Container; // 可选的「字段级覆盖」钩子(M7 回显用):(FrameSpec, Records) → Records。 // 只在帧是结构化形态(有 Records、非 Hex 透传)时调用;返回的新数组会替换原记录再组帧。 // 返回原数组(或 null)⇒ 一字不改,帧字节与抓包保持逐字节一致(对照验收因此不受污染)。 const RecordOverride = typeof Opts.RecordOverride === 'function' ? Opts.RecordOverride : null; const All = Frames || []; const Sent = new Set(); const Waited = new Set(); const Timers = []; const BytesByFile = new Map(); const State = { Stall: 0 }; // 纯推送帧 = After 为空的帧(真实服没人问就推) function IsPush(F) { return !(F.After || []).length; } // 帧字节按需构建(车库那两帧是 58KB / 775KB,进空间时没必要全编一遍) function BytesOf(F) { if (BytesByFile.has(F.File)) return BytesByFile.get(F.File); let Buf; if (F.Hex !== undefined) { // 形态表达不了的整帧原样兜底:帧体 → [nullmap][payload] 交给 Link.EncodeFrame(长度头由它补) const Body = Buffer.from(String(F.Hex), 'hex'); const NmSize = Link.NullmapSize(Body, 0); Buf = Link.EncodeFrame(Body.slice(NmSize), Body.slice(0, NmSize)); } else { // 字段级覆盖(可选):只在结构化帧上生效;没差异时 RecordOverride 会返回原数组 let Spec = F; if (RecordOverride && Array.isArray(F.Records)) { const Out = RecordOverride(F, F.Records); if (Out && Array.isArray(Out) && Out !== F.Records) Spec = Object.assign({}, F, { Records: Out }); } Buf = Space.BuildInitFrames({ Frames: [Spec] }, Container, Semantic)[0].Frame; } BytesByFile.set(F.File, Buf); return Buf; } function Send(F, SendFrames, Why) { const Buf = BytesOf(F); Log(' ← 重放:发 ' + F.File + ' ' + Buf.length + 'B(' + Why + ')'); const Ok = SendFrames ? SendFrames(F, Buf) !== false : true; if (Ok) Sent.add(F.File); else Log(' ⚠ 重放 ' + F.File + ' 没真正发出(连接已断),不标记已发'); return Ok; } // 进空间时:只有纯推送帧走这条(DelayMs=0 立刻发,>0 的按真实时延定时发) function OnEnter(SendFrames) { let Count = 0; for (const F of All) { if (Sent.has(F.File)) continue; if (!IsPush(F)) continue; const Delay = Number(F.DelayMs) || 0; if (Delay <= 0) { if (Send(F, SendFrames, '纯推送帧,进空间即发')) Count++; continue; } const T = setTimeout(() => Send(F, SendFrames, '纯推送帧,按真实时延 +' + Delay + 'ms'), Delay); if (T && typeof T.unref === 'function') T.unref(); Timers.push(T); } return Count; } // 会话顺序里最靠前的「还没发的、需要上行触发」的帧;跳过错过的纯推送帧 function NextGated() { for (const F of All) { if (Sent.has(F.File)) continue; if (IsPush(F)) continue; return F; } return null; } // After 清单的可读摘要(只用于日志:让人一眼看出这帧在等什么) function Describe(After) { const Count = new Map(); for (const A of After || []) { const Key = String(A.MethodId) + (A.Target === undefined || A.Target === null || A.Target === '' ? '' : '@' + A.Target); Count.set(Key, (Count.get(Key) || 0) + 1); } return Array.from(Count.entries()).map(([K, N]) => K + (N > 1 ? '×' + N : '')).join(' + '); } // 收到一条上行记录:只推进一帧(会话顺序里最靠前的那个待触发帧),返回本次发出的帧数 // Options.Suppress = true(可选,查库定向应答用):这条上行已经被查库路径接管, // 命中的那帧**只标记已发、不发字节**——顺序照旧推进,避免后面所有待触发帧被堵住(看门狗要 40 条才跳)。 function OnUplink(Record, SendFrames, Options) { const F = NextGated(); if (!F) return 0; if (Match(F.After, Record)) { State.Stall = 0; if (Options && Options.Suppress) { Sent.add(F.File); Log(' (' + F.File + ' 本由 ' + Record.MethodId + ' 触发,但该上行已由查库定向应答接管 → 跳过重放)'); return 0; } const Why = '被 ' + Record.MethodId + ' 触发' + (Record.Name ? ',' + Record.Name : ''); return Send(F, SendFrames, Why) ? 1 : 0; } State.Stall++; // 每条待触发帧只提醒一次,避免刷屏 if (!Waited.has(F.File)) { Waited.add(F.File); Log(' ⚠ 重放等待:' + F.File + ' 需要 [' + Describe(F.After) + '],本条上行是 ' + Record.MethodId + (Record.Target === undefined ? '' : '@' + Record.Target)); } // 卡住看门狗:会话顺序本该与我们的客户端一致;连续多次对不上说明抓包与本次客户端行为不一致, // 这时**大声**跳过这一帧继续(不静默兜底——跳过意味着这一步的下行缺失,必须能在日志里看见) if (State.Stall >= STALL_LIMIT) { Log(' ⚠ 重放卡住:' + F.File + ' 连续 ' + State.Stall + ' 条上行都没等到 [' + Describe(F.After) + '],跳过它继续(该帧的下行缺失)'); Sent.add(F.File); State.Stall = 0; } return 0; } return { OnEnter, OnUplink }; } module.exports = { Load, MakeReplay, NAMES_DIR };