mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
rom finder
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+881
-6
File diff suppressed because it is too large
Load Diff
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user