diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index d5e20f2b..004c5bfa 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -672,12 +672,10 @@ function SaveData.defaultOptions() -- GitHub release checks for mods with a manifest "github" field -- (src/mods/ModUpdate.lua). Keyed by owner/repo; TTL is six hours. modUpdateCache = {}, - -- Community mod indexes the player has chosen to browse - -- (src/mods/ModIndex.lua), in the order they added them. Empty by - -- default and never populated automatically: adding an index is how a - -- player says they trust whoever publishes it, so the launcher asks - -- rather than shipping one. Rows are { url, feed, base, fallback, - -- label }. + -- Player-added mod indexes, in their chosen order. ModIndex.sources() + -- includes the permanent main index alongside these and reuses any + -- main-index row saved by an older launcher. Rows are + -- { url, feed, base, fallback, label }. modIndexes = {}, -- Parsed index listings keyed by feed URL; TTL is 24 hours, matching how -- often the feeds themselves rebuild. diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 6536c606..9b577a46 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -5005,10 +5005,11 @@ local function buildFilterModal(imp, m) action = function() imp._filterPopup = nil end }) end --- Index manager: every source with its Remove, plus Add and Refresh all. +-- Index manager: built-in source and removable additions, plus Add and Refresh all. -- This replaces both the old always-visible source rows above the search -- field and the lone "Add index" header button. local function buildIndexesModal(imp, m) + local ModIndex = require("src.mods.ModIndex") local sources = imp.findSources or {} local pad = math.floor(18 * m.s) local w = math.floor(520 * m.s) @@ -5031,17 +5032,24 @@ local function buildIndexesModal(imp, m) else for i, source in ipairs(sources) do local feed = source.feed + local builtIn = ModIndex.isBuiltIn(feed) + local actionLabel = builtIn and Strings("Built-in") or Strings("Remove") local id = "idx-rm-" .. tostring(feed) local cy = place(id, (i - 1) * (rowH + gap), rowH) - local rmW = Kit.textWidth("small", Strings("Remove")) + local rmW = Kit.textWidth("small", actionLabel) + math.floor(20 * m.s) Kit.text("small", Kit.ellipsize("small", source.label or feed, rw - rmW - math.floor(12 * m.s)), x, cy + (rowH - Kit.textHeight("small")) / 2, PAL.detail) - btn(imp, x + rw - rmW, cy, rmW, rowH, - id, Strings("Remove"), { - kind = "danger", font = "small", - action = function() imp:_removeIndex(feed) end }) + if builtIn then + Kit.text("small", actionLabel, x + rw - rmW, + cy + (rowH - Kit.textHeight("small")) / 2, PAL.muted) + else + btn(imp, x + rw - rmW, cy, rmW, rowH, + id, actionLabel, { + kind = "danger", font = "small", + action = function() imp:_removeIndex(feed) end }) + end end end btn(imp, x, place("idx-add", addY, m.btnH), rw, m.btnH, "idx-add", diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 626bc4c8..9ef3002f 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -8032,10 +8032,8 @@ end -- -- The index is metadata only (src/mods/ModIndex.lua): it says where a mod's -- zip lives, and the install runs through exactly the same path "Import mod --- .zip" does. Nothing here is automatic -- no index ships with the launcher, --- and the tab stays an empty "Add an index" prompt until the player names one, --- because subscribing to somebody's list of mods is a trust decision and not a --- default. +-- .zip" does. The main index is included by default; players can add other +-- indexes alongside it. Installing a listed mod remains an explicit action. -- -- Fetching is the same synchronous curl the update checks already use, cached -- in options for a day, so the first open of the tab costs one round trip and @@ -8556,9 +8554,7 @@ function RomImporter:_pumpFindStats() if next(pending) == nil then self._findStatsPending = nil end end --- Open the "add an index" text prompt. Deliberately a typed URL rather than a --- picked-from-a-list affair: there is no blessed index, and presenting one --- would make the launcher's choice look like an endorsement. +-- Open the text prompt for an additional index URL. function RomImporter:_promptAddIndex() self._indexPrompt = { text = "" } self:_armTextInput() diff --git a/src/mods/ModIndex.lua b/src/mods/ModIndex.lua index 7208d48d..48f7f5ea 100644 --- a/src/mods/ModIndex.lua +++ b/src/mods/ModIndex.lua @@ -11,9 +11,8 @@ -- no network) so the engine tier can table-drive it, and the fetch/cache half -- reaches for curl and options.lua. -- --- Sources are never added automatically. options.modIndexes is a player-built --- list -- adding an index is a deliberate act of trusting whoever publishes it, --- so the launcher ships with none and asks. +-- The main index is built in and cannot be removed. options.modIndexes keeps +-- player-added sources, including main-index rows saved by older launchers. -- -- schema_version is a hard gate, not a hint: a bumped feed may reuse a field -- name for something else, so an unknown version is refused outright rather @@ -637,18 +636,34 @@ local function loadOptions() return require("src.core.SaveData").loadOptions() end --- The player's index list, normalised. Rows are { url, feed, base, fallback, +local BUILTIN = ModIndex.resolveSource("bryanthaboi/gen1recomp-mod-index") +BUILTIN.url = "bryanthaboi/gen1recomp-mod-index" + +function ModIndex.isBuiltIn(feed) + return feed == BUILTIN.feed +end + +-- The player's index list plus the built-in source. Rows are { url, feed, base, fallback, -- label }; `url` is what they typed, kept so the row reads back the way they --- entered it. +-- entered it. Keep an existing main-index row in place to preserve source +-- precedence and its cache; otherwise append it without rewriting options. function ModIndex.sources() local ok, opts = pcall(loadOptions) - if not ok or type(opts) ~= "table" then return {} end - local out = {} - for _, row in ipairs(opts.modIndexes or {}) do + local saved = ok and type(opts) == "table" and opts.modIndexes or {} + local out, hasBuiltin = {}, false + for _, row in ipairs(type(saved) == "table" and saved or {}) do if type(row) == "table" and type(row.feed) == "string" then - out[#out + 1] = row + if not ModIndex.isBuiltIn(row.feed) or not hasBuiltin then + out[#out + 1] = row + end + if ModIndex.isBuiltIn(row.feed) then hasBuiltin = true end end end + if not hasBuiltin then + local row = {} + for k, v in pairs(BUILTIN) do row[k] = v end + out[#out + 1] = row + end return out end @@ -657,6 +672,7 @@ end function ModIndex.addSource(input) local source, err = ModIndex.resolveSource(input) if not source then return nil, err end + if ModIndex.isBuiltIn(source.feed) then return nil, "that index is already added" end local ok, result, addErr = pcall(function() local SaveData = require("src.core.SaveData") local opts = loadOptions() @@ -680,6 +696,7 @@ end -- outlives the index it came from. function ModIndex.removeSource(feed) if type(feed) ~= "string" or feed == "" then return nil, "missing index" end + if ModIndex.isBuiltIn(feed) then return nil, "the built-in index cannot be removed" end local ok, result = pcall(function() local SaveData = require("src.core.SaveData") local opts = loadOptions() diff --git a/tests/engine/cart_launcher.lua b/tests/engine/cart_launcher.lua index 9c21701f..15956e13 100644 --- a/tests/engine/cart_launcher.lua +++ b/tests/engine/cart_launcher.lua @@ -1229,6 +1229,7 @@ check(find.findBase == nil, "with no base-game filter armed") find.tab = "find" find.modScope = nil find.findLoaded = true +find._findFetch = nil find.findSources = { { feed = "https://example.test/data/index.json", base = "https://example.test/", label = "example/index" } } diff --git a/tests/engine/launcher_modal_scroll_2437.lua b/tests/engine/launcher_modal_scroll_2437.lua index 0a19617a..c371e1a6 100644 --- a/tests/engine/launcher_modal_scroll_2437.lua +++ b/tests/engine/launcher_modal_scroll_2437.lua @@ -8,6 +8,7 @@ local View = require("src.import.LauncherView") local Transition = require("src.ui.kit.Transition") local Importer = require("src.import.RomImporter") local GameVersion = require("src.core.GameVersion") +local ModIndex = require("src.mods.ModIndex") local width, height = 853, 480 love.graphics.getDimensions = function() return width, height end @@ -23,6 +24,7 @@ local function fixture(tab) local imp = Importer.new(function() end, { launcher = true, onEditSave = function() end }) for _, v in ipairs(GameVersion.ORDER) do imp.ready[v] = true end imp._ensureSlots, imp._ensureMods = function() end, function() end + imp._ensureFind = function() end imp._refreshMods = function() end imp._resetModOrder = function(self) self.didReset = true end imp.mods = {} @@ -44,6 +46,7 @@ local MODALS = { open = function(imp) imp.findSources = {} for i = 1, 6 do imp.findSources[i] = { feed = "https://x/" .. i, label = "Index " .. i } end + imp.findSources[1] = ModIndex.resolveSource("bryanthaboi/gen1recomp-mod-index") imp._indexManage = true end }, } @@ -85,7 +88,9 @@ for _, size in ipairs({ { 853, 480 }, { 780, 360 }, { 1280, 720 } }) do local state = imp._modalScroll and imp._modalScroll[spec.key] T.check(state ~= nil and state.rect ~= nil, tag .. " records a scroll rect") local closeSeen, escaped = false, {} + local removes = 0 for _, r in ipairs(rects) do + if r.label == "Remove" then removes = removes + 1 end if r.clip then if not inside(r.clip, width, height) then escaped[#escaped + 1] = r.label .. " clip" end if state and not within(r.clip, state.rect) then escaped[#escaped + 1] = r.label .. " clip outside body" end @@ -98,6 +103,9 @@ for _, size in ipairs({ { 853, 480 }, { 780, 360 }, { 1280, 720 } }) do end end T.check(closeSeen, tag .. " draws a pinned Close") + if spec.key == "_indexManage" then + T.eq(removes, 5, tag .. " only offers Remove for the five custom indexes") + end T.check(#escaped == 0, tag .. " keeps every control on screen or in the scroll clip: " .. table.concat(escaped, ", ")) if state and state.rect then diff --git a/tests/engine/launcher_update_all.lua b/tests/engine/launcher_update_all.lua index f72de42e..d6cc0804 100644 --- a/tests/engine/launcher_update_all.lua +++ b/tests/engine/launcher_update_all.lua @@ -108,6 +108,7 @@ local function launcher(secondStatus, feedCarts) findIndex = { mods = {}, carts = feedCarts or {} }, }, RomImporter) ri._refreshMods = function() end + ri._refreshFindSources = function(self) self.findSources = {} end return ri end diff --git a/tests/engine/mod_index_tests.lua b/tests/engine/mod_index_tests.lua index 8d30f22b..6d509ff8 100644 --- a/tests/engine/mod_index_tests.lua +++ b/tests/engine/mod_index_tests.lua @@ -48,6 +48,66 @@ do check(bad == nil and err ~= nil, "nil input soft-fails") end +-- ------- permanent main source, including options saved before it was built in + +do + local oldSaveData = package.loaded["src.core.SaveData"] + local opts, writes = {}, 0 + package.loaded["src.core.SaveData"] = { + loadOptions = function() return opts end, + saveOptions = function(saved) opts = saved; writes = writes + 1; return saved end, + } + local main = ModIndex.resolveSource("bryanthaboi/gen1recomp-mod-index") + local custom = ModIndex.resolveSource("other/community-index") + local cached = { checkedAt = 123, mods = { { id = "existing" } } } + + local sources = ModIndex.sources() + eq(#sources, 1, "fresh options include the main index") + eq(sources[1].feed, main.feed, "the default uses the main feed") + eq(sources[1].fallback, main.fallback, "the default includes the raw fallback") + sources[1].feed = "changed by caller" + eq(ModIndex.sources()[1].feed, main.feed, "callers cannot alter the built-in definition") + eq(writes, 0, "reading sources does not rewrite options") + + for _, url in ipairs({ "bryanthaboi/gen1recomp-mod-index", + "https://github.com/bryanthaboi/gen1recomp-mod-index", + main.base, main.base:sub(1, -2), main.feed }) do + local savedMain = ModIndex.resolveSource(url) + savedMain.url = url + opts = { modIndexes = { custom, savedMain, main }, + modIndexCache = { [main.feed] = cached } } + sources = ModIndex.sources() + eq(#sources, 2, "an already-added main index is listed once: " .. url) + eq(sources[1], custom, "the existing source precedence is preserved") + eq(sources[2], savedMain, "the first saved main-index row is reused") + eq(sources[2].url, url, "the player's original URL is preserved") + eq(ModIndex.readCache(main.feed), cached, "the existing main-index cache survives") + local added, addErr = ModIndex.addSource(url) + check(added == nil and addErr ~= nil, "the built-in source cannot be added again") + local removed, removeErr = ModIndex.removeSource(main.feed) + check(removed == nil and removeErr ~= nil, "an older main-index row cannot be removed") + eq(opts.modIndexCache[main.feed], cached, "blocked removal keeps its cache") + end + eq(writes, 0, "duplicate additions and blocked removals do not write options") + + opts = { modIndexes = { custom }, modIndexCache = { [custom.feed] = cached } } + sources = ModIndex.sources() + eq(#sources, 2, "existing custom-only options gain the default") + eq(sources[1], custom, "adding the default preserves custom-source precedence") + eq(sources[2].feed, main.feed, "the missing main source is appended") + check(ModIndex.isBuiltIn(main.feed), "the main index is protected") + check(not ModIndex.isBuiltIn(custom.feed), "a custom index remains removable") + check(ModIndex.removeSource(custom.feed), "a custom source can still be removed") + eq(opts.modIndexCache[custom.feed], nil, "custom-source removal clears its cache") + eq(#ModIndex.sources(), 1, "removing the last custom source leaves the main index") + check(ModIndex.addSource("other/community-index") ~= nil, "custom sources can still be added") + eq(#ModIndex.sources(), 2, "the added custom source appears beside the main index") + + package.loaded["src.core.SaveData"].loadOptions = function() error("unavailable options") end + eq(ModIndex.sources()[1].feed, main.feed, "the main index remains available if options cannot load") + package.loaded["src.core.SaveData"] = oldSaveData +end + do local base = "https://bryanthaboi.github.io/gen1recomp-mod-index/" eq(ModIndex.joinUrl(base, "data/mods/bryanthaboi@nuzlocke/thumbnail.png"), @@ -351,6 +411,20 @@ end -- ------- search / filter +do + local index = ModIndex.parse(feed({ + { id = "untamed_advanced", title = "Untamed Advanced", + games = { "firered", "leafgreen", "emerald" }, categories = { "GAMEPLAY" } }, + })) + for _, game in ipairs({ "gen3", "firered", "leafgreen", "emerald" }) do + local rows = ModIndex.filter(index.mods, { game = game, query = "untamed" }) + eq(#rows, 1, "Untamed Advanced appears when filtering by " .. game) + eq(rows[1] and rows[1].id, "untamed_advanced", "the filtered listing is Untamed Advanced") + end + eq(#ModIndex.filter(index.mods, { game = "gen2", query = "untamed" }), 0, + "Untamed Advanced remains excluded from unsupported generations") +end + do local mods = { { id = "nuzlocke", title = "Nuzlocke", author = "bryanthaboi", @@ -613,4 +687,4 @@ do "a cartless feed offers no base games") end -print("ok mod_index_tests") +T.finish("mod index")