local SyncMods = {} SyncMods.REV = 2 SyncMods.MAX_OPTION_KEYS = 64 SyncMods.MAX_OPTION_TEXT = 256 local function versions() local ok, GameVersion = pcall(require, "src.core.GameVersion") if ok and GameVersion and GameVersion.ORDER then return GameVersion.ORDER end return { "red", "blue", "yellow", "gold", "silver", "crystal" } end local function defaultDeps() return { installed = function() return require("src.mods.LauncherMods").list() end, indexes = function() return require("src.mods.ModIndex").sources() end, addIndex = function(url) return require("src.mods.ModIndex").addSource(url) end, findEntry = function(id) local ModIndex = require("src.mods.ModIndex") for _, source in ipairs(ModIndex.sources()) do local cached = ModIndex.readCache(source.feed) for _, entry in ipairs((cached and cached.mods) or {}) do if entry.id == id then return entry end end end return nil end, install = function(entry) return require("src.mods.LauncherMods").installFromIndex(entry) end, setEnabled = function(id, enabled, version) return require("src.mods.LauncherMods").setEnabled(id, enabled, version) end, modOptions = function() return require("src.mods.LauncherMods").modOptions() end, setOptions = function(id, values) return require("src.mods.LauncherMods").setModOptions(id, values) end, } end local function deps(given) local out = defaultDeps() if type(given) == "table" then for k, v in pairs(given) do out[k] = v end end return out end local function sanitizeOptions(bucket) if type(bucket) ~= "table" then return nil end local keys = {} for k, v in pairs(bucket) do local t = type(v) if type(k) == "string" and k ~= "" and (t == "string" or t == "number" or t == "boolean") then keys[#keys + 1] = k end end if #keys == 0 then return nil end table.sort(keys) local out, n = {}, 0 for _, k in ipairs(keys) do if n >= SyncMods.MAX_OPTION_KEYS then break end local v = bucket[k] if type(v) == "string" then v = v:sub(1, SyncMods.MAX_OPTION_TEXT) end local finite = type(v) ~= "number" or (v == v and v ~= math.huge and v ~= -math.huge) if finite then out[k] = v n = n + 1 end end if n == 0 then return nil end return out end local function sameOptions(a, b) for k, v in pairs(a) do if (b or {})[k] ~= v then return false end end return true end local function sourceOf(row) local github = row.github or (type(row.manifest) == "table" and row.manifest.github) if type(github) == "string" and github ~= "" then return "github:" .. github end return "local" end function SyncMods.build(given, includeOptions) local d = deps(given) local manifest = { rev = SyncMods.REV, indexes = {}, mods = {} } local stored = {} if includeOptions then local ok, live = pcall(d.modOptions) if ok and type(live) == "table" then stored = live end end for _, row in ipairs(d.indexes() or {}) do local url = row.url or row.feed if type(url) == "string" and url ~= "" then manifest.indexes[#manifest.indexes + 1] = url end end table.sort(manifest.indexes) for _, row in ipairs(d.installed() or {}) do if type(row) == "table" and type(row.id) == "string" then local enabledFor = {} local answers = row.enabledByVersion or {} for _, version in ipairs(versions()) do if answers[version] then enabledFor[#enabledFor + 1] = version end end local options = includeOptions and sanitizeOptions(stored[row.id]) or nil if options then manifest.hasOptions = true end manifest.mods[#manifest.mods + 1] = { id = row.id, version = row.version, source = sourceOf(row), enabledFor = enabledFor, options = options, } end end table.sort(manifest.mods, function(a, b) return a.id < b.id end) return manifest end function SyncMods.plan(manifest, given) local d = deps(given) local plan = { indexes = {}, toInstall = {}, toEnable = {}, missing = {}, options = {}, applyOptions = nil } if type(manifest) ~= "table" then return plan end local liveOptions = {} do local ok, live = pcall(d.modOptions) if ok and type(live) == "table" then liveOptions = live end end local haveIndex = {} for _, row in ipairs(d.indexes() or {}) do if type(row.url) == "string" then haveIndex[row.url] = true end if type(row.feed) == "string" then haveIndex[row.feed] = true end end for _, url in ipairs(manifest.indexes or {}) do if type(url) == "string" and url ~= "" and not haveIndex[url] then plan.indexes[#plan.indexes + 1] = url haveIndex[url] = true end end local installed = {} for _, row in ipairs(d.installed() or {}) do if type(row) == "table" and type(row.id) == "string" then installed[row.id] = row end end for _, mod in ipairs(manifest.mods or {}) do if type(mod) == "table" and type(mod.id) == "string" then local here = installed[mod.id] local available = here ~= nil if not here then local entry = d.findEntry(mod.id) if entry then available = true plan.toInstall[#plan.toInstall + 1] = { id = mod.id, version = mod.version, entry = entry } else plan.missing[#plan.missing + 1] = { id = mod.id, version = mod.version, source = mod.source } end end if available then local answers = (here and here.enabledByVersion) or {} for _, version in ipairs(mod.enabledFor or {}) do if answers[version] ~= true then plan.toEnable[#plan.toEnable + 1] = { id = mod.id, version = version } end end local wanted = sanitizeOptions(mod.options) if wanted and not sameOptions(wanted, liveOptions[mod.id]) then plan.options[#plan.options + 1] = { id = mod.id, values = wanted } end end end end return plan end function SyncMods.planEmpty(plan) if type(plan) ~= "table" then return true end if plan.applyOptions and #(plan.options or {}) > 0 then return false end return #(plan.indexes or {}) == 0 and #(plan.toInstall or {}) == 0 and #(plan.toEnable or {}) == 0 end function SyncMods.planHasOptions(plan) return type(plan) == "table" and #(plan.options or {}) > 0 end function SyncMods.optionsAnswered(plan) return not SyncMods.planHasOptions(plan) or plan.applyOptions ~= nil end function SyncMods.answerOptions(plan, importThem) if type(plan) ~= "table" then return false end plan.applyOptions = importThem and true or false return plan.applyOptions end function SyncMods.optionModIds(plan) local out = {} for _, row in ipairs((type(plan) == "table" and plan.options) or {}) do out[#out + 1] = row.id end return out end function SyncMods.steps(plan, given) local d = deps(given) local out = {} if type(plan) ~= "table" then return out end local broken = {} for _, url in ipairs(plan.indexes or {}) do out[#out + 1] = { label = url, run = function() local ok, err = d.addIndex(url) if not ok then return nil, tostring(err or url) end return true end } end for _, mod in ipairs(plan.toInstall or {}) do out[#out + 1] = { label = mod.id, run = function() local ok, err = d.install(mod.entry) if not ok then broken[mod.id] = true return nil, mod.id .. ": " .. tostring(err or "install failed") end return true end } end for _, want in ipairs(plan.toEnable or {}) do out[#out + 1] = { label = want.id, run = function() if broken[want.id] then return true end local ok, err = d.setEnabled(want.id, true, want.version) if ok == false then return nil, want.id .. ": " .. tostring(err or "could not enable") end return true end } end if plan.applyOptions then for _, want in ipairs(plan.options or {}) do out[#out + 1] = { label = want.id, run = function() if broken[want.id] then return true end local ok, err = d.setOptions(want.id, want.values) if ok == false then return nil, want.id .. ": " .. tostring(err or "could not set the mod options") end return true end } end end return out end function SyncMods.apply(plan, progress, given) if type(plan) ~= "table" then return false, "nothing to apply" end local steps = SyncMods.steps(plan, given) local failures = {} for i, step in ipairs(steps) do local ok, err = step.run() if not ok then failures[#failures + 1] = err end if progress then progress(i, #steps, step.label) end end if #failures > 0 then return false, table.concat(failures, "; ") end return true end return SyncMods