mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-15 07:41:21 +02:00
Merge pull request #1246 from 1Jamie/fix/gen2-battle-rng-static-damage
This commit is contained in:
+5
-1
@@ -3,8 +3,9 @@
|
||||
data/generated/
|
||||
assets/generated/
|
||||
|
||||
# LÖVE packages
|
||||
# LÖVE packages & archives
|
||||
*.love
|
||||
*.zip
|
||||
|
||||
# Local saves (LÖVE writes to its save dir, but keep the repo clean anyway)
|
||||
save/
|
||||
@@ -75,3 +76,6 @@ mobile/ios/bundle_id.local
|
||||
/dist/native/
|
||||
/dist/win/
|
||||
/.bazinga/
|
||||
|
||||
# Local options / preferences
|
||||
/options.lua*
|
||||
|
||||
@@ -245,7 +245,8 @@ real Gold boot.
|
||||
### 5. `mod.card`
|
||||
|
||||
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
|
||||
merge — only by tooling and the manager's detail pane — so an absent or
|
||||
malformed card can never break a load.
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
wording (`depends on X, which does not 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
|
||||
with it** (unless scoped to specific games, e.g.
|
||||
`dependencies: [{ id = "x", games = ["gen2"] }]`), as a skip rather than a
|
||||
failure and carrying the dependency's own wording (`depends on X, which does not
|
||||
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
|
||||
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
|
||||
|
||||
@@ -21,6 +21,76 @@ luajit tools/gen_registry_docs.lua
|
||||
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)
|
||||
|
||||
The mod API is one API across both generations, but Gold runs its own battle
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
**Enablement is per game.** The overlay
|
||||
|
||||
@@ -415,6 +415,9 @@ EOF
|
||||
grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \
|
||||
|| 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
|
||||
# bundled runtime can read it.
|
||||
local sfs_out="$WORK/game.squashfs"
|
||||
|
||||
@@ -405,6 +405,12 @@ if [ -z "\$LUA_CPATH" ]; then
|
||||
fi
|
||||
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" "\$@"
|
||||
EOF
|
||||
chmod +x "$APPDIR/AppRun"
|
||||
|
||||
@@ -35,4 +35,10 @@ find_love() {
|
||||
LOVE_BIN="$(find_love)" \
|
||||
|| 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" "$@"
|
||||
|
||||
@@ -116,6 +116,8 @@ local physfsMountFn = nil
|
||||
local function resolveMount()
|
||||
if physfsMountFn ~= nil then return physfsMountFn end
|
||||
physfsMountFn = false
|
||||
if Platform.isUWP() then return physfsMountFn end
|
||||
if love and love.filesystem and love.filesystem._mounts then return physfsMountFn end
|
||||
local ok, ffi = pcall(require, "ffi")
|
||||
if not ok then return physfsMountFn end
|
||||
pcall(ffi.cdef,
|
||||
@@ -159,6 +161,8 @@ local physfsUnmountFn = nil
|
||||
local function resolveUnmount()
|
||||
if physfsUnmountFn ~= nil then return physfsUnmountFn end
|
||||
physfsUnmountFn = false
|
||||
if Platform.isUWP() then return physfsUnmountFn end
|
||||
if love and love.filesystem and love.filesystem._mounts then return physfsUnmountFn end
|
||||
local ok, ffi = pcall(require, "ffi")
|
||||
if not ok then return physfsUnmountFn end
|
||||
pcall(ffi.cdef, "int PHYSFS_unmount(const char *oldDir);")
|
||||
|
||||
+673
-52
@@ -568,10 +568,10 @@ local function modStatusColor(status)
|
||||
return Strings("Incompatible"), PAL.yellow
|
||||
end
|
||||
|
||||
-- MODS panel scope row: which game the list is answering for. Drawn from
|
||||
-- GameVersion.ORDER so a new game needs nothing here.
|
||||
-- MODS panel scope row: which game the list is answering for, plus dedicated Profile control (cycle + gear).
|
||||
local function buildModScopeRow(imp, x, y, w, m)
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local h = math.max(Kit.tapMin(), math.floor(26 * m.s))
|
||||
local gap = math.floor(6 * m.s)
|
||||
local label = Strings("Show for:")
|
||||
@@ -584,16 +584,63 @@ local function buildModScopeRow(imp, x, y, w, m)
|
||||
{ id = version, label = GameVersion.info(version).label }
|
||||
end
|
||||
end
|
||||
if #options < 2 then return 0 end
|
||||
for _, opt in ipairs(options) do
|
||||
local cw = Kit.textWidth("micro", opt.label) + math.floor(18 * m.s)
|
||||
if Kit.chip(cx, y, cw, h, opt.label, imp.modScope == opt.id, PAL.lineStrong,
|
||||
"mod-scope-" .. tostring(opt.id or "all")) then
|
||||
local want = opt.id
|
||||
queueAction(imp, "mod-scope-" .. tostring(want or "all"),
|
||||
function() imp:_setModScope(want) end)
|
||||
|
||||
-- Dedicated Profile control section (cycle button + gear icon button) on right side of Scope Bar
|
||||
local profiles, activeProf = LauncherMods.getProfiles()
|
||||
local isCompact = (w < math.floor(500 * m.s))
|
||||
local nameText = tostring(activeProf or "Default")
|
||||
local profLabel = isCompact and nameText or Strings("Profile: %s", nameText)
|
||||
local profW = Kit.textWidth("micro", profLabel) + math.floor(20 * m.s)
|
||||
local gearW = h
|
||||
local gearX = x + w - gearW
|
||||
local profX = gearX - profW - math.floor(4 * m.s)
|
||||
|
||||
-- Tapping main profile button cycles to the next profile (styles match iconButton)
|
||||
Kit._audit("control", profX, y, profW, h, "mod-scope-profile")
|
||||
local focused = Kit.focusable("mod-scope-profile", profX, y, profW, h)
|
||||
local hot = focused or Kit.hover(profX, y, profW, h)
|
||||
Theme.fillRounded(profX, y, profW, h, hot and PAL.ink or PAL.surface, 1)
|
||||
Theme.strokeRounded(profX, y, profW, h, PAL.line,
|
||||
hot and Theme.A.focus or Theme.A.hairline, 1)
|
||||
Kit.textCenterBold("micro", profLabel, profX,
|
||||
y + (h - Kit.textHeight("micro")) / 2, profW,
|
||||
hot and PAL.inverse or PAL.heading)
|
||||
if Kit.press(profX, y, profW, h) or Kit._activateId == "mod-scope-profile" then
|
||||
local nextIdx = 1
|
||||
for i, p in ipairs(profiles) do
|
||||
if p.name == activeProf then
|
||||
nextIdx = (i % #profiles) + 1
|
||||
break
|
||||
end
|
||||
end
|
||||
local nextProf = profiles[nextIdx] and profiles[nextIdx].name
|
||||
if nextProf then
|
||||
queueAction(imp, "mod-scope-profile", function()
|
||||
LauncherMods.applyProfile(nextProf)
|
||||
if imp._refreshMods then imp:_refreshMods() end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
-- Tapping gear button opens the Profile Manager modal
|
||||
imp._gearIcon = imp._gearIcon or (love and love.graphics and love.graphics.newImage and love.graphics.newImage("assets/launcher/gear.png"))
|
||||
iconButton(imp, "mod-profile-gear", gearX, y, gearW, imp._gearIcon, function()
|
||||
imp._profilesPopup = true
|
||||
end)
|
||||
|
||||
if #options >= 2 then
|
||||
for _, opt in ipairs(options) do
|
||||
local cw = Kit.textWidth("micro", opt.label) + math.floor(18 * m.s)
|
||||
if cx + cw <= profX - gap then
|
||||
if Kit.chip(cx, y, cw, h, opt.label, imp.modScope == opt.id, PAL.lineStrong,
|
||||
"mod-scope-" .. tostring(opt.id or "all")) then
|
||||
local want = opt.id
|
||||
queueAction(imp, "mod-scope-" .. tostring(want or "all"),
|
||||
function() imp:_setModScope(want) end)
|
||||
end
|
||||
cx = cx + cw + gap
|
||||
end
|
||||
end
|
||||
cx = cx + cw + gap
|
||||
end
|
||||
return h + math.floor(8 * m.s)
|
||||
end
|
||||
@@ -1380,33 +1427,31 @@ end
|
||||
local function drawCheck(x, y, size, color)
|
||||
love.graphics.push("all")
|
||||
love.graphics.setColor(color)
|
||||
love.graphics.setLineWidth(math.max(2, size * 0.16))
|
||||
love.graphics.setLineWidth(math.max(2.2, size * 0.17))
|
||||
love.graphics.setLineJoin("bevel")
|
||||
love.graphics.line(
|
||||
x, y + size * 0.55,
|
||||
x + size * 0.35, y + size * 0.85,
|
||||
x + size * 0.95, y + size * 0.15)
|
||||
x + size * 0.02, y + size * 0.52,
|
||||
x + size * 0.38, y + size * 0.80,
|
||||
x + size * 1.015, y + size * 0.18)
|
||||
love.graphics.pop()
|
||||
end
|
||||
|
||||
-- One compact coloured checkbox for each game. The cartridge colour carries
|
||||
-- the game identity even when the row is narrow; the letter keeps an unchecked
|
||||
-- box legible without relying on colour alone.
|
||||
-- the game identity even when the row is narrow.
|
||||
local function modGameCheckbox(x, y, size, checked, game, id)
|
||||
local color = cartColor(game)
|
||||
local focused = Kit.focusable(id, x, y, size, size)
|
||||
local hot = focused or Kit.hover(x, y, size, size)
|
||||
if love.graphics then
|
||||
Theme.fillRounded(x, y, size, size, PAL.bg, 1)
|
||||
if checked then
|
||||
Theme.fillRounded(x, y, size, size, color, 1)
|
||||
drawCheck(x, y, size, PAL.inverse)
|
||||
Theme.strokeRounded(x, y, size, size, color,
|
||||
hot and Theme.A.focus or 0.9, 1.5)
|
||||
drawCheck(x, y, size, color)
|
||||
else
|
||||
Theme.fillRounded(x, y, size, size, PAL.bg, 1)
|
||||
Kit.textCenterBold("micro", game:sub(1, 1):upper(), x,
|
||||
y + (size - Kit.textHeight("micro")) / 2, size, color)
|
||||
Theme.strokeRounded(x, y, size, size, color,
|
||||
hot and Theme.A.focus or Theme.A.hairline, 1)
|
||||
end
|
||||
Theme.strokeRounded(x, y, size, size, color,
|
||||
hot and Theme.A.focus or Theme.A.hover, 1)
|
||||
end
|
||||
return Kit.press(x, y, size, size) or Kit._activateId == id
|
||||
end
|
||||
@@ -1418,28 +1463,79 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
local gap = m.gap
|
||||
local cy = y
|
||||
|
||||
-- header: just the action cluster, right-aligned. No "Mods" headline (the
|
||||
-- active tab already says it) and no enabled count (the toggles show it).
|
||||
local place = Layout.rightCluster(x, w, math.floor(6 * m.s))
|
||||
-- header: progressive action cluster. Surfaces primary/frequent actions
|
||||
-- (Import, Updates, Sort) directly on the bar across screen sizes, placing
|
||||
-- bulk actions (Enable all / Disable all) into More... on compact viewports.
|
||||
local bh = m.btnH
|
||||
local importLabel = imp:_modsImportButtonLabel()
|
||||
local iw2 = Kit.textWidth("small", importLabel) + math.floor(24 * m.s)
|
||||
btn(imp, place(iw2), cy, iw2, bh, "mods-import", importLabel, {
|
||||
kind = "accent", font = "small",
|
||||
action = function() imp:chooseMod() end })
|
||||
local importW = Kit.textWidth("small", importLabel) + math.floor(24 * m.s)
|
||||
|
||||
if #mods > 0 then
|
||||
local dw = Kit.textWidth("small", Strings("Disable all")) + math.floor(20 * m.s)
|
||||
btn(imp, place(dw), cy, dw, bh, "mods-disable-all", Strings("Disable all"), {
|
||||
kind = "warn", font = "small",
|
||||
action = function() imp:_setAllMods(false) end })
|
||||
local ew = Kit.textWidth("small", Strings("Enable all")) + math.floor(20 * m.s)
|
||||
btn(imp, place(ew), cy, ew, bh, "mods-enable-all", Strings("Enable all"), {
|
||||
kind = "good", font = "small",
|
||||
action = function() imp:_setAllMods(true) end })
|
||||
local sw = Kit.textWidth("small", Strings("Sort")) + math.floor(24 * m.s)
|
||||
btn(imp, place(sw), cy, sw, bh, "mods-sort", Strings("Sort"), {
|
||||
font = "small",
|
||||
action = function() imp._sortPopup = true end })
|
||||
local disableW = Kit.textWidth("small", Strings("Disable all")) + math.floor(20 * m.s)
|
||||
local enableW = Kit.textWidth("small", Strings("Enable all")) + math.floor(20 * m.s)
|
||||
local checkFullW = Kit.textWidth("small", Strings("Check for updates")) + math.floor(20 * m.s)
|
||||
local checkShortW = Kit.textWidth("small", Strings("Updates")) + math.floor(20 * m.s)
|
||||
local sortW = Kit.textWidth("small", Strings("Sort")) + math.floor(24 * m.s)
|
||||
local moreW = Kit.textWidth("small", Strings("More...")) + math.floor(20 * m.s)
|
||||
|
||||
local fullReq = importW + disableW + enableW + checkFullW + sortW + math.floor(30 * m.s)
|
||||
local medReq = importW + checkShortW + sortW + moreW + math.floor(24 * m.s)
|
||||
|
||||
local place = Layout.rightCluster(x, w, math.floor(6 * m.s))
|
||||
|
||||
if fullReq <= w then
|
||||
-- Tier 1 (Desktop / Wide): Show all 5 full-text buttons
|
||||
btn(imp, place(importW), cy, importW, bh, "mods-import", importLabel, {
|
||||
kind = "accent", font = "small",
|
||||
action = function() imp:chooseMod() end })
|
||||
btn(imp, place(disableW), cy, disableW, bh, "mods-disable-all", Strings("Disable all"), {
|
||||
kind = "warn", font = "small",
|
||||
action = function() imp:_setAllMods(false) end })
|
||||
btn(imp, place(enableW), cy, enableW, bh, "mods-enable-all", Strings("Enable all"), {
|
||||
kind = "good", font = "small",
|
||||
action = function() imp:_setAllMods(true) end })
|
||||
btn(imp, place(checkFullW), cy, checkFullW, bh, "mods-check-updates", Strings("Check for updates"), {
|
||||
font = "small",
|
||||
action = function() imp:_syncModUpdateInfo(true) end })
|
||||
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
|
||||
font = "small",
|
||||
action = function() imp._sortPopup = true end })
|
||||
elseif medReq <= w then
|
||||
-- Tier 2 (Medium / Compact): Surface Import, Updates, and Sort directly
|
||||
btn(imp, place(importW), cy, importW, bh, "mods-import", importLabel, {
|
||||
kind = "accent", font = "small",
|
||||
action = function() imp:chooseMod() end })
|
||||
btn(imp, place(checkShortW), cy, checkShortW, bh, "mods-check-updates", Strings("Updates"), {
|
||||
font = "small",
|
||||
action = function() imp:_syncModUpdateInfo(true) end })
|
||||
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
|
||||
font = "small",
|
||||
action = function() imp._sortPopup = true end })
|
||||
btn(imp, place(moreW), cy, moreW, bh, "mods-more-actions", Strings("More..."), {
|
||||
font = "small",
|
||||
action = function() imp._modHeaderActionsPopup = true end })
|
||||
else
|
||||
-- Tier 3 (Ultra-Compact Mobile): Surface Import, Sort + More...
|
||||
local importShortLabel = Strings("Import")
|
||||
local importShortW = Kit.textWidth("small", importShortLabel) + math.floor(20 * m.s)
|
||||
local miniReq = importShortW + sortW + moreW + math.floor(18 * m.s)
|
||||
local useImportW = (miniReq <= w) and importShortW or importW
|
||||
|
||||
btn(imp, place(useImportW), cy, useImportW, bh, "mods-import", (miniReq <= w) and importShortLabel or importLabel, {
|
||||
kind = "accent", font = "small",
|
||||
action = function() imp:chooseMod() end })
|
||||
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
|
||||
font = "small",
|
||||
action = function() imp._sortPopup = true end })
|
||||
btn(imp, place(moreW), cy, moreW, bh, "mods-more-actions", Strings("More..."), {
|
||||
font = "small",
|
||||
action = function() imp._modHeaderActionsPopup = true end })
|
||||
end
|
||||
else
|
||||
local place = Layout.rightCluster(x, w, math.floor(6 * m.s))
|
||||
btn(imp, place(importW), cy, importW, bh, "mods-import", importLabel, {
|
||||
kind = "accent", font = "small",
|
||||
action = function() imp:chooseMod() end })
|
||||
end
|
||||
cy = cy + bh + math.floor(8 * m.s)
|
||||
|
||||
@@ -1534,17 +1630,28 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
local mod = mods[i]
|
||||
local ry = listTop + (i - first) * (rowH + gap) - scroll
|
||||
local rowKey = "mod-row-" .. mod.id
|
||||
-- The whole row is the control: it opens the per-mod actions popup
|
||||
-- (update / versions / delete moved there). Only the enable toggle
|
||||
-- stays inline, because flipping a mod on and off is the everyday act.
|
||||
local isFullyDisabled = true
|
||||
if mod.enabledByVersion then
|
||||
for _, on in pairs(mod.enabledByVersion) do
|
||||
if on then isFullyDisabled = false; break end
|
||||
end
|
||||
else
|
||||
isFullyDisabled = not mod.enabled
|
||||
end
|
||||
|
||||
local focused = Kit.focusable(rowKey, x, ry, w, rowH)
|
||||
local hot = focused or Kit.hover(x, ry, w, rowH)
|
||||
Kit.card(x, ry, w, rowH, hot)
|
||||
if isFullyDisabled then
|
||||
Theme.fillRounded(x, ry, w, rowH, PAL.bg, 0.8, Theme.cardRadius())
|
||||
Theme.strokeRounded(x, ry, w, rowH, PAL.muted, hot and Theme.A.hover or 0.25, 1, Theme.cardRadius())
|
||||
else
|
||||
Kit.card(x, ry, w, rowH, hot)
|
||||
end
|
||||
local pad = math.floor(12 * m.s)
|
||||
local px, inner = x + pad, w - 2 * pad
|
||||
local ly = ry + math.floor(10 * m.s)
|
||||
|
||||
local togGap = math.floor(4 * m.s)
|
||||
local togGap = math.floor(5 * m.s) + 1
|
||||
local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id)
|
||||
|
||||
-- These answer separate games, not a single shared install flag. The
|
||||
@@ -1582,7 +1689,8 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
and Kit.textWidth("micro", mod.targets) + math.floor(12 * m.s) or 0
|
||||
local nameShown = Kit.ellipsize("button", mod.name,
|
||||
textW - badgeW - gamesW - math.floor(12 * m.s))
|
||||
Kit.text("button", nameShown, px, ly, PAL.heading)
|
||||
local headingCol = isFullyDisabled and PAL.muted or PAL.heading
|
||||
Kit.text("button", nameShown, px, ly, headingCol)
|
||||
local tagX = px + Kit.textWidth("button", nameShown) + math.floor(8 * m.s)
|
||||
Kit.tag(tagX, ly, badgeW, Kit.textHeight("button"), mod.badge,
|
||||
mod.experimental and PAL.yellow or PAL.muted)
|
||||
@@ -2164,6 +2272,219 @@ local function buildVersionsModal(imp, m)
|
||||
action = function() imp._modVersions = nil end })
|
||||
end
|
||||
|
||||
-- Modal for per-profile actions (Duplicate, Rename, Delete) for compact / mobile / RG device compatibility
|
||||
local function buildSingleProfileActionsModal(imp, m)
|
||||
local pName = imp._singleProfileActions and imp._singleProfileActions.name
|
||||
if not pName then imp._singleProfileActions = nil return end
|
||||
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local options = SaveData.loadOptions()
|
||||
local profiles, active = LauncherMods.getProfiles(options)
|
||||
|
||||
local pad = math.floor(18 * m.s)
|
||||
local w = math.min(math.floor(380 * m.s), m.w - 2 * m.pad)
|
||||
local gap = math.floor(8 * m.s)
|
||||
local canDelete = (#profiles > 1)
|
||||
local armed = deleteArmed(imp, "profile", pName, nil)
|
||||
|
||||
local btns = {
|
||||
{
|
||||
label = Strings("Duplicate profile"),
|
||||
kind = "accent",
|
||||
action = function()
|
||||
LauncherMods.duplicateProfile(pName, options)
|
||||
imp._singleProfileActions = nil
|
||||
end
|
||||
},
|
||||
{
|
||||
label = Strings("Rename profile"),
|
||||
font = "small",
|
||||
action = function()
|
||||
imp._singleProfileActions = nil
|
||||
imp._profileRenamePrompt = { oldName = pName, text = pName }
|
||||
imp:_armTextInput(pName)
|
||||
end
|
||||
},
|
||||
}
|
||||
if canDelete then
|
||||
btns[#btns + 1] = {
|
||||
label = DELETE_LABEL(armed),
|
||||
kind = armed and "warn" or "danger",
|
||||
keepArm = true,
|
||||
action = function()
|
||||
imp:pressDelete("profile", pName, nil, function()
|
||||
LauncherMods.deleteProfile(pName, options)
|
||||
imp._singleProfileActions = nil
|
||||
end)
|
||||
end
|
||||
}
|
||||
end
|
||||
|
||||
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||
+ #btns * (m.btnH + gap) + m.btnH + pad
|
||||
local px, py, pw = modalPanel(m, w, h)
|
||||
local cy = py + pad
|
||||
|
||||
Kit.text("button", Kit.ellipsize("button", pName, pw - 2 * pad), px + pad, cy, PAL.heading)
|
||||
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||
|
||||
for i, b in ipairs(btns) do
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "profact-" .. i, b.label, {
|
||||
kind = b.kind,
|
||||
font = "small",
|
||||
keepArm = b.keepArm,
|
||||
action = function()
|
||||
b.action()
|
||||
if imp._refreshMods then imp:_refreshMods() end
|
||||
end
|
||||
})
|
||||
cy = cy + m.btnH + gap
|
||||
end
|
||||
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "profact-close",
|
||||
Strings("Close"), {
|
||||
font = "small",
|
||||
action = function() imp._singleProfileActions = nil end })
|
||||
end
|
||||
|
||||
-- Modal for Mod Profiles (#593) - interactive profile manager (switch, edit, duplicate, delete)
|
||||
local function buildProfilesModal(imp, m)
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local options = SaveData.loadOptions()
|
||||
local profiles, active = LauncherMods.getProfiles(options)
|
||||
|
||||
local pad = math.floor(18 * m.s)
|
||||
local w = math.min(math.floor(460 * m.s), m.w - 2 * m.pad)
|
||||
local gap = math.floor(8 * m.s)
|
||||
local rowH = math.max(Kit.tapMin(), math.floor(40 * m.s))
|
||||
|
||||
local n = #profiles
|
||||
local maxVisible = 4
|
||||
local listH = math.min(maxVisible, math.max(1, n)) * (rowH + gap) - gap
|
||||
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||
+ m.btnH + gap + listH + math.floor(12 * m.s) + m.btnH + pad
|
||||
|
||||
local px, py, pw = modalPanel(m, w, h)
|
||||
local cy = py + pad
|
||||
|
||||
Kit.text("button", Strings("Mod Profiles"), px + pad, cy, PAL.heading)
|
||||
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||
|
||||
-- New Profile button
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "prof-new-top",
|
||||
Strings("+ Create New Profile"), {
|
||||
kind = "accent", font = "small",
|
||||
action = function()
|
||||
imp._profileSavePrompt = { text = "PROFILE " .. tostring(#profiles + 1) }
|
||||
imp:_armTextInput(imp._profileSavePrompt.text)
|
||||
end,
|
||||
})
|
||||
cy = cy + m.btnH + gap
|
||||
|
||||
-- Scrollable Profile Rows
|
||||
local scrollMax = math.max(0, n * (rowH + gap) - gap - listH)
|
||||
local scroll = clamp(imp._profScrollOffset or 0, 0, scrollMax)
|
||||
if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and Kit.hit(px + pad, cy, pw - 2 * pad, listH) then
|
||||
scroll = clamp(scroll - Kit.wheelY * math.floor(36 * m.s), 0, scrollMax)
|
||||
Kit.wheelY = 0
|
||||
end
|
||||
imp._profScrollOffset = scroll
|
||||
|
||||
Kit.pushClip(px + pad, cy, pw - 2 * pad, listH)
|
||||
for i, p in ipairs(profiles) do
|
||||
local ry = cy + (i - 1) * (rowH + gap) - scroll
|
||||
if ry + rowH >= cy and ry <= cy + listH then
|
||||
local isCur = (p.name == active)
|
||||
local rowKey = "prof-row-" .. i
|
||||
Kit.card(px + pad, ry, pw - 2 * pad, rowH, isCur)
|
||||
|
||||
local rx = px + pad + math.floor(12 * m.s)
|
||||
local editBtnW = math.floor(64 * m.s)
|
||||
local swBtnW = isCur and 0 or math.floor(64 * m.s)
|
||||
local rightClusterW = editBtnW + swBtnW + (isCur and 0 or math.floor(4 * m.s))
|
||||
local nameW = math.max(math.floor(80 * m.s), pw - 2 * pad - 2 * math.floor(12 * m.s) - rightClusterW - math.floor(50 * m.s))
|
||||
local nameText = Kit.ellipsize("small", p.name, nameW)
|
||||
Kit.text("small", nameText, rx, ry + (rowH - Kit.textHeight("small")) / 2, isCur and PAL.heading or PAL.muted)
|
||||
|
||||
if isCur then
|
||||
Kit.tag(rx + Kit.textWidth("small", nameText) + math.floor(6 * m.s),
|
||||
ry + (rowH - Kit.textHeight("micro")) / 2,
|
||||
Kit.textWidth("micro", Strings("Active")) + math.floor(8 * m.s),
|
||||
Kit.textHeight("micro"), Strings("Active"), PAL.green)
|
||||
end
|
||||
|
||||
-- Right side controls: [Switch] (if not active) + [Edit]
|
||||
local place = Layout.rightCluster(px + pad, pw - 2 * pad, math.floor(4 * m.s))
|
||||
|
||||
-- Edit button (opens per-profile action sheet)
|
||||
btn(imp, place(editBtnW), ry + math.floor(4 * m.s), editBtnW, rowH - math.floor(8 * m.s), "prof-ed-" .. i,
|
||||
Strings("Edit"), {
|
||||
font = "micro",
|
||||
action = function()
|
||||
imp._singleProfileActions = { name = p.name }
|
||||
end,
|
||||
})
|
||||
|
||||
-- Switch button (if not active)
|
||||
if not isCur then
|
||||
btn(imp, place(swBtnW), ry + math.floor(4 * m.s), swBtnW, rowH - math.floor(8 * m.s), "prof-sw-" .. i,
|
||||
Strings("Switch"), {
|
||||
kind = "good", font = "micro",
|
||||
action = function()
|
||||
LauncherMods.applyProfile(p.name, options)
|
||||
if imp._refreshMods then imp:_refreshMods() end
|
||||
end,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
Kit.popClip()
|
||||
|
||||
cy = cy + listH + math.floor(12 * m.s)
|
||||
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "prof-close",
|
||||
Strings("Close"), {
|
||||
font = "small",
|
||||
action = function() imp._profilesPopup = nil end })
|
||||
end
|
||||
|
||||
-- Modal for MODS tab header actions on mobile / compact displays
|
||||
local function buildModHeaderActionsModal(imp, m)
|
||||
local pad = math.floor(18 * m.s)
|
||||
local w = math.floor(380 * m.s)
|
||||
local gap = math.floor(8 * m.s)
|
||||
local btns = {
|
||||
{ label = Strings("Mod profiles..."), action = function() imp._profilesPopup = true end },
|
||||
{ label = Strings("Check for updates"), action = function() imp:_syncModUpdateInfo(true) end },
|
||||
{ label = Strings("Enable all mods"), kind = "good", action = function() imp:_setAllMods(true) end },
|
||||
{ label = Strings("Disable all mods"), kind = "warn", action = function() imp:_setAllMods(false) end },
|
||||
{ label = Strings("Sort mods..."), action = function() imp._sortPopup = true end },
|
||||
}
|
||||
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||
+ #btns * (m.btnH + gap) + m.btnH + pad
|
||||
local px, py, pw = modalPanel(m, w, h)
|
||||
local cy = py + pad
|
||||
Kit.text("button", Strings("More Mod Actions"), px + pad, cy, PAL.heading)
|
||||
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||
|
||||
for i, b in ipairs(btns) do
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modheadact-" .. i, b.label, {
|
||||
kind = b.kind or "ghost", font = "small",
|
||||
action = function()
|
||||
imp._modHeaderActionsPopup = nil
|
||||
b.action()
|
||||
end
|
||||
})
|
||||
cy = cy + m.btnH + gap
|
||||
end
|
||||
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modheadact-close",
|
||||
Strings("Close"), { font = "small",
|
||||
action = function() imp._modHeaderActionsPopup = nil end })
|
||||
end
|
||||
|
||||
-- Sort chooser, shared by the MODS and FIND MODS tabs (they share the
|
||||
-- persisted key, so one popup serves both).
|
||||
local function buildSortModal(imp, m)
|
||||
@@ -2299,11 +2620,13 @@ local function buildModActionsModal(imp, m)
|
||||
end
|
||||
if not mod then imp._modActions = nil return end
|
||||
local hasGit = mod.github and mod.github ~= ""
|
||||
local depSpecs = mod.dependencySpecs or (mod.manifest and mod.manifest.dependencySpecs)
|
||||
local hasDeps = depSpecs and #depSpecs > 0
|
||||
local info = hasGit and imp:_modUpdateInfo(mod.id)
|
||||
local pad = math.floor(18 * m.s)
|
||||
local w = math.floor(440 * m.s)
|
||||
local gap = math.floor(8 * m.s)
|
||||
local nBtns = (hasGit and 2 or 0) + 2
|
||||
local nBtns = (hasGit and 2 or 0) + (hasDeps and 1 or 0) + 2
|
||||
local h = pad + Kit.textHeight("button") + math.floor(4 * m.s)
|
||||
+ Kit.textHeight("small") + math.floor(12 * m.s)
|
||||
+ nBtns * (m.btnH + gap) - gap + pad
|
||||
@@ -2339,6 +2662,20 @@ local function buildModActionsModal(imp, m)
|
||||
action = function() imp:_modGithubAction(id, "versions") end })
|
||||
cy = cy + m.btnH + gap
|
||||
end
|
||||
if hasDeps then
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modact-deps",
|
||||
Strings("Check dependencies"), {
|
||||
kind = "accent", font = "small",
|
||||
action = function()
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local depCheck = LauncherMods.checkDependencies(mod.manifest or mod)
|
||||
if depCheck then
|
||||
imp._modDepResolver = depCheck
|
||||
end
|
||||
imp._modActions = nil
|
||||
end })
|
||||
cy = cy + m.btnH + gap
|
||||
end
|
||||
local armed = deleteArmed(imp, "mod", id, nil)
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modact-del",
|
||||
DELETE_LABEL(armed), {
|
||||
@@ -2655,6 +2992,238 @@ local function buildSettingsModal(imp, m)
|
||||
Kit.pager(px + pad, cy, pw - 2 * pad, cur, n, perPage, "settings"))
|
||||
end
|
||||
|
||||
local function buildDepResolverModal(imp, m)
|
||||
local res = imp._modDepResolver
|
||||
if not res then return end
|
||||
|
||||
local pad = math.floor(18 * m.s)
|
||||
local w = math.floor(540 * m.s)
|
||||
local chipH = math.max(Kit.tapMin(), math.floor(28 * m.s))
|
||||
local rowH = math.floor(74 * m.s)
|
||||
local gap = math.floor(8 * m.s)
|
||||
local warnH = math.floor(38 * m.s)
|
||||
|
||||
local n = #(res.deps or {})
|
||||
local anyUnsatisfied = false
|
||||
for _, d in ipairs(res.deps or {}) do
|
||||
if d.status ~= "satisfied" and d.status ~= "disabled" then anyUnsatisfied = true; break end
|
||||
end
|
||||
|
||||
local totalContentH = n > 0 and (n * rowH + (n - 1) * gap) or 0
|
||||
|
||||
-- Calculate content height dynamically so modal auto-fits small lists snuggly
|
||||
local headerH = Kit.textHeight("button") + math.floor(4 * m.s)
|
||||
+ Kit.textHeight("small") + math.floor(10 * m.s)
|
||||
local warnTotalH = warnH + math.floor(12 * m.s)
|
||||
local listMaxH = math.floor(240 * m.s)
|
||||
local itemsH = math.min(totalContentH > 0 and totalContentH or rowH, listMaxH)
|
||||
local footerH = math.floor(10 * m.s) + m.btnH
|
||||
local wantedH = pad + headerH + warnTotalH + itemsH + footerH + pad
|
||||
local h = math.floor(math.min(m.H - 2 * m.pad, math.max(260 * m.s, wantedH)))
|
||||
|
||||
local px, py, pw, ph = modalPanel(m, w, h)
|
||||
local cy = py + pad
|
||||
|
||||
-- Title
|
||||
local titleText = Strings("Dependency Resolver: ") .. tostring(res.targetMod.name or res.targetMod.id)
|
||||
Kit.text("button", Kit.ellipsize("button", titleText, pw - 2 * pad), px + pad, cy, PAL.heading)
|
||||
cy = cy + Kit.textHeight("button") + math.floor(4 * m.s)
|
||||
|
||||
-- Subtitle / intro
|
||||
local subText = Strings("This mod requires additional dependencies or has conflicts:")
|
||||
Kit.text("small", subText, px + pad, cy, PAL.muted)
|
||||
cy = cy + Kit.textHeight("small") + math.floor(10 * m.s)
|
||||
|
||||
-- Security Disclaimer Banner Callout Card
|
||||
Theme.fillRounded(px + pad, cy, pw - 2 * pad, warnH, PAL.rowBg, 1, Theme.radius())
|
||||
Theme.strokeRounded(px + pad, cy, pw - 2 * pad, warnH, PAL.yellow, Theme.A.hover, 1, Theme.radius())
|
||||
local warnMsg = Strings("Caution: Only pull dependencies from sources you trust.\nVerify source repositories before fetching.")
|
||||
Kit.text("micro", warnMsg, px + pad + math.floor(12 * m.s), cy + math.floor(5 * m.s), PAL.yellow)
|
||||
cy = cy + warnH + math.floor(12 * m.s)
|
||||
|
||||
-- List area bounds
|
||||
local listH = (py + ph - pad) - cy - m.btnH - math.floor(10 * m.s)
|
||||
local scrollMax = math.max(0, totalContentH - listH)
|
||||
|
||||
-- Mouse wheel scroll handling matching upstream pattern
|
||||
if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and Kit.hit(px + pad, cy, pw - 2 * pad, listH) then
|
||||
imp._depScrollOffset = clamp((imp._depScrollOffset or 0) - Kit.wheelY * math.floor(48 * m.s), 0, scrollMax)
|
||||
Kit.wheelY = 0
|
||||
elseif scrollMax == 0 then
|
||||
imp._depScrollOffset = 0
|
||||
else
|
||||
imp._depScrollOffset = clamp(imp._depScrollOffset or 0, 0, scrollMax)
|
||||
end
|
||||
|
||||
-- Pump active in-flight pulls
|
||||
if imp._pumpDepPulls then
|
||||
imp:_pumpDepPulls()
|
||||
end
|
||||
|
||||
-- Clipped vertical scroll container
|
||||
Kit.pushClip(px + pad, cy, pw - 2 * pad, listH)
|
||||
local startY = cy - (imp._depScrollOffset or 0)
|
||||
|
||||
for i = 1, n do
|
||||
local dep = res.deps[i]
|
||||
local ry = startY + (i - 1) * (rowH + gap)
|
||||
|
||||
-- Cull rows completely outside the list viewport rectangle
|
||||
if ry + rowH >= cy and ry <= cy + listH then
|
||||
-- Item Card Fill & Stroke (matching launcher card interiors & radius)
|
||||
local hot = Kit.hover(px + pad, ry, pw - 2 * pad, rowH)
|
||||
Theme.row(px + pad, ry, pw - 2 * pad, rowH, hot and "hover" or "normal")
|
||||
|
||||
local ix = px + pad + math.floor(12 * m.s)
|
||||
local innerW = pw - 2 * pad - math.floor(24 * m.s)
|
||||
|
||||
-- Dep title & range
|
||||
local depHeader = tostring(dep.name or dep.id)
|
||||
if dep.range and dep.range ~= "" then
|
||||
depHeader = depHeader .. " (" .. dep.range .. ")"
|
||||
end
|
||||
Kit.text("small", Kit.ellipsize("small", depHeader, innerW - math.floor(210 * m.s)),
|
||||
ix, ry + math.floor(8 * m.s), PAL.heading)
|
||||
|
||||
-- Status Badge & Subtext
|
||||
local statusText, statusCol
|
||||
if dep.status == "satisfied" then
|
||||
statusText = Strings("Installed & Compatible (v%s)", tostring(dep.installedVersion or "?"))
|
||||
statusCol = PAL.green
|
||||
elseif dep.status == "incompatible" then
|
||||
statusText = Strings("Incompatible (installed v%s, needs %s)", tostring(dep.installedVersion or "?"), tostring(dep.range or ""))
|
||||
statusCol = PAL.yellow
|
||||
elseif dep.status == "conflict" then
|
||||
statusText = Strings("Incompatible mod enabled (v%s)", tostring(dep.installedVersion or "?"))
|
||||
statusCol = PAL.red
|
||||
elseif dep.status == "disabled" then
|
||||
statusText = Strings("Disabled (conflict resolved)")
|
||||
statusCol = PAL.green
|
||||
else
|
||||
statusText = Strings("Missing")
|
||||
statusCol = PAL.red
|
||||
end
|
||||
Kit.text("micro", statusText, ix, ry + math.floor(8 * m.s) + Kit.textHeight("small") + math.floor(2 * m.s), statusCol)
|
||||
|
||||
-- Repo source line or Conflict reason
|
||||
local repoLine
|
||||
if dep.status == "conflict" or dep.kind == "conflict" then
|
||||
repoLine = Strings("Listed as incompatible with ") .. tostring(res.targetMod.name or res.targetMod.id)
|
||||
elseif dep.github then
|
||||
repoLine = Strings("Source: github.com/") .. dep.github
|
||||
else
|
||||
repoLine = Strings("Source: Unknown (no repo listed)")
|
||||
end
|
||||
Kit.text("micro", repoLine, ix, ry + math.floor(8 * m.s) + Kit.textHeight("small") + Kit.textHeight("micro") + math.floor(4 * m.s), PAL.muted)
|
||||
|
||||
-- Action buttons right cluster (vertically centered inside card)
|
||||
local ly = ry + math.floor((rowH - chipH) / 2)
|
||||
local place = Layout.rightCluster(ix, innerW, math.floor(8 * m.s))
|
||||
|
||||
local pState = imp._depPullState and imp._depPullState[dep.id]
|
||||
|
||||
if pState and pState.stage ~= "done" and pState.stage ~= "error" then
|
||||
local label = Strings("Pulling...")
|
||||
if pState.stage == "fetching" then label = Strings("Fetching...")
|
||||
elseif pState.stage == "downloading" then
|
||||
if pState.progress and pState.progress > 0 then
|
||||
label = Strings("Downloading %d%%", math.floor(pState.progress * 100))
|
||||
else
|
||||
label = Strings("Downloading...")
|
||||
end
|
||||
elseif pState.stage == "installing" then label = Strings("Installing...")
|
||||
end
|
||||
Kit.chip(place(Kit.textWidth("small", label) + math.floor(16 * m.s)), ly,
|
||||
Kit.textWidth("small", label) + math.floor(16 * m.s), chipH, label, true, PAL.yellow, "dep-pulling-" .. i)
|
||||
elseif dep.status == "conflict" then
|
||||
local btnLabel = Strings("Disable mod")
|
||||
local bw = Kit.textWidth("small", btnLabel) + math.floor(20 * m.s)
|
||||
btn(imp, place(bw), ly, bw, chipH, "dep-dis-" .. i, btnLabel, {
|
||||
kind = "warn", font = "small",
|
||||
action = function()
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
LauncherMods.setEnabled(dep.id, false, imp.modScope)
|
||||
dep.status = "disabled"
|
||||
if imp._refreshMods then imp:_refreshMods() end
|
||||
end,
|
||||
})
|
||||
elseif dep.status == "disabled" then
|
||||
local chipLabel = Strings("Disabled")
|
||||
local cw = Kit.textWidth("small", chipLabel) + math.floor(16 * m.s)
|
||||
Kit.chip(place(cw), ly, cw, chipH, chipLabel, true, PAL.green, "dep-dischip-" .. i)
|
||||
else
|
||||
-- Pull / Update button if github repo is known and not satisfied
|
||||
if dep.github and dep.status ~= "satisfied" then
|
||||
local btnLabel = dep.status == "incompatible" and Strings("Update") or Strings("Pull from GitHub")
|
||||
local bw = Kit.textWidth("small", btnLabel) + math.floor(20 * m.s)
|
||||
btn(imp, place(bw), ly, bw, chipH, "dep-pull-" .. i, btnLabel, {
|
||||
kind = "accent", font = "small",
|
||||
action = function()
|
||||
if imp._startDepPull then
|
||||
imp:_startDepPull(dep)
|
||||
end
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
-- Open Source link button if safeUrl is present
|
||||
if dep.safeUrl then
|
||||
local bw = Kit.textWidth("small", Strings("View Source")) + math.floor(20 * m.s)
|
||||
btn(imp, place(bw), ly, bw, chipH, "dep-view-" .. i, Strings("View Source"), {
|
||||
font = "small",
|
||||
action = function()
|
||||
if love and love.system and love.system.openURL then
|
||||
love.system.openURL(dep.safeUrl)
|
||||
end
|
||||
end,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Kit.popClip()
|
||||
|
||||
-- Scrollbar indicator if scrollMax > 0
|
||||
if scrollMax > 0 then
|
||||
local barW = math.floor(4 * m.s)
|
||||
local barX = px + pw - pad - barW
|
||||
local thumbH = math.max(math.floor(20 * m.s), math.floor(listH * (listH / totalContentH)))
|
||||
local thumbY = cy + (listH - thumbH) * ((imp._depScrollOffset or 0) / scrollMax)
|
||||
Theme.fill(barX, cy, barW, listH, PAL.bg, 0.4)
|
||||
Theme.fill(barX, thumbY, barW, thumbH, PAL.muted, 0.7)
|
||||
end
|
||||
|
||||
cy = cy + listH + math.floor(10 * m.s)
|
||||
|
||||
-- Bottom Action Buttons
|
||||
if anyUnsatisfied then
|
||||
local btnW = math.floor((pw - 2 * pad - math.floor(10 * m.s)) / 2)
|
||||
btn(imp, px + pad, cy, btnW, m.btnH, "depresolver-pullall", Strings("Pull All Available"), {
|
||||
kind = "accent", font = "small",
|
||||
action = function()
|
||||
for _, dep in ipairs(res.deps or {}) do
|
||||
if dep.github and dep.status ~= "satisfied" and dep.status ~= "disabled" and imp._startDepPull then
|
||||
imp:_startDepPull(dep)
|
||||
end
|
||||
end
|
||||
end,
|
||||
})
|
||||
btn(imp, px + pad + btnW + math.floor(10 * m.s), cy, btnW, m.btnH, "depresolver-close", Strings("Done"), {
|
||||
font = "small",
|
||||
action = function()
|
||||
imp._modDepResolver = nil
|
||||
end,
|
||||
})
|
||||
else
|
||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "depresolver-close", Strings("Done"), {
|
||||
kind = "accent", font = "small",
|
||||
action = function()
|
||||
imp._modDepResolver = nil
|
||||
end,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
-- Whether ANY modal will draw this frame. draw() consults this BEFORE the
|
||||
-- panels build: immediate mode hit-tests each control as it draws, so the
|
||||
-- panels underneath a modal must run with Kit.blockClicks already raised or
|
||||
@@ -2663,12 +3232,60 @@ end
|
||||
local function modalUp(imp)
|
||||
return (imp._settingsText or imp._settings or imp._rename
|
||||
or imp._indexPrompt or imp._modConfirm or imp._modReleaseNotes
|
||||
or imp._findDetails or imp._modVersions or imp._sortPopup
|
||||
or imp._findDetails or imp._modVersions or imp._modDepResolver or imp._sortPopup
|
||||
or imp._filterPopup or imp._indexManage or imp._modActions
|
||||
or imp._findEntry or imp._gameManage) ~= nil
|
||||
or imp._modHeaderActionsPopup or imp._profilesPopup or imp._singleProfileActions or imp._profileSavePrompt
|
||||
or imp._profileRenamePrompt or imp._findEntry or imp._gameManage) ~= nil
|
||||
end
|
||||
|
||||
local function buildModals(imp, m)
|
||||
if imp._profileRenamePrompt then
|
||||
buildPrompt(imp, m, {
|
||||
key = "profren", title = Strings("Rename profile"),
|
||||
hint = Strings("Enter a new name for this profile:"),
|
||||
text = imp._profileRenamePrompt.text or "", okLabel = Strings("Save"),
|
||||
commit = function()
|
||||
local txt = imp._profileRenamePrompt and imp._profileRenamePrompt.text
|
||||
local old = imp._profileRenamePrompt and imp._profileRenamePrompt.oldName
|
||||
if txt and txt ~= "" and old then
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
LauncherMods.renameProfile(old, txt)
|
||||
imp._profileRenamePrompt = nil
|
||||
imp:_disarmTextInput()
|
||||
if imp._refreshMods then imp:_refreshMods() end
|
||||
end
|
||||
end,
|
||||
cancel = function()
|
||||
imp._profileRenamePrompt = nil
|
||||
imp:_disarmTextInput()
|
||||
end,
|
||||
footnote = Strings("Enter to save - Esc to cancel"),
|
||||
})
|
||||
return true
|
||||
end
|
||||
if imp._profileSavePrompt then
|
||||
buildPrompt(imp, m, {
|
||||
key = "profsave", title = Strings("Save mod profile"),
|
||||
hint = Strings("Enter a name for this mod profile:"),
|
||||
text = imp._profileSavePrompt.text or "", okLabel = Strings("Save"),
|
||||
commit = function()
|
||||
local txt = imp._profileSavePrompt and imp._profileSavePrompt.text
|
||||
if txt and txt ~= "" then
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
LauncherMods.saveProfile(txt)
|
||||
imp._profileSavePrompt = nil
|
||||
imp:_disarmTextInput()
|
||||
if imp._refreshMods then imp:_refreshMods() end
|
||||
end
|
||||
end,
|
||||
cancel = function()
|
||||
imp._profileSavePrompt = nil
|
||||
imp:_disarmTextInput()
|
||||
end,
|
||||
footnote = Strings("Enter to save - Esc to cancel"),
|
||||
})
|
||||
return true
|
||||
end
|
||||
if imp._settingsText then
|
||||
local st = imp._settingsText
|
||||
buildPrompt(imp, m, {
|
||||
@@ -2733,10 +3350,14 @@ local function buildModals(imp, m)
|
||||
return true
|
||||
end
|
||||
if imp._modVersions then buildVersionsModal(imp, m) return true end
|
||||
if imp._modDepResolver then buildDepResolverModal(imp, m) return true end
|
||||
-- The lighter popups come after the deep ones on purpose: opening
|
||||
-- Versions or Details from inside an actions popup draws the deeper modal
|
||||
-- while the popup's own state stays set, so closing the deep one drops
|
||||
-- you back where you were.
|
||||
if imp._singleProfileActions then buildSingleProfileActionsModal(imp, m) return true end
|
||||
if imp._profilesPopup then buildProfilesModal(imp, m) return true end
|
||||
if imp._modHeaderActionsPopup then buildModHeaderActionsModal(imp, m) return true end
|
||||
if imp._sortPopup then buildSortModal(imp, m) return true end
|
||||
if imp._filterPopup then buildFilterModal(imp, m) return true end
|
||||
if imp._indexManage then buildIndexesModal(imp, m) return true end
|
||||
|
||||
+127
-1
@@ -1649,7 +1649,7 @@ end
|
||||
function RomImporter:_installMod(source)
|
||||
if self.workState == "working" then return end
|
||||
self.tab = "mods"
|
||||
local ok, installed, res = pcall(function()
|
||||
local ok, installed, res, manifest = pcall(function()
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
return LauncherMods.installZip(source)
|
||||
end)
|
||||
@@ -1661,6 +1661,17 @@ function RomImporter:_installMod(source)
|
||||
if installed then
|
||||
pcall(self._refreshMods, self)
|
||||
self.modNotice = { ok = true, text = "Installed " .. tostring(res) }
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local checkTarget = manifest
|
||||
if not checkTarget and type(res) == "string" then
|
||||
checkTarget = { id = res }
|
||||
end
|
||||
if checkTarget and LauncherMods.checkDependencies then
|
||||
local depCheck = LauncherMods.checkDependencies(checkTarget)
|
||||
if depCheck and depCheck.hasIssues then
|
||||
self._modDepResolver = depCheck
|
||||
end
|
||||
end
|
||||
else
|
||||
self.modNotice = { ok = false, text = tostring(res) }
|
||||
end
|
||||
@@ -2716,6 +2727,43 @@ function RomImporter:fileUrl(path)
|
||||
end
|
||||
|
||||
function RomImporter:keypressed(key)
|
||||
if self._profileSavePrompt then
|
||||
if key == "backspace" then
|
||||
self._profileSavePrompt.text = utf8Back(self._profileSavePrompt.text or "")
|
||||
elseif key == "return" or key == "kpenter" then
|
||||
local txt = self._profileSavePrompt and self._profileSavePrompt.text
|
||||
if txt and txt ~= "" then
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
LauncherMods.saveProfile(txt)
|
||||
self._profileSavePrompt = nil
|
||||
self:_disarmTextInput()
|
||||
if self._refreshMods then self:_refreshMods() end
|
||||
end
|
||||
elseif key == "escape" then
|
||||
self._profileSavePrompt = nil
|
||||
self:_disarmTextInput()
|
||||
end
|
||||
return
|
||||
end
|
||||
if self._profileRenamePrompt then
|
||||
if key == "backspace" then
|
||||
self._profileRenamePrompt.text = utf8Back(self._profileRenamePrompt.text or "")
|
||||
elseif key == "return" or key == "kpenter" then
|
||||
local txt = self._profileRenamePrompt and self._profileRenamePrompt.text
|
||||
local old = self._profileRenamePrompt and self._profileRenamePrompt.oldName
|
||||
if txt and txt ~= "" and old then
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
LauncherMods.renameProfile(old, txt)
|
||||
self._profileRenamePrompt = nil
|
||||
self:_disarmTextInput()
|
||||
if self._refreshMods then self:_refreshMods() end
|
||||
end
|
||||
elseif key == "escape" then
|
||||
self._profileRenamePrompt = nil
|
||||
self:_disarmTextInput()
|
||||
end
|
||||
return
|
||||
end
|
||||
if self._settingsText then
|
||||
if key == "backspace" then
|
||||
self._settingsText.text = utf8Back(self._settingsText.text)
|
||||
@@ -2885,6 +2933,14 @@ function RomImporter:_commitRename()
|
||||
end
|
||||
|
||||
function RomImporter:textinput(text)
|
||||
if self._profileSavePrompt then
|
||||
self._profileSavePrompt.text = utf8Cap((self._profileSavePrompt.text or "") .. text, MAX_SLOT_LABEL)
|
||||
return
|
||||
end
|
||||
if self._profileRenamePrompt then
|
||||
self._profileRenamePrompt.text = utf8Cap((self._profileRenamePrompt.text or "") .. text, MAX_SLOT_LABEL)
|
||||
return
|
||||
end
|
||||
if self._settingsText then
|
||||
local st = self._settingsText
|
||||
st.text = utf8Cap(st.text .. text, st.maxLen or MAX_SLOT_LABEL)
|
||||
@@ -3371,6 +3427,76 @@ function RomImporter:_pumpModInstall()
|
||||
else
|
||||
self.modNotice = { ok = true, text = text }
|
||||
end
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local depCheck = LauncherMods.checkDependencies({ id = spec.modId })
|
||||
if depCheck and depCheck.hasIssues then
|
||||
self._modDepResolver = depCheck
|
||||
end
|
||||
end
|
||||
|
||||
-- Start an async pull for a single dependency
|
||||
function RomImporter:_startDepPull(dep)
|
||||
if not dep or not dep.github then return end
|
||||
self._depPullState = self._depPullState or {}
|
||||
local hFetch = require("src.mods.ModUpdate").beginFetchReleases(dep.github, dep.id, { force = true })
|
||||
self._depPullState[dep.id] = {
|
||||
dep = dep,
|
||||
stage = "fetching",
|
||||
fetchHandle = hFetch,
|
||||
}
|
||||
end
|
||||
|
||||
-- Pump all in-flight dependency pulls
|
||||
function RomImporter:_pumpDepPulls()
|
||||
if not self._depPullState then return end
|
||||
local ModUpdate = require("src.mods.ModUpdate")
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
|
||||
for depId, state in pairs(self._depPullState) do
|
||||
if state.stage == "fetching" then
|
||||
local done, releases, err = ModUpdate.pumpFetchReleases(state.fetchHandle)
|
||||
if done then
|
||||
if err or not releases or #releases == 0 then
|
||||
state.stage = "error"
|
||||
state.err = err or "No downloadable releases found on GitHub"
|
||||
else
|
||||
local rel = releases[1]
|
||||
if not rel or not rel.zip or not rel.zip.url then
|
||||
state.stage = "error"
|
||||
state.err = "Latest release has no downloadable .zip asset"
|
||||
else
|
||||
local tmpName = ("dep_%s_%s.zip"):format(depId, tostring(rel.version or os.time()))
|
||||
state.dlHandle = ModUpdate.beginDownloadZip(rel.zip.url, tmpName, rel.zip.size)
|
||||
state.stage = "downloading"
|
||||
state.targetVersion = rel.version
|
||||
end
|
||||
end
|
||||
end
|
||||
elseif state.stage == "downloading" then
|
||||
local done, localPath, err, progress = ModUpdate.pumpDownloadZip(state.dlHandle)
|
||||
state.progress = progress
|
||||
if done then
|
||||
if err or not localPath then
|
||||
state.stage = "error"
|
||||
state.err = err or "Download failed"
|
||||
else
|
||||
state.stage = "installing"
|
||||
local okInst, versionRes = LauncherMods.installDownloadedZip(depId, localPath, state.targetVersion)
|
||||
if okInst then
|
||||
state.stage = "done"
|
||||
pcall(self._refreshMods, self)
|
||||
if self._modDepResolver and self._modDepResolver.targetMod then
|
||||
local updated = LauncherMods.checkDependencies(self._modDepResolver.targetMod)
|
||||
self._modDepResolver = updated
|
||||
end
|
||||
else
|
||||
state.stage = "error"
|
||||
state.err = tostring(versionRes or "Installation failed")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function RomImporter:_confirmModUpdate(modId, release)
|
||||
|
||||
+334
-19
@@ -41,6 +41,8 @@ local CacheFs = require("src.import.CacheFs")
|
||||
|
||||
local LauncherMods = {}
|
||||
|
||||
local discover -- forward declaration for helper functions above line 343
|
||||
|
||||
-- ------- pure status derivation
|
||||
|
||||
-- A hard-dependency / conflict / version verdict for one manifest. mods is
|
||||
@@ -85,26 +87,190 @@ local function statusFor(mods, id, enabledSet, enabled, version, forcedFor)
|
||||
-- resolveToggle would cascade-enable a merely-disabled dep rather than flag
|
||||
-- it, so the disabled case is judged straight off the manifest here.
|
||||
for _, spec in ipairs(m.dependencySpecs or {}) do
|
||||
local dep = mods[spec.id]
|
||||
if not dep then
|
||||
return "warn", "Needs " .. spec.id .. " (not installed)"
|
||||
elseif not enabledSet[spec.id] then
|
||||
return "warn", "Needs " .. spec.id .. " (disabled)"
|
||||
-- installed and on, but not for THIS game: the loader skips the
|
||||
-- dependency and the skip is contagious (Loader:_enforceDependencies),
|
||||
-- so a mod that runs everywhere still does not run here
|
||||
elseif version
|
||||
and not ModTargets.runsHere(dep, version, nil, forcedFor(spec.id)) then
|
||||
return "warn", "Needs " .. spec.id .. " (not for "
|
||||
.. ModTargets.gameLabel(version) .. ")"
|
||||
elseif spec.range
|
||||
and not Semver.satisfies(dep.version, spec.range) then
|
||||
return "warn", "Needs " .. spec.id .. " " .. spec.range
|
||||
if ModTargets.specApplies(spec, version) then
|
||||
local dep = mods[spec.id]
|
||||
if not dep then
|
||||
return "warn", "Needs " .. spec.id .. " (not installed)"
|
||||
elseif not enabledSet[spec.id] then
|
||||
return "warn", "Needs " .. spec.id .. " (disabled)"
|
||||
-- installed and on, but not for THIS game: the loader skips the
|
||||
-- dependency and the skip is contagious (Loader:_enforceDependencies),
|
||||
-- so a mod that runs everywhere still does not run here
|
||||
elseif version
|
||||
and not ModTargets.runsHere(dep, version, nil, forcedFor(spec.id)) then
|
||||
return "warn", "Needs " .. spec.id .. " (not for "
|
||||
.. ModTargets.gameLabel(version) .. ")"
|
||||
elseif spec.range
|
||||
and not Semver.satisfies(dep.version, spec.range) then
|
||||
return "warn", "Needs " .. spec.id .. " " .. spec.range
|
||||
end
|
||||
end
|
||||
end
|
||||
return "ok", "Ready"
|
||||
end
|
||||
|
||||
-- Resolves the best-known GitHub owner/repo string for a dependency spec, if any.
|
||||
function LauncherMods.resolveDependencyRepo(depId, parentManifest, installedDep)
|
||||
if not depId or depId == "" then return nil end
|
||||
-- 1. Check spec hint if parentManifest dependencySpecs carries it
|
||||
if parentManifest and parentManifest.dependencySpecs then
|
||||
for _, spec in ipairs(parentManifest.dependencySpecs) do
|
||||
if spec.id == depId and spec.github then
|
||||
return spec.github
|
||||
end
|
||||
end
|
||||
end
|
||||
-- 2. Check parentManifest raw dependency_sources
|
||||
if parentManifest and parentManifest.raw and type(parentManifest.raw.dependency_sources) == "table" then
|
||||
local src = parentManifest.raw.dependency_sources[depId]
|
||||
if src then
|
||||
local ok, clean = pcall(Manifest.parseGithub, src)
|
||||
if ok and clean then return clean end
|
||||
end
|
||||
end
|
||||
-- 3. Check installed dependency manifest
|
||||
if installedDep and installedDep.github then
|
||||
return installedDep.github
|
||||
end
|
||||
-- 4. Check ModIndex entries if available
|
||||
local okModIndex, ModIndex = pcall(require, "src.mods.ModIndex")
|
||||
if okModIndex and ModIndex and type(ModIndex.sources) == "function" then
|
||||
for _, src in ipairs(ModIndex.sources() or {}) do
|
||||
local cached = ModIndex.readCache and ModIndex.readCache(src.feed)
|
||||
if cached and type(cached.index) == "table" then
|
||||
for _, entry in ipairs(cached.index) do
|
||||
if type(entry) == "table" and entry.id == depId and entry.github then
|
||||
local ok, clean = pcall(Manifest.parseGithub, entry.github)
|
||||
if ok and clean then return clean end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Inspect manifest dependencies and conflicts against installed mods.
|
||||
-- Returns: { hasIssues = bool, targetMod = {...}, deps = [ { id, name, range, status, kind, installedVersion, github, safeUrl }, ... ] }
|
||||
function LauncherMods.checkDependencies(manifest, options, version, installedManifests)
|
||||
if not manifest then
|
||||
return { hasIssues = false, targetMod = { name = "Unknown" }, deps = {} }
|
||||
end
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local manifests = installedManifests or discover()
|
||||
local installedMap = {}
|
||||
for _, m in ipairs(manifests) do
|
||||
installedMap[m.id] = m
|
||||
end
|
||||
|
||||
local depsResult = {}
|
||||
local hasIssues = false
|
||||
|
||||
-- 1. Hard Dependencies (dependencySpecs)
|
||||
if type(manifest.dependencySpecs) == "table" then
|
||||
for _, spec in ipairs(manifest.dependencySpecs) do
|
||||
if not version or ModTargets.specApplies(spec, version) then
|
||||
local depId = spec.id
|
||||
local range = spec.range
|
||||
local installedDep = installedMap[depId]
|
||||
local status = "satisfied"
|
||||
local installedVersion = installedDep and installedDep.version or nil
|
||||
|
||||
if not installedDep then
|
||||
status = "missing"
|
||||
hasIssues = true
|
||||
elseif range and not Semver.satisfies(installedDep.version, range) then
|
||||
status = "incompatible"
|
||||
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
|
||||
end
|
||||
|
||||
-- 2. Conflicts / Incompatible Mods (conflictSpecs)
|
||||
local conflictIdsSeen = {}
|
||||
local scope = SaveData.modScope and SaveData.modScope(version) or version
|
||||
|
||||
local isEnabled = function(modId)
|
||||
if not options then return true end
|
||||
local dec = SaveData.modEnabled(options, modId, scope)
|
||||
if dec ~= nil then return dec == true end
|
||||
local m = installedMap[modId]
|
||||
return m and not m.experimental
|
||||
end
|
||||
|
||||
-- (a) Conflicts declared by target manifest
|
||||
if type(manifest.conflictSpecs) == "table" then
|
||||
for _, spec in ipairs(manifest.conflictSpecs) do
|
||||
local conflictId = spec.id
|
||||
local installedOther = installedMap[conflictId]
|
||||
if installedOther and isEnabled(conflictId) and not conflictIdsSeen[conflictId] then
|
||||
conflictIdsSeen[conflictId] = true
|
||||
hasIssues = true
|
||||
depsResult[#depsResult + 1] = {
|
||||
id = conflictId,
|
||||
name = installedOther.name or conflictId,
|
||||
status = "conflict",
|
||||
kind = "conflict",
|
||||
installedVersion = installedOther.version or "?",
|
||||
github = nil,
|
||||
safeUrl = nil,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- (b) Reverse conflicts declared by installed mods against target manifest
|
||||
if manifest.id then
|
||||
for _, other in ipairs(manifests) do
|
||||
if other.id ~= manifest.id and isEnabled(other.id) and not conflictIdsSeen[other.id] then
|
||||
local conflicts = other.conflictSpecs or {}
|
||||
for _, spec in ipairs(conflicts) do
|
||||
if spec.id == manifest.id then
|
||||
conflictIdsSeen[other.id] = true
|
||||
hasIssues = true
|
||||
depsResult[#depsResult + 1] = {
|
||||
id = other.id,
|
||||
name = other.name or other.id,
|
||||
status = "conflict",
|
||||
kind = "conflict",
|
||||
installedVersion = other.version or "?",
|
||||
github = nil,
|
||||
safeUrl = nil,
|
||||
}
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
hasIssues = hasIssues,
|
||||
targetMod = {
|
||||
id = manifest.id,
|
||||
name = manifest.name or manifest.id,
|
||||
version = manifest.version or "?",
|
||||
},
|
||||
deps = depsResult,
|
||||
}
|
||||
end
|
||||
|
||||
-- deriveList(manifests, options [, version]) -> the panel row list, pure.
|
||||
-- manifests is an array of validated manifests (Manifest.validate output);
|
||||
-- options is the options table (options.mods, options.modsByVersion and
|
||||
@@ -165,6 +331,8 @@ function LauncherMods.deriveList(manifests, options, version)
|
||||
statusDetail = detail,
|
||||
github = m.github,
|
||||
experimental = m.experimental == true,
|
||||
dependencySpecs = m.dependencySpecs,
|
||||
manifest = m,
|
||||
-- what game this mod is for, and whether it will run on the one the
|
||||
-- panel is showing (src/mods/ModTargets.lua)
|
||||
targets = ModTargets.chip(m),
|
||||
@@ -252,7 +420,7 @@ end
|
||||
-- Scan "mods/" one level deep for valid manifests (mirrors Loader:_discover,
|
||||
-- but validates only -- no entry chunk is ever loaded). First id wins on a
|
||||
-- duplicate. Returns an array of validated manifests.
|
||||
local function discover()
|
||||
discover = function()
|
||||
local fs = love and love.filesystem
|
||||
local out = {}
|
||||
if not (fs and fs.getInfo and fs.getDirectoryItems) then return out end
|
||||
@@ -386,6 +554,7 @@ function LauncherMods.setEnabled(id, enabled, version)
|
||||
local options = SaveData.loadOptions()
|
||||
SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version))
|
||||
SaveData.saveOptions(options)
|
||||
LauncherMods.syncActiveProfile(options)
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -409,6 +578,7 @@ function LauncherMods.setAllEnabled(ids, enabled, version)
|
||||
end
|
||||
end
|
||||
SaveData.saveOptions(options)
|
||||
LauncherMods.syncActiveProfile(options)
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -674,9 +844,9 @@ function LauncherMods.adoptStrays() return scanStrays(true) end
|
||||
-- opts.replace = true uninstalls an existing same-id mod first (updates /
|
||||
-- rollbacks). opts.expectId, when set, refuses a zip whose manifest id differs.
|
||||
function LauncherMods.installZip(source, opts)
|
||||
local ok, result, err = pcall(LauncherMods._installZipInner, source, opts)
|
||||
local ok, result, res2, res3 = pcall(LauncherMods._installZipInner, source, opts)
|
||||
if not ok then return nil, "import failed: " .. tostring(result) end
|
||||
return result, err
|
||||
return result, res2, res3
|
||||
end
|
||||
|
||||
function LauncherMods._installZipInner(source, opts)
|
||||
@@ -786,7 +956,7 @@ function LauncherMods._installZipInner(source, opts)
|
||||
return nil, copyErr or "could not copy the mod files"
|
||||
end
|
||||
cleanup()
|
||||
return true, manifest.id
|
||||
return true, manifest.id, manifest
|
||||
end
|
||||
|
||||
-- Install (or replace) a mod from a GitHub release zip URL.
|
||||
@@ -912,4 +1082,149 @@ function LauncherMods.uninstall(id)
|
||||
return true
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------- Mod Profiles (#593)
|
||||
local ModProfile = require("src.mods.ModProfile")
|
||||
|
||||
function LauncherMods.getProfiles(options)
|
||||
options = options or SaveData.loadOptions()
|
||||
local manifests = discover()
|
||||
ModProfile.ensureFirst(options, manifests, options.modOptions)
|
||||
return options.modProfiles or {}, options.activeProfile or "PROFILE 1"
|
||||
end
|
||||
|
||||
function LauncherMods.applyProfile(profileName, options)
|
||||
options = options or SaveData.loadOptions()
|
||||
local profiles = options.modProfiles or {}
|
||||
local targetProfile
|
||||
for _, p in ipairs(profiles) do
|
||||
if p.name == profileName then targetProfile = p; break end
|
||||
end
|
||||
if not targetProfile then return false end
|
||||
ModProfile.restoreVersions(targetProfile, options)
|
||||
options.activeProfile = profileName
|
||||
SaveData.saveOptions(options)
|
||||
return true
|
||||
end
|
||||
|
||||
function LauncherMods.saveProfile(profileName, options)
|
||||
options = options or SaveData.loadOptions()
|
||||
local manifests = discover()
|
||||
options.modProfiles = options.modProfiles or {}
|
||||
local snap = ModProfile.capture(manifests, options.modOptions, options.modsByVersion)
|
||||
snap.name = profileName
|
||||
local existingIdx
|
||||
for i, p in ipairs(options.modProfiles) do
|
||||
if p.name == profileName then existingIdx = i; break end
|
||||
end
|
||||
if existingIdx then
|
||||
options.modProfiles[existingIdx] = snap
|
||||
else
|
||||
options.modProfiles[#options.modProfiles + 1] = snap
|
||||
end
|
||||
options.activeProfile = profileName
|
||||
SaveData.saveOptions(options)
|
||||
return snap
|
||||
end
|
||||
|
||||
local function copyTable(tbl)
|
||||
if type(tbl) ~= "table" then return tbl end
|
||||
local copy = {}
|
||||
for k, v in pairs(tbl) do
|
||||
copy[k] = type(v) == "table" and copyTable(v) or v
|
||||
end
|
||||
return copy
|
||||
end
|
||||
|
||||
function LauncherMods.syncActiveProfile(options)
|
||||
options = options or SaveData.loadOptions()
|
||||
local activeName = options.activeProfile or "PROFILE 1"
|
||||
local profiles = options.modProfiles or {}
|
||||
local manifests = discover()
|
||||
local snap = ModProfile.capture(manifests, options.modOptions, options.modsByVersion)
|
||||
snap.name = activeName
|
||||
|
||||
local found = false
|
||||
for i, p in ipairs(profiles) do
|
||||
if p.name == activeName then
|
||||
profiles[i] = snap
|
||||
found = true
|
||||
break
|
||||
end
|
||||
end
|
||||
if not found then
|
||||
profiles[#profiles + 1] = snap
|
||||
end
|
||||
options.modProfiles = profiles
|
||||
options.activeProfile = activeName
|
||||
SaveData.saveOptions(options)
|
||||
return snap
|
||||
end
|
||||
|
||||
function LauncherMods.duplicateProfile(sourceName, options)
|
||||
options = options or SaveData.loadOptions()
|
||||
local profiles = options.modProfiles or {}
|
||||
local sourceProfile
|
||||
for _, p in ipairs(profiles) do
|
||||
if p.name == sourceName then sourceProfile = p; break end
|
||||
end
|
||||
if not sourceProfile then return nil end
|
||||
|
||||
local baseName = sourceName .. " (Copy)"
|
||||
local newName = baseName
|
||||
local n = 1
|
||||
local taken = {}
|
||||
for _, p in ipairs(profiles) do taken[p.name] = true end
|
||||
while taken[newName] do
|
||||
n = n + 1
|
||||
newName = sourceName .. " (" .. n .. ")"
|
||||
end
|
||||
|
||||
local snap = {
|
||||
name = newName,
|
||||
enabled = copyTable(sourceProfile.enabled),
|
||||
options = copyTable(sourceProfile.options),
|
||||
slots = copyTable(sourceProfile.slots),
|
||||
enabledByVersion = copyTable(sourceProfile.enabledByVersion),
|
||||
}
|
||||
profiles[#profiles + 1] = snap
|
||||
options.modProfiles = profiles
|
||||
options.activeProfile = newName
|
||||
SaveData.saveOptions(options)
|
||||
return snap
|
||||
end
|
||||
|
||||
function LauncherMods.renameProfile(oldName, newName, options)
|
||||
options = options or SaveData.loadOptions()
|
||||
if not newName or newName == "" then return false end
|
||||
local profiles = options.modProfiles or {}
|
||||
for i, p in ipairs(profiles) do
|
||||
if p.name == oldName then
|
||||
p.name = newName
|
||||
if options.activeProfile == oldName then
|
||||
options.activeProfile = newName
|
||||
end
|
||||
SaveData.saveOptions(options)
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function LauncherMods.deleteProfile(profileName, options)
|
||||
options = options or SaveData.loadOptions()
|
||||
options.modProfiles = options.modProfiles or {}
|
||||
local newProfiles = {}
|
||||
for _, p in ipairs(options.modProfiles) do
|
||||
if p.name ~= profileName then newProfiles[#newProfiles + 1] = p end
|
||||
end
|
||||
options.modProfiles = newProfiles
|
||||
if options.activeProfile == profileName then
|
||||
local fallback = newProfiles[1] and newProfiles[1].name or "PROFILE 1"
|
||||
LauncherMods.applyProfile(fallback, options)
|
||||
else
|
||||
SaveData.saveOptions(options)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return LauncherMods
|
||||
|
||||
+57
-39
@@ -561,42 +561,46 @@ end
|
||||
-- hard dependencies must exist, be enabled, have survived, and satisfy their
|
||||
-- range; run to a fixpoint so failures propagate to dependents transitively
|
||||
function Loader:_enforceDependencies()
|
||||
local targetVersion = self:_targetVersion()
|
||||
local generation = self.generation
|
||||
local changed = true
|
||||
while changed do
|
||||
changed = false
|
||||
for _, id in ipairs(orderedIds(self.mods, isActive)) do
|
||||
local mod = self.mods[id]
|
||||
for _, spec in ipairs(mod.manifest.dependencySpecs) do
|
||||
local dep = self.mods[spec.id]
|
||||
local reason, skip
|
||||
if not dep then
|
||||
reason = "missing dependency: " .. spec.id
|
||||
elseif not dep.enabled then
|
||||
reason = ("dependency %s is disabled"):format(spec.id)
|
||||
elseif dep.state == "wrong_generation" then
|
||||
-- the gate's skip is contagious as a SKIP, not as a failure: the
|
||||
-- dependency has no bug to report and neither does this mod, so
|
||||
-- nothing here lands on the boot error list
|
||||
skip = true
|
||||
-- carry the dependency's own reason: it names the game or the
|
||||
-- missing gen2compat, and a guess here would name the wrong one
|
||||
reason = ("depends on %s, which does not run here (%s)")
|
||||
:format(spec.id, dep.skipReason or "not made for this game")
|
||||
elseif dep.failed then
|
||||
reason = ("dependency %s failed to load"):format(spec.id)
|
||||
elseif spec.range
|
||||
and not Semver.satisfies(dep.manifest.version, spec.range) then
|
||||
reason = ("needs %s@%s, found %s")
|
||||
:format(spec.id, spec.range, dep.manifest.version)
|
||||
end
|
||||
if reason then
|
||||
if skip then
|
||||
self:_skip(mod, "wrong_generation", reason)
|
||||
else
|
||||
self:_fail(mod, "blocked_dependency", reason)
|
||||
if ModTargets.specApplies(spec, targetVersion, generation) then
|
||||
local dep = self.mods[spec.id]
|
||||
local reason, skip
|
||||
if not dep then
|
||||
reason = "missing dependency: " .. spec.id
|
||||
elseif not dep.enabled then
|
||||
reason = ("dependency %s is disabled"):format(spec.id)
|
||||
elseif dep.state == "wrong_generation" then
|
||||
-- the gate's skip is contagious as a SKIP, not as a failure: the
|
||||
-- dependency has no bug to report and neither does this mod, so
|
||||
-- nothing here lands on the boot error list
|
||||
skip = true
|
||||
-- carry the dependency's own reason: it names the game or the
|
||||
-- missing gen2compat, and a guess here would name the wrong one
|
||||
reason = ("depends on %s, which does not run here (%s)")
|
||||
:format(spec.id, dep.skipReason or "not made for this game")
|
||||
elseif dep.failed then
|
||||
reason = ("dependency %s failed to load"):format(spec.id)
|
||||
elseif spec.range
|
||||
and not Semver.satisfies(dep.manifest.version, spec.range) then
|
||||
reason = ("needs %s@%s, found %s")
|
||||
:format(spec.id, spec.range, dep.manifest.version)
|
||||
end
|
||||
if reason then
|
||||
if skip then
|
||||
self:_skip(mod, "wrong_generation", reason)
|
||||
else
|
||||
self:_fail(mod, "blocked_dependency", reason)
|
||||
end
|
||||
changed = true
|
||||
break
|
||||
end
|
||||
changed = true
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -606,6 +610,8 @@ end
|
||||
-- Tarjan SCC over the hard-dependency graph: only a cycle's own members
|
||||
-- fail, so an unrelated mod beside a cycle still loads
|
||||
function Loader:_failCycles()
|
||||
local targetVersion = self:_targetVersion()
|
||||
local generation = self.generation
|
||||
local mods = self.mods
|
||||
local counter, stack, onStack, index, low = 0, {}, {}, {}, {}
|
||||
local cycles = {}
|
||||
@@ -616,14 +622,16 @@ function Loader:_failCycles()
|
||||
onStack[id] = true
|
||||
local selfEdge = false
|
||||
for _, spec in ipairs(mods[id].manifest.dependencySpecs) do
|
||||
local dep = mods[spec.id]
|
||||
if spec.id == id then selfEdge = true end
|
||||
if dep and isActive(dep) and spec.id ~= id then
|
||||
if not index[spec.id] then
|
||||
connect(spec.id)
|
||||
if low[spec.id] < low[id] then low[id] = low[spec.id] end
|
||||
elseif onStack[spec.id] and index[spec.id] < low[id] then
|
||||
low[id] = index[spec.id]
|
||||
if ModTargets.specApplies(spec, targetVersion, generation) then
|
||||
local dep = mods[spec.id]
|
||||
if spec.id == id then selfEdge = true end
|
||||
if dep and isActive(dep) and spec.id ~= id then
|
||||
if not index[spec.id] then
|
||||
connect(spec.id)
|
||||
if low[spec.id] < low[id] then low[id] = low[spec.id] end
|
||||
elseif onStack[spec.id] and index[spec.id] < low[id] then
|
||||
low[id] = index[spec.id]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -674,6 +682,8 @@ end
|
||||
-- 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
|
||||
function Loader:_order()
|
||||
local targetVersion = self:_targetVersion()
|
||||
local generation = self.generation
|
||||
local pending, indegree, dependents = {}, {}, {}
|
||||
for _, id in ipairs(orderedIds(self.mods, isActive)) do
|
||||
pending[id], indegree[id] = true, 0
|
||||
@@ -686,9 +696,17 @@ function Loader:_order()
|
||||
dependents[depId][#dependents[depId] + 1] = id
|
||||
indegree[id] = indegree[id] + 1
|
||||
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
|
||||
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
|
||||
local ordered = {}
|
||||
local function nextId()
|
||||
|
||||
+65
-22
@@ -31,26 +31,6 @@ local function violation(strict, id, message)
|
||||
Logger.warn("[%s] %s", tostring(id), message)
|
||||
end
|
||||
|
||||
-- "id" or "id@<range>"; a malformed id or range fails for every api level
|
||||
-- because there is no sane fallback reading for it
|
||||
local function parseSpecs(list, field)
|
||||
local specs = {}
|
||||
for _, entry in ipairs(list) do
|
||||
assert(type(entry) == "string" and entry ~= "",
|
||||
field .. " entries must be non-empty strings")
|
||||
local id, range = entry:match("^([%w_%-]+)@(.+)$")
|
||||
if not id then
|
||||
id = entry:match("^([%w_%-]+)$")
|
||||
assert(id, ("malformed %s entry %q"):format(field, entry))
|
||||
range = nil
|
||||
end
|
||||
local ok, err = Semver.validRange(range)
|
||||
assert(ok, ("malformed %s range in %q: %s"):format(field, entry, tostring(err)))
|
||||
specs[#specs + 1] = { id = id, range = range }
|
||||
end
|
||||
return specs
|
||||
end
|
||||
|
||||
-- Optional GitHub repo for launcher auto-update / other-versions.
|
||||
-- Accepts "owner/repo" or a github.com URL; empty/absent means no updates.
|
||||
function Manifest.parseGithub(value)
|
||||
@@ -73,6 +53,69 @@ function Manifest.parseGithub(value)
|
||||
return owner .. "/" .. repo
|
||||
end
|
||||
|
||||
-- "id", "id@<range>", "id@<range>#<github>", "id#<github>", or table entry
|
||||
local function parseSpecs(list, field, sources)
|
||||
local specs = {}
|
||||
sources = type(sources) == "table" and sources or {}
|
||||
for _, entry in ipairs(list) do
|
||||
local id, range, ghHint, gamesRaw, gameVersion
|
||||
if type(entry) == "table" then
|
||||
id = entry.id
|
||||
range = entry.range or entry.version
|
||||
ghHint = entry.github or entry.repo
|
||||
gamesRaw = entry.games or entry.game
|
||||
gameVersion = entry.game_version
|
||||
elseif type(entry) == "string" and entry ~= "" then
|
||||
local main, hashRepo = entry:match("^([^#]+)#(.*)$")
|
||||
if main then
|
||||
entry = main
|
||||
ghHint = hashRepo
|
||||
end
|
||||
id, range = entry:match("^([%w_%-]+)@(.+)$")
|
||||
if not id then
|
||||
id = entry:match("^([%w_%-]+)$")
|
||||
assert(id, ("malformed %s entry %q"):format(field, entry))
|
||||
range = nil
|
||||
end
|
||||
else
|
||||
error(field .. " entries must be non-empty strings or tables")
|
||||
end
|
||||
assert(id, ("malformed %s entry"):format(field))
|
||||
local ok, err = Semver.validRange(range)
|
||||
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 rawGh = ghHint or sources[id]
|
||||
if rawGh then
|
||||
local okGh, cleanGh = pcall(Manifest.parseGithub, rawGh)
|
||||
if okGh and cleanGh then parsedGh = cleanGh end
|
||||
end
|
||||
|
||||
specs[#specs + 1] = {
|
||||
id = id,
|
||||
range = range,
|
||||
github = parsedGh,
|
||||
games = parsedGames,
|
||||
game_version = gameVersion,
|
||||
}
|
||||
end
|
||||
return specs
|
||||
end
|
||||
|
||||
-- conflicts + incompatible (alias) merged, first-wins on duplicate ids
|
||||
local function mergeConflictLists(conflicts, incompatible)
|
||||
local seen, out = {}, {}
|
||||
@@ -248,8 +291,8 @@ function Manifest.validate(raw, path)
|
||||
optional_dependencies = array(raw.optional_dependencies),
|
||||
conflicts = conflicts,
|
||||
incompatible = array(raw.incompatible),
|
||||
dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies"),
|
||||
optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies"),
|
||||
dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies", raw.dependency_sources),
|
||||
optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies", raw.dependency_sources),
|
||||
conflictSpecs = parseSpecs(conflicts, "conflicts"),
|
||||
category = raw.category or "OTHER",
|
||||
game_version = raw.game_version,
|
||||
|
||||
@@ -334,9 +334,15 @@ function ModIndex.compatIssues(entry, ctx)
|
||||
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)
|
||||
if type(k) == "number" then
|
||||
if type(v) == "table" and type(v.id) == "string" then
|
||||
fn(v.id, v.range or v.version, v.github or v.repo)
|
||||
elseif type(v) == "string" then
|
||||
local main, hashRepo = v:match("^([^#]+)#(.*)$")
|
||||
if main then v = main end
|
||||
local id, range = v:match("^([^@]+)@(.+)$")
|
||||
fn(id or v, range, hashRepo)
|
||||
end
|
||||
elseif type(k) == "string" then
|
||||
fn(k, type(v) == "string" and v or nil)
|
||||
end
|
||||
|
||||
@@ -168,4 +168,39 @@ function ModTargets.detail(manifest, version)
|
||||
ModTargets.gameLabel(version))
|
||||
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
|
||||
|
||||
@@ -8,11 +8,16 @@ local function quote(value)
|
||||
return "'" .. tostring(value):gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
local function execOk(cmd)
|
||||
local status = os.execute(cmd)
|
||||
return status == 0 or status == true
|
||||
end
|
||||
|
||||
local function full(path) return root .. "/" .. path end
|
||||
|
||||
local fs = {}
|
||||
function fs.createDirectory(path)
|
||||
return os.execute("mkdir -p " .. quote(full(path))) == 0
|
||||
return execOk("mkdir -p " .. quote(full(path)))
|
||||
end
|
||||
function fs.write(path, body)
|
||||
local parent = path:match("^(.*)/[^/]+$")
|
||||
@@ -34,7 +39,7 @@ function fs.remove(path)
|
||||
return true
|
||||
end
|
||||
function fs.getInfo(path)
|
||||
if os.execute("test -d " .. quote(full(path))) == 0 then
|
||||
if execOk("test -d " .. quote(full(path))) then
|
||||
return { type = "directory" }
|
||||
end
|
||||
local handle = io.open(full(path), "rb")
|
||||
|
||||
@@ -138,7 +138,25 @@ check(not pcall(Manifest.parseGithub, "not a repo"),
|
||||
check(not pcall(Manifest.validate, {
|
||||
id = "badgh", name = "Bad", version = "1.0.0", entry = "main.lua",
|
||||
github = "ftp://example.com/x",
|
||||
}), "a bad github field fails manifest validation")
|
||||
}), "an unsupported github URL fails validation")
|
||||
|
||||
-- ------- dependency github repo spec hints & dependency resolver
|
||||
local depGh = Manifest.validate({
|
||||
id = "depgh", name = "DepGH", version = "1.0.0", entry = "main.lua",
|
||||
dependencies = { "colorlib@^1.2.0#Acme/ColorLib", "soundpack#Acme/SoundPack" },
|
||||
dependency_sources = { helper = "Acme/Helper" },
|
||||
})
|
||||
check(depGh.dependencySpecs[1].id == "colorlib" and depGh.dependencySpecs[1].github == "Acme/ColorLib",
|
||||
"dependency spec hash hint parses github owner/repo")
|
||||
check(depGh.dependencySpecs[2].id == "soundpack" and depGh.dependencySpecs[2].github == "Acme/SoundPack",
|
||||
"dependency spec hash hint without range parses github owner/repo")
|
||||
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local depCheck = LauncherMods.checkDependencies(depGh)
|
||||
check(depCheck.hasIssues == true, "missing dependencies trigger issues verdict")
|
||||
check(#depCheck.deps == 2, "dependency check lists all specs")
|
||||
check(depCheck.deps[1].status == "missing", "absent dependency reports missing")
|
||||
check(depCheck.deps[1].safeUrl == "https://github.com/Acme/ColorLib", "safeUrl built from validated github repo")
|
||||
|
||||
local v1 = Manifest.validate({
|
||||
id = "v1", name = "V1", version = "1.0.0", entry = "main.lua",
|
||||
@@ -519,8 +537,61 @@ local emptyLoader = Loader.new({ fs = memfs({}) })
|
||||
check(emptyLoader:load(pristine) == true, "an empty mods dir still loads clean")
|
||||
check(#emptyLoader:status().errors == 0, "no mods means no diagnostics")
|
||||
check(#emptyLoader.order == 0, "no mods means an empty load order")
|
||||
check(pristine.pokemon.A.hp == 1 and next(pristine.items) == nil,
|
||||
"no-mod load leaves data untouched")
|
||||
-- ------- dependency resolver conflict detection test
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local testTargetManifest = Manifest.validate({
|
||||
id = "new_mod",
|
||||
name = "New Mod",
|
||||
version = "1.0.0",
|
||||
entry = "main.lua",
|
||||
incompatible = { "colorlib" },
|
||||
}, "mods/new_mod")
|
||||
local installedColorlib = Manifest.validate({
|
||||
id = "colorlib",
|
||||
name = "Color Lib",
|
||||
version = "1.0.0",
|
||||
entry = "main.lua",
|
||||
}, "mods/colorlib")
|
||||
-- ------- scoped dependency tests
|
||||
local Json = require("src.link.Json")
|
||||
local scopedDepManifest = Manifest.validate({
|
||||
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" }, 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)
|
||||
|
||||
|
||||
@@ -1162,6 +1162,18 @@ seedOpts.modProfiles = {}
|
||||
ModProfile.ensureFirst(seedOpts, ms.status.available, {})
|
||||
check(#seedOpts.modProfiles == 0, "seeding never runs twice")
|
||||
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local testProfOpts = { activeProfile = "P1", modProfiles = { { name = "P1", enabled = { a = true } } } }
|
||||
local dupSnap = LauncherMods.duplicateProfile("P1", testProfOpts)
|
||||
check(dupSnap and dupSnap.name == "P1 (Copy)" and testProfOpts.activeProfile == "P1 (Copy)",
|
||||
"duplicateProfile creates P1 (Copy) and activates it")
|
||||
check(LauncherMods.renameProfile("P1 (Copy)", "RenamedP", testProfOpts) == true,
|
||||
"renameProfile renames active profile")
|
||||
check(testProfOpts.activeProfile == "RenamedP", "activeProfile updates on rename")
|
||||
check(LauncherMods.deleteProfile("RenamedP", testProfOpts) == true, "deleteProfile removes profile")
|
||||
check(#testProfOpts.modProfiles == 1 and testProfOpts.modProfiles[1].name == "P1", "only original profile remains")
|
||||
check(testProfOpts.activeProfile == "P1", "activeProfile falls back to remaining profile")
|
||||
|
||||
-- permissions rows
|
||||
local permy = manifest("permy", { permissions = { "network" } })
|
||||
local msP = ManagerState.new(managerGame(fakeLoader({ permy })))
|
||||
|
||||
+11
-4
@@ -2989,16 +2989,23 @@ def check_gen2_manifest(repo, mod_dir, manifest, named):
|
||||
"manifest.json"))
|
||||
deps = manifest.get("dependencies") or []
|
||||
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
|
||||
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:
|
||||
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):
|
||||
findings.append(Finding(
|
||||
"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",
|
||||
"manifest.json"))
|
||||
return findings, notes
|
||||
|
||||
Reference in New Issue
Block a user