diff --git a/manager/javascripts/manager.js b/manager/javascripts/manager.js index 3fa081cf4..954333398 100644 --- a/manager/javascripts/manager.js +++ b/manager/javascripts/manager.js @@ -10,6 +10,8 @@ const WizardPage = require("./wizardpage"); const { fetchWithTimeout } = require("./net"); const { exec } = require("child_process"); const { sleep } = require("./utils"); +const { ModInventoryService } = require('./modinventory'); +const { generateConfigs } = require('./modgenerator'); class Manager { options = { @@ -21,7 +23,11 @@ class Manager { logLocation: path.join(__dirname, "..", "manager.log"), mode: 'basic', state: 'IDLE', - forceBeta: false + forceBeta: false, + modManager: { + dismissed: false, + available: false + } }; /* Manager pages */ @@ -37,6 +43,12 @@ class Manager { resultPage = null; instancesPage = null; expertSettingsPage = null; + modsPage = null; + modInventoryService = null; + modInventory = []; + modImportState = {}; + currentModSummary = null; + modImportStateFile = path.join(__dirname, "..", "mods-import.json"); constructor() { /* Simple framework to define callbacks to events directly in the .ejs files. When an event happens, e.g. a button is clicked, the signal function is called with the function @@ -55,6 +67,8 @@ class Manager { window.olympus = { manager: this }; + + this.modImportState = this.loadModImportState(); } /** Asynchronously start the manager @@ -67,6 +81,7 @@ class Manager { /* Load the options from the json file */ try { this.options = { ...this.options, ...JSON.parse(fs.readFileSync("options.json")) }; + this.options.modManager = { dismissed: false, available: false, ...(this.options.modManager || {}) }; this.setConfigLoaded(true); } catch (e) { logger.error(`An error occurred while reading the options.json file: ${e}`); @@ -138,6 +153,9 @@ class Manager { /* Create all the HTML pages */ this.menuPage = new ManagerPage(this, "./ejs/menu.ejs"); + this.menuPage.options.onShow = () => { + this.updateModMenuAvailability(); + }; this.folderPage = new WizardPage(this, "./ejs/folder.ejs"); this.settingsPage = new ManagerPage(this, "./ejs/settings.ejs"); this.typePage = new WizardPage(this, "./ejs/type.ejs"); @@ -211,6 +229,7 @@ class Manager { /* Send an event on manager started */ document.dispatchEvent(new CustomEvent("managerStarted")); + this.promptModImportIfNeeded(); } } @@ -595,6 +614,79 @@ class Manager { document.querySelector('.close').click(); } + async onModsImportClicked() { + const summary = this.currentModSummary; + if (!summary || summary.totalCount === 0) { + showErrorPopup("
"); + this.closeModsPrompt(); + return; + } + if (summary.pendingCount === 0) { + showErrorPopup(""); + this.closeModsPrompt(); + return; + } + if (summary.importedCount > 0) { + showConfirmPopup( + ``, + async () => { + await this.applyModImport(summary); + } + ); + return; + } + await this.applyModImport(summary); + } + + async applyModImport(summary) { + try { + await this.markModsImported(summary.modsRoot, summary.pendingIds); + await this.generateModConfigs(summary.modsRoot); + await this.scanMods(); + await this.setModPromptDismissed(true); + const pendingLabel = `${summary.pendingCount} mod${summary.pendingCount === 1 ? "" : "s"}`; + const carriedLabel = summary.importedCount + ? `Already had ${summary.importedCount} imported${summary.importedCount === 1 ? "" : " mods"}.` + : "Everything was new."; + showErrorPopup(``); + this.closeModsPrompt(); + } catch (error) { + logger.error(`[mods] Import failed: ${error}`); + const message = error && error.message ? error.message : error; + showErrorPopup(``); + } + } + + async onModsReviewClicked() { + if (this.modsPage) { + const el = this.modsPage.getElement(); + if (el.classList.contains("review-mode")) { + el.classList.remove("review-mode"); + } else { + el.classList.add("review-mode"); + } + } + } + + async onModsSkipClicked() { + await this.setModPromptDismissed(true); + this.closeModsPrompt(); + } + + async onModsMenuClicked() { + const result = await this.scanMods(); + if (!result) { + if (!this.getInstances().length) { + showErrorPopup(""); + } else { + showErrorPopup(""); + } + return; + } + await this.setModPromptDismissed(false); + this.showModsPrompt(result.inventory, result.modsRoot, result.summary); + } + async checkPorts() { var frontendPortFree = await this.getActiveInstance().checkFrontendPort(); var backendPortFree = await this.getActiveInstance().checkBackendPort(); @@ -887,6 +979,251 @@ class Manager { getMode() { return this.options.mode; } + + async promptModImportIfNeeded() { + try { + const result = await this.scanMods(); + if (!result) { + return; + } + if (this.options.modManager && this.options.modManager.dismissed) { + return; + } + this.showModsPrompt(result.inventory, result.modsRoot, result.summary); + } catch (err) { + logger.log(`[mods] Unable to prompt for mods: ${err}`); + } + } + + resolveModsRoot() { + const candidate = this.getInstances().find((instance) => { + return instance && instance.folder && fs.existsSync(path.join(instance.folder, "Mods")); + }); + if (!candidate) { + return undefined; + } + return path.join(candidate.folder, "Mods"); + } + + showModsPrompt(inventory, modsRoot, summary) { + if (!this.modsPage) { + this.modsPage = new ManagerPage(this, "./ejs/mods.ejs"); + } + const effectiveSummary = summary || this.computeModSummary(modsRoot, inventory); + this.currentModSummary = effectiveSummary; + const importedSet = new Set(effectiveSummary.importedIds); + const decoratedInventory = inventory.map((mod) => ({ + ...mod, + imported: importedSet.has(mod.id) + })); + this.modInventory = decoratedInventory; + this.modsPage.options.inventory = decoratedInventory; + this.modsPage.options.modsRoot = modsRoot; + this.modsPage.options.summary = effectiveSummary; + this.modsPage.getElement().classList.remove("review-mode"); + this.modsPage.show(); + } + + closeModsPrompt() { + if (!this.modsPage) { + return; + } + const previousPage = this.modsPage.previousPage; + this.modsPage.hide(); + if (previousPage) { + previousPage.show(true); + } else if (this.getMode() === "basic" && this.menuPage) { + this.menuPage.show(true); + } else if (this.instancesPage) { + this.instancesPage.show(true); + } + } + + async setModPromptDismissed(dismissed) { + if (!fs.existsSync("options.json")) { + return; + } + const options = JSON.parse(fs.readFileSync("options.json")); + options.modManager = options.modManager || {}; + options.modManager.dismissed = dismissed; + fs.writeFileSync("options.json", JSON.stringify(options, null, 2)); + this.options.modManager = options.modManager; + } + + updateModMenuAvailability() { + // Button remains available at all times; hook reserved for future logic. + } + + async scanMods() { + try { + const modsRoot = this.resolveModsRoot(); + if (!modsRoot) { + this.options.modManager.available = false; + this.modInventory = []; + this.currentModSummary = null; + this.updateModMenuAvailability(); + return undefined; + } + if (!this.modInventoryService || this.modInventoryService.modsRoot !== modsRoot) { + this.modInventoryService = new ModInventoryService({ modsRoot }); + } + const inventory = this.modInventoryService.discover(); + this.options.modManager.available = inventory && inventory.length > 0; + if (!inventory || inventory.length === 0) { + this.modInventory = []; + this.currentModSummary = null; + this.updateModMenuAvailability(); + return undefined; + } + const summary = this.computeModSummary(modsRoot, inventory); + this.currentModSummary = summary; + this.modInventory = inventory.map((mod) => ({ + ...mod, + imported: summary.importedIds.includes(mod.id) + })); + this.updateModMenuAvailability(); + return { inventory, modsRoot, summary }; + } catch (err) { + logger.log(`[mods] scan failed: ${err}`); + return undefined; + } + } + + computeModSummary(modsRoot, inventory) { + const stored = this.ensureCanonicalImportedMods(modsRoot, inventory); + const importedSet = new Set(stored); + const importedIds = []; + const pendingIds = []; + let importedCount = 0; + for (const mod of inventory) { + if (importedSet.has(mod.id)) { + importedCount += 1; + importedIds.push(mod.id); + } else { + pendingIds.push(mod.id); + } + } + const totalCount = inventory.length; + return { + modsRoot, + totalCount, + importedCount, + pendingCount: pendingIds.length, + pendingIds, + importedIds + }; + } + + loadModImportState() { + try { + if (fs.existsSync(this.modImportStateFile)) { + const content = JSON.parse(fs.readFileSync(this.modImportStateFile)); + return content || {}; + } + } catch (err) { + logger.log(`[mods] Unable to read mod import state: ${err}`); + } + return {}; + } + + saveModImportState() { + try { + fs.writeFileSync(this.modImportStateFile, JSON.stringify(this.modImportState, null, 2)); + } catch (err) { + logger.log(`[mods] Unable to save mod import state: ${err}`); + } + } + + getImportedMods(modsRoot) { + if (!modsRoot) { + return []; + } + if (!this.modImportState[modsRoot]) { + this.modImportState[modsRoot] = []; + } + return this.modImportState[modsRoot]; + } + + ensureCanonicalImportedMods(modsRoot, inventory) { + const stored = this.getImportedMods(modsRoot); + if (!inventory || inventory.length === 0) { + return stored; + } + const storedSet = new Set(stored); + let changed = false; + inventory.forEach((mod) => { + if (storedSet.has(mod.id)) { + return; + } + const aliases = mod.aliases || []; + const aliasHit = aliases.find((alias) => storedSet.has(alias)); + if (aliasHit) { + storedSet.add(mod.id); + changed = true; + } + }); + const aliasMap = new Map(); + inventory.forEach((mod) => { + if (!mod.aliases) { + return; + } + mod.aliases.forEach((alias) => { + if (!aliasMap.has(alias)) { + aliasMap.set(alias, mod.id); + } + }); + }); + Array.from(storedSet).forEach((value) => { + const normalized = aliasMap.get(value); + if (normalized && normalized !== value) { + storedSet.delete(value); + storedSet.add(normalized); + changed = true; + } + }); + if (changed) { + this.modImportState[modsRoot] = Array.from(storedSet); + this.saveModImportState(); + } + return Array.from(storedSet); + } + + async markModsImported(modsRoot, modIds) { + if (!modsRoot || !Array.isArray(modIds) || modIds.length === 0) { + return; + } + const imported = new Set(this.getImportedMods(modsRoot)); + modIds.forEach((id) => imported.add(id)); + this.modImportState[modsRoot] = Array.from(imported); + this.saveModImportState(); + } + + async generateModConfigs(modsRoot) { + const importedIds = this.getImportedMods(modsRoot); + if (!importedIds || importedIds.length === 0) { + await generateConfigs({ modsRoot, descriptors: [] }); + return; + } + const descriptors = []; + const missing = []; + for (const id of importedIds) { + const descriptor = this.modInventory.find((mod) => mod.id === id); + if (descriptor) { + descriptors.push(descriptor); + } else { + missing.push(id); + } + } + if (missing.length > 0) { + this.modImportState[modsRoot] = importedIds.filter((id) => !missing.includes(id)); + this.saveModImportState(); + } + if (descriptors.length === 0) { + await generateConfigs({ modsRoot, descriptors: [], removedIds: missing }); + return; + } + generateConfigs({ modsRoot, descriptors, removedIds: missing }); + } } -module.exports = Manager; \ No newline at end of file +module.exports = Manager; diff --git a/manager/javascripts/modgenerator.js b/manager/javascripts/modgenerator.js new file mode 100644 index 000000000..c11cf61e5 --- /dev/null +++ b/manager/javascripts/modgenerator.js @@ -0,0 +1,176 @@ +const fs = require("fs"); +const path = require("path"); +const { logger } = require("./filesystem"); + +function backupFile(targetPath) { + if (!fs.existsSync(targetPath)) { + return; + } + const timestamp = new Date().toISOString().replace(/[-:T.]/g, "").slice(0, 14); + const backupName = `${path.basename(targetPath)}.${timestamp}.bak`; + fs.copyFileSync(targetPath, path.join(path.dirname(targetPath), backupName)); +} + +function ensureDir(dir) { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +function sanitizeShortLabel(label) { + if (!label) { + return "MOD"; + } + return label.replace(/\s+/g, "").replace(/[^A-Z0-9]/gi, "").slice(0, 6).toUpperCase(); +} + +function normalizeLoadout(loadout, fallbackDescription) { + if (!loadout) { + return { + name: "Clean Ferry", + items: [], + enabled: true, + code: "Clean Ferry", + roles: ["No task"], + description: fallbackDescription || "Autogenerated loadout" + }; + } + const items = Array.isArray(loadout.items) + ? loadout.items.map((item) => ({ + name: item?.name || item?.CLSID || "Item", + quantity: item?.quantity !== undefined ? item.quantity : item?.count || 0 + })) + : []; + const roles = Array.isArray(loadout.roles) ? loadout.roles.filter(Boolean) : []; + return { + name: loadout.name || "Custom Loadout", + items, + enabled: loadout.enabled !== false, + code: loadout.code || loadout.name || "Custom Loadout", + roles: roles.length ? roles : ["No task"], + description: loadout.description || fallbackDescription || "" + }; +} + +function buildJsonEntry(descriptor) { + const label = descriptor.displayName || descriptor.id; + const shortLabel = sanitizeShortLabel(descriptor.shortName || label); + const length = descriptor.length || 0; + const loadouts = (descriptor.loadouts || []).map((loadout) => normalizeLoadout(loadout, label)); + return { + name: descriptor.id, + coalition: descriptor.coalition || "blue", + era: descriptor.era || "Modern", + category: descriptor.category ? descriptor.category.toLowerCase() : "aircraft", + label, + shortLabel, + filename: descriptor.icon || "", + enabled: descriptor.enabled !== false, + type: descriptor.type || "Aircraft", + description: descriptor.description || `Autogenerated entry for ${label}.`, + abilities: descriptor.abilities || "", + canTargetPoint: descriptor.canTargetPoint !== undefined ? descriptor.canTargetPoint : true, + canRearm: descriptor.canRearm !== undefined ? descriptor.canRearm : descriptor.folderType === "aircraft", + length, + loadouts: loadouts.length ? loadouts : [normalizeLoadout(null, label)], + liveries: descriptor.liveries || {} + }; +} + +function formatLuaString(value) { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +function writeGeneratedLua(targetPath, descriptors) { + if (!descriptors.length) { + if (fs.existsSync(targetPath)) { + fs.unlinkSync(targetPath); + } + return; + } + + const modsListLines = []; + const payloadLines = []; + const payloadFileLines = []; + + for (const descriptor of descriptors) { + const category = descriptor.category || (descriptor.folderType === "tech" ? "Aircraft" : "Aircraft"); + modsListLines.push(`\t["${formatLuaString(descriptor.id)}"] = "${formatLuaString(category)}",`); + if (descriptor.unitPayloadRelativePath) { + payloadFileLines.push(`\t["${formatLuaString(descriptor.id)}"] = "${formatLuaString(descriptor.unitPayloadRelativePath)}",`); + } else { + payloadLines.push(`\t["${formatLuaString(descriptor.id)}"] = {`); + payloadLines.push(`\t\t["Clean Ferry"] = {}`); + payloadLines.push("\t},"); + } + } + + const content = [ + "local generated = {}", + "generated.modsList = {", + ...modsListLines, + "}", + "generated.payloadFiles = {", + ...payloadFileLines, + "}", + "generated.modsUnitPayloads = {", + ...payloadLines, + "}", + "return generated", + "" + ].join("\n"); + + ensureDir(path.dirname(targetPath)); + backupFile(targetPath); + fs.writeFileSync(targetPath, content, "utf-8"); +} + +function writeModsJson(jsonPath, descriptors, removedIds = []) { + if (!fs.existsSync(jsonPath)) { + throw new Error(`mods.json not found at ${jsonPath}`); + } + const raw = JSON.parse(fs.readFileSync(jsonPath, "utf-8")); + const managedIds = new Set(descriptors.map((d) => d.id)); + for (const id of removedIds) { + delete raw[id]; + } + for (const id of managedIds) { + delete raw[id]; + } + for (const descriptor of descriptors) { + raw[descriptor.id] = buildJsonEntry(descriptor); + } + backupFile(jsonPath); + const sortedKeys = Object.keys(raw).sort(); + const sortedObj = {}; + for (const key of sortedKeys) { + sortedObj[key] = raw[key]; + } + fs.writeFileSync(jsonPath, JSON.stringify(sortedObj, null, 2)); +} + +function generateConfigs(options) { + const { modsRoot, descriptors, removedIds = [] } = options; + if (!modsRoot) { + throw new Error("modsRoot is required"); + } + const scriptsPath = path.join(modsRoot, "Services", "Olympus", "scripts"); + const databasesPath = path.join(modsRoot, "Services", "Olympus", "databases", "units"); + const luaTarget = path.join(scriptsPath, "mods_generated.lua"); + const jsonTarget = path.join(databasesPath, "mods.json"); + + if (!descriptors || descriptors.length === 0) { + writeGeneratedLua(luaTarget, []); + if (removedIds && removedIds.length > 0) { + writeModsJson(jsonTarget, [], removedIds); + } + return; + } + + writeGeneratedLua(luaTarget, descriptors); + writeModsJson(jsonTarget, descriptors, removedIds); +} + +module.exports = { + generateConfigs +}; diff --git a/manager/javascripts/modinventory.js b/manager/javascripts/modinventory.js new file mode 100644 index 000000000..2767a1c57 --- /dev/null +++ b/manager/javascripts/modinventory.js @@ -0,0 +1,449 @@ +const path = require("path"); +const fs = require("fs"); +const { logger } = require("./filesystem"); + +const DEFAULT_MODS_ROOT = path.join(process.env.USERPROFILE || "", "Saved Games", "DCS", "Mods"); +const TASK_ROLE_MAP = { + 2: "SEAD", + 3: "CAS", + 4: "Transport", + 5: "AFAC", + 6: "Recon", + 7: "Trainer", + 8: "Strike", + 9: "Strike", + 10: "Fighter Sweep", + 11: "CAP", + 12: "Intercept", + 13: "Escort", + 14: "CAS", + 15: "CAS", + 16: "Ground Attack", + 17: "Anti-ship", + 18: "Escort", + 19: "Anti-submarine", + 20: "AWACS", + 22: "Tanker", + 28: "Transport", + 30: "Refuel", + 31: "Intercept", + 32: "Intercept" +}; + +function dedupe(list) { + return Array.from(new Set(list.filter(Boolean))); +} + +function sanitizeShortLabel(value) { + if (!value) { + return ""; + } + return value.replace(/\s+/g, "").replace(/[^A-Z0-9]/gi, "").slice(0, 6).toUpperCase(); +} + +function stripLocalizationWrapper(value) { + if (!value) { + return value; + } + return value.replace(/^_\(["']?/, "").replace(/["']?\)$/, ""); +} + +class ModInventoryService { + constructor(options = {}) { + this.modsRoot = options.modsRoot || DEFAULT_MODS_ROOT; + this.savedGamesRoot = options.savedGamesRoot || path.dirname(this.modsRoot); + this.cacheFile = options.cacheFile || path.join(__dirname, "..", "mods-inventory.json"); + this.inventory = []; + } + + discover() { + this.inventory = []; + try { + this._discoverAircraftMods(); + this._discoverTechMods(); + this.inventory.sort((a, b) => a.displayName.localeCompare(b.displayName)); + this._saveCache(); + } catch (error) { + logger.error(`[mods] Failed to build inventory: ${error}`); + } + return this.inventory; + } + + getInventory() { + if (this.inventory.length === 0 && fs.existsSync(this.cacheFile)) { + try { + this.inventory = JSON.parse(fs.readFileSync(this.cacheFile)); + } catch (error) { + logger.log(`[mods] Unable to parse inventory cache: ${error}`); + } + } + return this.inventory; + } + + _discoverAircraftMods() { + const base = path.join(this.modsRoot, "aircraft"); + if (!fs.existsSync(base)) { + return; + } + const entries = fs.readdirSync(base, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + const sourcePath = path.join(base, entry.name); + const entryFile = path.join(sourcePath, "entry.lua"); + const entryMetadata = this._readEntryMetadata(entryFile); + const payloadMetadata = this._readPayloadMetadata(sourcePath); + const id = payloadMetadata?.unitType || entryMetadata.typeName || entryMetadata.displayName || entry.name; + const issues = []; + if (!payloadMetadata?.relativePath) { + issues.push("Payload file not found"); + } + const descriptor = { + id, + modId: entry.name, + displayName: payloadMetadata?.displayName || entryMetadata.displayName || id, + shortName: sanitizeShortLabel(entryMetadata.shortName || entryMetadata.displayName || id), + category: "Aircraft", + coalition: entryMetadata.coalition || "blue", + sourcePath, + entryFile: fs.existsSync(entryFile) ? entryFile : undefined, + unitPayloadPath: payloadMetadata?.absolutePath, + unitPayloadRelativePath: payloadMetadata?.relativePath, + hasPayloads: Boolean(payloadMetadata?.relativePath), + loadouts: payloadMetadata?.loadouts?.length ? payloadMetadata.loadouts : this._defaultLoadouts(), + length: entryMetadata.length || payloadMetadata?.length || null, + description: entryMetadata.description || "", + folderType: "aircraft", + issues, + aliases: this._buildAliases(id, entry.name) + }; + this.inventory.push(descriptor); + } + } + + _discoverTechMods() { + const base = path.join(this.modsRoot, "tech"); + if (!fs.existsSync(base)) { + return; + } + const entries = fs.readdirSync(base, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + const sourcePath = path.join(base, entry.name); + const entryFile = path.join(sourcePath, "entry.lua"); + const entryMetadata = this._readEntryMetadata(entryFile); + const techUnits = this._readTechUnits(sourcePath); + if (techUnits.length === 0) { + const id = entryMetadata.typeName || entryMetadata.displayName || entry.name; + this.inventory.push({ + id, + modId: entry.name, + displayName: entryMetadata.displayName || id, + shortName: sanitizeShortLabel(entryMetadata.shortName || id), + category: "Aircraft", + coalition: entryMetadata.coalition || "blue", + sourcePath, + entryFile: fs.existsSync(entryFile) ? entryFile : undefined, + unitPayloadPath: undefined, + unitPayloadRelativePath: undefined, + hasPayloads: false, + loadouts: this._defaultLoadouts(), + length: entryMetadata.length || null, + description: entryMetadata.description || "", + folderType: "tech", + issues: ["No unit definitions detected"], + aliases: this._buildAliases(id, entry.name) + }); + continue; + } + for (const unit of techUnits) { + const id = unit.id || entryMetadata.typeName || unit.displayName || entry.name; + this.inventory.push({ + id, + modId: entry.name, + displayName: unit.displayName || entryMetadata.displayName || id, + shortName: sanitizeShortLabel(unit.shortName || unit.displayName || id), + category: "Aircraft", + coalition: entryMetadata.coalition || "blue", + sourcePath, + entryFile: fs.existsSync(entryFile) ? entryFile : undefined, + unitPayloadPath: undefined, + unitPayloadRelativePath: undefined, + hasPayloads: false, + loadouts: this._defaultLoadouts(), + length: unit.length || entryMetadata.length || null, + description: entryMetadata.description || "", + folderType: "tech", + issues: [], + aliases: this._buildAliases(id, entry.name, unit.aliases) + }); + } + } + } + + _buildAliases(id, modId, extra = []) { + const list = [id, id?.toLowerCase(), modId, modId?.toLowerCase(), ...(extra || [])]; + return dedupe(list.filter(Boolean)); + } + + _defaultLoadouts(label) { + return [ + { + name: "Clean Ferry", + items: [], + enabled: true, + code: "Clean Ferry", + roles: ["No task"], + description: label || "Autogenerated" + } + ]; + } + + _readEntryMetadata(entryPath) { + const metadata = {}; + if (!fs.existsSync(entryPath)) { + return metadata; + } + try { + const content = fs.readFileSync(entryPath, "utf-8"); + const displayMatch = content.match(/displayName\s*=\s*_?\(?["']([^"']+)["']/i); + if (displayMatch) { + metadata.displayName = displayMatch[1]; + } + const shortMatch = content.match(/shortName\s*=\s*_?\(?["']([^"']+)["']/i); + if (shortMatch) { + metadata.shortName = shortMatch[1]; + } + const lengthMatch = content.match(/length\s*=\s*([0-9]+\.?[0-9]*)/i); + if (lengthMatch) { + metadata.length = parseFloat(lengthMatch[1]); + } + const descMatch = content.match(/info\s*=\s*_?\(?["']([^"']+)["']/i); + if (descMatch) { + metadata.description = descMatch[1]; + } + const makeFlyableMatch = content.match(/(?:make_flyable|MAC_flyable)\s*\(\s*["']([^"']+)["']/i); + if (makeFlyableMatch) { + metadata.typeName = makeFlyableMatch[1]; + } else { + const logBookMatch = content.match(/LogBook\s*=\s*{[^}]+type\s*=\s*["']([^"']+)["']/i); + if (logBookMatch) { + metadata.typeName = logBookMatch[1]; + } + } + } catch (error) { + logger.log(`[mods] Unable to parse metadata from ${entryPath}: ${error}`); + } + return metadata; + } + + _readPayloadMetadata(sourcePath) { + const payloadDir = path.join(sourcePath, "UnitPayloads"); + if (!fs.existsSync(payloadDir)) { + return undefined; + } + const files = fs.readdirSync(payloadDir).filter((file) => file.toLowerCase().endsWith(".lua")); + for (const file of files) { + const absolutePath = path.join(payloadDir, file); + const parsed = this._parsePayloadFile(absolutePath); + if (!parsed) { + continue; + } + return { + unitType: parsed.unitType, + displayName: parsed.displayName, + loadouts: parsed.loadouts, + relativePath: this._relativeToSavedGames(absolutePath), + absolutePath + }; + } + return undefined; + } + + _parsePayloadFile(filePath) { + try { + const content = fs.readFileSync(filePath, "utf-8"); + const sanitized = content + .replace(/--\[\[[\s\S]*?\]\]/g, "") + .replace(/--.*$/gm, "") + .replace(/\r/g, ""); + const payloadKeyMatch = sanitized.match(/\["payloads"\]/i); + const payloadIndex = payloadKeyMatch ? payloadKeyMatch.index : -1; + const headerSection = payloadIndex > 0 ? sanitized.slice(0, payloadIndex) : sanitized; + const unitType = this._matchString(headerSection, "name"); + if (!unitType) { + return undefined; + } + const payloadBlock = this._extractTableBlock(sanitized, "payloads"); + const loadouts = []; + if (payloadBlock) { + const tables = this._splitTopLevelTables(payloadBlock); + for (const block of tables) { + const name = this._matchString(block, "name") || `Payload ${loadouts.length + 1}`; + const displayName = this._matchString(block, "displayName") || name; + const tasks = this._matchTasks(block); + const roles = this._mapTasksToRoles(tasks); + loadouts.push({ + name: displayName, + items: [], + enabled: true, + code: displayName, + roles: roles.length ? roles : ["No task"] + }); + } + } + return { unitType, displayName: unitType, loadouts }; + } catch (error) { + logger.log(`[mods] Unable to parse payloads from ${filePath}: ${error}`); + return undefined; + } + } + + _extractTableBlock(content, keyword) { + const idx = content.indexOf(keyword); + if (idx === -1) { + return undefined; + } + const braceStart = content.indexOf("{", idx); + if (braceStart === -1) { + return undefined; + } + let depth = 0; + for (let i = braceStart; i < content.length; i += 1) { + const char = content[i]; + if (char === "{") { + depth += 1; + } else if (char === "}") { + depth -= 1; + if (depth === 0) { + return content.slice(braceStart, i + 1); + } + } + } + return undefined; + } + + _splitTopLevelTables(block) { + if (!block) { + return []; + } + const inner = block.slice(1, -1); + const tables = []; + let depth = 0; + let startIndex = -1; + for (let i = 0; i < inner.length; i += 1) { + const char = inner[i]; + if (char === "{") { + if (depth === 0) { + startIndex = i; + } + depth += 1; + } else if (char === "}") { + depth -= 1; + if (depth === 0 && startIndex !== -1) { + tables.push(inner.slice(startIndex, i + 1)); + startIndex = -1; + } + } + } + return tables; + } + + _matchString(block, key) { + if (!block) { + return undefined; + } + const pattern = `(?:\\["${key}"\\]|${key})`; + const regex = new RegExp(`${pattern}\\s*=\\s*(?:_\\()?["']([^"']+)["']\\)?`, "i"); + const match = block.match(regex); + if (match) { + return stripLocalizationWrapper(match[1]); + } + return undefined; + } + + _matchTasks(block) { + const tasksMatch = block.match(/(?:\["tasks"\]|tasks)\s*=\s*{([^}]*)}/i); + if (!tasksMatch) { + return []; + } + const digits = tasksMatch[1].match(/[0-9]+/g); + if (!digits) { + return []; + } + return digits.map((n) => parseInt(n, 10)); + } + + _mapTasksToRoles(tasks) { + if (!tasks || tasks.length === 0) { + return []; + } + const roles = tasks.map((taskId) => TASK_ROLE_MAP[taskId] || "No task"); + return dedupe(roles); + } + + _readTechUnits(sourcePath) { + const descriptors = []; + const entries = fs.readdirSync(sourcePath, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + continue; + } + if (!entry.name.toLowerCase().endsWith(".lua") || entry.name.toLowerCase() === "entry.lua") { + continue; + } + if (entry.name.toLowerCase().startsWith("db_")) { + continue; + } + const filePath = path.join(sourcePath, entry.name); + const unit = this._parseTechUnitFile(filePath); + if (unit) { + descriptors.push(unit); + } + } + return descriptors; + } + + _parseTechUnitFile(filePath) { + try { + const content = fs.readFileSync(filePath, "utf-8"); + const nameMatch = content.match(/Name\s*=\s*['"]([^'"]+)['"]/i); + const displayMatch = content.match(/DisplayName\s*=\s*_?\(?["']([^"']+)["']/i); + const lengthMatch = content.match(/length\s*=\s*([0-9]+\.?[0-9]*)/i); + const id = nameMatch ? nameMatch[1] : path.basename(filePath, ".lua"); + return { + id, + displayName: displayMatch ? stripLocalizationWrapper(displayMatch[1]) : id, + shortName: sanitizeShortLabel(id), + length: lengthMatch ? parseFloat(lengthMatch[1]) : null, + aliases: [path.basename(filePath, ".lua"), id] + }; + } catch (error) { + logger.log(`[mods] Unable to parse ${filePath}: ${error}`); + return undefined; + } + } + + _relativeToSavedGames(targetPath) { + if (!this.savedGamesRoot) { + return targetPath; + } + const relative = path.relative(this.savedGamesRoot, targetPath); + return relative.split(path.sep).join("\\"); + } + + _saveCache() { + try { + fs.writeFileSync(this.cacheFile, JSON.stringify(this.inventory, null, 2)); + } catch (error) { + logger.log(`[mods] Unable to save inventory cache: ${error}`); + } + } +} + +module.exports = { + ModInventoryService +}; diff --git a/scripts/lua/backend/mods.lua b/scripts/lua/backend/mods.lua index 82b093d64..67eabc91b 100644 --- a/scripts/lua/backend/mods.lua +++ b/scripts/lua/backend/mods.lua @@ -1,27 +1,128 @@ --- Enter here any mods required by your mission as in the example below. --- Possible categories are: --- Aircraft --- Helicopter --- GroundUnit --- NavyUnit +-- Custom Olympus mod registration file (clean sample). +-- Add your mission-specific mods below or let the Mod Manager generate them automatically. +local function log(message) + if env and env.info then + env.info(string.format("[Olympus Mods] %s", message)) + end +end + +local lfs = lfs or require("lfs") + +local function resolvePath(relativePath) + if relativePath:match("^%a:[\\/]") or relativePath:sub(1, 1) == "\\" or relativePath:sub(1, 1) == "/" then + return relativePath + end + if lfs and lfs.writedir then + return lfs.writedir() .. relativePath + end + return relativePath +end + +local function ensurePayloadRoot(unitType) + Olympus.modsUnitPayloads = Olympus.modsUnitPayloads or {} + Olympus.modsUnitPayloads[unitType] = Olympus.modsUnitPayloads[unitType] or {} + return Olympus.modsUnitPayloads[unitType] +end + +local function registerPayload(unitType, payloadName, pylons) + local store = ensurePayloadRoot(unitType) + store[payloadName] = pylons or {} +end + +local function registerCleanPayload(unitType, payloadName) + registerPayload(unitType, payloadName or "Clean Ferry", {}) +end + +local function loadPayloadFile(unitType, relativePath) + local absolutePath = resolvePath(relativePath) + local chunk, loadError = loadfile(absolutePath) + if not chunk then + log(string.format("Unable to load %s payload file (%s): %s", tostring(unitType), absolutePath, tostring(loadError))) + return + end + local ok, payloadTable = pcall(chunk) + if not ok then + log(string.format("Execution error loading payloads for %s: %s", tostring(unitType), tostring(payloadTable))) + return + end + if type(payloadTable) ~= "table" then + log(string.format("Payload file %s did not return a table", absolutePath)) + return + end + local payloads = payloadTable.payloads or payloadTable + local name = unitType or payloadTable.unitType or payloadTable.name + if not name then + log(string.format("Payload file %s does not define a valid unitType/name", absolutePath)) + return + end + for _, payload in ipairs(payloads) do + if payload.name and payload.pylons then + local pylons = {} + for index, data in pairs(payload.pylons) do + local slot = data.num or index + pylons[slot] = { ["CLSID"] = data.CLSID } + end + registerPayload(name, payload.name, pylons) + end + end +end + +-- Default sample entries (A-4E-C + Bronco example from Olympus docs) Olympus.modsList = { - ["A-4E-C"] = "Aircraft", - ["Bronco-OV-10A"] = "Aircraft" + ["A-4E-C"] = "Aircraft", + ["Bronco-OV-10A"] = "Aircraft" } --- Enter here any unitPayloads you want to use for your mods. Remember to add the payload to the database in mods.json! --- DO NOT ADD PAYLOADS TO "ORIGINAL" DCS UNITS HERE! To add payloads to original DCS units, use the "unitPayload.lua" table instead and add them under the correct unit section. --- Provided example is for the A-4E-C mod, with a payload of 76 FFAR Mk1 HE rockets and a 300 gallon fuel tank. - Olympus.modsUnitPayloads = { ["A-4E-C"] = { ["FFAR Mk1 HE *76, Fuel 300G"] = { - [1] = {["CLSID"] = "{LAU3_FFAR_MK1HE}"}, - [2] = {["CLSID"] = "{LAU3_FFAR_MK1HE}"}, - [3] = {["CLSID"] = "{LAU3_FFAR_MK1HE}"}, - [4] = {["CLSID"] = "{LAU3_FFAR_MK1HE}"}, - [5] = {["CLSID"] = "{DFT-300gal}"} + [1] = { ["CLSID"] = "{LAU3_FFAR_MK1HE}" }, + [2] = { ["CLSID"] = "{LAU3_FFAR_MK1HE}" }, + [3] = { ["CLSID"] = "{LAU3_FFAR_MK1HE}" }, + [4] = { ["CLSID"] = "{LAU3_FFAR_MK1HE}" }, + [5] = { ["CLSID"] = "{DFT-300gal}" } } } } + +registerCleanPayload("Bronco-OV-10A", "Clean Ferry") + +local function loadGeneratedMods() + if not lfs or not lfs.writedir then + return + end + local generatedPath = lfs.writedir() .. "Mods\\Services\\Olympus\\scripts\\mods_generated.lua" + local chunk, err = loadfile(generatedPath) + if not chunk then + if err then + log(string.format("Unable to load generated mods file: %s", err)) + end + return + end + local ok, data = pcall(chunk) + if not ok or type(data) ~= "table" then + log("Generated mods file returned invalid data") + return + end + if data.modsList then + for modName, category in pairs(data.modsList) do + Olympus.modsList[modName] = category + end + end + if data.modsUnitPayloads then + Olympus.modsUnitPayloads = Olympus.modsUnitPayloads or {} + for unitType, payloads in pairs(data.modsUnitPayloads) do + Olympus.modsUnitPayloads[unitType] = payloads + end + end + if data.payloadFiles then + for unitType, relativePath in pairs(data.payloadFiles) do + if type(unitType) == "string" and type(relativePath) == "string" then + loadPayloadFile(unitType, relativePath) + end + end + end +end + +loadGeneratedMods()