Files
TermiNexus 6c36c5e98d feat: M13a 导出官方「型号枚举→图标」表 + 素材中继 + 物品名称/类别中文可展示
A 图标表:从 module_36659.js:5135 的 eA.upf(底盘)与 module_57128.js:840 的 Dn.duk(炮塔)解出 case→形参→实参→i(模块id)→resources.json 的映射(严格走 case→形参名→形参声明行→实参行,不假设 case 号等于实参位置,因为该模块实参表约 1000 项而工厂形参只有 714 个、多出的被丢弃)。自验:用同文件 :907 的 Dn.euk 抗性表 21 case 全命中且本地镜像均存在(去重 20 个文件)。规模:底盘 416 case(未解 1)、炮塔 20、抗性 21、7 格道具 7。B 物品信息:名称来自服务端模型 3935107155251354407(DescriptionModelCC)的 name 字段,类别中文用 lobby.garage.<枚举名>(ARMOR=底盘/WEAPON=炮塔/PAINT=迷彩/DRONE=辅助无人机…),15 件仓库物品名称 9/9、类别中文 9/9 命中;但图标 0/9 —— 承载底盘/炮塔型号的 TankInfoCC(5543162332469864498)在全部 68 个 s2c 帧与模板里 0 次出现,属数据缺失而非算法问题,已如实标注。C 素材中继 client/tools/AssetRelay.mjs(8130):/ 服务客户端产物、/play/static/** 映射 3DTank/web/play/static 本地镜像、正确 MIME 与宽 CORS、防目录穿越(%2e%2e 返回 400)。回归:CompareContainerDecode 5 帧/26950/0、selftest 1108/9/90、semantic --demo 全部断言通过、Kotlin/JS 构建 SUCCESSFUL。
2026-09-18 15:56:00 +08:00

190 lines
7.3 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.
// AssetRelay.mjs —— 素材中继(只读本地 HTTP 服务,默认端口 8130)
//
// 目的:把「客户端产物」与「官方静态素材本地镜像」拼成一个可浏览的站点,给浏览器侧客户端用。
// 1) `/` 与其它静态文件 → client/build/dist/js/productionExecutable/(Kotlin/JS 产物;不存在就清晰报错)
// 2) `/play/static/**` → 3DTank/web/play/static/**(**本地镜像优先**,只读)
// 3) 本地镜像缺文件时(可选)回落到 --upstream(用户的 3DTank/tools/serve.js,默认不启用),并打日志
//
// 安全:只读;路径做规范化 + 前缀校验,禁止用 `..`(含 %2e%2e)逃出映射根。
//
// 用法:
// node client/tools/AssetRelay.mjs
// node client/tools/AssetRelay.mjs --port 8130 --upstream http://localhost:8080
'use strict';
import fs from 'node:fs';
import http from 'node:http';
import https from 'node:https';
import path from 'node:path';
import url from 'node:url';
const Here = path.dirname(url.fileURLToPath(import.meta.url));
const ClientDir = path.resolve(Here, '..');
const RepoDir = path.resolve(ClientDir, '..');
const DistDir = path.join(ClientDir, 'build', 'dist', 'js', 'productionExecutable');
const MirrorRoot = path.join(RepoDir, '3DTank', 'web', 'play', 'static');
// ---- 参数
const Args = process.argv.slice(2);
function ArgOf(Name, Default) {
const I = Args.indexOf('--' + Name);
return I >= 0 && Args[I + 1] ? Args[I + 1] : Default;
}
const Port = Number(ArgOf('port', '8130'));
const Upstream = ArgOf('upstream', '');
// ---- MIME(按扩展名;够用即可,未列的按 application/octet-stream)
const MimeByExt = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.xml': 'application/xml; charset=utf-8',
'.svg': 'image/svg+xml',
'.webp': 'image/webp',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.ico': 'image/x-icon',
'.ttf': 'font/ttf',
'.otf': 'font/otf',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.mp3': 'audio/mpeg',
'.ogg': 'audio/ogg',
'.wav': 'audio/wav',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.3ds': 'application/octet-stream',
'.wasm': 'application/wasm',
};
function MimeOf(P) {
return MimeByExt[path.extname(P).toLowerCase()] || 'application/octet-stream';
}
// 防目录穿越:把相对路径 join 到根后规范化,必须仍在根之内
function SafeJoin(Root, Rel) {
const Normalized = path.normalize(Rel).replace(/^([/\\])+/, '');
const Full = path.normalize(path.join(Root, Normalized));
if (Full !== Root && !Full.startsWith(Root + path.sep)) return null;
return Full;
}
const UrlPathOf = (Req) => decodeURIComponent((Req.url || '/').split('?')[0].split('#')[0]);
function Log(Msg) {
console.log('TankIcAssetRelay | ' + Msg);
}
function SendFile(Res, File, ExtraHeaders) {
const Headers = Object.assign({ 'Content-Type': MimeOf(File), 'Content-Length': String(fs.statSync(File).size) }, ExtraHeaders || {});
Res.writeHead(200, Headers);
fs.createReadStream(File).pipe(Res);
}
function SendText(Res, Code, Text, ExtraHeaders) {
const Buf = Buffer.from(Text, 'utf8');
Res.writeHead(Code, Object.assign({ 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': String(Buf.length) }, ExtraHeaders || {}));
Res.end(Buf);
}
// 回落:把请求转发给 --upstream(只读代理;命中时打日志)
function RelayUpstream(Req, Res, RelPath) {
const Target = new URL(RelPath, Upstream);
const Lib = Target.protocol === 'https:' ? https : http;
const P = Lib.request(
Target,
{ method: 'GET', headers: { Accept: Req.headers.accept || '*/*' } },
(Up) => {
if (Up.statusCode !== 200) {
Up.resume();
SendText(Res, 404, '本地镜像没有,且上游返回 ' + Up.statusCode + ':' + RelPath + '\n');
return;
}
const Headers = {};
for (const K of ['content-type', 'content-length', 'etag', 'last-modified']) if (Up.headers[K]) Headers[K] = Up.headers[K];
Headers['Access-Control-Allow-Origin'] = '*';
Res.writeHead(200, Headers);
Up.pipe(Res);
},
);
P.on('error', (E) => SendText(Res, 502, '回落到上游失败:' + E.message + '\n'));
P.end();
}
if (!fs.existsSync(DistDir)) {
console.error('✗ 客户端产物目录不存在:' + DistDir);
console.error(' 请先构建:cd client && ' + (process.platform === 'win32' ? 'gradlew.bat' : './gradlew') + ' jsProductionExecutableCompileSync');
process.exit(1);
}
if (!fs.existsSync(MirrorRoot)) {
console.error('✗ 官方素材本地镜像目录不存在:' + MirrorRoot);
process.exit(1);
}
const Server = http.createServer((Req, Res) => {
const Pathname = UrlPathOf(Req);
// CORS(/play/static/** 加宽;其余也给,方便本地调试)
if (Req.method === 'OPTIONS') {
Res.writeHead(204, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
'Access-Control-Allow-Headers': '*',
'Access-Control-Max-Age': '86400',
});
Res.end();
return;
}
// 1) 官方素材:/play/static/** → 3DTank/web/play/static/**
if (Pathname === '/play/static' || Pathname.startsWith('/play/static/')) {
const Rel = Pathname.slice('/play/static/'.length);
const CORS = { 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'no-cache' };
const Full = SafeJoin(MirrorRoot, Rel);
if (Full === null) {
Log('✗ 目录穿越被拒:' + Pathname);
SendText(Res, 400, '非法路径(试图逃出映射根):' + Pathname + '\n', CORS);
return;
}
if (Rel && fs.existsSync(Full) && fs.statSync(Full).isFile()) {
Log('200 镜像 ' + Pathname + ' (' + MimeOf(Full) + ', ' + fs.statSync(Full).size + 'B)');
SendFile(Res, Full, CORS);
return;
}
if (Upstream) {
Log('↩ 回落上游 ' + Upstream + Pathname + '(本地镜像缺 ' + Rel + ')');
RelayUpstream(Req, Res, Pathname);
return;
}
Log('404 镜像缺文件 ' + Pathname);
SendText(Res, 404, '本地镜像里没有这个素材:' + Rel + '\n(默认不启用上游回落;可用 --upstream http://localhost:8080)\n', CORS);
return;
}
// 2) 客户端产物:/ 与其它路径 → build/dist/js/productionExecutable/
const Rel = Pathname === '/' ? 'index.html' : Pathname.slice(1);
const Full = SafeJoin(DistDir, Rel);
if (Full === null) {
Log('✗ 目录穿越被拒:' + Pathname);
SendText(Res, 400, '非法路径(试图逃出产物根):' + Pathname + '\n');
return;
}
if (fs.existsSync(Full) && fs.statSync(Full).isFile()) {
Log('200 产物 ' + Pathname + ' (' + MimeOf(Full) + ', ' + fs.statSync(Full).size + 'B)');
SendFile(Res, Full);
return;
}
Log('404 产物缺文件 ' + Pathname);
SendText(Res, 404, '客户端产物里没有:' + Rel + '\n产物目录:' + DistDir + '\n');
});
Server.listen(Port, () => {
Log('素材中继已启动:http://localhost:' + Port + '/');
Log(' 产物目录 ' + DistDir);
Log(' 素材镜像 ' + MirrorRoot + '(只读,/play/static/**)');
Log(' 上游回落 ' + (Upstream || '未启用(本地镜像优先;缺文件返回 404)'));
});