Files
TermiNexus ab1a905d28 feat: M7 点装备即落库,重启服务端后仍回显(闭合「能切换但没持久化」)
客户端:Uplink 加 M_.fcn 构造(6 条抓包样本逐字节相同)、仓库每行加「装备」按钮、Equip() 入队 + 本地乐观标记(对齐真实客户端的 jU reducer)。服务端:store.js 加 LoadMountedFlags/SetMounted(UPDATE ... WHERE Mounted<>1,幂等)/OverrideMountedRecord;session.js 加可选 RecordOverride 钩子;index.js 进车库时刷新快照、收 M_.fcn 落库。回显只在库与模板有差异时覆盖并打日志(装备前帧仍与抓包逐字节一致 775441B;装备后 775449B,+8B = 多一个 i64)。验收:浏览器点装备 → 服务端落盘 c2s_0064.bin 与抓包 c2s_0141.bin 逐字节相同;MySQL Items.Mounted=1 共 6 行;重启 9092 后启动日志显示 Items.Mounted=1 共 6 件、重进车库 UI 6 个装备按钮置灰与库一致。回归:selftest 1108/9/90、CompareContainerDecode 5 帧 0 不一致、三构建全绿。未实现并如实记录:类别互斥(物品类别来源未解出,不硬猜)、挂载位设备 mountedDevice 未处理。
2026-09-18 12:01:30 +08:00

198 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// session.js - 会话重放应答器:把真实官方服会话里「上行 → 下行」的对应关系搬到我们服务器上
//
// 为什么要有它:车库空间(100073070)在真实服里是**一问一答**的——
// 模板 names/space-init-<id>.json 只装得下"进空间时服务器主动推的那几帧"(车库 5 帧),
// 而客户端进车库后发的 G.q1wd / ba.lcr / M_.hcn / ls.xbl / M_.fcn / Qh.ycw 换来的 s2c
// (对象数据、资源、容器增量)原来一条都没回,于是武器列表/切换全都不动。
// 本模块读的是 names/space-session-<id>.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-<SpaceId>.json;不存在返回 null(调用方回落到 space-init-<id>.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 };