- adding ability to specify gen specific deps in manifest system.

- fixed issue with wayland users drag and drop causing CTD
This commit is contained in:
1jamie
2026-08-13 18:18:59 -05:00
parent 35d44efb8b
commit dfeacc36b0
14 changed files with 324 additions and 96 deletions
+2 -1
View File
@@ -3,8 +3,9 @@
data/generated/ data/generated/
assets/generated/ assets/generated/
# LÖVE packages # LÖVE packages & archives
*.love *.love
*.zip
# Local saves (LÖVE writes to its save dir, but keep the repo clean anyway) # Local saves (LÖVE writes to its save dir, but keep the repo clean anyway)
save/ save/
+2 -1
View File
@@ -245,7 +245,8 @@ real Gold boot.
### 5. `mod.card` ### 5. `mod.card`
The manifest is the *engine's* contract: identity, load order, dependencies, The manifest is the *engine's* contract: identity, load order, dependencies,
permissions, profile. The card is the *human-facing* one: who made this, permissions, profile (see [Manifest specification](docs/modding.md#manifest-specification-manifestjson)).
The card is the *human-facing* one: who made this,
what it changes, what it does not do yet. It is never read by the loader's what it changes, what it does not do yet. It is never read by the loader's
merge — only by tooling and the manager's detail pane — so an absent or merge — only by tooling and the manager's detail pane — so an absent or
malformed card can never break a load. malformed card can never break a load.
+5 -4
View File
@@ -118,10 +118,11 @@ Gen 2 games no longer loads on Red, Blue or Yellow. Say `["all"]` or list both
generations if you want both. generations if you want both.
Two riders. **A hard dependency that does not run here takes the dependent down Two riders. **A hard dependency that does not run here takes the dependent down
with it**, as a skip rather than a failure and carrying the dependency's own with it** (unless scoped to specific games, e.g.
wording (`depends on X, which does not run here (For Blue, not Red)`), so the `dependencies: [{ id = "x", games = ["gen2"] }]`), as a skip rather than a
whole chain has to cover the same games. And **the claim is yours, not the last failure and carrying the dependency's own wording (`depends on X, which does not
word**: it is the manager's `TRY HERE ANYWAY` row that lets a player run a mod run here (For Blue, not Red)`), so the whole chain has to cover the same games.
And **the claim is yours, not the last word**: it is the manager's `TRY HERE ANYWAY` row that lets a player run a mod
whose author never opted in, which is the only route for a mod written before whose author never opted in, which is the only route for a mod written before
the field existed. The override is per game -- `options.modsGen2[id]` is a the field existed. The override is per game -- `options.modsGen2[id]` is a
`{ [version] = true }` table, so forcing a mod onto Red does not force it onto `{ [version] = true }` table, so forcing a mod onto Red does not force it onto
+70
View File
@@ -21,6 +21,76 @@ luajit tools/gen_registry_docs.lua
luajit tools/gen_registry_docs.lua ../gen1recomp.wiki luajit tools/gen_registry_docs.lua ../gen1recomp.wiki
``` ```
## Manifest specification (`manifest.json`)
Every mod contains a root `manifest.json` defining its metadata, supported games, and dependencies for the engine loader.
```json
{
"id": "my_mod",
"name": "My Cool Mod",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "GAMEPLAY",
"games": ["gen1", "gen2"],
"game_version": ">=0.0.0-dev <2.0.0",
"priority": 100,
"dependencies": [
"helper_lib@^1.0.0",
{ "id": "pokegear_cards", "games": ["gen2"], "range": "^1.0.0", "github": "1jamie/pokegear_cards" }
],
"optional_dependencies": [
"gen1_modern_ui"
],
"conflicts": [],
"permissions": ["engine_internals"],
"description": "A brief description of the mod.",
"github": "author/my_mod"
}
```
### Manifest Fields
| Field | Type | Description |
| --- | --- | --- |
| `id` | `string` | Unique identifier (lowercase alphanumeric, underscores, hyphens). |
| `name` | `string` | Human-readable title shown in launcher and manager. |
| `version` | `string` | Semantic version string (e.g. `"1.0.0"`). |
| `api` | `integer` | Mod API level (`2` for current standard, `1` for legacy). |
| `entry` | `string` | Entry Lua file path relative to mod root (usually `"main.lua"`). |
| `profile` | `string` | Mod profile: `"content"`, `"overhaul"`, or `"total_conversion"`. |
| `category` | `string` | Categorization chip (e.g. `"GAMEPLAY"`, `"CONTENT"`, `"UI"`, `"AUDIO"`). |
| `games` | `array` | Supported game versions: `["gen1"]`, `["gen2"]`, `["red"]`, `["blue"]`, `["yellow"]`, `["gold"]`, or `["all"]`. |
| `game_version`| `string` | Semver range of required engine version (e.g. `">=0.0.0-dev <2.0.0"`). |
| `priority` | `integer` | Load priority order (lower numbers load earlier; dependencies always precede dependents regardless of priority). |
| `dependencies` | `array` | Hard required dependencies. A mod will not load if a required dependency is missing or disabled for the active game. |
| `optional_dependencies` | `array` | Soft dependencies. Guarantees that if the target mod is present and active, it loads *before* this mod without blocking load if absent. |
| `conflicts` / `incompatible` | `array` | List of mod IDs that cannot run concurrently with this mod. |
| `permissions` | `array` | Requested privileges (e.g. `["engine_internals"]`, `["network"]`, `["filesystem"]`). |
| `github` | `string` | GitHub repository (`"owner/repo"`) used for update checks and dependency download links. |
### Declaring Dependencies & Scoping
Dependencies in `dependencies` and `optional_dependencies` can be declared in several formats:
1. **Simple string**: `"mod_id"`
2. **Version-pinned string**: `"mod_id@^1.2.0"`
3. **Repository-hinted string**: `"mod_id#owner/repo"` or `"mod_id@^1.2.0#owner/repo"`
4. **Structured object**:
```json
{
"id": "mod_id",
"range": "^1.2.0",
"games": ["gen2"],
"github": "owner/repo"
}
```
#### Version-Scoped Dependencies
When a mod supports multiple games (`"games": ["gen1", "gen2"]`), a dependency can specify `"games": ["gen2"]` to indicate it is only required when booting Gen 2. When booting Gen 1, the engine will ignore the dependency, preventing unnecessary boot blocks on games that do not need it.
## Mods and Gold (Gen 2) ## Mods and Gold (Gen 2)
The mod API is one API across both generations, but Gold runs its own battle The mod API is one API across both generations, but Gold runs its own battle
+19
View File
@@ -258,6 +258,25 @@ row on the detail screen. The launcher's dependency verdict asks the same
question of your dependencies: a mod whose hard dependency does not run on the question of your dependencies: a mod whose hard dependency does not run on the
selected game reads `Needs <id> (not for Gold)` rather than `Ready`. selected game reads `Needs <id> (not for Gold)` rather than `Ready`.
### Scoping dependencies per game / generation
For mods targeting multiple generations (`"games": ["gen1", "gen2"]`), a hard
dependency can be scoped to specific games so that it is only enforced when
booting those games:
```json
"dependencies": [
{ "id": "pokegear_cards", "games": ["gen2"], "range": "^1.0.0", "github": "1jamie/pokegear_cards" }
]
```
When booting a Gen 1 game (Red, Blue, Yellow), the engine loader sees that
`pokegear_cards` is scoped to `"gen2"` and will not skip or block the parent mod
on Gen 1. When booting Gen 2 (Gold), `pokegear_cards` is strictly required.
For conditional integrations where the dependency is optional across the board,
`optional_dependencies` remains the standard pattern.
### One limit worth knowing ### One limit worth knowing
**Enablement is per game.** The overlay **Enablement is per game.** The overlay
+3
View File
@@ -400,6 +400,9 @@ EOF
grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \ grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \
|| fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)" || fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)"
sed -i '' 's|^exec "\$APPDIR/bin/love"|if [ -n "$WAYLAND_DISPLAY" ] \&\& [ -z "$SDL_VIDEODRIVER" ]; then export SDL_VIDEODRIVER=x11; fi\
exec "$APPDIR/bin/love"|' "$appdir/AppRun"
# Match the upstream image's compression (gzip, 128K blocks) so the # Match the upstream image's compression (gzip, 128K blocks) so the
# bundled runtime can read it. # bundled runtime can read it.
local sfs_out="$WORK/game.squashfs" local sfs_out="$WORK/game.squashfs"
+6
View File
@@ -405,6 +405,12 @@ if [ -z "\$LUA_CPATH" ]; then
fi fi
export LUA_CPATH="\$APPDIR/lib/lua/5.1/?.so;\$LUA_CPATH" export LUA_CPATH="\$APPDIR/lib/lua/5.1/?.so;\$LUA_CPATH"
# SDL2 on Wayland crashes during desktop drag-and-drop in certain compositors;
# default to X11/XWayland when available to ensure rock-solid drag-drop stability.
if [ -n "\$WAYLAND_DISPLAY" ] && [ -z "\$SDL_VIDEODRIVER" ]; then
export SDL_VIDEODRIVER=x11
fi
exec "\$APPDIR/bin/love" --fused "\$APPDIR/game.love" "\$@" exec "\$APPDIR/bin/love" --fused "\$APPDIR/game.love" "\$@"
EOF EOF
chmod +x "$APPDIR/AppRun" chmod +x "$APPDIR/AppRun"
+6
View File
@@ -35,4 +35,10 @@ find_love() {
LOVE_BIN="$(find_love)" \ LOVE_BIN="$(find_love)" \
|| fail "LÖVE not found, run scripts/setup.sh (or install from https://love2d.org)" || fail "LÖVE not found, run scripts/setup.sh (or install from https://love2d.org)"
# SDL2 on Wayland crashes during desktop drag-and-drop in certain compositors;
# default to X11/XWayland when available to ensure rock-solid drag-drop stability.
if [ -n "${WAYLAND_DISPLAY:-}" ] && [ -z "${SDL_VIDEODRIVER:-}" ]; then
export SDL_VIDEODRIVER=x11
fi
exec "$LOVE_BIN" "$ROOT" "$@" exec "$LOVE_BIN" "$ROOT" "$@"
+44 -40
View File
@@ -87,21 +87,23 @@ local function statusFor(mods, id, enabledSet, enabled, version, forcedFor)
-- resolveToggle would cascade-enable a merely-disabled dep rather than flag -- resolveToggle would cascade-enable a merely-disabled dep rather than flag
-- it, so the disabled case is judged straight off the manifest here. -- it, so the disabled case is judged straight off the manifest here.
for _, spec in ipairs(m.dependencySpecs or {}) do for _, spec in ipairs(m.dependencySpecs or {}) do
local dep = mods[spec.id] if ModTargets.specApplies(spec, version) then
if not dep then local dep = mods[spec.id]
return "warn", "Needs " .. spec.id .. " (not installed)" if not dep then
elseif not enabledSet[spec.id] then return "warn", "Needs " .. spec.id .. " (not installed)"
return "warn", "Needs " .. spec.id .. " (disabled)" elseif not enabledSet[spec.id] then
-- installed and on, but not for THIS game: the loader skips the return "warn", "Needs " .. spec.id .. " (disabled)"
-- dependency and the skip is contagious (Loader:_enforceDependencies), -- installed and on, but not for THIS game: the loader skips the
-- so a mod that runs everywhere still does not run here -- dependency and the skip is contagious (Loader:_enforceDependencies),
elseif version -- so a mod that runs everywhere still does not run here
and not ModTargets.runsHere(dep, version, nil, forcedFor(spec.id)) then elseif version
return "warn", "Needs " .. spec.id .. " (not for " and not ModTargets.runsHere(dep, version, nil, forcedFor(spec.id)) then
.. ModTargets.gameLabel(version) .. ")" return "warn", "Needs " .. spec.id .. " (not for "
elseif spec.range .. ModTargets.gameLabel(version) .. ")"
and not Semver.satisfies(dep.version, spec.range) then elseif spec.range
return "warn", "Needs " .. spec.id .. " " .. spec.range and not Semver.satisfies(dep.version, spec.range) then
return "warn", "Needs " .. spec.id .. " " .. spec.range
end
end end
end end
return "ok", "Ready" return "ok", "Ready"
@@ -168,33 +170,35 @@ function LauncherMods.checkDependencies(manifest, options, version, installedMan
-- 1. Hard Dependencies (dependencySpecs) -- 1. Hard Dependencies (dependencySpecs)
if type(manifest.dependencySpecs) == "table" then if type(manifest.dependencySpecs) == "table" then
for _, spec in ipairs(manifest.dependencySpecs) do for _, spec in ipairs(manifest.dependencySpecs) do
local depId = spec.id if not version or ModTargets.specApplies(spec, version) then
local range = spec.range local depId = spec.id
local installedDep = installedMap[depId] local range = spec.range
local status = "satisfied" local installedDep = installedMap[depId]
local installedVersion = installedDep and installedDep.version or nil local status = "satisfied"
local installedVersion = installedDep and installedDep.version or nil
if not installedDep then if not installedDep then
status = "missing" status = "missing"
hasIssues = true hasIssues = true
elseif range and not Semver.satisfies(installedDep.version, range) then elseif range and not Semver.satisfies(installedDep.version, range) then
status = "incompatible" status = "incompatible"
hasIssues = true hasIssues = true
end
local ghRepo = LauncherMods.resolveDependencyRepo(depId, manifest, installedDep)
local safeUrl = ghRepo and ("https://github.com/" .. ghRepo) or nil
depsResult[#depsResult + 1] = {
id = depId,
name = (installedDep and installedDep.name) or depId,
range = range,
status = status,
kind = "dependency",
installedVersion = installedVersion,
github = ghRepo,
safeUrl = safeUrl,
}
end end
local ghRepo = LauncherMods.resolveDependencyRepo(depId, manifest, installedDep)
local safeUrl = ghRepo and ("https://github.com/" .. ghRepo) or nil
depsResult[#depsResult + 1] = {
id = depId,
name = (installedDep and installedDep.name) or depId,
range = range,
status = status,
kind = "dependency",
installedVersion = installedVersion,
github = ghRepo,
safeUrl = safeUrl,
}
end end
end end
+57 -39
View File
@@ -561,42 +561,46 @@ end
-- hard dependencies must exist, be enabled, have survived, and satisfy their -- hard dependencies must exist, be enabled, have survived, and satisfy their
-- range; run to a fixpoint so failures propagate to dependents transitively -- range; run to a fixpoint so failures propagate to dependents transitively
function Loader:_enforceDependencies() function Loader:_enforceDependencies()
local targetVersion = self:_targetVersion()
local generation = self.generation
local changed = true local changed = true
while changed do while changed do
changed = false changed = false
for _, id in ipairs(orderedIds(self.mods, isActive)) do for _, id in ipairs(orderedIds(self.mods, isActive)) do
local mod = self.mods[id] local mod = self.mods[id]
for _, spec in ipairs(mod.manifest.dependencySpecs) do for _, spec in ipairs(mod.manifest.dependencySpecs) do
local dep = self.mods[spec.id] if ModTargets.specApplies(spec, targetVersion, generation) then
local reason, skip local dep = self.mods[spec.id]
if not dep then local reason, skip
reason = "missing dependency: " .. spec.id if not dep then
elseif not dep.enabled then reason = "missing dependency: " .. spec.id
reason = ("dependency %s is disabled"):format(spec.id) elseif not dep.enabled then
elseif dep.state == "wrong_generation" then reason = ("dependency %s is disabled"):format(spec.id)
-- the gate's skip is contagious as a SKIP, not as a failure: the elseif dep.state == "wrong_generation" then
-- dependency has no bug to report and neither does this mod, so -- the gate's skip is contagious as a SKIP, not as a failure: the
-- nothing here lands on the boot error list -- dependency has no bug to report and neither does this mod, so
skip = true -- nothing here lands on the boot error list
-- carry the dependency's own reason: it names the game or the skip = true
-- missing gen2compat, and a guess here would name the wrong one -- carry the dependency's own reason: it names the game or the
reason = ("depends on %s, which does not run here (%s)") -- missing gen2compat, and a guess here would name the wrong one
:format(spec.id, dep.skipReason or "not made for this game") reason = ("depends on %s, which does not run here (%s)")
elseif dep.failed then :format(spec.id, dep.skipReason or "not made for this game")
reason = ("dependency %s failed to load"):format(spec.id) elseif dep.failed then
elseif spec.range reason = ("dependency %s failed to load"):format(spec.id)
and not Semver.satisfies(dep.manifest.version, spec.range) then elseif spec.range
reason = ("needs %s@%s, found %s") and not Semver.satisfies(dep.manifest.version, spec.range) then
:format(spec.id, spec.range, dep.manifest.version) reason = ("needs %s@%s, found %s")
end :format(spec.id, spec.range, dep.manifest.version)
if reason then end
if skip then if reason then
self:_skip(mod, "wrong_generation", reason) if skip then
else self:_skip(mod, "wrong_generation", reason)
self:_fail(mod, "blocked_dependency", reason) else
self:_fail(mod, "blocked_dependency", reason)
end
changed = true
break
end end
changed = true
break
end end
end end
end end
@@ -606,6 +610,8 @@ end
-- Tarjan SCC over the hard-dependency graph: only a cycle's own members -- Tarjan SCC over the hard-dependency graph: only a cycle's own members
-- fail, so an unrelated mod beside a cycle still loads -- fail, so an unrelated mod beside a cycle still loads
function Loader:_failCycles() function Loader:_failCycles()
local targetVersion = self:_targetVersion()
local generation = self.generation
local mods = self.mods local mods = self.mods
local counter, stack, onStack, index, low = 0, {}, {}, {}, {} local counter, stack, onStack, index, low = 0, {}, {}, {}, {}
local cycles = {} local cycles = {}
@@ -616,14 +622,16 @@ function Loader:_failCycles()
onStack[id] = true onStack[id] = true
local selfEdge = false local selfEdge = false
for _, spec in ipairs(mods[id].manifest.dependencySpecs) do for _, spec in ipairs(mods[id].manifest.dependencySpecs) do
local dep = mods[spec.id] if ModTargets.specApplies(spec, targetVersion, generation) then
if spec.id == id then selfEdge = true end local dep = mods[spec.id]
if dep and isActive(dep) and spec.id ~= id then if spec.id == id then selfEdge = true end
if not index[spec.id] then if dep and isActive(dep) and spec.id ~= id then
connect(spec.id) if not index[spec.id] then
if low[spec.id] < low[id] then low[id] = low[spec.id] end connect(spec.id)
elseif onStack[spec.id] and index[spec.id] < low[id] then if low[spec.id] < low[id] then low[id] = low[spec.id] end
low[id] = index[spec.id] elseif onStack[spec.id] and index[spec.id] < low[id] then
low[id] = index[spec.id]
end
end end
end end
end end
@@ -674,6 +682,8 @@ end
-- Kahn over the surviving graph with the ready set kept in (priority, id) -- Kahn over the surviving graph with the ready set kept in (priority, id)
-- order, so dependencies come first and the rest matches the v1 contract -- order, so dependencies come first and the rest matches the v1 contract
function Loader:_order() function Loader:_order()
local targetVersion = self:_targetVersion()
local generation = self.generation
local pending, indegree, dependents = {}, {}, {} local pending, indegree, dependents = {}, {}, {}
for _, id in ipairs(orderedIds(self.mods, isActive)) do for _, id in ipairs(orderedIds(self.mods, isActive)) do
pending[id], indegree[id] = true, 0 pending[id], indegree[id] = true, 0
@@ -686,9 +696,17 @@ function Loader:_order()
dependents[depId][#dependents[depId] + 1] = id dependents[depId][#dependents[depId] + 1] = id
indegree[id] = indegree[id] + 1 indegree[id] = indegree[id] + 1
end end
for _, spec in ipairs(manifest.dependencySpecs) do edge(spec.id) end for _, spec in ipairs(manifest.dependencySpecs) do
if ModTargets.specApplies(spec, targetVersion, generation) then
edge(spec.id)
end
end
-- optional dependencies order without requiring anything -- optional dependencies order without requiring anything
for _, spec in ipairs(manifest.optionalSpecs) do edge(spec.id) end for _, spec in ipairs(manifest.optionalSpecs) do
if ModTargets.specApplies(spec, targetVersion, generation) then
edge(spec.id)
end
end
end end
local ordered = {} local ordered = {}
local function nextId() local function nextId()
+24 -2
View File
@@ -58,11 +58,13 @@ local function parseSpecs(list, field, sources)
local specs = {} local specs = {}
sources = type(sources) == "table" and sources or {} sources = type(sources) == "table" and sources or {}
for _, entry in ipairs(list) do for _, entry in ipairs(list) do
local id, range, ghHint local id, range, ghHint, gamesRaw, gameVersion
if type(entry) == "table" then if type(entry) == "table" then
id = entry.id id = entry.id
range = entry.range or entry.version range = entry.range or entry.version
ghHint = entry.github or entry.repo ghHint = entry.github or entry.repo
gamesRaw = entry.games or entry.game
gameVersion = entry.game_version
elseif type(entry) == "string" and entry ~= "" then elseif type(entry) == "string" and entry ~= "" then
local main, hashRepo = entry:match("^([^#]+)#(.*)$") local main, hashRepo = entry:match("^([^#]+)#(.*)$")
if main then if main then
@@ -82,6 +84,20 @@ local function parseSpecs(list, field, sources)
local ok, err = Semver.validRange(range) local ok, err = Semver.validRange(range)
assert(ok, ("malformed %s range in %q: %s"):format(field, tostring(entry), tostring(err))) assert(ok, ("malformed %s range in %q: %s"):format(field, tostring(entry), tostring(err)))
if gameVersion then
local okV, errV = Semver.validRange(gameVersion)
assert(okV, ("malformed %s game_version range in %q: %s"):format(field, tostring(entry), tostring(errV)))
end
local parsedGames = nil
if gamesRaw ~= nil then
if type(gamesRaw) == "string" then gamesRaw = { gamesRaw } end
assert(type(gamesRaw) == "table", "dependency games must be a string or table")
local normalized, unknown = ModTargets.normalize(gamesRaw)
assert(#unknown == 0, ("unknown game in %s: %s"):format(field, table.concat(unknown, ", ")))
parsedGames = normalized
end
local parsedGh = nil local parsedGh = nil
local rawGh = ghHint or sources[id] local rawGh = ghHint or sources[id]
if rawGh then if rawGh then
@@ -89,7 +105,13 @@ local function parseSpecs(list, field, sources)
if okGh and cleanGh then parsedGh = cleanGh end if okGh and cleanGh then parsedGh = cleanGh end
end end
specs[#specs + 1] = { id = id, range = range, github = parsedGh } specs[#specs + 1] = {
id = id,
range = range,
github = parsedGh,
games = parsedGames,
game_version = gameVersion,
}
end end
return specs return specs
end end
+35
View File
@@ -168,4 +168,39 @@ function ModTargets.detail(manifest, version)
ModTargets.gameLabel(version)) ModTargets.gameLabel(version))
end end
-- Does a dependency spec apply to this game / version?
-- If spec.games is provided, it must match version / generation.
-- If spec.game_version is provided, the engine version must satisfy it.
function ModTargets.specApplies(spec, version, generation)
if not spec then return false end
if type(spec.games) == "table" and #spec.games > 0 then
if version or generation then
local match = false
for _, id in ipairs(spec.games) do
if version and id == version then
match = true
break
end
if generation and GameVersion.generation(id) == generation then
match = true
break
end
end
if not match then return false end
end
end
if spec.game_version then
local ok = pcall(function()
local Semver = require("src.mods.Semver")
local Version = require("src.core.Version")
if Version and Version.engine and Version.engine:match("^0%.0%.0%-") == nil then
return Semver.satisfies(Version.engine, spec.game_version)
end
return true
end)
if not ok then return false end
end
return true
end
return ModTargets return ModTargets
+40 -5
View File
@@ -552,11 +552,46 @@ local installedColorlib = Manifest.validate({
version = "1.0.0", version = "1.0.0",
entry = "main.lua", entry = "main.lua",
}, "mods/colorlib") }, "mods/colorlib")
local depConflictCheck = LauncherMods.checkDependencies(testTargetManifest, nil, nil, { installedColorlib }) -- ------- scoped dependency tests
check(depConflictCheck.hasIssues == true, "conflict with colorlib triggers hasIssues") local Json = require("src.link.Json")
check(#depConflictCheck.deps == 1 and depConflictCheck.deps[1].status == "conflict", local scopedDepManifest = Manifest.validate({
"incompatible mod is flagged as status conflict") id = "dual_gen_mod",
check(depConflictCheck.deps[1].kind == "conflict", "conflict item carries kind conflict") name = "Dual Gen Mod",
version = "1.0.0",
entry = "main.lua",
games = { "gen1", "gen2" },
dependencies = {
{ id = "gen2_only_dep", games = { "gen2" }, version = "^1.0.0" }
},
}, "mods/dual_gen_mod")
check(#scopedDepManifest.dependencySpecs == 1, "scoped dependency parsed")
check(scopedDepManifest.dependencySpecs[1].games ~= nil, "dependency carries games list")
local dualGenFiles = {
["mods/dual_gen_mod/manifest.json"] = Json.encode({
id = "dual_gen_mod",
name = "Dual Gen Mod",
version = "1.0.0",
entry = "main.lua",
games = { "gen1", "gen2" },
dependencies = {
{ id = "gen2_only_dep", games = { "gen2" } }
},
}),
["mods/dual_gen_mod/main.lua"] = [[
return function(mod)
mod.content.pokemon:register("DUAL_MON", { hp = 100 })
end
]],
}
local gen1Loader = Loader.new({ fs = memfs(dualGenFiles), generation = 1 })
check(gen1Loader:load({}) == true, "dual gen mod loads on Gen 1 when Gen 2 dep is absent")
check(gen1Loader.content.pokemon:get("DUAL_MON") ~= nil, "dual gen mod executed on Gen 1")
local gen2Loader = Loader.new({ fs = memfs(dualGenFiles), generation = 2 })
check(gen2Loader:load({}) == false, "loader returns false on Gen 2 when missing required Gen 2 dep")
check(gen2Loader.content.pokemon:get("DUAL_MON") == nil, "dual gen mod is blocked on Gen 2 when missing required Gen 2 dep")
check(#gen2Loader:status().errors > 0, "missing dependency error logged on Gen 2")
Runtime.install(savedEvents, savedHooks) Runtime.install(savedEvents, savedHooks)
+11 -4
View File
@@ -2989,16 +2989,23 @@ def check_gen2_manifest(repo, mod_dir, manifest, named):
"manifest.json")) "manifest.json"))
deps = manifest.get("dependencies") or [] deps = manifest.get("dependencies") or []
for dep in deps if isinstance(deps, list) else []: for dep in deps if isinstance(deps, list) else []:
if not isinstance(dep, str): dep_id = dep if isinstance(dep, str) else dep.get("id") if isinstance(dep, dict) else None
if not dep_id:
continue continue
found = named.get(dep) or find_mod_by_id(repo, mod_dir, dep) if isinstance(dep, dict) and "games" in dep:
g_list = dep.get("games")
if isinstance(g_list, str):
g_list = [g_list]
if isinstance(g_list, list) and not any(g in ["gen2", "gold", "silver", "crystal", "all"] for g in g_list):
continue
found = named.get(dep_id) or find_mod_by_id(repo, mod_dir, dep_id)
if found is None: if found is None:
notes.append("unresolved: dependency %s is not installed beside " notes.append("unresolved: dependency %s is not installed beside "
"this mod, so its games list could not be read" % dep) "this mod, so its games list could not be read" % dep_id)
elif not declares_gen2(repo, found): elif not declares_gen2(repo, found):
findings.append(Finding( findings.append(Finding(
"MK401", "error", "MK401", "error",
f"depends on {dep}, which claims no Gen 2 game; the " f"depends on {dep_id}, which claims no Gen 2 game; the "
f"loader disables a mod whose dependency a Gen 2 boot skipped", f"loader disables a mod whose dependency a Gen 2 boot skipped",
"manifest.json")) "manifest.json"))
return findings, notes return findings, notes