388 lines
12 KiB
JavaScript
388 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
"use strict";
|
|
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
|
|
function printUsage() {
|
|
console.log(`
|
|
Usage:
|
|
node tools/ggs-dist-cli.js extract [--ggs-root <path>] [--out <file>]
|
|
node tools/ggs-dist-cli.js apply --profile <file> [--out <file>]
|
|
|
|
Commands:
|
|
extract Build a full firearm/attachment/magazine spawn catalog from GaelGunStore.
|
|
apply Convert a webapp profile JSON into Lua used by the B42 mod patcher.
|
|
`.trim());
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const args = { _: [] };
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const token = argv[i];
|
|
if (token.startsWith("--")) {
|
|
const key = token.slice(2);
|
|
const next = argv[i + 1];
|
|
if (!next || next.startsWith("--")) {
|
|
args[key] = true;
|
|
} else {
|
|
args[key] = next;
|
|
i += 1;
|
|
}
|
|
} else {
|
|
args._.push(token);
|
|
}
|
|
}
|
|
return args;
|
|
}
|
|
|
|
function ensureDir(filePath) {
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
}
|
|
|
|
function readText(filePath) {
|
|
return fs.readFileSync(filePath, "utf8");
|
|
}
|
|
|
|
function writeText(filePath, content) {
|
|
ensureDir(filePath);
|
|
fs.writeFileSync(filePath, content, "utf8");
|
|
}
|
|
|
|
function walkFiles(rootDir, predicate, out) {
|
|
const entries = fs.readdirSync(rootDir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(rootDir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
walkFiles(fullPath, predicate, out);
|
|
} else if (predicate(fullPath)) {
|
|
out.push(fullPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
function collectItemTypesFromScripts(rootDir) {
|
|
const files = [];
|
|
walkFiles(rootDir, (fullPath) => fullPath.toLowerCase().endsWith(".txt"), files);
|
|
const itemSet = new Set();
|
|
const itemPattern = /^\s*item\s+([A-Za-z0-9_]+)/gm;
|
|
for (const filePath of files) {
|
|
const text = readText(filePath);
|
|
let match = itemPattern.exec(text);
|
|
while (match) {
|
|
itemSet.add(`Base.${match[1]}`);
|
|
match = itemPattern.exec(text);
|
|
}
|
|
}
|
|
return Array.from(itemSet).sort();
|
|
}
|
|
|
|
function parseLootEntries(lootLuaPath) {
|
|
const lootLua = readText(lootLuaPath);
|
|
const pattern =
|
|
/\{\s*list\s*=\s*"([^"]+)"\s*,\s*item\s*=\s*"([^"]+)"\s*,\s*weight\s*=\s*([0-9]+(?:\.[0-9]+)?)\s*,\s*sv\s*=\s*"([^"]+)"\s*\}/g;
|
|
const entries = [];
|
|
let match = pattern.exec(lootLua);
|
|
while (match) {
|
|
entries.push({
|
|
list: match[1],
|
|
item: match[2],
|
|
weight: Number.parseFloat(match[3]),
|
|
sv: match[4],
|
|
});
|
|
match = pattern.exec(lootLua);
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
function aggregatePlacements(entries) {
|
|
const placementMap = new Map();
|
|
for (const entry of entries) {
|
|
const previous = placementMap.get(entry.list) || 0;
|
|
placementMap.set(entry.list, previous + entry.weight);
|
|
}
|
|
return Array.from(placementMap.entries())
|
|
.map(([list, weight]) => ({ list, weight: Number(weight.toFixed(6)) }))
|
|
.sort((a, b) => a.list.localeCompare(b.list));
|
|
}
|
|
|
|
function normalizeItemType(item) {
|
|
if (typeof item !== "string") {
|
|
return null;
|
|
}
|
|
const trimmed = item.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
return trimmed.includes(".") ? trimmed : `Base.${trimmed}`;
|
|
}
|
|
|
|
function isMagazineType(itemType) {
|
|
const s = String(itemType || "").toLowerCase();
|
|
if (!s) {
|
|
return false;
|
|
}
|
|
if (s.startsWith("base.clip_")) {
|
|
return true;
|
|
}
|
|
if (s.includes("magazine")) {
|
|
return true;
|
|
}
|
|
if (s.includes("drum")) {
|
|
return true;
|
|
}
|
|
if (s.includes("clip")) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function buildCatalog(ggsRoot) {
|
|
const cwd = process.cwd();
|
|
const firearmScriptsDir = path.join(ggsRoot, "media", "scripts", "Firearms");
|
|
const attachmentScriptsDir = path.join(ggsRoot, "media", "scripts", "GunPartItem");
|
|
const lootLuaPath = path.join(ggsRoot, "media", "lua", "server", "item", "loot.lua");
|
|
|
|
if (!fs.existsSync(firearmScriptsDir)) {
|
|
throw new Error(`Missing firearms scripts dir: ${firearmScriptsDir}`);
|
|
}
|
|
if (!fs.existsSync(attachmentScriptsDir)) {
|
|
throw new Error(`Missing attachments scripts dir: ${attachmentScriptsDir}`);
|
|
}
|
|
if (!fs.existsSync(lootLuaPath)) {
|
|
throw new Error(`Missing loot file: ${lootLuaPath}`);
|
|
}
|
|
|
|
const firearms = collectItemTypesFromScripts(firearmScriptsDir);
|
|
const attachments = collectItemTypesFromScripts(attachmentScriptsDir);
|
|
const firearmSet = new Set(firearms);
|
|
const attachmentSet = new Set(attachments);
|
|
const magazineSet = new Set();
|
|
|
|
const lootEntries = parseLootEntries(lootLuaPath);
|
|
const perItemLoot = new Map();
|
|
const allLists = new Set();
|
|
const allItems = new Set([...firearms, ...attachments]);
|
|
|
|
for (const entry of lootEntries) {
|
|
const normalized = normalizeItemType(entry.item);
|
|
if (!normalized) {
|
|
continue;
|
|
}
|
|
if (isMagazineType(normalized)) {
|
|
magazineSet.add(normalized);
|
|
allItems.add(normalized);
|
|
}
|
|
}
|
|
|
|
for (const entry of lootEntries) {
|
|
const normalized = normalizeItemType(entry.item);
|
|
if (!normalized) {
|
|
continue;
|
|
}
|
|
if (!allItems.has(normalized)) {
|
|
continue;
|
|
}
|
|
allLists.add(entry.list);
|
|
|
|
const existing = perItemLoot.get(normalized) || [];
|
|
existing.push({
|
|
list: entry.list,
|
|
weight: entry.weight,
|
|
sv: entry.sv,
|
|
});
|
|
perItemLoot.set(normalized, existing);
|
|
}
|
|
|
|
const items = Array.from(allItems)
|
|
.sort()
|
|
.map((itemType) => {
|
|
const placements = perItemLoot.get(itemType) || [];
|
|
const aggregatedPlacements = aggregatePlacements(placements);
|
|
const svKeys = Array.from(new Set(placements.map((p) => p.sv))).sort();
|
|
let category = "unknown";
|
|
if (firearmSet.has(itemType)) {
|
|
category = "firearm";
|
|
} else if (magazineSet.has(itemType)) {
|
|
category = "magazine";
|
|
} else if (attachmentSet.has(itemType)) {
|
|
category = "attachment";
|
|
}
|
|
|
|
return {
|
|
item: itemType,
|
|
shortId: itemType.replace(/^Base\./, ""),
|
|
category,
|
|
defaultEnabled: false,
|
|
spawnControlKeys: svKeys,
|
|
placements,
|
|
aggregatedPlacements,
|
|
};
|
|
});
|
|
|
|
return {
|
|
formatVersion: 1,
|
|
generatedAt: new Date().toISOString(),
|
|
source: {
|
|
ggsRoot: path.relative(cwd, path.resolve(ggsRoot)) || ".",
|
|
lootLuaPath: path.relative(cwd, path.resolve(lootLuaPath)),
|
|
firearmScriptsDir: path.relative(cwd, path.resolve(firearmScriptsDir)),
|
|
attachmentScriptsDir: path.relative(cwd, path.resolve(attachmentScriptsDir)),
|
|
},
|
|
notes: {
|
|
spawnRateFormula: "effectiveWeight = baseWeight * lootAmountMultiplier * SandboxVars[sv]",
|
|
lootAmountMultiplierLookup: [0, 0.25, 0.5, 1, 2, 4],
|
|
},
|
|
counts: {
|
|
firearms: firearms.length,
|
|
attachments: attachments.length,
|
|
magazines: magazineSet.size,
|
|
totalItems: items.length,
|
|
placementRows: lootEntries.length,
|
|
distributionLists: allLists.size,
|
|
},
|
|
lists: Array.from(allLists).sort(),
|
|
items,
|
|
};
|
|
}
|
|
|
|
function formatLuaString(value) {
|
|
return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
}
|
|
|
|
function toLuaProfile(profileJson) {
|
|
if (!profileJson || !Array.isArray(profileJson.entries)) {
|
|
throw new Error("Profile JSON must contain an entries array.");
|
|
}
|
|
|
|
const itemsMap = new Map();
|
|
for (const rawEntry of profileJson.entries) {
|
|
const itemType = normalizeItemType(rawEntry && rawEntry.item);
|
|
if (!itemType) {
|
|
continue;
|
|
}
|
|
|
|
const enabled = rawEntry.enabled !== false;
|
|
const placements = {};
|
|
|
|
if (Array.isArray(rawEntry.placements)) {
|
|
for (const placement of rawEntry.placements) {
|
|
const listName =
|
|
placement && typeof placement.list === "string" ? placement.list.trim() : "";
|
|
const weight = Number.parseFloat(placement && placement.weight);
|
|
if (!listName || !Number.isFinite(weight) || weight <= 0) {
|
|
continue;
|
|
}
|
|
placements[listName] = Number(weight.toFixed(6));
|
|
}
|
|
} else if (rawEntry && typeof rawEntry.placements === "object" && rawEntry.placements) {
|
|
for (const [listName, rawWeight] of Object.entries(rawEntry.placements)) {
|
|
const cleanList = listName.trim();
|
|
const weight = Number.parseFloat(rawWeight);
|
|
if (!cleanList || !Number.isFinite(weight) || weight <= 0) {
|
|
continue;
|
|
}
|
|
placements[cleanList] = Number(weight.toFixed(6));
|
|
}
|
|
}
|
|
|
|
itemsMap.set(itemType, {
|
|
enabled,
|
|
placements,
|
|
});
|
|
}
|
|
|
|
const itemTypes = Array.from(itemsMap.keys()).sort();
|
|
const lines = [];
|
|
lines.push("-- Auto-generated by tools/ggs-dist-cli.js apply");
|
|
lines.push(`-- Generated at: ${new Date().toISOString()}`);
|
|
lines.push("return {");
|
|
lines.push(" items = {");
|
|
|
|
for (const itemType of itemTypes) {
|
|
const config = itemsMap.get(itemType);
|
|
lines.push(` [${formatLuaString(itemType)}] = {`);
|
|
lines.push(` enabled = ${config.enabled ? "true" : "false"},`);
|
|
lines.push(" placements = {");
|
|
|
|
const listNames = Object.keys(config.placements).sort();
|
|
for (const listName of listNames) {
|
|
const weight = config.placements[listName];
|
|
lines.push(` [${formatLuaString(listName)}] = ${weight},`);
|
|
}
|
|
|
|
lines.push(" },");
|
|
lines.push(" },");
|
|
}
|
|
|
|
lines.push(" },");
|
|
lines.push("}");
|
|
lines.push("");
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function commandExtract(args) {
|
|
const cwd = process.cwd();
|
|
const ggsRoot = path.resolve(cwd, args["ggs-root"] || path.join("source", "GaelGunStore", "42"));
|
|
const outPath = path.resolve(cwd, args.out || path.join("data", "ggs-spawn-catalog.json"));
|
|
|
|
const catalog = buildCatalog(ggsRoot);
|
|
writeText(outPath, `${JSON.stringify(catalog, null, 2)}\n`);
|
|
|
|
console.log(`Extracted catalog: ${outPath}`);
|
|
console.log(
|
|
`Items=${catalog.counts.totalItems}, Firearms=${catalog.counts.firearms}, Attachments=${catalog.counts.attachments}, Magazines=${catalog.counts.magazines}, Lists=${catalog.counts.distributionLists}`
|
|
);
|
|
}
|
|
|
|
function commandApply(args) {
|
|
const cwd = process.cwd();
|
|
const profilePath = args.profile ? path.resolve(cwd, args.profile) : null;
|
|
const outPath = path.resolve(
|
|
cwd,
|
|
args.out || path.join("common", "media", "lua", "shared", "OFSpawnProfile.lua")
|
|
);
|
|
|
|
if (!profilePath) {
|
|
throw new Error("Missing required --profile <file> argument.");
|
|
}
|
|
if (!fs.existsSync(profilePath)) {
|
|
throw new Error(`Profile file not found: ${profilePath}`);
|
|
}
|
|
|
|
const profileJson = JSON.parse(readText(profilePath));
|
|
const luaProfile = toLuaProfile(profileJson);
|
|
writeText(outPath, luaProfile);
|
|
|
|
console.log(`Applied profile to Lua: ${outPath}`);
|
|
}
|
|
|
|
function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const command = args._[0];
|
|
|
|
if (!command || command === "help" || args.help) {
|
|
printUsage();
|
|
return;
|
|
}
|
|
|
|
if (command === "extract") {
|
|
commandExtract(args);
|
|
return;
|
|
}
|
|
|
|
if (command === "apply") {
|
|
commandApply(args);
|
|
return;
|
|
}
|
|
|
|
throw new Error(`Unknown command: ${command}`);
|
|
}
|
|
|
|
try {
|
|
main();
|
|
} catch (error) {
|
|
console.error(`[ggs-dist-cli] ${error.message}`);
|
|
process.exitCode = 1;
|
|
}
|