rom finder

This commit is contained in:
bryanthaboi
2026-07-31 11:58:21 -04:00
parent 97e3fb296c
commit 1bc252741a
6 changed files with 1822 additions and 6 deletions
+9
View File
@@ -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.
+10
View File
@@ -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
File diff suppressed because it is too large Load Diff
+23
View File
@@ -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/<id>/ from wherever it was installed (the portable game folder
-- or the save directory, CacheFs decides -- #330) and clears options.mods[id]
+623
View File
@@ -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@<range>" 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
+276
View File
@@ -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("<!DOCTYPE html><html>404</html>")
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")