Files
shoot-miniprograms/scripts/generate-match-schema.mjs
T

441 lines
13 KiB
JavaScript
Raw 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.
import { createHash } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import vm from "node:vm";
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
const projectRoot = resolve(scriptDirectory, "..");
const sourcePath = resolve(projectRoot, "src/utils/match.min.js");
const runtimePath = resolve(projectRoot, "src/utils/matchProtocol.js");
const generatedStartMarker = "// <match-schema-generated>";
const generatedEndMarker = "// </match-schema-generated>";
const supportedScalarKinds = new Set([
"int32",
"int64",
"float",
"double",
"bool",
"string",
"bytes",
]);
const supportedMapKeyKinds = new Set(["int32", "int64", "bool", "string"]);
// 新版描述文件未携带旧协议的 oneof 元数据,保留现有解码结果中的 payload 标识。
const compatibilityOneofs = {
ServerMessage: {
match_info: "payload",
shoot_data: "payload",
practice_info: "payload",
},
};
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function extractRootCreateArgument(source) {
const match = /(?:\$protobuf\.)?Root\.create\s*\(/.exec(source);
if (!match) {
throw new Error("match.min.js 中未找到 Root.create(...) 协议描述");
}
const openIndex = source.indexOf("(", match.index);
let depth = 0;
let quote = "";
let escaped = false;
let lineComment = false;
let blockComment = false;
for (let index = openIndex; index < source.length; index += 1) {
const char = source[index];
const next = source[index + 1];
if (lineComment) {
if (char === "\n") lineComment = false;
continue;
}
if (blockComment) {
if (char === "*" && next === "/") {
blockComment = false;
index += 1;
}
continue;
}
if (quote) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === quote) {
quote = "";
}
continue;
}
if (char === '"' || char === "'" || char === "`") {
quote = char;
continue;
}
if (char === "/" && next === "/") {
lineComment = true;
index += 1;
continue;
}
if (char === "/" && next === "*") {
blockComment = true;
index += 1;
continue;
}
if (char === "(") {
depth += 1;
continue;
}
if (char === ")") {
depth -= 1;
if (depth === 0) {
return source.slice(openIndex + 1, index).trim();
}
}
}
throw new Error("match.min.js 中的 Root.create(...) 括号不完整");
}
function stripQuotedText(source) {
let output = "";
let quote = "";
let escaped = false;
for (const char of source) {
if (quote) {
output += " ";
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === quote) {
quote = "";
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
output += " ";
} else {
output += char;
}
}
return output;
}
function parseDescriptor(argumentSource) {
if (!argumentSource.startsWith("{") || !argumentSource.endsWith("}")) {
throw new Error("Root.create(...) 参数不是静态对象字面量");
}
try {
return JSON.parse(argumentSource);
} catch {
// 兼容旧版 pbjs 生成的未加引号对象键;只允许静态对象语法。
const syntaxOnly = stripQuotedText(argumentSource);
if (/[();`=]/.test(syntaxOnly)) {
throw new Error("旧版协议描述包含非静态表达式,已拒绝执行");
}
const descriptor = vm.runInNewContext(
`(${argumentSource})`,
Object.create(null),
{ timeout: 1000 }
);
return JSON.parse(JSON.stringify(descriptor));
}
}
function findProtocolNamespace(node, path = []) {
if (!isRecord(node)) return null;
const entries = isRecord(node.nested) ? node.nested : node;
if (isRecord(entries.ServerMessage?.fields) && isRecord(entries.ClientMessage?.fields)) {
return { entries, path };
}
for (const [name, child] of Object.entries(entries)) {
if (!isRecord(child) || isRecord(child.fields)) continue;
const found = findProtocolNamespace(child, [...path, name]);
if (found) return found;
}
return null;
}
function getEnumValues(definition) {
if (!isRecord(definition)) return null;
const candidate = isRecord(definition.values) ? definition.values : definition;
const entries = Object.entries(candidate);
if (entries.length === 0 || entries.some(([, value]) => !Number.isInteger(value))) {
return null;
}
return Object.fromEntries(entries.sort((left, right) => left[1] - right[1]));
}
function normalizeTypeName(type) {
return String(type || "")
.replace(/^\./, "")
.split(".")
.pop();
}
function getOneofByField(definition) {
const result = new Map();
if (!isRecord(definition.oneofs)) return result;
for (const [groupName, groupDefinition] of Object.entries(definition.oneofs)) {
const fieldNames = Array.isArray(groupDefinition)
? groupDefinition
: groupDefinition?.oneof;
if (!Array.isArray(fieldNames)) continue;
for (const fieldName of fieldNames) result.set(fieldName, groupName);
}
return result;
}
function buildSchema({ messageName, definition, messages, enumNames }) {
const schema = {};
const usedIds = new Set();
const oneofByField = getOneofByField(definition);
for (const [fieldKey, fieldDefinition] of Object.entries(definition.fields)) {
const id = Number(fieldDefinition.id);
if (!Number.isInteger(id) || id <= 0) {
throw new Error(`${messageName}.${fieldKey} 的字段编号无效`);
}
if (usedIds.has(id)) {
throw new Error(`${messageName} 存在重复字段编号 ${id}`);
}
usedIds.add(id);
const rule = fieldDefinition.rule;
if (rule && !["optional", "required", "repeated", "map"].includes(rule)) {
throw new Error(`${messageName}.${fieldKey} 使用了不支持的规则 ${rule}`);
}
const fieldName = fieldDefinition.protoName || fieldKey;
const typeName = normalizeTypeName(fieldDefinition.type);
const keyTypeName = normalizeTypeName(
fieldDefinition.keyType ?? fieldDefinition.keytype
);
const isMap = rule === "map" || Boolean(keyTypeName);
if (isMap) {
if (!supportedMapKeyKinds.has(keyTypeName)) {
throw new Error(
`${messageName}.${fieldKey} 使用了不支持的 map key 类型 ${keyTypeName}`
);
}
const valueIsMessage = messages.has(typeName);
const valueKind = enumNames.has(typeName) ? "int32" : typeName;
if (!valueIsMessage && !supportedScalarKinds.has(valueKind)) {
throw new Error(
`${messageName}.${fieldKey} 使用了不支持的 map value 类型 ${typeName}`
);
}
schema[id] = valueIsMessage
? {
name: fieldName,
kind: "map",
keyKind: keyTypeName,
valueKind: "message",
valueType: typeName,
}
: {
name: fieldName,
kind: "map",
keyKind: keyTypeName,
valueKind,
};
continue;
}
const isMessage = messages.has(typeName);
const kind = enumNames.has(typeName) ? "int32" : typeName;
if (!isMessage && !supportedScalarKinds.has(kind)) {
throw new Error(`${messageName}.${fieldKey} 使用了不支持的类型 ${typeName}`);
}
const repeated = rule === "repeated";
if (repeated && !isMessage && !["string", "bytes"].includes(kind)) {
throw new Error(
`${messageName}.${fieldKey} 是 packed scalar repeated,当前通用解码器尚不支持`
);
}
const field = isMessage
? { name: fieldName, kind: "message", type: typeName }
: { name: fieldName, kind };
if (repeated) field.repeated = true;
const oneof =
fieldDefinition.oneof ||
oneofByField.get(fieldKey) ||
compatibilityOneofs[messageName]?.[fieldName];
if (oneof) field.oneof = oneof;
schema[id] = field;
}
return schema;
}
function formatPropertyKey(key) {
return /^(?:[A-Za-z_$][\w$]*|\d+)$/.test(key) ? key : JSON.stringify(key);
}
function formatJsValue(value, depth = 0) {
if (!isRecord(value)) return JSON.stringify(value);
const entries = Object.entries(value);
if (entries.length === 0) return "{}";
const indent = " ".repeat(depth);
const primitiveEntries = entries.every(([, child]) => !isRecord(child));
if (primitiveEntries && value.kind !== "map") {
const singleLine = `{ ${entries
.map(([key, child]) => `${formatPropertyKey(key)}: ${JSON.stringify(child)}`)
.join(", ")} }`;
if (indent.length + singleLine.length <= 100) return singleLine;
}
const childIndent = " ".repeat(depth + 1);
const lines = entries.map(
([key, child]) =>
`${childIndent}${formatPropertyKey(key)}: ${formatJsValue(child, depth + 1)},`
);
return `{\n${lines.join("\n")}\n${indent}}`;
}
function createGeneratedSource(source) {
const descriptor = parseDescriptor(extractRootCreateArgument(source));
const namespace = findProtocolNamespace(descriptor);
if (!namespace) {
throw new Error("协议描述中未找到 ServerMessage 和 ClientMessage");
}
const messages = new Map();
const enums = new Map();
for (const [name, definition] of Object.entries(namespace.entries)) {
if (isRecord(definition?.fields)) {
messages.set(name, definition);
continue;
}
const values = getEnumValues(definition);
if (values) enums.set(name, values);
}
const serverMessageType = enums.get("ServerMessageType");
const clientMessageType = enums.get("ClientMessageType");
if (!serverMessageType || !clientMessageType) {
throw new Error("协议描述缺少 ServerMessageType 或 ClientMessageType");
}
const enumNames = new Set(enums.keys());
const schemaByName = new Map();
for (const [messageName, definition] of messages) {
schemaByName.set(
messageName,
buildSchema({ messageName, definition, messages, enumNames })
);
}
const reachable = new Set();
function collectReachable(messageName) {
if (reachable.has(messageName)) return;
const schema = schemaByName.get(messageName);
if (!schema) throw new Error(`找不到消息定义 ${messageName}`);
reachable.add(messageName);
for (const field of Object.values(schema)) {
if (field.kind === "message") collectReachable(field.type);
if (field.kind === "map" && field.valueKind === "message") {
collectReachable(field.valueType);
}
}
}
collectReachable("ServerMessage");
const schemas = {};
for (const messageName of messages.keys()) {
if (reachable.has(messageName)) schemas[messageName] = schemaByName.get(messageName);
}
const sourceHash = createHash("sha256").update(source).digest("hex").slice(0, 16);
const namespaceName = namespace.path.join(".") || "root";
const fieldCount = Object.values(schemas).reduce(
(total, schema) => total + Object.keys(schema).length,
0
);
const generatedBlock = [
generatedStartMarker,
"// 此区块由 scripts/generate-match-schema.mjs 自动生成,请勿手动修改。",
`// 来源:src/utils/match.min.jssha256: ${sourceHash}`,
`// 协议命名空间:${namespaceName};消息数:${Object.keys(schemas).length};字段数:${fieldCount}`,
"",
`export const ServerMessageType = ${formatJsValue(serverMessageType)};`,
"",
`export const ClientMessageType = ${formatJsValue(clientMessageType)};`,
"",
`const SCHEMAS = ${formatJsValue(schemas)};`,
generatedEndMarker,
].join("\n");
return {
generatedBlock,
messageCount: Object.keys(schemas).length,
fieldCount,
};
}
function main() {
const args = process.argv.slice(2);
const unknownArgs = args.filter((arg) => arg !== "--check");
if (unknownArgs.length > 0) {
throw new Error(`未知参数:${unknownArgs.join(", ")}`);
}
const source = readFileSync(sourcePath, "utf8");
const { generatedBlock, messageCount, fieldCount } = createGeneratedSource(source);
const runtimeSource = readFileSync(runtimePath, "utf8");
const startIndex = runtimeSource.indexOf(generatedStartMarker);
const endMarkerIndex = runtimeSource.indexOf(generatedEndMarker);
if (startIndex < 0 || endMarkerIndex < startIndex) {
throw new Error("matchProtocol.js 缺少协议生成区块标记");
}
const endIndex = endMarkerIndex + generatedEndMarker.length;
const currentBlock = runtimeSource.slice(startIndex, endIndex);
if (args.includes("--check")) {
if (currentBlock !== generatedBlock) {
throw new Error(
"matchProtocol.js 的生成区块与 match.min.js 不同步,请运行 npm run proto:generate"
);
}
console.log(`[match-schema] 同步校验通过:${messageCount} 个消息,${fieldCount} 个字段`);
return;
}
if (currentBlock === generatedBlock) {
console.log(`[match-schema] 无需更新:${messageCount} 个消息,${fieldCount} 个字段`);
return;
}
const nextRuntimeSource =
runtimeSource.slice(0, startIndex) + generatedBlock + runtimeSource.slice(endIndex);
writeFileSync(runtimePath, nextRuntimeSource, "utf8");
console.log(`[match-schema] 已生成:${messageCount} 个消息,${fieldCount} 个字段`);
}
try {
main();
} catch (error) {
console.error(`[match-schema] ${error.message}`);
process.exitCode = 1;
}