#!/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 ] [--out ] node tools/ggs-dist-cli.js apply --profile [--out ] 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 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; }