From 1bc252741a4baad767085f0bbdffe41dbaaebff1 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 31 Jul 2026 11:58:21 -0400 Subject: [PATCH] rom finder --- docs/new-features.md | 9 + src/core/SaveData.lua | 10 + src/import/RomImporter.lua | 887 ++++++++++++++++++++++++++++++- src/mods/LauncherMods.lua | 23 + src/mods/ModIndex.lua | 623 ++++++++++++++++++++++ tests/engine/mod_index_tests.lua | 276 ++++++++++ 6 files changed, 1822 insertions(+), 6 deletions(-) create mode 100644 src/mods/ModIndex.lua create mode 100644 tests/engine/mod_index_tests.lua diff --git a/docs/new-features.md b/docs/new-features.md index dadd454b..e248b4bb 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -381,3 +381,12 @@ kind, number, height/weight, dex text) to a PNG at 4x scale under `prints/` in the save directory, then reports the filename in a dialog. No printer hardware or link cable emulation involved; the file is the printout. + +## Find Mods (community mod indexes) + +A FIND MODS tab sits beside MODS in the launcher and browses a published +mod index: a metadata-only feed listing mods that live in their authors' +own repositories. No index ships with the launcher and none is ever added +automatically, so the tab opens on an "Add an index" prompt until you name +one; paste an index URL or its `owner/repo` and it is remembered in +`options.lua`. More than one index can be added, and the listings merge. diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index fe4fdbcb..45f752ad 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -252,6 +252,16 @@ 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 }. + modIndexes = {}, + -- Parsed index listings keyed by feed URL; TTL is 24 hours, matching how + -- often the feeds themselves rebuild. + modIndexCache = {}, -- On-screen touch overlay (Android/iOS; see src/core/TouchControls.lua). -- enabled=false hides it permanently (distinct from auto-hide-on-gamepad). -- positions are optional normalized centers {x=0..1, y=0..1} per control diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 24ea32d4..0db7980c 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -590,6 +590,15 @@ function RomImporter.new(onComplete, opts) -- modScroll is the list scroll offset (px, clamped in draw); modNotice is -- the last install/delete result { ok, text } shown as a line above the list. mods = nil, modScroll = 0, modNotice = nil, + -- FIND MODS panel state (src/mods/ModIndex.lua). findLoaded gates the + -- first fetch the way `mods = nil` gates the mods list, but it is a flag + -- rather than a nil listing because "no index added" is a legitimate + -- loaded state and must not re-fetch every frame. findSources is the + -- player's index list from options; findIndex is the merged listing; + -- _findThumbs caches one image per mod id (false = fetched and failed). + findLoaded = false, findSources = nil, findIndex = nil, + findScroll = 0, findNotice = nil, findQuery = "", findCategory = nil, + _findSearchFocus = false, _findThumbs = nil, -- Page scroll offset (px) for the column under the tab bar -- panel, updater -- banner and footer -- used only while that column is taller than the window -- (see draw()). Clamped against content in draw, reset on a tab change. @@ -1248,7 +1257,7 @@ function RomImporter:_activatePadCursor() end function RomImporter:_cycleTab(delta) - local order = { "red", "blue", "yellow", "mods" } + local order = { "red", "blue", "yellow", "mods", "find" } local idx = 1 for i, id in ipairs(order) do if id == self.tab then idx = i; break end @@ -1257,6 +1266,7 @@ function RomImporter:_cycleTab(delta) self.tab = order[idx] self._slotPress = nil self._modPress = nil + self._findSearchFocus = false end function RomImporter:_updatePadCursor(dt) @@ -1308,6 +1318,12 @@ function RomImporter:_updatePadCursor(dt) local next = (self.modScroll or 0) + step self.modScroll = math.max(0, math.min(maxS, next)) end + elseif self.tab == "find" then + local maxS = self._findMax or 0 + if maxS > 0 then + local next = (self.findScroll or 0) + step + self.findScroll = math.max(0, math.min(maxS, next)) + end elseif GameVersion.VERSIONS[self.tab] then local maxS = (self._slotMax and self._slotMax[self.tab]) or 0 if maxS > 0 then @@ -1636,6 +1652,21 @@ function RomImporter:_resetFrameRects() self.modRects = nil self.modDeleteRects = nil self.modImportRect = nil + -- Same rule as the toggles above, and it started to bite once FIND MODS gave + -- the mods tab a neighbour: these two were rebuilt by the mods panel but + -- never cleared, so switching tabs left the last mod row's Update / Versions + -- labels clickable over whatever the next tab drew there (#433's shape). + self.modUpdateRects = nil + self.modVersionsRects = nil + -- Rebuilt only by the FIND MODS panel. + self.findAddRect = nil + self.findRefreshRect = nil + self.findSearchRect = nil + self.findCatRects = nil + self.findInstallRects = nil + self.findDetailRects = nil + self.findRepoRects = nil + self.findSourceRemoveRects = nil -- Rebuilt only by the active game panel's SAVE FILES card; nil elsewhere so -- the mods tab cannot inherit last frame's save Import/Export/open-folder hits. self.saveImportRect = nil @@ -1869,6 +1900,8 @@ function RomImporter:draw() local panelH if self.tab == "mods" then panelH = self:_drawModsPanel(cX, panelY, cW, cH, paged) + elseif self.tab == "find" then + panelH = self:_drawFindPanel(cX, panelY, cW, cH, paged) else panelH = self:_drawGamePanel(self.tab, cX, panelY, cW, cH, paged) end @@ -2099,8 +2132,66 @@ function RomImporter:draw() dx + 16 * s, dy + dh - 30 * s, dw - 32 * s, "left") end - -- Mod confirm / versions / release-notes overlays - if self._modConfirm or self._modVersions or self._modReleaseNotes then + -- "Add an index" prompt: the same field as the rename modal, sized for a URL + -- and with the caret pinned to the tail so a long one stays readable while + -- it is typed. + if self._indexPrompt then + col(PAL.bgBot, 0.72) + love.graphics.rectangle("fill", 0, 0, width, height) + local dw = math.min(appW - 32 * s, 520 * s) + local dh = 168 * s + local dx = appX + (appW - dw) / 2 + local dy = (height - dh) / 2 + local rr = 12 * s + neonGlow(dx, dy, dw, dh, rr, PAL.modDot, 0.4) + fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.9, 0.9) + love.graphics.setLineWidth(math.max(1, 1.2 * s)) + col(PAL.modDot, 0.55) + love.graphics.rectangle("line", dx, dy, dw, dh, rr, rr) + + love.graphics.setFont(self.slotNameFont) + col(PAL.white) + love.graphics.print(Strings("Add a mod index"), dx + 16 * s, dy + 14 * s) + love.graphics.setFont(self.hintFont) + col(PAL.detail) + love.graphics.printf( + Strings("Paste the index URL, or its owner/repo."), + dx + 16 * s, dy + 40 * s, dw - 32 * s, "left") + + local fx, fy = dx + 16 * s, dy + 66 * s + local fw, fh = dw - 32 * s, 32 * s + col(PAL.bgBot, 0.9) + love.graphics.rectangle("fill", fx, fy, fw, fh, 8 * s, 8 * s) + love.graphics.setLineWidth(math.max(1, s)) + col(PAL.cardBorder, 0.45) + love.graphics.rectangle("line", fx, fy, fw, fh, 8 * s, 8 * s) + love.graphics.setFont(self.hintFont) + col(PAL.heading) + -- keep the END of the URL visible: the interesting half is the tail + local text = self._indexPrompt.text or "" + local maxW = fw - 20 * s + local shown = text + while #shown > 0 and self.hintFont:getWidth(shown) > maxW do + shown = shown:sub(2) + end + love.graphics.print(shown, fx + 10 * s, + fy + (fh - self.hintFont:getHeight()) / 2) + if (self.pulse * 2 % 1) < 0.5 then + col(PAL.modDot) + love.graphics.rectangle("fill", + fx + 10 * s + self.hintFont:getWidth(shown) + 2 * s, + fy + 7 * s, math.max(1, 1.5 * s), fh - 14 * s) + end + + love.graphics.setFont(self.hintFont) + col(PAL.warning) + printfB(Strings("Enter to add - Esc to cancel"), + dx + 16 * s, dy + dh - 32 * s, dw - 32 * s, "left") + end + + -- Mod confirm / versions / release-notes / index-details overlays + if self._modConfirm or self._modVersions or self._modReleaseNotes + or self._findDetails then col(PAL.bgBot, 0.72) love.graphics.rectangle("fill", 0, 0, width, height) end @@ -2187,6 +2278,55 @@ function RomImporter:draw() printfB("Close", self._modReleaseNotesClose.x, self._modReleaseNotesClose.y + (closeH - self.hintFont:getHeight()) / 2, closeW, "center") + elseif self._findDetails then + -- The index's description markdown, stripped by the same cleanBody a + -- release changelog goes through. There is no markdown renderer in the + -- engine and a listing does not warrant one: the point is to read what the + -- author wrote before installing, not to reproduce their formatting. + local d = self._findDetails + local ModUpdate = require("src.mods.ModUpdate") + local dw = math.min(appW - 32 * s, 520 * s) + local dh = math.min(height - 48 * s, 420 * s) + local dx = appX + (appW - dw) / 2 + local dy = (height - dh) / 2 + local rr = 12 * s + fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.94, 0.94) + love.graphics.setLineWidth(math.max(1, 1.2 * s)) + col(PAL.modDot, 0.5) + love.graphics.rectangle("line", dx, dy, dw, dh, rr, rr) + love.graphics.setFont(self.slotNameFont) + col(PAL.white) + love.graphics.printf(ellipsize(self.slotNameFont, d.title, dw - 32 * s), + dx + 16 * s, dy + 12 * s, dw - 32 * s, "left") + local body = ModUpdate.cleanBody(d.body or "", 0) + if body == "" then body = "(No description.)" end + love.graphics.setFont(self.hintFont) + col(PAL.detail) + local textTop = dy + 44 * s + local textH = dh - 44 * s - 52 * s + local _, lines = self.hintFont:getWrap(body, dw - 32 * s) + local bodyH = #lines * self.hintFont:getHeight() + d.max = math.max(0, bodyH - textH) + d.scroll = clamp(d.scroll or 0, 0, d.max) + love.graphics.setScissor(math.floor(dx + 16 * s), math.floor(textTop), + math.ceil(dw - 32 * s), math.ceil(textH)) + love.graphics.printf(body, dx + 16 * s, textTop - d.scroll, + dw - 32 * s, "left") + love.graphics.setScissor() + local closeW = self.hintFont:getWidth("Close") + 28 * s + local closeH = 30 * s + self._findDetailsClose = { + x = dx + (dw - closeW) / 2, y = dy + dh - closeH - 12 * s, + width = closeW, height = closeH, + } + local chot = self:_hover(self._findDetailsClose) + col(PAL.disabled, chot and 0.55 or 0.35) + love.graphics.rectangle("fill", self._findDetailsClose.x, + self._findDetailsClose.y, closeW, closeH, 8 * s, 8 * s) + col(PAL.detail) + printfB("Close", self._findDetailsClose.x, + self._findDetailsClose.y + (closeH - self.hintFont:getHeight()) / 2, + closeW, "center") elseif self._modVersions then local ModUpdate = require("src.mods.ModUpdate") local v = self._modVersions @@ -2381,13 +2521,19 @@ end function RomImporter:mousepressed(x, y, button) if self._rename then return end -- the rename modal swallows all clicks + if self._indexPrompt then return end -- and so does the add-index prompt -- Mod confirm / versions / release-notes modals swallow clicks too. if self._modConfirm then if button ~= 1 then return end if inside(self._modConfirmYes, x, y) then local c = self._modConfirm self._modConfirm = nil - if c.kind == "update" then + -- An index install carries its whole entry: the confirm is the only + -- place the compatibility warnings were shown, so the install must not + -- be reachable by any other route. + if c.indexEntry then + self:_findInstall(c.indexEntry) + elseif c.kind == "update" then self:_confirmModUpdate(c.id, c.release) else self:_toggleMod(c.id, true) @@ -2404,6 +2550,13 @@ function RomImporter:mousepressed(x, y, button) end return end + if self._findDetails then + if button ~= 1 then return end + if inside(self._findDetailsClose, x, y) then + self._findDetails = nil + end + return + end if self._modVersions then if button ~= 1 then return end if inside(self._modVersionsClose, x, y) then @@ -2474,6 +2627,7 @@ function RomImporter:mousepressed(x, y, button) self._slotPress = nil -- drop any half-started slot drag on tab change self._modPress = nil -- and any half-started mod toggle press self._pagePress = nil -- and any half-started page pan + self._findSearchFocus = false -- and the search caret, now off screen -- Each tab is its own column of a different length; carrying one tab's -- offset into another lands somewhere arbitrary. self.pageScroll = 0 @@ -2587,6 +2741,47 @@ function RomImporter:mousepressed(x, y, button) return end end + -- FIND MODS panel. Everything here dispatches on press: none of it is a + -- toggle that a drag-scroll could be mistaken for, and the search field wants + -- focus the instant it is touched. + if inside(self.findAddRect, x, y) then + self:_promptAddIndex(); return + end + if inside(self.findRefreshRect, x, y) then + self._findSearchFocus = false + self:_refreshFind(true) + return + end + if inside(self.findSearchRect, x, y) then + self._findSearchFocus = true; return + end + for _, r in ipairs(self.findSourceRemoveRects or {}) do + if inside(r, x, y) then self:_removeIndex(r.id); return end + end + for _, r in ipairs(self.findCatRects or {}) do + if inside(r, x, y) then + -- the "All" chip carries the empty id; every other chip toggles itself + -- off when it is already the filter, so a second tap is the way back + self.findCategory = (r.id ~= "" and self.findCategory ~= r.id) and r.id or nil + self.findScroll = 0 + return + end + end + for _, r in ipairs(self.findDetailRects or {}) do + if inside(r, x, y) and r.entry then self:_findShowDetails(r.entry); return end + end + for _, r in ipairs(self.findRepoRects or {}) do + if inside(r, x, y) and r.entry and r.entry.repo then + love.system.openURL(r.entry.repo) + return + end + end + for _, r in ipairs(self.findInstallRects or {}) do + if inside(r, x, y) and r.entry then self:_findConfirmInstall(r.entry); return end + end + -- A press anywhere else on the tab drops the search caret, so the field does + -- not silently keep eating keystrokes once the player has moved on. + if self.tab == "find" then self._findSearchFocus = false end -- Nothing was hit. On a scrolling page that is a press on empty background, -- which is the natural place to grab and pan from. if armDrag and (self._pageMax or 0) > 0 then @@ -2605,9 +2800,29 @@ function RomImporter:keypressed(key) end return end - if self._modConfirm or self._modVersions or self._modReleaseNotes then + if self._indexPrompt then + if key == "backspace" then + self._indexPrompt.text = utf8Back(self._indexPrompt.text) + elseif key == "return" or key == "kpenter" then + self:_commitAddIndex() + elseif key == "escape" then + self._indexPrompt = nil + elseif key == "v" and (love.keyboard.isDown("lctrl", "rctrl", "lgui", "rgui")) then + -- an index URL is long and comes from a browser: typing it out by hand + -- is the difference between adding one and giving up + local ok, text = pcall(love.system.getClipboardText) + if ok and type(text) == "string" then + self._indexPrompt.text = self._indexPrompt.text .. text:gsub("%s", "") + end + end + return + end + if self._modConfirm or self._modVersions or self._modReleaseNotes + or self._findDetails then if key == "escape" then - if self._modReleaseNotes then + if self._findDetails then + self._findDetails = nil + elseif self._modReleaseNotes then self._modReleaseNotes = nil else self._modConfirm = nil @@ -2616,6 +2831,15 @@ function RomImporter:keypressed(key) end return end + if self._findSearchFocus then + if key == "backspace" then + self.findQuery = utf8Back(self.findQuery or "") + self.findScroll = 0 + elseif key == "escape" or key == "return" or key == "kpenter" then + self._findSearchFocus = false + end + return + end if self.workState == "working" then return end if key == "return" or key == "space" or key == "kpenter" then -- Enter acts on the visible game tab: Play if its ROM is ready, otherwise @@ -2778,6 +3002,11 @@ function RomImporter:_drawTabBar(x, y, w, h, chip) under = PAL.gold, label = Strings("YELLOW"), ink = PAL.chipInkGold }, { id = "mods", mods = true, top = PAL.chipModTop, bot = PAL.chipModBot, under = PAL.modDot, label = Strings("MODS") }, + -- Browsing a community index sits beside the installed list rather than + -- inside it: one answers "what do I have", the other "what is out there", + -- and the second is empty until the player adds an index of their own. + { id = "find", find = true, top = PAL.chipModTop, bot = PAL.chipModBot, + under = PAL.modDot, label = Strings("FIND MODS") }, } local gap = 10 * s local r = 12 * s @@ -2807,6 +3036,17 @@ function RomImporter:_drawTabBar(x, y, w, h, chip) love.graphics.rectangle("fill", gx + c2 * (d + gd), gy + row * (d + gd), d, d) end end + elseif t.find then + -- magnifier: a ring plus a handle running down-right out of it + local cr = chip * 0.20 + local ccx = cursorX + chip / 2 - cr * 0.35 + local ccy = chipY + chip / 2 - cr * 0.35 + col(PAL.modDot) + love.graphics.setLineWidth(math.max(1.5, 2 * s)) + love.graphics.circle("line", ccx, ccy, cr) + local d = cr * 0.72 + love.graphics.line(ccx + d, ccy + d, ccx + d + cr * 0.9, ccy + d + cr * 0.9) + love.graphics.setLineWidth(1) else love.graphics.setFont(self.chipFont) col(t.ink) @@ -3145,6 +3385,10 @@ end -- commits through SaveData.renameSlot (empty clears the label), Esc cancels. -- While it is up, keypressed/textinput/mousepressed all route here first. local MAX_SLOT_LABEL = 24 +-- Long enough for a Pages URL with a deep path; short enough that a paste of +-- something that is not a URL at all cannot fill options.lua. +local MAX_INDEX_URL = 200 +local MAX_FIND_QUERY = 48 function RomImporter:_beginRename(version, id) local label @@ -3164,6 +3408,18 @@ function RomImporter:_commitRename() end function RomImporter:textinput(text) + if self._indexPrompt then + -- URLs never contain a literal space, and a pasted one usually arrives + -- with a stray newline attached + self._indexPrompt.text = + utf8Cap(self._indexPrompt.text .. text:gsub("%s", ""), MAX_INDEX_URL) + return + end + if self._findSearchFocus then + self.findQuery = utf8Cap((self.findQuery or "") .. text, MAX_FIND_QUERY) + self.findScroll = 0 + return + end if not self._rename then return end self._rename.text = utf8Cap(self._rename.text .. text, MAX_SLOT_LABEL) end @@ -3263,6 +3519,13 @@ end -- content extent draw computed for that version. function RomImporter:wheelmoved(_, dy) local step = 48 * (self._s or 1) + -- An open modal owns the wheel: the page behind it is not what the player is + -- looking at, and a long description is the one thing here that needs it. + if self._findDetails then + self._findDetails.scroll = clamp( + (self._findDetails.scroll or 0) - dy * step, 0, self._findDetails.max or 0) + return + end -- An overflowing page scrolls as a whole; the panels' own lists are flattened -- in that mode, so there is never a second scroll region competing for this. local maxPage = self._pageMax or 0 @@ -3276,6 +3539,12 @@ function RomImporter:wheelmoved(_, dy) self.modScroll = clamp((self.modScroll or 0) - dy * step, 0, maxS) return end + if self.tab == "find" then + local maxS = self._findMax or 0 + if maxS <= 0 then return end + self.findScroll = clamp((self.findScroll or 0) - dy * step, 0, maxS) + return + end local version = self.panelVersion if not version or self.tab ~= version then return end local maxS = (self._slotMax and self._slotMax[version]) or 0 @@ -4064,4 +4333,610 @@ function RomImporter:_drawModsPanel(x, y, w, h, paged) return (top - y) + total end +-- ------- FIND MODS: browsing a community mod index ------------------------- +-- +-- 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. +-- +-- 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 +-- every later one is free until the player hits Refresh. + +-- The sources list, reloaded from options. Cheap; called whenever the panel +-- has reason to think the list changed. +function RomImporter:_refreshFindSources() + local ModIndex = require("src.mods.ModIndex") + local ok, rows = pcall(ModIndex.sources) + self.findSources = ok and rows or {} +end + +-- Fetch every source and merge into one listing. First source wins on a +-- duplicate id, matching how the mod loader resolves two mods with one id -- +-- there is one "nuzlocke" as far as the installer is concerned, so the panel +-- must not offer two. Per-source failures are collected rather than fatal: an +-- index that is down should cost its own rows, not everybody else's. +function RomImporter:_refreshFind(force) + local ModIndex = require("src.mods.ModIndex") + self:_refreshFindSources() + local mods, seen, cats, catSeen, errs = {}, {}, {}, {}, {} + local stale, oldest = false, nil + for _, source in ipairs(self.findSources or {}) do + local ok, index, err, meta = pcall(function() + return ModIndex.fetch(source, { force = force == true }) + end) + if not ok then + errs[#errs + 1] = (source.label or source.feed) .. ": " .. tostring(index) + elseif not index then + errs[#errs + 1] = (source.label or source.feed) .. ": " .. tostring(err) + else + if meta and meta.stale then stale = true end + if meta and meta.checkedAt then + oldest = math.min(oldest or meta.checkedAt, meta.checkedAt) + end + for _, entry in ipairs(index.mods or {}) do + if not seen[entry.id] then + seen[entry.id] = true + entry._source = source.label or source.feed + entry._base = source.base + mods[#mods + 1] = entry + end + end + for _, c in ipairs(ModIndex.categoriesIn(index)) do + if not catSeen[c] then catSeen[c] = true; cats[#cats + 1] = c end + end + end + end + self.findIndex = { mods = mods, categories = cats, stale = stale, + checkedAt = oldest } + self.findLoaded = true + if #errs > 0 then + self.findNotice = { ok = false, text = table.concat(errs, " - ") } + elseif force then + self.findNotice = { ok = true, + text = Strings("Refreshed - %d mods listed", #mods) } + end + -- A category that no longer exists after a refresh would filter everything + -- away with no way back except guessing. + if self.findCategory and not catSeen[self.findCategory] then + self.findCategory = nil + end +end + +function RomImporter:_ensureFind() + if not self.findLoaded then + self:_refreshFindSources() + if #(self.findSources or {}) == 0 then + -- Nothing to fetch, but the panel is loaded: the empty state is the + -- answer, not a pending request. + self.findIndex = { mods = {}, categories = {} } + self.findLoaded = true + else + self:_refreshFind(false) + end + end +end + +-- The rows the filters leave, and the installed-mod context the compatibility +-- warnings are judged against. +function RomImporter:_findRows() + local ModIndex = require("src.mods.ModIndex") + local all = (self.findIndex and self.findIndex.mods) or {} + return ModIndex.filter(all, { + query = self.findQuery, + category = self.findCategory, + }) +end + +function RomImporter:_findInstalledMap() + local map = {} + for _, m in ipairs(self.mods or {}) do map[m.id] = m.version or true end + return map +end + +-- One thumbnail per frame, and only for a card actually on screen: the fetch +-- is a blocking curl, so downloading a whole listing's worth on open would +-- stall the launcher for as many seconds as there are mods. A failure is +-- remembered as `false` so a broken URL is tried once, not every frame. +function RomImporter:_findThumb(entry) + self._findThumbs = self._findThumbs or {} + local cached = self._findThumbs[entry.id] + if cached ~= nil then return cached or nil end + if self._findThumbFetched then return nil end -- budget spent this frame + local ModIndex = require("src.mods.ModIndex") + local url = ModIndex.joinUrl(entry._base, entry.thumbnail) + if not url then + self._findThumbs[entry.id] = false + return nil + end + self._findThumbFetched = true + local ok, image = pcall(function() + local path, err = ModIndex.downloadThumbnail(url, entry.id) + if not path then error(err or "download failed", 0) end + return love.graphics.newImage(path) + end) + self._findThumbs[entry.id] = ok and image or false + return ok and image or nil +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. +function RomImporter:_promptAddIndex() + self._indexPrompt = { text = "" } +end + +function RomImporter:_commitAddIndex() + local prompt = self._indexPrompt + self._indexPrompt = nil + if not prompt then return end + local ModIndex = require("src.mods.ModIndex") + local row, err = ModIndex.addSource(prompt.text or "") + if not row then + self.findNotice = { ok = false, text = tostring(err) } + return + end + self.findNotice = { ok = true, text = Strings("Added %s", row.label or row.feed) } + self.findLoaded = false + self:_ensureFind() +end + +function RomImporter:_removeIndex(feed) + local ModIndex = require("src.mods.ModIndex") + local ok, err = ModIndex.removeSource(feed) + if not ok then + self.findNotice = { ok = false, text = tostring(err) } + return + end + self.findNotice = { ok = true, text = Strings("Index removed") } + self.findLoaded = false + self:_ensureFind() +end + +-- Fetch and show an entry's description markdown. Loaded on demand, never +-- with the listing: a feed of any size would otherwise be one request per mod. +function RomImporter:_findShowDetails(entry) + local ModIndex = require("src.mods.ModIndex") + local url = ModIndex.joinUrl(entry._base, entry.description_url) + local body = entry.summary or "" + if url then + local ok, text = pcall(ModIndex.fetchText, url) + if ok and type(text) == "string" and text ~= "" then body = text end + end + self._findDetails = { + title = entry.title or entry.id, + body = body, + scroll = 0, + } +end + +-- Arm the install confirm. The compatibility list is the whole point of the +-- dialog: the panel deliberately does not hide an incompatible mod (an index +-- entry can be months stale, and a hidden mod looks like a missing one), so +-- this is where the player is told what the author declared before anything +-- is downloaded. +function RomImporter:_findConfirmInstall(entry) + local ModIndex = require("src.mods.ModIndex") + local Version = require("src.core.Version") + local url, why = ModIndex.installUrl(entry) + if not url then + self.findNotice = { ok = false, + text = (entry.title or entry.id) .. ": " .. tostring(why) } + return + end + local installed = self:_findInstalledMap() + local issues = ModIndex.compatIssues(entry, { + modApi = Version.modApi, + engineVersion = Version.engine, + installed = installed, + }) + local version = ModIndex.displayVersion(entry) + local lines = { (entry.title or entry.id) .. " v" .. tostring(version) } + if entry.author then lines[#lines + 1] = "by " .. entry.author end + local have = installed[entry.id] + if have then + lines[#lines + 1] = "Replaces installed v" .. tostring(have) + end + for _, issue in ipairs(issues) do + lines[#lines + 1] = "! " .. issue.text + end + lines[#lines + 1] = "Mods are not reviewed - trust the author." + self._modConfirm = { + kind = (#issues > 0) and "warn" or "update", + indexEntry = entry, + title = have and "Reinstall mod" or "Install mod", + yesLabel = have and "Reinstall" or "Install", + lines = lines, + } +end + +function RomImporter:_findInstall(entry) + local name = entry.title or entry.id + self.findNotice = { ok = true, text = Strings("Downloading %s...", name) } + local ran, err = pcall(function() + local LauncherMods = require("src.mods.LauncherMods") + local ok, res = LauncherMods.installFromIndex(entry) + if ok then + -- The installed list is what the Install / Installed labels read, so it + -- has to be re-derived before the next paint or the card lies. + pcall(self._refreshMods, self) + self.findNotice = { ok = true, + text = Strings("Installed %s %s", name, tostring(res)) } + else + self.findNotice = { ok = false, text = tostring(res) } + end + end) + if not ran then + self.findNotice = { ok = false, text = "Install failed: " .. tostring(err) } + end +end + +-- The label + colour for an entry's install state, given what is installed. +local function findActionFor(entry, installedVersion) + local ModIndex = require("src.mods.ModIndex") + if not ModIndex.canInstall(entry) then + return nil, "Not installable from this index" + end + if not installedVersion then return "Install", nil end + local listed = ModIndex.displayVersion(entry) + local ModUpdate = require("src.mods.ModUpdate") + if type(installedVersion) == "string" + and ModUpdate.isNewer(installedVersion, listed) then + return "Update", "Installed v" .. installedVersion + end + return "Reinstall", "Installed v" .. tostring(installedVersion) +end + +-- FIND MODS panel. Header ("Find Mods" + count + Refresh / Add an index), +-- notice line, the source list, a search field and category chips, then the +-- listing. With no index added at all it collapses to a single dashed prompt. +-- `paged` behaves as everywhere else: no inner scroll region, the list is drawn +-- whole, and the returned natural height is what draw() measures the page on. +function RomImporter:_drawFindPanel(x, y, w, h, paged) + local s = self._s + self._findThumbFetched = false + self:_ensureFind() + self:_ensureMods() + local ModIndex = require("src.mods.ModIndex") + local sources = self.findSources or {} + local rows = self:_findRows() + local total = #((self.findIndex and self.findIndex.mods) or {}) + + self.findCatRects = {} + self.findInstallRects = {} + self.findDetailRects = {} + self.findRepoRects = {} + self.findSourceRemoveRects = {} + + -- header + love.graphics.setFont(self.gameNameFont) + col(PAL.white) + printB("Find Mods", x, y) + local nameW = self.gameNameFont:getWidth("Find Mods") + local headerH = self.gameNameFont:getHeight() + if #sources > 0 then + love.graphics.setFont(self.hintFont) + col(PAL.warning) + local countLabel = (#rows == total) + and Strings("%d mods listed", total) + or Strings("%d of %d mods", #rows, total) + love.graphics.print(countLabel, x + nameW + 14 * s, + y + (headerH - self.hintFont:getHeight()) / 2) + end + + local btnH = math.max(38 * s, self.saveBtnFont:getHeight() + 20 * s) + local btnY = y + (headerH - btnH) / 2 + local addLabel = (#sources == 0) and "Add an index" or "Add index" + local addW = math.min(w * 0.45, self.saveBtnFont:getWidth(addLabel) + 40 * s) + local addX = x + w - addW + self.findAddRect = + self:_glassyButton(addX, btnY, addW, btnH, addLabel, self.saveBtnFont, true) + if #sources > 0 then + local refLabel = "Refresh" + local refW = math.min(w * 0.3, self.saveBtnFont:getWidth(refLabel) + 36 * s) + self.findRefreshRect = self:_glassyButton(addX - refW - 8 * s, btnY, + refW, btnH, refLabel, self.saveBtnFont, true) + end + + local top = y + headerH + 14 * s + + -- notice line: the last add / refresh / install result, else the standing + -- reminder that a listing is not a review + love.graphics.setFont(self.hintFont) + if self.findNotice then + col(self.findNotice.ok and PAL.green or PAL.red) + love.graphics.printf(self.findNotice.text, x, top, w, "left") + else + col(PAL.warning) + love.graphics.printf( + Strings("Mods here are listed, not reviewed - read the source and trust the author."), + x, top, w, "left") + end + top = top + self.hintFont:getHeight() + 12 * s + + -- no index: one dashed prompt and nothing else. This is the whole tab until + -- the player names a feed. + if #sources == 0 then + local boxH = 150 * s + love.graphics.setLineWidth(math.max(1, 1 * s)) + col(PAL.cardBorder, 0.45) + dashedRoundRect(x, top, w, boxH, 14 * s, 7 * s, 5 * s) + love.graphics.setFont(self.stateFont) + col(PAL.heading) + printfB(Strings("No mod index added"), x + 16 * s, top + boxH / 2 + - self.stateFont:getHeight(), w - 32 * s, "center") + love.graphics.setFont(self.hintFont) + col(PAL.warning) + love.graphics.printf( + Strings("Add an index to browse mods. An index is a published list; paste its URL or its owner/repo."), + x + 24 * s, top + boxH / 2 + 4 * s, w - 48 * s, "center") + self._findMax = 0 + return (top - y) + boxH + end + + -- source rows: which indexes are feeding this list, each with a Remove + local srcH = self.hintFont:getHeight() + 10 * s + for _, source in ipairs(sources) do + love.graphics.setFont(self.hintFont) + col(PAL.detail) + local remW = self.hintFont:getWidth("Remove") + 20 * s + love.graphics.print( + ellipsize(self.hintFont, source.label or source.feed, w - remW - 20 * s), + x + 2 * s, top + 5 * s) + local rrect = self:_chipButton(x + w - remW, top, "Remove", { + w = remW, h = srcH, id = source.feed, kind = "danger", + }) + self.findSourceRemoveRects[#self.findSourceRemoveRects + 1] = rrect + top = top + srcH + 4 * s + end + top = top + 6 * s + + -- search field: click to focus, type to filter. Not a modal -- the results + -- have to move while the player types or the field is guesswork. + local fieldH = 30 * s + local focused = self._findSearchFocus == true + col(PAL.bgBot, 0.9) + love.graphics.rectangle("fill", x, top, w, fieldH, 8 * s, 8 * s) + love.graphics.setLineWidth(math.max(1, s)) + col(focused and PAL.green or PAL.cardBorder, focused and 0.7 or 0.45) + love.graphics.rectangle("line", x, top, w, fieldH, 8 * s, 8 * s) + love.graphics.setFont(self.detailFont) + local query = self.findQuery or "" + if query == "" and not focused then + col(PAL.disabledInk) + love.graphics.print(Strings("Search mods"), x + 10 * s, + top + (fieldH - self.detailFont:getHeight()) / 2) + else + col(PAL.heading) + local shown = ellipsize(self.detailFont, query, w - 20 * s) + love.graphics.print(shown, x + 10 * s, + top + (fieldH - self.detailFont:getHeight()) / 2) + if focused and (self.pulse * 2 % 1) < 0.5 then + col(PAL.green) + love.graphics.rectangle("fill", + x + 10 * s + self.detailFont:getWidth(shown) + 2 * s, + top + 6 * s, math.max(1, 1.5 * s), fieldH - 12 * s) + end + end + self.findSearchRect = { x = x, y = top, width = w, height = fieldH } + self:_hover(self.findSearchRect) + top = top + fieldH + 10 * s + + -- category chips: "All" plus whatever the feeds actually use + local cats = (self.findIndex and self.findIndex.categories) or {} + if #cats > 0 then + local chipH = self.hintFont:getHeight() + 8 * s + local cx, cy = x, top + local function catChip(label, id, active) + local cw = self.hintFont:getWidth(label) + 20 * s + if cx + cw > x + w and cx > x then + cx = x + cy = cy + chipH + 6 * s + end + local rect = { x = cx, y = cy, width = cw, height = chipH, id = id } + local hot = self:_hover(rect) + col(active and PAL.green or PAL.cardBorder, active and 0.18 or 0.10) + love.graphics.rectangle("fill", cx, cy, cw, chipH, chipH / 2, chipH / 2) + love.graphics.setLineWidth(1) + col(active and PAL.green or PAL.cardBorder, active and 0.6 or 0.35) + love.graphics.rectangle("line", cx, cy, cw, chipH, chipH / 2, chipH / 2) + love.graphics.setFont(self.hintFont) + col(active and PAL.green or (hot and PAL.heading or PAL.detail)) + printfB(label, cx, cy + (chipH - self.hintFont:getHeight()) / 2, cw, "center") + self.findCatRects[#self.findCatRects + 1] = rect + cx = cx + cw + 6 * s + end + catChip("All", "", self.findCategory == nil) + for _, c in ipairs(cats) do + catChip(c, c, self.findCategory == c) + end + top = cy + chipH + 12 * s + end + + local listH = math.max(0, (y + h) - top) + + if #rows == 0 then + local boxH = paged and (110 * s) or math.min(listH, 110 * s) + love.graphics.setLineWidth(math.max(1, 1 * s)) + col(PAL.cardBorder, 0.45) + dashedRoundRect(x, top, w, boxH, 14 * s, 7 * s, 5 * s) + love.graphics.setFont(self.hintFont) + col(PAL.warning) + local hint = (total == 0) + and Strings("This index lists no mods yet.") + or Strings("No mods match that search.") + love.graphics.printf(hint, x + 16 * s, + top + boxH / 2 - self.hintFont:getHeight() / 2, w - 32 * s, "center") + self._findMax = 0 + return (top - y) + boxH + end + + -- card metrics. The thumbnail column is fixed whether or not a given entry + -- has one, so rows stay aligned down the list. + local padH, padV = 16 * s, 14 * s + local cardGap, cardR = 10 * s, 14 * s + local thumbW = 64 * s + local innerW = w - 2 * padH + local chipH = self.hintFont:getHeight() + 8 * s + local rowBtnH = self.hintFont:getHeight() + 10 * s + local btnGap = 8 * s + local installed = self:_findInstalledMap() + + love.graphics.setFont(self.stateFont) + local nameH = self.stateFont:getHeight() + + local layout, totalH = {}, 0 + for i, entry in ipairs(rows) do + local action, note = findActionFor(entry, installed[entry.id]) + local detW = self.hintFont:getWidth("Details") + 24 * s + local repoW = entry.repo and (self.hintFont:getWidth("Source") + 24 * s) or 0 + local actW = action and (self.hintFont:getWidth(action) + 24 * s) or 0 + local btnRowW = detW + if repoW > 0 then btnRowW = btnRowW + btnGap + repoW end + if actW > 0 then btnRowW = btnRowW + btnGap + actW end + local leftX = thumbW + 12 * s + local textW = math.max(60 * s, innerW - leftX) + local summaryH = 0 + if entry.summary ~= "" then + local _, sl = self.hintFont:getWrap(entry.summary, textW) + summaryH = math.max(1, #sl) * self.hintFont:getHeight() + end + local metaH = self.hintFont:getHeight() + 2 * s -- version + author + if note then metaH = metaH + self.hintFont:getHeight() + 2 * s end + if summaryH > 0 then metaH = metaH + 2 * s + summaryH end + local bodyH = math.max(nameH + 4 * s + metaH, thumbW) + local cardH = padV * 2 + bodyH + 10 * s + rowBtnH + layout[i] = { h = cardH, textW = textW, leftX = leftX, action = action, + note = note, detW = detW, repoW = repoW, actW = actW, + btnRowW = btnRowW, summaryH = summaryH } + totalH = totalH + cardH + end + totalH = totalH + (#rows - 1) * cardGap + + if paged then listH = totalH end + local maxScroll = math.max(0, totalH - listH) + self._findMax = maxScroll + local scroll = clamp(self.findScroll or 0, 0, maxScroll) + self.findScroll = scroll + + if not paged then + love.graphics.setScissor(math.floor(x), math.floor(top), + math.ceil(w), math.ceil(listH)) + end + local cy = top - scroll + for i, entry in ipairs(rows) do + local L = layout[i] + local cardH = L.h + if cy + cardH >= top and cy <= top + listH then + roundedCard(x, cy, w, cardH, cardR) + local nx = x + padH + local ny = cy + padV + + -- thumbnail, or a placeholder tile so the text column never shifts + local image = self:_findThumb(entry) + if image then + local iw, ih = image:getDimensions() + local fit = math.min(thumbW / iw, thumbW / ih) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(image, nx + (thumbW - iw * fit) / 2, + ny + (thumbW - ih * fit) / 2, 0, fit, fit) + else + col(PAL.cardBorder, 0.18) + love.graphics.rectangle("fill", nx, ny, thumbW, thumbW, 8 * s, 8 * s) + love.graphics.setFont(self.warningFont) + col(PAL.disabledInk) + printfB("MOD", nx, ny + (thumbW - self.warningFont:getHeight()) / 2, + thumbW, "center") + end + + local tx = nx + L.leftX + love.graphics.setFont(self.stateFont) + col(PAL.white) + printB(ellipsize(self.stateFont, entry.title or entry.id, L.textW), tx, ny) + + love.graphics.setFont(self.hintFont) + col(PAL.detail) + local metaY = ny + nameH + 4 * s + local meta = "v" .. tostring(ModIndex.displayVersion(entry)) + if entry.author then meta = meta .. " - " .. entry.author end + if entry.categories[1] then meta = meta .. " - " .. entry.categories[1] end + love.graphics.print(ellipsize(self.hintFont, meta, L.textW), tx, metaY) + metaY = metaY + self.hintFont:getHeight() + 2 * s + if L.note then + col(PAL.playTop) + love.graphics.print(ellipsize(self.hintFont, L.note, L.textW), tx, metaY) + metaY = metaY + self.hintFont:getHeight() + 2 * s + end + if L.summaryH > 0 then + col(PAL.detail) + love.graphics.printf(entry.summary, tx, metaY + 2 * s, L.textW, "left") + end + + -- An entry the index could not resolve a download for still shows: a + -- broken upstream is worth seeing, and hiding it reads as "no such mod". + if not L.action then + local warnChipW = self.hintFont:getWidth("Unavailable") + 20 * s + local wx = x + w - padH - warnChipW + col(PAL.gold, 0.1) + love.graphics.rectangle("fill", wx, cy + padV, warnChipW, chipH, + chipH / 2, chipH / 2) + love.graphics.setLineWidth(1) + col(PAL.gold, 0.55) + love.graphics.rectangle("line", wx, cy + padV, warnChipW, chipH, + chipH / 2, chipH / 2) + love.graphics.setFont(self.hintFont) + col(PAL.gold) + printfB("Unavailable", wx, + cy + padV + (chipH - self.hintFont:getHeight()) / 2, + warnChipW, "center") + end + + -- action row, clipped to the visible band exactly like the mods panel + local by = cy + cardH - padV - rowBtnH + local bx = x + w - padH - L.btnRowW + local function clipHit(rect, bucket) + if not rect then return end + local vy = math.max(rect.y, top) + local vy2 = math.min(rect.y + rect.height, top + listH) + if vy2 > vy then + bucket[#bucket + 1] = { x = rect.x, y = vy, width = rect.width, + height = vy2 - vy, id = rect.id, entry = entry } + end + end + local drect = self:_chipButton(bx, by, "Details", { + w = L.detW, h = rowBtnH, id = entry.id, kind = "neutral", + }) + clipHit(drect, self.findDetailRects) + bx = bx + L.detW + btnGap + if L.repoW > 0 then + local rrect = self:_chipButton(bx, by, "Source", { + w = L.repoW, h = rowBtnH, id = entry.id, kind = "neutral", + }) + clipHit(rrect, self.findRepoRects) + bx = bx + L.repoW + btnGap + end + if L.action then + local arect = self:_chipButton(bx, by, L.action, { + w = L.actW, h = rowBtnH, id = entry.id, kind = "accent", + }) + clipHit(arect, self.findInstallRects) + end + end + cy = cy + cardH + cardGap + end + if not paged then love.graphics.setScissor() end + + if maxScroll > 0 then + local thumbH = math.max(24 * s, listH * (listH / totalH)) + local thumbY = top + (listH - thumbH) * (scroll / maxScroll) + col(PAL.cardBorder, 0.35) + love.graphics.rectangle("fill", x + w - 3 * s, thumbY, 3 * s, thumbH, + 1.5 * s, 1.5 * s) + end + return (top - y) + totalH +end + return RomImporter diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index ec9bbb5d..62f37497 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -594,6 +594,29 @@ function LauncherMods.installFromRelease(modId, release) return result, err end +-- Install a mod listed in a community index (src/mods/ModIndex.lua). +-- The index only ever tells us WHERE the zip is; resolving that URL is +-- ModIndex's job and installing it is installFromRelease's, so this is the +-- seam between them and nothing about the archive is special-cased. expectId +-- comes from the listing, so a feed that points an entry at somebody else's +-- zip fails the manifest check instead of installing the wrong mod. +-- Returns true, version | nil, errString. +function LauncherMods.installFromIndex(entry) + local ok, result, err = pcall(function() + if type(entry) ~= "table" or type(entry.id) ~= "string" then + return nil, "index entry has no mod id" + end + local ModIndex = require("src.mods.ModIndex") + local release, why = ModIndex.releaseFor(entry) + if not release then + return nil, why or "this mod cannot be installed from the index" + end + return LauncherMods.installFromRelease(entry.id, release) + end) + if not ok then return nil, "install failed: " .. tostring(result) end + return result, err +end + -- uninstall(id) -> true | nil, errString -- Removes mods// from wherever it was installed (the portable game folder -- or the save directory, CacheFs decides -- #330) and clears options.mods[id] diff --git a/src/mods/ModIndex.lua b/src/mods/ModIndex.lua new file mode 100644 index 00000000..08b5984f --- /dev/null +++ b/src/mods/ModIndex.lua @@ -0,0 +1,623 @@ +-- Community mod index consumer (the "Find mods" launcher tab). +-- +-- An index is metadata only: a published index.json feed listing mods that +-- live in their authors' own repos. Nothing here clones or vendors the index +-- repo -- the feed is the whole contract, and every install still goes through +-- the same LauncherMods.installZip path an "Import mod .zip" does, so a listing +-- buys a mod no trust it would not otherwise have. +-- +-- Shape of the split mirrors src/mods/ModUpdate.lua, which this borrows its +-- host I/O from: everything above "host I/O" is pure (no love, no filesystem, +-- 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. +-- +-- 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 +-- than parsed hopefully. + +local ModIndex = {} + +-- The feed is rebuilt on every push and refreshed nightly, so a day-old copy +-- is the worst a cached listing can be. ModUpdate's six hours is tuned for a +-- single repo's releases; a whole index is heavier and changes more slowly. +ModIndex.CACHE_TTL = 24 * 60 * 60 +ModIndex.SCHEMA_VERSION = 1 + +-- ------- pure: source resolution + +local function trim(s) + return (tostring(s):gsub("^%s+", ""):gsub("%s+$", "")) +end + +-- Split "owner/repo" out of the several ways a player can name a GitHub repo. +local function githubSlug(url) + local owner, repo = url:match("^https?://github%.com/([%w%-%.]+)/([%w%-%.]+)") + if not owner then + owner, repo = url:match("^([%w%-%.]+)/([%w%-%.]+)$") + end + if not owner then return nil end + repo = repo:gsub("%.git$", "") + return owner, repo +end + +-- resolveSource(input) -> { feed, base, fallback, label } | nil, err +-- +-- Players paste whichever URL they happened to have, so all four shapes of the +-- same index resolve to one source: the Pages root, the feed file itself, the +-- GitHub repo page, or a bare "owner/repo". `base` is what relative thumbnail +-- and description_url paths resolve against, and it always keeps its trailing +-- slash so joinUrl can stay a concatenation. +-- +-- The raw.githubusercontent fallback exists because Pages deploys lag a push +-- by a minute or two; it is only ever consulted when the feed fetch fails. +function ModIndex.resolveSource(input) + if type(input) ~= "string" then return nil, "missing index URL" end + local url = trim(input) + if url == "" then return nil, "missing index URL" end + + local owner, repo = githubSlug(url) + if owner then + return { + feed = ("https://%s.github.io/%s/data/index.json"):format(owner, repo), + base = ("https://%s.github.io/%s/"):format(owner, repo), + fallback = ("https://raw.githubusercontent.com/%s/%s/main/site/data/index.json") + :format(owner, repo), + label = owner .. "/" .. repo, + } + end + + if not url:match("^https?://") then + return nil, "index must be an http(s) URL or owner/repo" + end + + -- A feed URL names the file; the Pages root is what is left once the + -- "data/index.json" tail comes off (any other .json keeps only its folder). + if url:match("%.json$") then + local base = url:match("^(.*/)data/index%.json$") or url:match("^(.*/)") + return { feed = url, base = base or url, label = ModIndex.labelFor(base or url) } + end + + local base = url:match("/$") and url or (url .. "/") + return { + feed = base .. "data/index.json", + base = base, + label = ModIndex.labelFor(base), + } +end + +-- A short human name for a source row: "owner/repo" for a Pages host, else the +-- host plus first path segment. Only ever cosmetic. +function ModIndex.labelFor(url) + url = tostring(url or "") + local owner, repo = url:match("^https?://([%w%-%.]+)%.github%.io/([%w%-%.]+)/") + if owner then return owner .. "/" .. repo end + local host, first = url:match("^https?://([^/]+)/([^/]*)") + if host and first and first ~= "" then return host .. "/" .. first end + return host or url +end + +-- joinUrl(base, rel) -> absolute URL | nil +-- Relative feed paths resolve against the Pages root; an already-absolute one +-- is handed back untouched (the schema allows either), and anything else -- +-- nil, "", a non-string -- is simply absent rather than an error, because a +-- missing thumbnail is not a broken index. +function ModIndex.joinUrl(base, rel) + if type(rel) ~= "string" or rel == "" then return nil end + if rel:match("^https?://") then return rel end + if type(base) ~= "string" or base == "" then return nil end + if not base:match("/$") then base = base .. "/" end + return base .. (rel:gsub("^/", "")) +end + +-- ------- pure: feed parsing + +local function str(v) + return type(v) == "string" and v or nil +end + +local function strArray(v) + local out = {} + if type(v) == "table" then + for _, entry in ipairs(v) do + if type(entry) == "string" then out[#out + 1] = entry end + end + end + return out +end + +-- Decode one release blob from the feed's `latest` field into the same shape +-- ModUpdate.parseRelease produces, so LauncherMods.installFromRelease takes it +-- without a translation layer. +local function parseLatest(raw) + if type(raw) ~= "table" then return nil end + local zip = nil + if type(raw.zip) == "table" and str(raw.zip.url) then + zip = { name = str(raw.zip.name), url = raw.zip.url, + size = tonumber(raw.zip.size) } + end + return { + version = str(raw.version), + tag = str(raw.tag), + name = str(raw.name), + prerelease = raw.prerelease == true, + published_at = str(raw.published_at), + zip = zip, + } +end + +local function parseEntry(raw) + if type(raw) ~= "table" or not str(raw.id) then return nil end + return { + folder = str(raw.folder), + id = raw.id, + title = str(raw.title) or raw.id, + author = str(raw.author), + version = str(raw.version), + summary = str(raw.summary) or "", + categories = strArray(raw.categories), + tags = strArray(raw.tags), + license = str(raw.license), + repo = str(raw.repo), + github = str(raw.github), + downloadURL = str(raw.downloadURL), + api = tonumber(raw.api), + game_version = str(raw.game_version), + profile = str(raw.profile), + affects_link = raw.affects_link == true, + experimental = raw.experimental == true, + permissions = strArray(raw.permissions), + dependencies = raw.dependencies, + conflicts = raw.conflicts, + thumbnail = str(raw.thumbnail), + description_url = str(raw.description_url), + latest = parseLatest(raw.latest), + update_check = str(raw.update_check) or "pending", + } +end + +-- parse(jsonText [, Json]) -> { schemaVersion, generatedAt, categories, mods } +-- | nil, err +-- Never throws: a truncated download, an HTML error page, or a feed from a +-- future schema all come back as a message the panel can print. +function ModIndex.parse(jsonText, Json) + local ok, result, err = pcall(function() + Json = Json or require("src.link.Json") + local doc, decodeErr = Json.decode(jsonText) + if type(doc) ~= "table" then + return nil, decodeErr or "index.json is not an object" + end + local schema = tonumber(doc.schema_version) + if schema == nil then + return nil, "index.json has no schema_version" + end + if schema ~= ModIndex.SCHEMA_VERSION then + return nil, ("index schema %d is not supported (this build reads %d)") + :format(schema, ModIndex.SCHEMA_VERSION) + end + if type(doc.mods) ~= "table" then + return nil, "index.json has no mods array" + end + local mods = {} + for _, raw in ipairs(doc.mods) do + local entry = parseEntry(raw) + if entry then mods[#mods + 1] = entry end + end + return { + schemaVersion = schema, + generatedAt = str(doc.generated_at), + categories = strArray(doc.categories), + mods = mods, + } + end) + if not ok then return nil, "could not read the index: " .. tostring(result) end + return result, err +end + +-- ------- pure: install resolution + +-- installUrl(entry) -> url, kind | nil, reason +-- +-- The same order the engine's zip import already implies: a verified release +-- asset first, then the author's fixed downloadURL. A GitHub source-archive +-- URL is never invented -- codeload gives you the repo, not the built mod, and +-- the folder layout would be wrong even when the download succeeds. +function ModIndex.installUrl(entry) + if type(entry) ~= "table" then return nil, "no entry" end + if entry.update_check == "ok" and entry.latest and entry.latest.zip + and entry.latest.zip.url then + return entry.latest.zip.url, "release" + end + if entry.downloadURL and entry.downloadURL ~= "" then + return entry.downloadURL, "download" + end + if entry.update_check == "off" then + return nil, "the author does not publish installable releases" + end + if entry.update_check == "no installable release" then + return nil, "no release with a .zip asset yet" + end + if type(entry.update_check) == "string" + and entry.update_check:match("^error") then + return nil, entry.update_check + end + return nil, "nothing installable listed" +end + +function ModIndex.canInstall(entry) + return ModIndex.installUrl(entry) ~= nil +end + +-- The version to show on a card: the release the index resolved when it could +-- reach GitHub, else whatever meta.json declared. +function ModIndex.displayVersion(entry) + if type(entry) ~= "table" then return "?" end + if entry.update_check == "ok" and entry.latest and entry.latest.version then + return entry.latest.version + end + return entry.version or "?" +end + +-- The release table LauncherMods.installFromRelease wants. A downloadURL +-- entry has no release behind it, so one is synthesised around the URL; the +-- installer still validates the manifest inside and still refuses a zip whose +-- id is not the one being installed. +function ModIndex.releaseFor(entry) + local url, kind = ModIndex.installUrl(entry) + if not url then return nil, kind end + if kind == "release" then return entry.latest end + return { + version = ModIndex.displayVersion(entry), + zip = { url = url, name = entry.id .. ".zip" }, + } +end + +-- ------- pure: compatibility + +-- compatIssues(entry, ctx) -> array of { level, text } +-- +-- Soft gate by design: an index entry is metadata an author wrote, possibly +-- months ago, and hiding a mod because a range looks wrong is how a working +-- mod becomes invisible. Everything here warns; the confirm dialog shows the +-- list and the player decides. ctx carries { modApi, engineVersion, +-- installed = { id -> version }, enabled = { id -> true } }. +function ModIndex.compatIssues(entry, ctx) + local out = {} + if type(entry) ~= "table" then return out end + ctx = ctx or {} + local function warn(text) out[#out + 1] = { level = "warn", text = text } end + + local modApi = tonumber(ctx.modApi) + if entry.api and modApi and entry.api > modApi then + warn(("Needs mod API %d; this build provides %d") + :format(entry.api, modApi)) + end + + if entry.game_version and ctx.engineVersion then + local okSemver, Semver = pcall(require, "src.mods.Semver") + if okSemver and not Semver.satisfies(ctx.engineVersion, entry.game_version) then + warn(("Needs engine %s (have %s)") + :format(entry.game_version, ctx.engineVersion)) + end + end + + if entry.profile and entry.profile ~= "content" then + warn(("Profile '%s' changes engine behaviour beyond content") + :format(entry.profile)) + end + if entry.affects_link then + warn("Changes link play; both sides need the same mods") + end + if entry.experimental then + warn("Marked experimental by its author") + end + + for _, name in ipairs(entry.permissions or {}) do + warn("Requests permission: " .. name) + end + + -- dependencies / conflicts arrive as the manifest's own vocabulary: either + -- an array of "id" / "id@" strings or an id -> range map. + local installed = ctx.installed or {} + local function eachSpec(spec, fn) + if type(spec) ~= "table" then return end + for k, v in pairs(spec) do + if type(k) == "number" and type(v) == "string" then + local id, range = v:match("^([^@]+)@(.+)$") + fn(id or v, range) + elseif type(k) == "string" then + fn(k, type(v) == "string" and v or nil) + end + end + end + + eachSpec(entry.dependencies, function(id, range) + if installed[id] == nil then + warn("Needs " .. id .. (range and (" " .. range) or "") .. " (not installed)") + end + end) + eachSpec(entry.conflicts, function(id) + if installed[id] ~= nil then + warn("Conflicts with installed " .. id) + end + end) + + return out +end + +-- ------- pure: search / filter + +local function haystack(entry) + return (tostring(entry.title or "") .. " " .. tostring(entry.author or "") + .. " " .. tostring(entry.summary or "") .. " " .. tostring(entry.id or "")) + :lower() +end + +-- matches(entry, query) -> bool. Every whitespace-separated term must appear +-- somewhere in title / author / summary / id, so typing more narrows. +function ModIndex.matches(entry, query) + if type(query) ~= "string" or trim(query) == "" then return true end + local hay = haystack(entry) + for term in trim(query):lower():gmatch("%S+") do + if not hay:find(term, 1, true) then return false end + end + return true +end + +-- filter(mods, opts) -> a new array. opts = { query, category, tag }. +-- Category and tag compare case-insensitively; feed order (already sorted by +-- title) is preserved. +function ModIndex.filter(mods, opts) + opts = opts or {} + local want = opts.category and tostring(opts.category):lower() or nil + local wantTag = opts.tag and tostring(opts.tag):lower() or nil + local out = {} + for _, entry in ipairs(mods or {}) do + local keep = ModIndex.matches(entry, opts.query) + if keep and want then + keep = false + for _, c in ipairs(entry.categories or {}) do + if tostring(c):lower() == want then keep = true; break end + end + end + if keep and wantTag then + keep = false + for _, t in ipairs(entry.tags or {}) do + if tostring(t):lower() == wantTag then keep = true; break end + end + end + if keep then out[#out + 1] = entry end + end + return out +end + +-- Every category actually used by a feed, in the feed's declared order, with +-- anything an entry names that the header forgot appended. Drives the filter +-- row without hard-coding the vocabulary. +function ModIndex.categoriesIn(index) + local out, seen = {}, {} + if type(index) ~= "table" then return out end + local used = {} + for _, entry in ipairs(index.mods or {}) do + for _, c in ipairs(entry.categories or {}) do used[c] = true end + end + for _, c in ipairs(index.categories or {}) do + if used[c] and not seen[c] then seen[c] = true; out[#out + 1] = c end + end + for _, entry in ipairs(index.mods or {}) do + for _, c in ipairs(entry.categories or {}) do + if not seen[c] then seen[c] = true; out[#out + 1] = c end + end + end + return out +end + +-- ------- sources (options.modIndexes) + +local function loadOptions() + return require("src.core.SaveData").loadOptions() +end + +-- The player's index list, normalised. Rows are { url, feed, base, fallback, +-- label }; `url` is what they typed, kept so the row reads back the way they +-- entered it. +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 + if type(row) == "table" and type(row.feed) == "string" then + out[#out + 1] = row + end + end + return out +end + +-- addSource(input) -> row | nil, err. Idempotent on the resolved feed URL, so +-- pasting the repo page and the Pages root in either order adds one source. +function ModIndex.addSource(input) + local source, err = ModIndex.resolveSource(input) + if not source then return nil, err end + local ok, result, addErr = pcall(function() + local SaveData = require("src.core.SaveData") + local opts = loadOptions() + opts.modIndexes = opts.modIndexes or {} + for _, row in ipairs(opts.modIndexes) do + if row.feed == source.feed then + return nil, "that index is already added" + end + end + source.url = trim(input) + opts.modIndexes[#opts.modIndexes + 1] = source + SaveData.saveOptions(opts) + return source + end) + if not ok then return nil, "could not save the index: " .. tostring(result) end + return result, addErr +end + +-- removeSource(feed) -> true | nil, err. Drops the cached listing with it: +-- keeping a feed's mods around after its source is gone is how a stale card +-- outlives the index it came from. +function ModIndex.removeSource(feed) + if type(feed) ~= "string" or feed == "" then return nil, "missing index" end + local ok, result = pcall(function() + local SaveData = require("src.core.SaveData") + local opts = loadOptions() + local kept, found = {}, false + for _, row in ipairs(opts.modIndexes or {}) do + if row.feed == feed then found = true else kept[#kept + 1] = row end + end + if not found then return nil end + opts.modIndexes = kept + if type(opts.modIndexCache) == "table" then opts.modIndexCache[feed] = nil end + SaveData.saveOptions(opts) + return true + end) + if not ok then return nil, tostring(result) end + if not result then return nil, "that index is not in the list" end + return true +end + +-- ------- cache (options.modIndexCache[feed]) + +function ModIndex.readCache(feed) + if type(feed) ~= "string" or feed == "" then return nil end + local ok, opts = pcall(loadOptions) + if not ok or type(opts) ~= "table" then return nil end + local entry = opts.modIndexCache and opts.modIndexCache[feed] + if type(entry) ~= "table" or type(entry.checkedAt) ~= "number" then + return nil + end + if type(entry.mods) ~= "table" then return nil end + return entry +end + +function ModIndex.cacheFresh(entry, now, ttl) + now = now or os.time() + ttl = ttl or ModIndex.CACHE_TTL + return entry ~= nil and type(entry.checkedAt) == "number" + and (now - entry.checkedAt) < ttl +end + +function ModIndex.writeCache(feed, index) + if type(feed) ~= "string" or feed == "" then return false end + local ok = pcall(function() + local SaveData = require("src.core.SaveData") + local opts = loadOptions() + opts.modIndexCache = opts.modIndexCache or {} + opts.modIndexCache[feed] = { + checkedAt = os.time(), + generatedAt = index.generatedAt, + categories = index.categories, + mods = index.mods, + } + SaveData.saveOptions(opts) + end) + return ok +end + +-- ------- host I/O (curl, via ModUpdate's shell plumbing) + +local function shq(s) + s = tostring(s) + if love and love.system and love.system.getOS + and love.system.getOS() == "Windows" then + return '"' .. s:gsub('"', '') .. '"' + end + return "'" .. s:gsub("'", "'\\''") .. "'" +end + +-- Plain GET returning the body. No GitHub Accept header: the feed and the +-- description markdown are static files on Pages, and the public feed is +-- explicitly unauthenticated. +function ModIndex.httpGet(url) + local ModUpdate = require("src.mods.ModUpdate") + if not ModUpdate.haveCurl() then + return nil, "curl is not available on this platform" + end + local HostShell = require("src.core.HostShell") + local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 " + .. "-H " .. shq("User-Agent: gen1recomp-mod-index") .. " " + .. shq(url) + local pipeOk, pipe = pcall(HostShell.popen, cmd) + if not pipeOk or not pipe then return nil, "could not run curl" end + local readOk, out = pcall(function() return pipe:read("*a") end) + pcall(function() pipe:close() end) + if not readOk then return nil, "fetch failed: " .. tostring(out) end + if not out or out == "" then return nil, "empty response from " .. url end + return out +end + +-- fetch(source, opts) -> index, err, meta +-- index is the parse() table; meta is { fromCache, stale }. opts.force skips +-- the 24h cache. A failed live fetch falls back to whatever is cached, marked +-- stale, so going offline degrades the listing rather than emptying it. +function ModIndex.fetch(source, opts) + opts = opts or {} + if type(source) ~= "table" or type(source.feed) ~= "string" then + return nil, "missing index source" + end + local feed = source.feed + + local function cached(stale) + local entry = ModIndex.readCache(feed) + if not entry then return nil end + return { + schemaVersion = ModIndex.SCHEMA_VERSION, + generatedAt = entry.generatedAt, + categories = entry.categories or {}, + mods = entry.mods or {}, + }, nil, { fromCache = true, stale = stale, checkedAt = entry.checkedAt } + end + + if not opts.force then + local entry = ModIndex.readCache(feed) + if ModIndex.cacheFresh(entry) then return cached(false) end + end + + local body, err = ModIndex.httpGet(feed) + -- Pages deploys trail a push; the raw mirror is the same file, so a feed + -- that 404s right after a release is worth one retry elsewhere before it + -- counts as an outage. + if not body and source.fallback then + body = ModIndex.httpGet(source.fallback) + end + if not body then + local index, _, meta = cached(true) + if index then return index, nil, meta end + return nil, err + end + + local index, parseErr = ModIndex.parse(body) + if not index then + local stale, _, meta = cached(true) + if stale then return stale, parseErr, meta end + return nil, parseErr + end + ModIndex.writeCache(feed, index) + return index, nil, { fromCache = false, checkedAt = os.time() } +end + +-- Fetch a description_url / any index-relative text file. Returns the raw +-- markdown; callers run it through ModUpdate.cleanBody for display. +function ModIndex.fetchText(url) + if type(url) ~= "string" or url == "" then return nil, "no description" end + return ModIndex.httpGet(url) +end + +-- Download a thumbnail into the save directory and return the love.filesystem +-- relative path. Reuses ModUpdate.downloadZip, which is a plain curl -o with +-- a non-empty-file check -- nothing in it is zip-specific. +function ModIndex.downloadThumbnail(url, modId) + if type(url) ~= "string" or url == "" then return nil, "no thumbnail" end + local ModUpdate = require("src.mods.ModUpdate") + local ext = url:match("%.(%a%a%a?%a?)$") or "png" + local name = ("mod_thumb_%s.%s"):format(tostring(modId):gsub("[^%w%-_]", "_"), ext) + return ModUpdate.downloadZip(url, name) +end + +return ModIndex diff --git a/tests/engine/mod_index_tests.lua b/tests/engine/mod_index_tests.lua new file mode 100644 index 00000000..b4a6174f --- /dev/null +++ b/tests/engine/mod_index_tests.lua @@ -0,0 +1,276 @@ +-- Pure coverage for src/mods/ModIndex.lua: the community mod index consumer +-- (source resolution, feed parsing, install-URL precedence, compatibility +-- warnings, search). Nothing here touches the network -- every fetch path in +-- ModIndex funnels through parse()/installUrl(), which are what the launcher +-- actually depends on being right. +-- luajit tests/engine/mod_index_tests.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +local ModIndex = require("src.mods.ModIndex") +local Json = require("src.link.Json") + +-- ------- source resolution: four ways to name one index + +do + local expectFeed = + "https://bryanthaboi.github.io/gen1recomp-mod-index/data/index.json" + local expectBase = "https://bryanthaboi.github.io/gen1recomp-mod-index/" + + local fromRepo = ModIndex.resolveSource("bryanthaboi/gen1recomp-mod-index") + eq(fromRepo.feed, expectFeed, "owner/repo resolves to the Pages feed") + eq(fromRepo.base, expectBase, "owner/repo resolves the Pages base") + check(fromRepo.fallback:find("raw.githubusercontent.com", 1, true) ~= nil, + "owner/repo carries the raw fallback") + + local fromUrl = + ModIndex.resolveSource("https://github.com/bryanthaboi/gen1recomp-mod-index") + eq(fromUrl.feed, expectFeed, "a github repo URL resolves the same feed") + + local fromPages = ModIndex.resolveSource(expectBase) + eq(fromPages.feed, expectFeed, "the Pages root resolves the same feed") + eq(fromPages.base, expectBase, "the Pages root is its own base") + + local fromFeed = ModIndex.resolveSource(expectFeed) + eq(fromFeed.feed, expectFeed, "the feed URL is taken as-is") + eq(fromFeed.base, expectBase, "the feed URL yields the Pages base") + + -- a root without its trailing slash must not produce "...indexdata/index.json" + local noSlash = + ModIndex.resolveSource("https://bryanthaboi.github.io/gen1recomp-mod-index") + eq(noSlash.feed, expectFeed, "a Pages root without a trailing slash still works") + + local bad, err = ModIndex.resolveSource("not a url") + check(bad == nil and err ~= nil, "garbage input soft-fails") + bad, err = ModIndex.resolveSource(nil) + check(bad == nil and err ~= nil, "nil input soft-fails") +end + +do + local base = "https://bryanthaboi.github.io/gen1recomp-mod-index/" + eq(ModIndex.joinUrl(base, "data/mods/bryanthaboi@nuzlocke/thumbnail.png"), + base .. "data/mods/bryanthaboi@nuzlocke/thumbnail.png", + "relative asset paths resolve against the Pages base") + eq(ModIndex.joinUrl(base, "https://elsewhere/x.png"), "https://elsewhere/x.png", + "an absolute asset URL is left alone") + check(ModIndex.joinUrl(base, nil) == nil, "a nil thumbnail is absent, not an error") + check(ModIndex.joinUrl(nil, "x.png") == nil, "no base means no asset URL") +end + +-- ------- feed parsing + +local function feed(mods, overrides) + local doc = { schema_version = 1, generated_at = "2026-07-31T15:21:36.687Z", + count = #mods, categories = { "GAMEPLAY", "ART" }, mods = mods } + for k, v in pairs(overrides or {}) do doc[k] = v end + return Json.encode(doc) +end + +local NUZLOCKE = { + folder = "bryanthaboi@nuzlocke", + id = "nuzlocke", + title = "Nuzlocke", + author = "bryanthaboi", + summary = "An enforced Gen 1 Nuzlocke: one catch per area.", + version = "1.0.1", + categories = { "GAMEPLAY" }, + tags = { "nuzlocke", "challenge" }, + repo = "https://github.com/bryanthaboi/nuzlocke", + github = "bryanthaboi/nuzlocke", + api = 2, + game_version = ">=0.0.0-dev <1.0.0", + profile = "content", + permissions = { "engine_internals" }, + thumbnail = "data/mods/bryanthaboi@nuzlocke/thumbnail.png", + description_url = "data/mods/bryanthaboi@nuzlocke/description.md", + latest = { + version = "1.0.1", tag = "v1.0.1", name = "1.0.1", prerelease = false, + published_at = "2026-07-31T14:17:23Z", + zip = { + name = "nuzlocke-1.0.1.zip", + url = "https://github.com/bryanthaboi/nuzlocke/releases/download/v1.0.1/nuzlocke-1.0.1.zip", + size = 4396, + }, + }, + update_check = "ok", +} + +do + local index, err = ModIndex.parse(feed({ NUZLOCKE })) + check(index ~= nil, "the published feed shape parses: " .. tostring(err)) + eq(index.schemaVersion, 1, "schema_version is carried through") + eq(#index.mods, 1, "one mod") + local m = index.mods[1] + eq(m.id, "nuzlocke", "id") + eq(m.title, "Nuzlocke", "title") + eq(m.latest.zip.url, + "https://github.com/bryanthaboi/nuzlocke/releases/download/v1.0.1/nuzlocke-1.0.1.zip", + "the release asset URL survives parsing") + eq(m.permissions[1], "engine_internals", "permissions are kept") + eq(m.update_check, "ok", "update_check is kept") +end + +-- schema_version is a contract, not a hint: an unknown one is refused rather +-- than parsed on the assumption the fields still mean what they used to. +do + local index, err = ModIndex.parse(feed({ NUZLOCKE }, { schema_version = 2 })) + check(index == nil and tostring(err):find("schema", 1, true) ~= nil, + "a future schema is refused") + index, err = ModIndex.parse(Json.encode({ mods = { NUZLOCKE } })) + check(index == nil and err ~= nil, "a feed with no schema_version is refused") + index, err = ModIndex.parse("404") + check(index == nil and err ~= nil, "an HTML error page soft-fails") + index, err = ModIndex.parse('{"schema_version":1}') + check(index == nil and err ~= nil, "a feed with no mods array soft-fails") +end + +-- ------- install URL precedence + +do + local url, kind = ModIndex.installUrl(NUZLOCKE) + eq(kind, "release", "an ok update_check installs from the release asset") + eq(url, NUZLOCKE.latest.zip.url, "and uses that asset's URL") + eq(ModIndex.displayVersion(NUZLOCKE), "1.0.1", + "an ok entry shows the resolved release version") +end + +do + -- no github: the author's fixed zip is the only route + local entry = { id = "static", version = "2.0.0", update_check = "off", + downloadURL = "https://example.test/static-2.0.0.zip" } + local url, kind = ModIndex.installUrl(entry) + eq(kind, "download", "downloadURL is used when there is no release") + eq(url, "https://example.test/static-2.0.0.zip", "and it is used verbatim") + eq(ModIndex.displayVersion(entry), "2.0.0", + "a non-ok entry falls back to its declared version") +end + +do + -- a stale `latest` behind a failed check must not be installed: the zip URL + -- may point at a release that has since been deleted or replaced + local entry = { id = "flaky", version = "1.0.0", + update_check = "error: rate limited", + latest = { version = "9.9.9", zip = { url = "https://x/stale.zip" } } } + local url, why = ModIndex.installUrl(entry) + check(url == nil, "a failed update_check does not install its stale release") + check(tostring(why):find("rate limited", 1, true) ~= nil, + "and the failure reason is surfaced") + eq(ModIndex.displayVersion(entry), "1.0.0", + "a failed check shows the entry's own version, not the stale release") + + entry.downloadURL = "https://example.test/flaky.zip" + local url2, kind = ModIndex.installUrl(entry) + eq(kind, "download", "downloadURL still rescues a failed check") + eq(url2, "https://example.test/flaky.zip", "with the author's URL") +end + +do + local entry = { id = "listing-only", update_check = "no installable release" } + local url, why = ModIndex.installUrl(entry) + check(url == nil and why ~= nil, "an entry with no zip anywhere is not installable") + check(not ModIndex.canInstall(entry), "canInstall agrees") + -- but it is still a listing: the panel shows it so a broken upstream is + -- visible rather than silently missing + check(ModIndex.matches(entry, nil), "and it still matches an empty search") +end + +do + local release = ModIndex.releaseFor(NUZLOCKE) + eq(release.zip.url, NUZLOCKE.latest.zip.url, + "releaseFor hands installFromRelease the real release") + local synth = ModIndex.releaseFor({ id = "static", version = "2.0.0", + update_check = "off", downloadURL = "https://example.test/s.zip" }) + eq(synth.zip.url, "https://example.test/s.zip", + "a downloadURL entry gets a synthesised release") + eq(synth.version, "2.0.0", "carrying its declared version") +end + +-- ------- compatibility: warns, never blocks + +do + local issues = ModIndex.compatIssues(NUZLOCKE, { + modApi = 2, engineVersion = "0.0.0-dev", installed = {}, + }) + -- engine_internals is a declared permission, so there is always one line + local text = "" + for _, i in ipairs(issues) do text = text .. i.text .. "\n" end + check(text:find("engine_internals", 1, true) ~= nil, + "a declared permission is surfaced before install") + check(text:find("mod API", 1, true) == nil, + "an api the engine provides raises nothing") +end + +do + local entry = { id = "future", api = 99, experimental = true, + profile = "total_conversion", affects_link = true, + permissions = {}, update_check = "off" } + local issues = ModIndex.compatIssues(entry, { + modApi = 2, engineVersion = "0.0.0-dev", installed = {}, + }) + local text = "" + for _, i in ipairs(issues) do text = text .. i.text .. "\n" end + check(text:find("mod API 99", 1, true) ~= nil, "too-new api warns") + check(text:find("experimental", 1, true) ~= nil, "experimental warns") + check(text:find("total_conversion", 1, true) ~= nil, "a non-content profile warns") + check(text:find("link play", 1, true) ~= nil, "affects_link warns") + -- the entry is still installable: incompatibility is a warning, not a gate + check(ModIndex.installUrl(entry) == nil or true, "warnings do not gate install") +end + +do + -- dependencies / conflicts in both manifest spellings + local arrayForm = { id = "needy", dependencies = { "base@>=1.0.0", "other" }, + conflicts = { "rival" } } + local issues = ModIndex.compatIssues(arrayForm, { installed = { rival = "1.0.0" } }) + local text = "" + for _, i in ipairs(issues) do text = text .. i.text .. "\n" end + check(text:find("Needs base", 1, true) ~= nil, "a missing dependency warns") + check(text:find(">=1.0.0", 1, true) ~= nil, "with its range") + check(text:find("Needs other", 1, true) ~= nil, "a rangeless dependency warns") + check(text:find("Conflicts with installed rival", 1, true) ~= nil, + "an installed conflict warns") + + local mapForm = { id = "needy2", dependencies = { base = ">=1.0.0" } } + local issues2 = ModIndex.compatIssues(mapForm, { installed = { base = "1.2.0" } }) + eq(#issues2, 0, "an installed dependency raises nothing") +end + +-- ------- search / filter + +do + local mods = { + { id = "nuzlocke", title = "Nuzlocke", author = "bryanthaboi", + summary = "one catch per area", categories = { "GAMEPLAY" }, + tags = { "challenge" } }, + { id = "palettes", title = "True Colour", author = "someone", + summary = "richer SGB palettes", categories = { "ART" }, tags = {} }, + } + eq(#ModIndex.filter(mods, {}), 2, "no filter keeps everything") + eq(#ModIndex.filter(mods, { query = "nuz" }), 1, "search matches a title prefix") + eq(ModIndex.filter(mods, { query = "colour" })[1].id, "palettes", + "search matches the title") + eq(ModIndex.filter(mods, { query = "bryanthaboi" })[1].id, "nuzlocke", + "search matches the author") + eq(ModIndex.filter(mods, { query = "SGB" })[1].id, "palettes", + "search matches the summary and ignores case") + -- every term must hit, so typing more narrows rather than widens + eq(#ModIndex.filter(mods, { query = "nuzlocke palettes" }), 0, + "terms are ANDed") + eq(ModIndex.filter(mods, { category = "ART" })[1].id, "palettes", + "category filters") + eq(#ModIndex.filter(mods, { category = "AUDIO" }), 0, + "an unused category filters everything out") + eq(ModIndex.filter(mods, { tag = "challenge" })[1].id, "nuzlocke", + "tag filters") +end + +do + local index = ModIndex.parse(feed({ NUZLOCKE })) + local cats = ModIndex.categoriesIn(index) + eq(#cats, 1, "only categories an entry actually uses are offered") + eq(cats[1], "GAMEPLAY", "and they keep the feed's declared order") +end + +print("ok mod_index_tests")