mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +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.
|
`prints/` in the save directory, then reports the filename in a dialog.
|
||||||
No printer hardware or link cable emulation involved; the file is the
|
No printer hardware or link cable emulation involved; the file is the
|
||||||
printout.
|
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
|
-- GitHub release checks for mods with a manifest "github" field
|
||||||
-- (src/mods/ModUpdate.lua). Keyed by owner/repo; TTL is six hours.
|
-- (src/mods/ModUpdate.lua). Keyed by owner/repo; TTL is six hours.
|
||||||
modUpdateCache = {},
|
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).
|
-- On-screen touch overlay (Android/iOS; see src/core/TouchControls.lua).
|
||||||
-- enabled=false hides it permanently (distinct from auto-hide-on-gamepad).
|
-- enabled=false hides it permanently (distinct from auto-hide-on-gamepad).
|
||||||
-- positions are optional normalized centers {x=0..1, y=0..1} per control
|
-- 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
|
return result, err
|
||||||
end
|
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
|
-- uninstall(id) -> true | nil, errString
|
||||||
-- Removes mods/<id>/ from wherever it was installed (the portable game folder
|
-- Removes mods/<id>/ from wherever it was installed (the portable game folder
|
||||||
-- or the save directory, CacheFs decides -- #330) and clears options.mods[id]
|
-- 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