From 9d3ee6a597a7bce7dd00037b646741987a10cda7 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Fri, 31 Jul 2026 10:19:51 -0400 Subject: [PATCH] mod manager now supports auto updates --- mods/nuzlocke/README.md | 13 + mods/nuzlocke/main.lua | 224 ++++++++ mods/nuzlocke/manifest.json | 19 + src/core/SaveData.lua | 3 + src/import/RomImporter.lua | 786 ++++++++++++++++++++++++--- src/mods/LauncherMods.lua | 87 ++- src/mods/Loader.lua | 12 + src/mods/ManagerState.lua | 31 +- src/mods/Manifest.lua | 51 +- src/mods/ModUpdate.lua | 361 ++++++++++++ tests/engine/launcher_mods_tests.lua | 19 + tests/engine/mod_update_tests.lua | 111 ++++ tests/mod_manifest_tests.lua | 37 ++ tools/mod_release_workflow.yml | 200 +++++++ tools/modkit.py | 143 ++++- 15 files changed, 2002 insertions(+), 95 deletions(-) create mode 100644 mods/nuzlocke/README.md create mode 100644 mods/nuzlocke/main.lua create mode 100644 mods/nuzlocke/manifest.json create mode 100644 src/mods/ModUpdate.lua create mode 100644 tests/engine/mod_update_tests.lua create mode 100644 tools/mod_release_workflow.yml diff --git a/mods/nuzlocke/README.md b/mods/nuzlocke/README.md new file mode 100644 index 00000000..25dd15d5 --- /dev/null +++ b/mods/nuzlocke/README.md @@ -0,0 +1,13 @@ +# Nuzlocke + +An enforced Gen 1 Nuzlocke. + +Oak configures Slow Start, whether duplicate evolutionary families are skipped +or consume an area's encounter, and whether Safari maps are separate areas. + +After Slow Start, the mod enforces mandatory nicknames for starters, gifts, +and catches; one capture per area; no duplicate evolutionary families; and +permanent death. A party Pokémon says that it died and is removed immediately. +If the final party member dies, the game runs the credits to THE END and then +deletes the active save. + diff --git a/mods/nuzlocke/main.lua b/mods/nuzlocke/main.lua new file mode 100644 index 00000000..e77da325 --- /dev/null +++ b/mods/nuzlocke/main.lua @@ -0,0 +1,224 @@ +-- Nuzlocke rules. Uses the current engine's internal seams while the +-- equivalent public API hooks are being added. +return function(mod) + mod.hooks:wrap("intro.oak_speech.build", function(next, steps, speech) + steps = next(steps, speech) + mod.ui.insertStepAfter(steps, "oak_welcome", { + id = "nuzlocke_intro", kind = "say", pic = "oak", + text = "A Nuzlocke is a\npromise.\fEvery loss is\npermanent.", + }) + mod.ui.insertStepAfter(steps, "nuzlocke_intro", { + id = "nuzlocke_slow_start", kind = "yesno", pic = "oak", + saveKey = "slow_start", defaultNo = true, + text = "Use SLOW START?\nRules start with\nPOKé BALLS.", + }) + mod.ui.insertStepAfter(steps, "nuzlocke_slow_start", { + id = "nuzlocke_dupes", kind = "choice", pic = "oak", + saveKey = "dupes_mode", text = "When you meet a\nknown family?", + choices = { "SKIP", "LOSE" }, values = { "skip", "strict" }, + }) + mod.ui.insertStepAfter(steps, "nuzlocke_dupes", { + id = "nuzlocke_safari", kind = "yesno", pic = "oak", + saveKey = "safari_sectors", text = "Separate SAFARI\nsectors?", + }) + mod.ui.insertStepAfter(steps, "nuzlocke_safari", { + id = "nuzlocke_close", kind = "say", pic = "oak", + text = "Give every friend\na name. Keep them\nsafe. Good luck!", + }) + return steps + end) + + mod.events:on("intro.oak_speech.answered", function(ev) + if ev.saveKey then mod.save:set(ev.saveKey, ev.value) end + end) + + local function active(game, battle) + if not (game and game.save) or (battle and (battle.demo or battle.ghost)) then return false end + if not mod.save:get("slow_start", false) then return true end + if mod.save:get("balls_unlocked", false) then return true end + for id, count in pairs(game.save.inventory or {}) do + if count > 0 and game.data.items[id] and game.data.items[id].ball then + mod.save:set("balls_unlocked", true) + return true + end + end + return false + end + + local function areaKey(game, battle) + if battle and battle.safari and not mod.save:get("safari_sectors", false) then + return "SAFARI_ZONE" + end + return (game.overworld and game.overworld.map and game.overworld.map.id) + or (game.save.player and game.save.player.map) or "UNKNOWN" + end + + local function family(data, species) + local found, pending = {}, { species } + while #pending > 0 do + local id = table.remove(pending) + if not found[id] then + found[id] = true + for _, evo in ipairs((data.pokemon[id] or {}).evolutions or {}) do pending[#pending + 1] = evo.species end + for parent, def in pairs(data.pokemon or {}) do + for _, evo in ipairs(def.evolutions or {}) do + if evo.species == id then pending[#pending + 1] = parent end + end + end + end + end + return found + end + + local function ownsFamily(game, species) + local members = family(game.data, species) + local function owns(mon) return mon and members[mon.species] end + for _, mon in ipairs(game.save.party or {}) do if owns(mon) then return true end end + for _, box in ipairs(game.save.boxes or {}) do + for _, mon in ipairs(box) do if owns(mon) then return true end end + end + return false + end + + local function caughtAreas() + local areas = mod.save:get("caught_areas") + if type(areas) ~= "table" then areas = {}; mod.save:set("caught_areas", areas) end + return areas + end + + local function denied(game, battle, species) + if not active(game, battle) then return nil end + if caughtAreas()[areaKey(game, battle)] then return "area" end + if ownsFamily(game, species) then return "dupes" end + end + + mod.events:on("pokemon.caught", function(ev) + -- A successful capture proves that Slow Start has ended even when it was + -- the last ball in the bag. + if mod.save:get("slow_start", false) then mod.save:set("balls_unlocked", true) end + if active(ev.game, ev.battle) then + caughtAreas()[areaKey(ev.game, ev.battle)] = ev.species + mod.save:set("caught_areas", caughtAreas()) + end + end) + + mod.events:on("game.ready", function() + local BattleState = require("src.battle.BattleState") + local Commands = require("src.script.Commands") + local Party = require("src.pokemon.Party") + local Boxes = require("src.pokemon.Boxes") + local Pokemon = require("src.pokemon.Pokemon") + local Runtime = require("src.mods.Runtime") + local Screens = require("src.ui.Screens") + local Strings = require("src.core.Strings") + local SaveData = require("src.core.SaveData") + local GameVersion = require("src.core.GameVersion") + local Bag = require("src.inventory.Bag") + + BattleState.askNicknameUI = function(self, mon) + self.lockedBall, self.blankForAskName = nil, false + return self:buildScreen("NamingScreen", { + title = Strings("NICKNAME?"), maxLen = 10, + onDone = function(name) mon.nickname = name or "A" end, + }) + end + + -- Gifts and starters use the same mandatory naming screen. + Commands.give_pokemon = function(ctx, species, level) + local gift = { ctx = ctx, species = species, level = level } + if ctx.game.mods then ctx.game.mods.events:emit("pokemon.before_give", gift) end + local mon = Pokemon.new(ctx.game.data, gift.species, gift.level) + ctx.game.stringBuffer, ctx.pendingPokemonName = ctx.game.data.pokemon[gift.species].name or gift.species, gift.species + BattleState.stampOT(ctx.save, mon) + local inParty = Party.add(ctx.save.party, mon) + local boxNum = inParty and nil or Boxes.deposit(ctx.save, mon) + if not inParty and not boxNum then ctx.lastCheck = false; return end + if ctx.save.pokedex then ctx.save.pokedex.seen[gift.species], ctx.save.pokedex.owned[gift.species] = true, true end + ctx.lastCheck, ctx.addedToParty, ctx.boxNum = true, inParty, boxNum + if ctx.runner then + Screens.push(ctx.game, "NamingScreen", { + title = Strings("NICKNAME?"), maxLen = 10, + onDone = function(name) mon.nickname = name or "A"; ctx.runner:resume() end, + }) + ctx.runner:yield() + else mon.nickname = "A" end + if boxNum then + ctx.game.boxMonNicks, ctx.game.stringBuffer = mon.nickname, tostring(boxNum) + if ctx.runner then Commands.show_text(ctx, "_SentToBoxText") end + end + end + + local vanillaThrowBall = BattleState.throwBall + BattleState.throwBall = function(self, ball) + local reason = denied(self.game, self, self.enemy and self.enemy.mon.species) + if reason then + if reason == "dupes" and mod.save:get("dupes_mode", "skip") == "strict" then + caughtAreas()[areaKey(self.game, self)] = "DUPES_LOST" + mod.save:set("caught_areas", caughtAreas()) + end + Bag.add(self.game.save, ball, 1) + self:say(reason == "area" and "This area already\nhas a captured POKéMON!" + or "You already have\nthis POKéMON family!") + return + end + return vanillaThrowBall(self, ball) + end + + local vanillaOnFaint = BattleState.onFaint + BattleState.onFaint = function(self, battler) + if not (battler.isPlayer and active(self.game, self)) then return vanillaOnFaint(self, battler) end + if battler.faintQueued then return end + battler.faintQueued = true + if self.participants then self.participants[battler.mon] = nil end + Runtime.emit("battle.fainted", { battle = self, battler = battler }) + for i, mon in ipairs(self.game.save.party) do + if mon == battler.mon then table.remove(self.game.save.party, i); break end + end + self:actNext(function() + battler.fainted = true + require("src.core.Sound").playCry(self.data, battler.mon.species) + require("src.core.Sound").play(self.data, "Faint_Fall") + self.fx = self.fx or {}; self.fx.faint = { battler = battler, frames = 30 } + end) + self.nextInsert = (self.nextInsert or 0) + 1 + table.insert(self.queue, self.nextInsert, { wait = 30 }) + self:sayNext(Strings("%s\ndied!", battler.name)) + self:act(function() self:playerMonFainted() end) + end + + local vanillaPlayerFainted = BattleState.playerMonFainted + BattleState.playerMonFainted = function(self) + if active(self.game, self) and not Party.firstHealthy(self.game.save.party) then + self.nuzlockeGameOver, self.result, self.afterQueue = true, "nuzlocke_game_over", "finish" + self:sayNext(Strings("All of your\nPOKéMON are dead...")) + return + end + return vanillaPlayerFainted(self) + end + + local vanillaFinish = BattleState.finish + BattleState.finish = function(self) + if not self.nuzlockeGameOver then return vanillaFinish(self) end + self.nuzlockeGameOver = nil + self.game.stack:pop() + Runtime.emit("battle.ended", { battle = self, result = "nuzlocke_game_over" }) + -- Game over has no victory lap: delete the slot, then use Credits only + -- as its existing THE END renderer / A-or-B wait screen. + local version = GameVersion.get() + local slot = SaveData.activeSlot(version) + if slot then SaveData.deleteSlot(version, slot) + elseif love and love.filesystem then + local main = SaveData.saveFilename(version) + love.filesystem.remove(main); love.filesystem.remove(main .. ".bak"); love.filesystem.remove(main .. ".tmp") + end + local ending = Screens.push(self.game, "Credits", function() + require("src.core.Music").stop() + while self.game.stack:top() do self.game.stack:pop() end + Screens.push(self.game, "IntroMovie", function() + if self.game.makeTitleState then self.game.stack:push(self.game:makeTitleState()) end + end) + end) + ending.phase, ending.timer = "end_wait", 0 + end + end) +end diff --git a/mods/nuzlocke/manifest.json b/mods/nuzlocke/manifest.json new file mode 100644 index 00000000..4f8b073c --- /dev/null +++ b/mods/nuzlocke/manifest.json @@ -0,0 +1,19 @@ +{ + "id": "nuzlocke", + "name": "Nuzlocke", + "version": "1.0.0", + "api": 2, + "entry": "main.lua", + "profile": "content", + "category": "GAMEPLAY", + "game_version": ">=0.0.0-dev <1.0.0", + "priority": 100, + "permissions": [ + "engine_internals" + ], + "dependencies": [], + "optional_dependencies": [], + "conflicts": [], + "description": "A configurable Gen 1 Nuzlocke with permanent death and area catches.", + "github": "bryanthaboi/nuzlocke" +} diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 7775c686..fe4fdbcb 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -249,6 +249,9 @@ function SaveData.defaultOptions() -- Native mod enablement is an installation option, not save-slot data. -- Missing entries mean enabled so newly installed mods work by default. mods = {}, + -- GitHub release checks for mods with a manifest "github" field + -- (src/mods/ModUpdate.lua). Keyed by owner/repo; TTL is six hours. + modUpdateCache = {}, -- On-screen touch overlay (Android/iOS; see src/core/TouchControls.lua). -- enabled=false hides it permanently (distinct from auto-hide-on-gamepad). -- positions are optional normalized centers {x=0..1, y=0..1} per control diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 7a36d12d..24ea32d4 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -923,24 +923,38 @@ end -- or a love DroppedFile. function RomImporter:_installMod(source) if self.workState == "working" then return end - local LauncherMods = require("src.mods.LauncherMods") - local ok, res = LauncherMods.installZip(source) - if ok then - self:_refreshMods() + self.tab = "mods" + local ok, installed, res = pcall(function() + local LauncherMods = require("src.mods.LauncherMods") + return LauncherMods.installZip(source) + end) + if not ok then + self.modNotice = { ok = false, + text = "Import failed: " .. tostring(installed) } + return + end + if installed then + pcall(self._refreshMods, self) self.modNotice = { ok = true, text = "Installed " .. tostring(res) } else self.modNotice = { ok = false, text = tostring(res) } end - self.tab = "mods" end -- Remove an installed mod from the save-dir mods/ tree and refresh the panel. function RomImporter:_deleteMod(id) if self.workState == "working" then return end - local LauncherMods = require("src.mods.LauncherMods") - local ok, res = LauncherMods.uninstall(id) - if ok then - self:_refreshMods() + local ok, deleted, res = pcall(function() + local LauncherMods = require("src.mods.LauncherMods") + return LauncherMods.uninstall(id) + end) + if not ok then + self.modNotice = { ok = false, + text = "Delete failed: " .. tostring(deleted) } + return + end + if deleted then + pcall(self._refreshMods, self) self.modNotice = { ok = true, text = "Deleted " .. tostring(id) } else self.modNotice = { ok = false, text = tostring(res) } @@ -1520,13 +1534,15 @@ end -- Clip text to a pixel width, appending an ellipsis when it overflows (the UI -- font has no built-in truncation). Used for save-slot names / meta lines. +-- Drops whole UTF-8 codepoints (never mid-byte) so Font:getWidth cannot see +-- a truncated multi-byte sequence and throw "UTF-8 decoding error". local function ellipsize(font, text, maxW) text = tostring(text or "") if maxW <= 0 or font:getWidth(text) <= maxW then return text end local ell = "..." local ew = font:getWidth(ell) while #text > 0 and font:getWidth(text) + ew > maxW do - text = text:sub(1, #text - 1) + text = utf8Back(text) end return text .. ell end @@ -2083,6 +2099,224 @@ function RomImporter:draw() dx + 16 * s, dy + dh - 30 * s, dw - 32 * s, "left") end + -- Mod confirm / versions / release-notes overlays + if self._modConfirm or self._modVersions or self._modReleaseNotes then + col(PAL.bgBot, 0.72) + love.graphics.rectangle("fill", 0, 0, width, height) + end + if self._modConfirm then + local c = self._modConfirm + local dw = math.min(appW - 32 * s, 400 * s) + local lineH = self.hintFont:getHeight() + 4 * s + local dh = 36 * s + (#c.lines) * lineH + 56 * s + local dx = appX + (appW - dw) / 2 + local dy = (height - dh) / 2 + local rr = 12 * s + fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.92, 0.92) + love.graphics.setLineWidth(math.max(1, 1.2 * s)) + col((c.kind == "update") and PAL.green or PAL.gold, 0.65) + love.graphics.rectangle("line", dx, dy, dw, dh, rr, rr) + love.graphics.setFont(self.slotNameFont) + col(PAL.white) + love.graphics.printf(c.title or "Confirm", dx + 16 * s, dy + 14 * s, + dw - 32 * s, "left") + love.graphics.setFont(self.hintFont) + col(PAL.detail) + local ty = dy + 42 * s + for _, line in ipairs(c.lines) do + love.graphics.printf(line, dx + 16 * s, ty, dw - 32 * s, "left") + ty = ty + lineH + end + local btnH = 34 * s + local btnW = (dw - 48 * s) / 2 + local by = dy + dh - btnH - 14 * s + self._modConfirmYes = { x = dx + 16 * s, y = by, width = btnW, height = btnH } + self._modConfirmNo = { x = dx + dw - 16 * s - btnW, y = by, + width = btnW, height = btnH } + local yhot = self:_hover(self._modConfirmYes) + local nhot = self:_hover(self._modConfirmNo) + fillGradRounded(self._modConfirmYes.x, by, btnW, btnH, 8 * s, + PAL.playTop, PAL.playBot, yhot and 1 or 0.85, yhot and 1 or 0.85) + col(PAL.disabled, nhot and 0.55 or 0.35) + love.graphics.rectangle("fill", self._modConfirmNo.x, by, btnW, btnH, 8 * s, 8 * s) + love.graphics.setFont(self.saveBtnFont) + col(PAL.white) + printfB(c.yesLabel or "OK", self._modConfirmYes.x, + by + (btnH - self.saveBtnFont:getHeight()) / 2, btnW, "center") + col(PAL.detail) + printfB("Cancel", self._modConfirmNo.x, by + (btnH - self.saveBtnFont:getHeight()) / 2, + btnW, "center") + elseif self._modReleaseNotes then + local n = self._modReleaseNotes + local ModUpdate = require("src.mods.ModUpdate") + local dw = math.min(appW - 32 * s, 480 * s) + local dh = math.min(height - 48 * s, 360 * s) + local dx = appX + (appW - dw) / 2 + local dy = (height - dh) / 2 + local rr = 12 * s + fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.92, 0.92) + love.graphics.setLineWidth(math.max(1, 1.2 * s)) + col(PAL.green, 0.5) + love.graphics.rectangle("line", dx, dy, dw, dh, rr, rr) + love.graphics.setFont(self.slotNameFont) + col(PAL.white) + love.graphics.printf("v" .. tostring(n.version) .. " notes", + dx + 16 * s, dy + 12 * s, dw - 32 * s, "left") + local body = ModUpdate.cleanBody(n.body or "", 0) + if body == "" then body = "(No release notes.)" end + love.graphics.setFont(self.hintFont) + col(PAL.detail) + local textTop = dy + 44 * s + local textH = dh - 44 * s - 52 * s + love.graphics.setScissor(math.floor(dx + 16 * s), math.floor(textTop), + math.ceil(dw - 32 * s), math.ceil(textH)) + love.graphics.printf(body, dx + 16 * s, textTop - (n.scroll or 0), + dw - 32 * s, "left") + love.graphics.setScissor() + local closeW = self.hintFont:getWidth("Close") + 28 * s + local closeH = 30 * s + self._modReleaseNotesClose = { + x = dx + (dw - closeW) / 2, y = dy + dh - closeH - 12 * s, + width = closeW, height = closeH, + } + local chot = self:_hover(self._modReleaseNotesClose) + col(PAL.disabled, chot and 0.55 or 0.35) + love.graphics.rectangle("fill", self._modReleaseNotesClose.x, + self._modReleaseNotesClose.y, closeW, closeH, 8 * s, 8 * s) + col(PAL.detail) + printfB("Close", self._modReleaseNotesClose.x, + self._modReleaseNotesClose.y + (closeH - self.hintFont:getHeight()) / 2, + closeW, "center") + elseif self._modVersions then + local ModUpdate = require("src.mods.ModUpdate") + local v = self._modVersions + local dw = math.min(appW - 40 * s, 520 * s) + local pad = 16 * s + local headerH = 56 * s + local rowH = 52 * s + local footerH = 48 * s + local listN = math.min(6, math.max(0, #v.releases)) + local listH = math.max(rowH, listN * rowH) + local dh = headerH + listH + footerH + dh = math.min(dh, height - 40 * s) + -- recompute how many rows fit under the clamped dialog height + local fitN = math.max(1, math.floor((dh - headerH - footerH) / rowH)) + listN = math.min(listN, fitN) + listH = listN * rowH + dh = headerH + listH + footerH + local dx = appX + (appW - dw) / 2 + local dy = (height - dh) / 2 + local rr = 12 * s + fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.96, 0.96) + love.graphics.setLineWidth(math.max(1, 1.2 * s)) + col(PAL.green, 0.5) + love.graphics.rectangle("line", dx, dy, dw, dh, rr, rr) + + love.graphics.setFont(self.slotNameFont) + col(PAL.white) + love.graphics.printf("Other versions — " .. tostring(v.name), + dx + pad, dy + 10 * s, dw - pad * 2, "left") + love.graphics.setFont(self.hintFont) + local info = self:_modUpdateInfo(v.id) + local statusTxt = "Installed: v" .. tostring(v.current) + local statusCol = PAL.detail + if info and info.status == "available" then + statusTxt = statusTxt .. " - Update v" .. tostring(info.latest) + statusCol = PAL.playTop + elseif info and info.status == "current" then + statusTxt = statusTxt .. " - Up to date" + statusCol = PAL.playTop + end + col(statusCol) + love.graphics.printf(statusTxt, dx + pad, dy + 34 * s, dw - pad * 2, "left") + + self._modVersionRects = {} + self._modVersionNotesRects = {} + local listTop = dy + headerH + local notesW = self.hintFont:getWidth("Read more") + 20 * s + local installW = self.hintFont:getWidth("Install") + 20 * s + local btnH = 26 * s + -- clip the list to the band above the footer so nothing can paint over Close + love.graphics.setScissor(math.floor(dx + 2 * s), math.floor(listTop), + math.ceil(dw - 4 * s), math.ceil(listH)) + for i = 1, listN do + local rel = v.releases[i] + local ly = listTop + (i - 1) * rowH + local rect = { x = dx + 12 * s, y = ly + 2 * s, width = dw - 24 * s, + height = rowH - 6 * s, release = rel } + col(PAL.bgBot, 0.45) + love.graphics.rectangle("fill", rect.x, rect.y, rect.width, rect.height, 8 * s, 8 * s) + love.graphics.setLineWidth(1) + col(PAL.cardBorder, 0.35) + love.graphics.rectangle("line", rect.x, rect.y, rect.width, rect.height, 8 * s, 8 * s) + + love.graphics.setFont(self.hintFont) + local label = "v" .. rel.version + if rel.version == v.current then label = label .. " (installed)" end + if rel.prerelease then label = label .. " pre" end + col(rel.version == v.current and PAL.warning or PAL.white) + love.graphics.print(label, rect.x + 12 * s, rect.y + 6 * s) + + -- one-line ellipsized preview only (never wrap changelog into the row) + local btnStackW = 0 + local hasNotes = type(rel.body) == "string" and rel.body:match("%S") + local canInstall = rel.version ~= v.current + if hasNotes then btnStackW = btnStackW + notesW end + if canInstall then btnStackW = btnStackW + (hasNotes and 8 * s or 0) + installW end + local previewW = math.max(24 * s, rect.width - 24 * s - btnStackW - 12 * s) + local preview = ModUpdate.previewLine(rel.body or "", 90) + if preview ~= "" then + col(PAL.detail) + love.graphics.print( + ellipsize(self.hintFont, preview, previewW), + rect.x + 12 * s, + rect.y + 6 * s + self.hintFont:getHeight() + 2 * s) + end + + local btnY = rect.y + (rect.height - btnH) / 2 + local bx = rect.x + rect.width - 10 * s + if canInstall then + bx = bx - installW + local irect = self:_chipButton(bx, btnY, "Install", { + w = installW, h = btnH, id = v.id, kind = "accent", + }) + irect.release = rel + self._modVersionRects[#self._modVersionRects + 1] = irect + bx = bx - 8 * s + end + if hasNotes then + bx = bx - notesW + local nrect = self:_chipButton(bx, btnY, "Read more", { + w = notesW, h = btnH, id = v.id, kind = "neutral", + }) + nrect.release = rel + self._modVersionNotesRects[#self._modVersionNotesRects + 1] = nrect + end + end + love.graphics.setScissor() + + -- opaque footer so list content can never bleed under Close + local footerY = dy + dh - footerH + col(PAL.slotBg, 1) + love.graphics.rectangle("fill", dx + 2 * s, footerY, dw - 4 * s, footerH - 2 * s) + local closeW = self.hintFont:getWidth("Close") + 32 * s + local closeH = 32 * s + self._modVersionsClose = { + x = dx + (dw - closeW) / 2, + y = footerY + (footerH - closeH) / 2 - 2 * s, + width = closeW, height = closeH, + } + local chot = self:_hover(self._modVersionsClose) + col(PAL.disabled, chot and 0.55 or 0.35) + love.graphics.rectangle("fill", self._modVersionsClose.x, + self._modVersionsClose.y, closeW, closeH, 8 * s, 8 * s) + love.graphics.setFont(self.hintFont) + col(PAL.detail) + printfB("Close", self._modVersionsClose.x, + self._modVersionsClose.y + (closeH - self.hintFont:getHeight()) / 2, + closeW, "center") + end + -- pointer cursor over any interactive element (desktop only) if self._hoverEnabled and not self._padCursorActive and love.mouse.isCursorSupported and love.mouse.isCursorSupported() then @@ -2147,6 +2381,53 @@ end function RomImporter:mousepressed(x, y, button) if self._rename then return end -- the rename modal swallows all clicks + -- Mod confirm / versions / release-notes modals swallow clicks too. + if self._modConfirm then + if button ~= 1 then return end + if inside(self._modConfirmYes, x, y) then + local c = self._modConfirm + self._modConfirm = nil + if c.kind == "update" then + self:_confirmModUpdate(c.id, c.release) + else + self:_toggleMod(c.id, true) + end + elseif inside(self._modConfirmNo, x, y) then + self._modConfirm = nil + end + return + end + if self._modReleaseNotes then + if button ~= 1 then return end + if inside(self._modReleaseNotesClose, x, y) then + self._modReleaseNotes = nil + end + return + end + if self._modVersions then + if button ~= 1 then return end + if inside(self._modVersionsClose, x, y) then + self._modVersions = nil + return + end + for _, r in ipairs(self._modVersionNotesRects or {}) do + if inside(r, x, y) and r.release then + self._modReleaseNotes = { + version = r.release.version, + body = r.release.body or "", + scroll = 0, + } + return + end + end + for _, r in ipairs(self._modVersionRects or {}) do + if inside(r, x, y) and r.release then + self:_installModVersion(self._modVersions.id, r.release) + return + end + end + return + end -- Whether a press can be ARMED and resolved on release, which needs a -- pollable pointer: always on desktop, on Android only where love.touch is. local armDrag = (not self.android) or self.touchPollable @@ -2283,6 +2564,18 @@ function RomImporter:mousepressed(x, y, button) return end end + for _, r in ipairs(self.modUpdateRects or {}) do + if inside(r, x, y) then + self:_modGithubAction(r.id, "update") + return + end + end + for _, r in ipairs(self.modVersionsRects or {}) do + if inside(r, x, y) then + self:_modGithubAction(r.id, "versions") + return + end + end for _, r in ipairs(self.modRects or {}) do if inside(r, x, y) then if not armDrag then @@ -2312,6 +2605,17 @@ function RomImporter:keypressed(key) end return end + if self._modConfirm or self._modVersions or self._modReleaseNotes then + if key == "escape" then + if self._modReleaseNotes then + self._modReleaseNotes = nil + else + self._modConfirm = nil + self._modVersions = nil + end + end + return + end if self.workState == "working" then return end if key == "return" or key == "space" or key == "kpenter" then -- Enter acts on the visible game tab: Play if its ROM is ready, otherwise @@ -2373,6 +2677,52 @@ function RomImporter:_glassyButton(x, y, w, h, label, font, enabled) return rect end +-- Compact pill button for row actions (Edit / Delete / Update / Versions). +-- kind: "neutral" (default), "accent" (green), "danger" (red), "dangerArmed" +-- (filled confirm). Returns the hit rect; opts.id is copied onto it. +function RomImporter:_chipButton(x, y, label, opts) + opts = opts or {} + local s = self._s + local font = opts.font or self.hintFont + local padX = opts.padX or (12 * s) + local h = opts.h or (font:getHeight() + 10 * s) + love.graphics.setFont(font) + local w = opts.w or (font:getWidth(label) + 2 * padX) + local r = opts.r or math.min(8 * s, h / 2) + local kind = opts.kind or "neutral" + local rect = { x = x, y = y, width = w, height = h, id = opts.id } + local hot = self:_hover(rect) + + if kind == "dangerArmed" then + fillGradRounded(x, y, w, h, r, PAL.chooseTop, PAL.chooseBot, + hot and 1 or 0.92, hot and 1 or 0.92) + col(PAL.white) + elseif kind == "danger" then + col(PAL.chooseTop, hot and 0.28 or 0.14) + love.graphics.rectangle("fill", x, y, w, h, r, r) + love.graphics.setLineWidth(math.max(1, s)) + col(PAL.chooseTop, hot and 0.95 or 0.7) + love.graphics.rectangle("line", x, y, w, h, r, r) + col(hot and PAL.white or PAL.chooseTop) + elseif kind == "accent" then + col(PAL.playTop, hot and 0.28 or 0.12) + love.graphics.rectangle("fill", x, y, w, h, r, r) + love.graphics.setLineWidth(math.max(1, s)) + col(PAL.playTop, hot and 0.95 or 0.65) + love.graphics.rectangle("line", x, y, w, h, r, r) + col(hot and PAL.white or PAL.playTop) + else + fillGradRounded(x, y, w, h, r, PAL.white, PAL.white, + hot and 0.22 or 0.12, 0.04) + love.graphics.setLineWidth(math.max(1, s)) + col(PAL.white, hot and 0.35 or 0.18) + love.graphics.rectangle("line", x, y, w, h, r, r) + col(PAL.white) + end + printfB(label, x, y + (h - font:getHeight()) / 2, w, "center") + return rect +end + -- The tall green Play button (ready) or a disabled placeholder. Sets -- self.playButtonRect. function RomImporter:_playButton(x, y, w, h, gameName, ready, locked) @@ -2955,9 +3305,15 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h, paged) local nameH = self.slotNameFont:getHeight() local metaH = self.labelFont:getHeight() local rowPadV = 10 * s - local rowH = rowPadV * 2 + nameH + 4 * s + metaH + local chipBtnH = self.hintFont:getHeight() + 10 * s + -- LOADED sits top-right; Edit/Delete sit bottom-right — row must fit both + -- without stacking on the same y (chip buttons are taller than the old text). + local loadedH = self.warningFont:getHeight() + 6 * s + local rowH = math.max(rowPadV * 2 + nameH + 4 * s + metaH, + rowPadV + loadedH + 4 * s + chipBtnH + rowPadV) local rowGap = 8 * s local rr = 12 * s + local btnGap = 8 * s -- an empty registry shows a fixed-height dashed hint box instead of rows local totalH = (n > 0) and (n * rowH + (n - 1) * rowGap) or (96 * s) local naturalH = pad + labelH + 12 * s + totalH + 10 * s + newBtnH + pad @@ -3024,48 +3380,41 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h, paged) col(selected and PAL.green or PAL.cardBorder, selected and 0.9 or 0.22) love.graphics.rectangle("line", rx, ry, rw, rowH, rr, rr) - -- Delete label (bottom-right); reserve its width so name/meta don't overlap + -- Edit + Delete chip buttons (bottom-right row). Delete arms on the + -- first click and asks "Sure?" on the second; width stays on "Delete" + -- so the row never reflows (#433). love.graphics.setFont(self.hintFont) - local delText = "Delete" - local delW = self.hintFont:getWidth(delText) - local delH = self.hintFont:getHeight() - local delX = rx + rw - 12 * s - delW - local delY = ry + rowH - rowPadV - delH - local drect = { x = delX - 6 * s, y = delY - 4 * s, - width = delW + 12 * s, height = delH + 8 * s, id = slot.id } - local dhot = self:_hover(drect) - -- Armed by a first click, the label asks before a second one commits; - -- the box stays sized to "Delete" so the row never reflows (#433). local darmed = armedDelete(self._confirmDelete, "slot", slot.id, version) - col((darmed or dhot) and PAL.red or PAL.warning) - love.graphics.printf(darmed and "Sure?" or delText, delX, delY, delW, "center") + local delLabel = darmed and "Sure?" or "Delete" + local delW = self.hintFont:getWidth("Delete") + 24 * s + local delX = rx + rw - 12 * s - delW + local delY = ry + rowH - rowPadV - chipBtnH + local drect = self:_chipButton(delX, delY, delLabel, { + w = delW, h = chipBtnH, id = slot.id, + kind = darmed and "dangerArmed" or "danger", + }) local rightReserve = delW + 18 * s - -- Edit label, immediately left of Delete: opens the bundled save - -- editor (tools/save-editor) on this slot's file. Only drawn when the - -- host supplied onEditSave and the slot actually holds a save -- there - -- is nothing to edit in an empty slot, and offering it would open the - -- editor on a new-game stub the player never asked for. + -- Edit, immediately left of Delete: opens the bundled save editor on + -- this slot's file. Only when the host supplied onEditSave and the + -- slot actually holds a save. local erect = nil if self.onEditSave and slot.exists then - local edText = "Edit" - local edW = self.hintFont:getWidth(edText) - local edX = delX - 14 * s - edW - erect = { x = edX - 6 * s, y = delY - 4 * s, - width = edW + 12 * s, height = delH + 8 * s, id = slot.id } - local ehot = self:_hover(erect) - col(ehot and PAL.blue or PAL.warning) - love.graphics.print(edText, edX, delY) - rightReserve = rightReserve + edW + 20 * s + local edW = self.hintFont:getWidth("Edit") + 24 * s + local edX = delX - btnGap - edW + erect = self:_chipButton(edX, delY, "Edit", { + w = edW, h = chipBtnH, id = slot.id, kind = "accent", + }) + rightReserve = rightReserve + edW + btnGap + 6 * s end - -- LOADED pill (top-right of the active row), then reserve its width + -- LOADED pill top-right (above the button row, never over Delete) local pillW = 0 if selected then love.graphics.setFont(self.warningFont) local pText = "LOADED" local pw = self.warningFont:getWidth(pText) + 14 * s - local ph = self.warningFont:getHeight() + 6 * s + local ph = loadedH local ppx = rx + rw - 12 * s - pw local ppy = ry + rowPadV col(PAL.green) @@ -3177,24 +3526,241 @@ function RomImporter:_refreshMods() end end self.mods = LauncherMods.list() or {} + self:_syncModUpdateInfo(false) end function RomImporter:_ensureMods() if not self.mods then self:_refreshMods() end end +-- Resolve cached (or freshly fetched) GitHub status for every mod that +-- declares a github field. force=true bypasses the 6h cache on every repo. +-- Results live on self.modUpdateInfo[id] = { status, latest, best, releases }. +function RomImporter:_syncModUpdateInfo(force) + local ModUpdate = require("src.mods.ModUpdate") + self.modUpdateInfo = self.modUpdateInfo or {} + for _, m in ipairs(self.mods or {}) do + if m.github and m.github ~= "" then + local ok, packed = pcall(function() + local releases, err, meta = ModUpdate.fetchReleases(m.github, m.id, { + force = force == true, + }) + local cached = ModUpdate.readCache(m.github) + return { + releases = releases, + err = err, + meta = meta, + checkedAt = (cached and cached.checkedAt) or os.time(), + } + end) + if not ok then + self.modUpdateInfo[m.id] = { + status = "error", err = tostring(packed), + } + elseif packed.releases then + local status, best = ModUpdate.statusFor(m.version, packed.releases) + self.modUpdateInfo[m.id] = { + status = status, + latest = best and best.version or nil, + best = best, + releases = packed.releases, + err = nil, + checkedAt = packed.checkedAt or os.time(), + } + else + self.modUpdateInfo[m.id] = { + status = "error", + latest = nil, + best = nil, + releases = nil, + err = tostring(packed.err), + } + end + else + self.modUpdateInfo[m.id] = nil + end + end +end + +function RomImporter:_modUpdateInfo(id) + return self.modUpdateInfo and self.modUpdateInfo[id] or nil +end + -- Flip a mod's enabled flag (persisted via LauncherMods.setEnabled) and relist -- so the toggle, count, and every status chip reflect the new resolution. -function RomImporter:_toggleMod(id) +-- Enabling an experimental mod arms a confirm first. +function RomImporter:_toggleMod(id, confirmed) local LauncherMods = require("src.mods.LauncherMods") - local cur = false + local cur, experimental = false, false for _, m in ipairs(self.mods or {}) do - if m.id == id then cur = m.enabled; break end + if m.id == id then + cur = m.enabled + experimental = m.experimental == true + break + end end - LauncherMods.setEnabled(id, not cur) + local want = not cur + if want and experimental and not confirmed then + self._modConfirm = { + kind = "experimental", id = id, + title = "Experimental mod", + yesLabel = "Enable", + lines = { + "This mod is marked experimental.", + "It may be unfinished or unstable.", + "Enable it anyway?", + }, + } + return + end + self._modConfirm = nil + LauncherMods.setEnabled(id, want) self:_refreshMods() end +-- GitHub Update / Check for updates / Versions. Soft-fails into modNotice. +-- Update button: when a newer release is known, confirm then install; when +-- already current, force-refresh the 6h cache and report / offer update. +function RomImporter:_modGithubAction(id, action) + local ran, err = pcall(function() + local ModUpdate = require("src.mods.ModUpdate") + local row + for _, m in ipairs(self.mods or {}) do + if m.id == id then row = m; break end + end + if not row or not row.github then + self.modNotice = { ok = false, text = "This mod has no github field" } + return + end + + if action == "versions" then + self.modNotice = { ok = true, text = "Loading versions..." } + local releases, fetchErr = ModUpdate.fetchReleases(row.github, row.id, {}) + if not releases then + self.modNotice = { ok = false, text = tostring(fetchErr) } + return + end + local status, best = ModUpdate.statusFor(row.version, releases) + self.modUpdateInfo = self.modUpdateInfo or {} + self.modUpdateInfo[row.id] = { + status = status, latest = best and best.version, best = best, + releases = releases, + } + self._modVersions = { + id = row.id, name = row.name, current = row.version, + releases = releases, scroll = 0, + } + self.modNotice = nil + return + end + + -- update / check + local info = self:_modUpdateInfo(id) + if info and info.status == "available" and info.best then + self._modConfirm = { + kind = "update", id = row.id, release = info.best, + title = "Update available", + yesLabel = "Update", + lines = { + "Update " .. row.name .. "?", + "Installed v" .. tostring(row.version), + "Latest v" .. tostring(info.best.version), + }, + } + return + end + + -- Manual check (or first click when status is current/unknown/error) + self.modNotice = { ok = true, text = "Checking " .. row.github .. "..." } + local releases, fetchErr = ModUpdate.fetchReleases(row.github, row.id, { + force = true, + }) + if not releases then + self.modNotice = { ok = false, text = tostring(fetchErr) } + return + end + if #releases == 0 then + self.modNotice = { ok = false, text = "No .zip releases found" } + return + end + local status, best = ModUpdate.statusFor(row.version, releases) + self.modUpdateInfo = self.modUpdateInfo or {} + self.modUpdateInfo[row.id] = { + status = status, latest = best and best.version, best = best, + releases = releases, checkedAt = os.time(), + } + if status == "available" and best then + self.modNotice = { ok = true, + text = row.name .. ": new version available (v" .. best.version .. ")" } + self._modConfirm = { + kind = "update", id = row.id, release = best, + title = "Update available", + yesLabel = "Update", + lines = { + "Update " .. row.name .. "?", + "Installed v" .. tostring(row.version), + "Latest v" .. tostring(best.version), + }, + } + else + self.modNotice = { ok = true, + text = row.name .. " is up to date (v" + .. tostring(row.version) .. ")" } + end + end) + if not ran then + self._modVersions = nil + self.modNotice = { ok = false, + text = "Update failed: " .. tostring(err) } + end +end + +function RomImporter:_confirmModUpdate(modId, release) + local row + for _, m in ipairs(self.mods or {}) do + if m.id == modId then row = m; break end + end + local name = row and row.name or modId + self.modNotice = { ok = true, + text = "Downloading " .. tostring(release and release.version or "?") .. "..." } + local ran, err = pcall(function() + local LauncherMods = require("src.mods.LauncherMods") + local ok, res = LauncherMods.installFromRelease(modId, release) + if ok then + pcall(self._refreshMods, self) + self.modNotice = { ok = true, + text = "Updated " .. name .. " to " .. tostring(res) } + else + self.modNotice = { ok = false, text = tostring(res) } + end + end) + if not ran then + self.modNotice = { ok = false, text = "Update failed: " .. tostring(err) } + end +end + +function RomImporter:_installModVersion(modId, release) + self._modVersions = nil + self._modReleaseNotes = nil + local version = release and release.version or "?" + self.modNotice = { ok = true, text = "Downloading " .. tostring(version) .. "..." } + local ran, err = pcall(function() + local LauncherMods = require("src.mods.LauncherMods") + local ok, res = LauncherMods.installFromRelease(modId, release) + if ok then + pcall(self._refreshMods, self) + self.modNotice = { ok = true, + text = "Installed " .. tostring(modId) .. " " .. tostring(res) } + else + self.modNotice = { ok = false, text = tostring(res) } + end + end) + if not ran then + self.modNotice = { ok = false, + text = "Install failed: " .. tostring(err) } + end +end + -- The status-chip label + colour for a mod row (deriveList's status verdict). local function modStatusChip(status) if status == "ok" then return "Ready", PAL.green end @@ -3267,18 +3833,21 @@ function RomImporter:_drawModsPanel(x, y, w, h, paged) x + 16 * s, top + boxH / 2 - self.hintFont:getHeight() / 2, w - 32 * s, "center") self.modRects = {} self.modDeleteRects = {} + self.modUpdateRects = {} + self.modVersionsRects = {} self._modMax = 0 return (top - y) + boxH end - -- card metrics (design: rounded 14, padding 14x16; toggle 56x28; Delete under) + -- card metrics: status + toggle top-right; action chip-buttons in one row + -- under the body (Update / Versions / Delete when github is set). local padH, padV = 16 * s, 14 * s local cardGap, cardR = 10 * s, 14 * s local tw, th = 52 * s, 28 * s local innerW = w - 2 * padH local chipH = self.hintFont:getHeight() + 8 * s - local delH = self.hintFont:getHeight() - local clusterH = chipH + 6 * s + th + 6 * s + delH + local btnH = self.hintFont:getHeight() + 10 * s + local btnGap = 8 * s love.graphics.setFont(self.stateFont) local nameH = self.stateFont:getHeight() @@ -3288,8 +3857,36 @@ function RomImporter:_drawModsPanel(x, y, w, h, paged) for i, m in ipairs(mods) do local chipText = modStatusChip(m.status) local chipW = self.hintFont:getWidth(chipText) + 20 * s - local delW = self.hintFont:getWidth("Delete") - local clusterW = math.max(chipW, tw, delW) + local delW = self.hintFont:getWidth("Delete") + 24 * s + local verW = self.hintFont:getWidth("Versions") + 24 * s + local hasGh = m.github and m.github ~= "" + local info = hasGh and self:_modUpdateInfo(m.id) or nil + local updLabel = "Check for updates" + local updateKind = "neutral" + -- checkLine: always on the mod row for github mods so the check result + -- is visible without relying on the top-of-panel notice. + local checkLine = nil + local checkLineColor = PAL.detail + if info and info.status == "available" then + updLabel = "Update" + updateKind = "accent" + checkLine = "Checked for updates - v" .. tostring(info.latest) .. " available" + checkLineColor = PAL.playTop + elseif info and info.status == "current" then + updLabel = "Check again" + checkLine = "Checked for updates - up to date" + checkLineColor = PAL.playTop + elseif info and info.status == "error" then + checkLine = "Checked for updates - failed" + checkLineColor = PAL.chooseTop + elseif hasGh then + checkLine = "Not checked for updates yet" + checkLineColor = PAL.warning + end + local updW = self.hintFont:getWidth(updLabel) + 24 * s + local btnRowW = delW + if hasGh then btnRowW = updW + btnGap + verW + btnGap + delW end + local clusterW = math.max(chipW, tw) local leftW = math.max(40 * s, innerW - clusterW - 14 * s) local descH = 0 if m.description ~= "" then @@ -3297,10 +3894,22 @@ function RomImporter:_drawModsPanel(x, y, w, h, paged) local _, dl = self.hintFont:getWrap(m.description, leftW) descH = math.max(1, #dl) * self.hintFont:getHeight() end - local contentH = padV * 2 + nameH + (descH > 0 and (6 * s + descH) or 0) - local cardH = math.max(contentH, padV * 2 + clusterH) + local clusterH = chipH + 6 * s + th + local metaH = self.hintFont:getHeight() + 2 * s + if checkLine then + metaH = metaH + self.hintFont:getHeight() + 2 * s + end + if descH > 0 then + metaH = metaH + self.hintFont:getHeight() + 2 * s + descH + end + local bodyH = math.max(nameH + 4 * s + metaH, clusterH) + local cardH = padV * 2 + bodyH + 10 * s + btnH layout[i] = { h = cardH, leftW = leftW, clusterW = clusterW, - chipText = chipText, chipW = chipW, delW = delW } + chipText = chipText, chipW = chipW, delW = delW, + updW = updW, verW = verW, hasGh = hasGh, clusterH = clusterH, + btnRowW = btnRowW, bodyH = bodyH, updLabel = updLabel, + updateKind = updateKind, checkLine = checkLine, + checkLineColor = checkLineColor } total = total + cardH end total = total + (#mods - 1) * cardGap @@ -3314,6 +3923,8 @@ function RomImporter:_drawModsPanel(x, y, w, h, paged) self.modScroll = scroll self.modRects = {} self.modDeleteRects = {} + self.modUpdateRects = {} + self.modVersionsRects = {} if not paged then love.graphics.setScissor(math.floor(x), math.floor(top), @@ -3343,20 +3954,30 @@ function RomImporter:_drawModsPanel(x, y, w, h, paged) col(PAL.cardBorder, 0.5) love.graphics.rectangle("line", bxx, byy, badgeW, badgeH, 5 * s, 5 * s) love.graphics.setFont(self.warningFont) - col(PAL.warning) + col(m.experimental and PAL.gold or PAL.warning) love.graphics.print(m.badge, bxx + 6 * s, byy + (badgeH - self.warningFont:getHeight()) / 2) - -- description under the name, wrapped in the left block + -- version + check status line + description under the name + love.graphics.setFont(self.hintFont) + col(PAL.detail) + local metaY = ny + nameH + 4 * s + love.graphics.print("v" .. tostring(m.version or "?"), nx, metaY) + metaY = metaY + self.hintFont:getHeight() + 2 * s + if L.checkLine then + col(L.checkLineColor or PAL.detail) + love.graphics.print( + ellipsize(self.hintFont, L.checkLine, L.leftW), nx, metaY) + metaY = metaY + self.hintFont:getHeight() + 2 * s + end if m.description ~= "" then - love.graphics.setFont(self.hintFont) col(PAL.detail) - love.graphics.printf(m.description, nx, ny + nameH + 6 * s, L.leftW, "left") + love.graphics.printf(m.description, nx, metaY, L.leftW, "left") end - -- right cluster: status chip, toggle, Delete -- vertically centred + -- right cluster: status chip + toggle (action buttons are a bottom row) local clusterX = x + w - padH - L.clusterW - local clusterY = cy + (cardH - clusterH) / 2 + local clusterY = cy + padV local _, chipColor = modStatusChip(m.status) local chipX = clusterX + (L.clusterW - L.chipW) / 2 col(chipColor, 0.1) @@ -3388,31 +4009,46 @@ function RomImporter:_drawModsPanel(x, y, w, h, paged) col(PAL.white) love.graphics.circle("fill", kcx, ty + th / 2, kd / 2) - -- Delete under the toggle - local delX = clusterX + (L.clusterW - L.delW) / 2 - local delY = ty + th + 6 * s - local drect = { x = delX - 6 * s, y = delY - 2 * s, - width = L.delW + 12 * s, height = delH + 4 * s, id = m.id } - local dhot = self:_hover(drect) - love.graphics.setFont(self.hintFont) - -- Same two-click arm as a save slot's Delete (#433). + -- Action chip-buttons in one right-aligned row under the body + local btnY = cy + cardH - padV - btnH + local btnX = x + w - padH - L.btnRowW local darmed = armedDelete(self._confirmDelete, "mod", m.id, nil) - col((darmed or dhot) and PAL.red or PAL.warning) - love.graphics.printf(darmed and "Sure?" or "Delete", delX, delY, L.delW, "center") + local function clipHit(rect, bucket) + if not rect then return end + local vy = math.max(rect.y, top) + local vy2 = math.min(rect.y + rect.height, top + listH) + if vy2 > vy then + bucket[#bucket + 1] = { + x = rect.x, y = vy, width = rect.width, height = vy2 - vy, + id = rect.id, + } + end + end + if L.hasGh then + local urect = self:_chipButton(btnX, btnY, L.updLabel, { + w = L.updW, h = btnH, id = m.id, kind = L.updateKind or "neutral", + }) + clipHit(urect, self.modUpdateRects) + btnX = btnX + L.updW + btnGap + local vrect = self:_chipButton(btnX, btnY, "Versions", { + w = L.verW, h = btnH, id = m.id, kind = "neutral", + }) + clipHit(vrect, self.modVersionsRects) + btnX = btnX + L.verW + btnGap + end + local drect = self:_chipButton(btnX, btnY, darmed and "Sure?" or "Delete", { + w = L.delW, h = btnH, id = m.id, + kind = darmed and "dangerArmed" or "danger", + }) + clipHit(drect, self.modDeleteRects) - -- hit rects clipped to the visible list band + -- toggle hit rect clipped to the visible list band local vy = math.max(trect.y, top) local vy2 = math.min(trect.y + trect.height, top + listH) if vy2 > vy then self.modRects[#self.modRects + 1] = { x = trect.x, y = vy, width = trect.width, height = vy2 - vy, id = m.id } end - local dvy = math.max(drect.y, top) - local dvy2 = math.min(drect.y + drect.height, top + listH) - if dvy2 > dvy then - self.modDeleteRects[#self.modDeleteRects + 1] = - { x = drect.x, y = dvy, width = drect.width, height = dvy2 - dvy, id = m.id } - end end cy = cy + cardH + cardGap end diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index 3dc397bb..ec9bbb5d 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -95,8 +95,15 @@ function LauncherMods.deriveList(manifests, options) local byId, enabledSet = {}, {} for _, m in ipairs(ordered) do byId[m.id] = m - -- missing entry means enabled, matching the loader's default - if mods[m.id] ~= false then enabledSet[m.id] = true end + -- missing entry means enabled, matching the loader -- except experimental + -- mods, which stay off until the player opts in + if mods[m.id] == false then + -- stay off + elseif mods[m.id] == true then + enabledSet[m.id] = true + elseif not m.experimental then + enabledSet[m.id] = true + end end local out = {} @@ -104,16 +111,19 @@ function LauncherMods.deriveList(manifests, options) local enabled = enabledSet[m.id] == true local status, detail = statusFor(byId, m.id, enabledSet, enabled) local raw = m.raw or {} + local badge = tostring(raw.category or m.profile or "MOD"):upper() + if m.experimental then badge = "EXPERIMENTAL" end out[#out + 1] = { id = m.id, name = m.name or m.id, version = m.version, - -- category, then profile, then a generic fallback -- uppercased - badge = tostring(raw.category or m.profile or "MOD"):upper(), + badge = badge, description = m.description or "", enabled = enabled, status = status, statusDetail = detail, + github = m.github, + experimental = m.experimental == true, } end return out @@ -231,8 +241,15 @@ end -- options.mods enable-state the loader persists, so a toggle here is what the -- game sees on its next boot. function LauncherMods.list() - local options = SaveData.loadOptions() - return LauncherMods.deriveList(discover(), options) + local ok, result = pcall(function() + local options = SaveData.loadOptions() + return LauncherMods.deriveList(discover(), options) + end) + if not ok then + -- a single bad options/mod file must not blank the launcher + return {} + end + return result or {} end -- setEnabled(id, enabled): persist options.mods[id] in the exact shape @@ -454,13 +471,22 @@ function LauncherMods.strays() return scanStrays(false) end -- on the player's behalf is not this function's call to make. function LauncherMods.adoptStrays() return scanStrays(true) end --- installZip(source) -> true, id | nil, errString +-- installZip(source [, opts]) -> true, id | nil, errString -- source is an external path or a love DroppedFile. The archive is validated -- BEFORE anything is copied; every path unmounts and clears the staged temp -- file, and a failed copy rolls its partial tree back. A dropped file outside -- the save dir is staged into a save-dir temp first, because -- love.filesystem.mount only reaches a save-directory-relative path. -function LauncherMods.installZip(source) +-- 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) + if not ok then return nil, "import failed: " .. tostring(result) end + return result, err +end + +function LauncherMods._installZipInner(source, opts) + opts = opts or {} if not (love and love.filesystem) then return nil, "mod install needs LOVE" end @@ -501,12 +527,24 @@ function LauncherMods.installZip(source) cleanup() return nil, "invalid mod manifest: " .. tostring(manifestErr) end + if opts.expectId and manifest.id ~= opts.expectId then + cleanup() + return nil, ("zip is for '%s', expected '%s'") + :format(manifest.id, opts.expectId) + end - -- reject a duplicate before touching the mods tree local dest = "mods/" .. manifest.id if fs.getInfo(dest) then - cleanup() - return nil, "a mod named '" .. manifest.id .. "' is already installed" + if not opts.replace then + cleanup() + return nil, "a mod named '" .. manifest.id .. "' is already installed" + end + -- drop the old tree before copy; enable-flag is preserved (uninstall + -- would clear it, which would surprise an update) + local savedPrefix = CacheFs.prefix + CacheFs.prefix = "" + removeTree(dest) + CacheFs.prefix = savedPrefix end -- CacheFs.prefix steers ROM-cache writes into a version subtree (blue/...); @@ -529,6 +567,33 @@ function LauncherMods.installZip(source) return true, manifest.id end +-- Install (or replace) a mod from a GitHub release zip URL. +-- Returns true, version | nil, errString. Soft-fails: download / install / +-- cleanup errors never throw into the launcher UI. +function LauncherMods.installFromRelease(modId, release) + local ok, result, err = pcall(function() + if type(modId) ~= "string" or modId == "" then + return nil, "missing mod id" + end + if type(release) ~= "table" or not release.zip or not release.zip.url then + return nil, "release has no downloadable .zip" + end + local ModUpdate = require("src.mods.ModUpdate") + local tmpName = ("mod_update_%s_%s.zip"):format( + tostring(modId), tostring(release.version or os.time())) + local localPath, dlErr = ModUpdate.downloadZip(release.zip.url, tmpName) + if not localPath then return nil, dlErr end + local installed, res = LauncherMods.installZip(localPath, { + replace = true, expectId = modId, + }) + pcall(love.filesystem.remove, localPath) + if not installed then return nil, res end + return true, release.version or res + end) + if not ok then return nil, "install failed: " .. tostring(result) end + return result, err +end + -- uninstall(id) -> true | nil, errString -- Removes mods// from wherever it was installed (the portable game folder -- or the save directory, CacheFs decides -- #330) and clears options.mods[id] diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 8f57a7bd..cc418493 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -834,6 +834,18 @@ function Loader:load(data) require("src.mods.Builtins").install(self.content, data) self:_loadState() self:_discover() + -- Experimental mods stay off until the player opts in: a missing + -- options.mods entry normally means enabled, but experimental flips that. + do + local options = SaveData.loadOptions(self.fs) + local modsOpt = options.mods or {} + for id, mod in pairs(self.mods) do + if not self.disabled[id] and modsOpt[id] == nil + and mod.manifest.experimental then + self.disabled[id] = true + end + end + end for id, mod in pairs(self.mods) do mod.enabled = not self.disabled[id] mod.state = mod.enabled and "pending" or "disabled" diff --git a/src/mods/ManagerState.lua b/src/mods/ManagerState.lua index 42671ecf..cf750abe 100644 --- a/src/mods/ManagerState.lua +++ b/src/mods/ManagerState.lua @@ -324,6 +324,12 @@ function ManagerState:detailRows(m) rows[#rows + 1] = { label = Strings("PERMISSIONS.."), action = function() self:goTo("permissions") end } end + if m.github then + rows[#rows + 1] = { inert = true, label = "GH " .. m.github } + end + if m.experimental then + rows[#rows + 1] = { inert = true, label = "EXPERIMENTAL" } + end if m.error then rows[#rows + 1] = { label = Strings("VIEW ERROR.."), action = function() self:goTo("errors") end } @@ -607,13 +613,26 @@ function ManagerState:beginToggle(m) r = ManagerState.resolveToggle(self:manifestMap(), m.id, want, self:enabledSet()) end - if #r.missing > 0 or #r.conflicts > 0 or #r.badVersion > 0 then - self:openBlocked(r) - elseif #r.alsoEnable > 0 or #r.alsoDisable > 0 then - self:openCascade(r, m, want) - else - self:commitToggle(r.apply) + local function proceed() + if #r.missing > 0 or #r.conflicts > 0 or #r.badVersion > 0 then + self:openBlocked(r) + elseif #r.alsoEnable > 0 or #r.alsoDisable > 0 then + self:openCascade(r, m, want) + else + self:commitToggle(r.apply) + end end + -- Experimental mods ask once on enable; disable is silent. + if want and m.experimental then + self:openConfirm({ + "EXPERIMENTAL MOD", + "THIS MOD IS MARKED", + "EXPERIMENTAL.", + "ENABLE ANYWAY?", + }, proceed) + return + end + proceed() end function ManagerState:commitToggle(apply) diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index e0622d96..d1f1545b 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -50,6 +50,42 @@ local function parseSpecs(list, field) 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) + if value == nil or value == "" then return nil end + assert(type(value) == "string", "github must be a string") + local trimmed = value:match("^%s*(.-)%s*$") or value + if trimmed == "" then return nil end + local owner, repo = trimmed:match( + "^https?://github%.com/([%w%._%-]+)/([%w%._%-]+)/?$") + if not owner then + owner, repo = trimmed:match( + "^https?://github%.com/([%w%._%-]+)/([%w%._%-]+)%.git/?$") + end + if not owner then + owner, repo = trimmed:match("^([%w%._%-]+)/([%w%._%-]+)$") + end + assert(owner and repo and owner ~= "" and repo ~= "", + "github must be owner/repo or a github.com URL") + repo = repo:gsub("%.git$", "") + return owner .. "/" .. repo +end + +-- conflicts + incompatible (alias) merged, first-wins on duplicate ids +local function mergeConflictLists(conflicts, incompatible) + local seen, out = {}, {} + for _, list in ipairs({ array(conflicts), array(incompatible) }) do + for _, entry in ipairs(list) do + if not seen[entry] then + seen[entry] = true + out[#out + 1] = entry + end + end + end + return out +end + function Manifest.validate(raw, path) assert(type(raw) == "table", "manifest must be an object") assert(type(raw.id) == "string" and raw.id:match("^[%w_%-]+$"), @@ -86,6 +122,12 @@ function Manifest.validate(raw, path) assert(gameVersionOk, ("malformed game_version %q: %s") :format(tostring(raw.game_version), tostring(gameVersionErr))) + local github = Manifest.parseGithub(raw.github) + + assert(raw.experimental == nil or type(raw.experimental) == "boolean", + "experimental must be a boolean") + local experimental = raw.experimental == true + -- overhauls and total conversions are assumed to move the link -- fingerprint unless the manifest says otherwise; content packs are not local affectsLink = profile ~= "content" @@ -97,6 +139,8 @@ function Manifest.validate(raw, path) return value end + local conflicts = mergeConflictLists(raw.conflicts, raw.incompatible) + return { id = raw.id, name = raw.name, @@ -106,13 +150,16 @@ function Manifest.validate(raw, path) priority = tonumber(raw.priority) or 0, dependencies = array(raw.dependencies), optional_dependencies = array(raw.optional_dependencies), - conflicts = array(raw.conflicts), + conflicts = conflicts, + incompatible = array(raw.incompatible), dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies"), optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies"), - conflictSpecs = parseSpecs(array(raw.conflicts), "conflicts"), + conflictSpecs = parseSpecs(conflicts, "conflicts"), category = raw.category or "OTHER", game_version = raw.game_version, description = raw.description or "", + github = github, + experimental = experimental, profile = profile, affects_link = affectsLink, permissions = permissions, diff --git a/src/mods/ModUpdate.lua b/src/mods/ModUpdate.lua new file mode 100644 index 00000000..71d87b6b --- /dev/null +++ b/src/mods/ModUpdate.lua @@ -0,0 +1,361 @@ +-- GitHub release helpers for mod auto-update / other-versions. +-- Pure parsing is love-free; fetch/download use HostShell + curl when available. +-- Release lists are cached in options.modUpdateCache for CACHE_TTL seconds +-- (default 6 hours). The launcher owns UI and install. + +local ModUpdate = {} + +ModUpdate.CACHE_TTL = 6 * 60 * 60 -- six hours + +local function stripV(tag) + return (tostring(tag):gsub("^[vV]", "")) +end + +-- Prefer "-.zip", then any "*.zip", then the first .zip. +function ModUpdate.pickZipAsset(assets, modId, version) + if type(assets) ~= "table" then return nil end + local prefer = nil + if modId and version then + prefer = tostring(modId) .. "-" .. tostring(version) .. ".zip" + end + local idPrefix, idPrefixZip, anyZip = nil, nil, nil + if modId then idPrefix = tostring(modId):lower() end + for _, a in ipairs(assets) do + if type(a) == "table" and type(a.name) == "string" then + local name = a.name + if name:lower():match("%.zip$") then + local row = { + name = name, + url = a.browser_download_url, + size = tonumber(a.size), + } + if prefer and name == prefer then return row end + if idPrefix and not idPrefixZip + and name:lower():find(idPrefix, 1, true) == 1 then + idPrefixZip = row + end + if not anyZip then anyZip = row end + end + end + end + return idPrefixZip or anyZip +end + +-- Byte-length cut that never lands mid UTF-8 codepoint. +local function utf8Cut(s, maxBytes) + if type(s) ~= "string" or maxBytes <= 0 then return "" end + if #s <= maxBytes then return s end + s = s:sub(1, maxBytes) + -- Drop trailing continuation bytes (10xxxxxx). + while #s > 0 do + local b = s:byte(#s) + if b < 0x80 or b >= 0xC0 then break end + s = s:sub(1, #s - 1) + end + -- Drop a lead byte whose continuation was truncated away. + if #s > 0 then + local b = s:byte(#s) + if b >= 0xC0 then + s = s:sub(1, #s - 1) + end + end + return s +end + +-- Strip common markdown / HTML noise for the launcher changelog preview. +function ModUpdate.cleanBody(text, maxChars) + if type(text) ~= "string" or text == "" then return "" end + local s = text + s = s:gsub("\r\n", "\n"):gsub("\r", "\n") + s = s:gsub("", "") + s = s:gsub("<[^>]+>", "") + s = s:gsub("%[%!%[[^%]]*%]%([^%)]*%)%]", "") -- images ![alt](url) + s = s:gsub("%[([^%]]+)%]%([^%)]+%)", "%1") -- links [text](url) -> text + s = s:gsub("```[^\n]*\n(.-)```", "%1") + s = s:gsub("`([^`]+)`", "%1") + s = s:gsub("^#+%s*", "", 1) + s = s:gsub("\n#+%s*", "\n") + s = s:gsub("%*%*([^*]+)%*%*", "%1") + s = s:gsub("%*([^*]+)%*", "%1") + s = s:gsub("__([^_]+)__", "%1") + s = s:gsub("_([^_]+)_", "%1") + s = s:gsub("^\n+", ""):gsub("\n+$", "") + s = s:gsub("\n\n\n+", "\n\n") + maxChars = tonumber(maxChars) or 0 + if maxChars > 0 and #s > maxChars then + -- ASCII "..." (not U+2026) so later pixel ellipsize stays byte-safe. + s = utf8Cut(s, maxChars):gsub("%s+%S*$", "") .. "..." + end + return s +end + +-- One-line preview for tight UI rows: cleaned, newlines collapsed, ellipsized. +function ModUpdate.previewLine(text, maxChars) + local s = ModUpdate.cleanBody(text, 0) + if s == "" then return "" end + s = s:gsub("\n+", " "):gsub("%s+", " ") + s = s:match("^%s*(.-)%s*$") or s + maxChars = tonumber(maxChars) or 80 + if #s > maxChars then + s = utf8Cut(s, maxChars):gsub("%s+%S*$", "") .. "..." + end + return s +end + +-- Decode one GitHub release object into { version, tag, zip, prerelease, body }. +function ModUpdate.parseRelease(doc, modId) + if type(doc) ~= "table" or not doc.tag_name then + return nil, "no tag_name in release" + end + local version = stripV(doc.tag_name) + if not version:match("^%d+%.%d+%.%d+") then + return nil, "release tag is not semver-like: " .. tostring(doc.tag_name) + end + local triple = version:match("^(%d+%.%d+%.%d+)") + local zip = ModUpdate.pickZipAsset(doc.assets, modId, triple) + local body = type(doc.body) == "string" and doc.body or "" + return { + version = triple, + tag = tostring(doc.tag_name), + zip = zip, + prerelease = doc.prerelease == true, + name = type(doc.name) == "string" and doc.name or triple, + body = body, + } +end + +-- Decode a releases array (GET /repos/.../releases) into a sorted list +-- (newest first). Releases without a .zip asset are dropped. Never throws. +function ModUpdate.parseReleases(jsonText, modId, Json) + local ok, result, err = pcall(function() + Json = Json or require("src.link.Json") + local doc, decodeErr = Json.decode(jsonText) + if type(doc) ~= "table" then + return nil, decodeErr or "releases json is not an array" + end + if type(doc.message) == "string" and not doc.tag_name and not doc[1] then + return nil, "GitHub: " .. doc.message + end + if doc.tag_name then + local one, oneErr = ModUpdate.parseRelease(doc, modId) + if not one then return nil, oneErr end + if not one.zip then return nil, "latest release has no .zip asset" end + return { one } + end + local out = {} + for _, entry in ipairs(doc) do + local rel = ModUpdate.parseRelease(entry, modId) + if rel and rel.zip then out[#out + 1] = rel end + end + return out + end) + if not ok then return nil, "could not parse releases: " .. tostring(result) end + return result, err +end + +function ModUpdate.apiReleasesUrl(repo) + return "https://api.github.com/repos/" .. repo .. "/releases?per_page=30" +end + +function ModUpdate.apiLatestUrl(repo) + return "https://api.github.com/repos/" .. repo .. "/releases/latest" +end + +function ModUpdate.isNewer(installed, candidate) + local Semver = require("src.mods.Semver") + if type(installed) ~= "string" or type(candidate) ~= "string" then + return false + end + local a, b = Semver.parse(installed), Semver.parse(candidate) + if not a or not b then return false end + return Semver.compare(candidate, installed) > 0 +end + +-- Newest non-prerelease, else newest overall. +function ModUpdate.pickBest(releases) + if type(releases) ~= "table" or #releases == 0 then return nil end + for _, rel in ipairs(releases) do + if not rel.prerelease then return rel end + end + return releases[1] +end + +-- ------- cache (options.modUpdateCache[repo]) + +local function cacheStore() + local SaveData = require("src.core.SaveData") + local opts = SaveData.loadOptions() + opts.modUpdateCache = opts.modUpdateCache or {} + return opts +end + +function ModUpdate.readCache(repo) + if type(repo) ~= "string" or repo == "" then return nil end + local ok, opts = pcall(function() + return require("src.core.SaveData").loadOptions() + end) + if not ok or type(opts) ~= "table" then return nil end + local entry = opts.modUpdateCache and opts.modUpdateCache[repo] + if type(entry) ~= "table" or type(entry.checkedAt) ~= "number" then + return nil + end + if type(entry.releases) ~= "table" then return nil end + return entry +end + +function ModUpdate.cacheFresh(entry, now, ttl) + now = now or os.time() + ttl = ttl or ModUpdate.CACHE_TTL + return entry and type(entry.checkedAt) == "number" + and (now - entry.checkedAt) < ttl +end + +function ModUpdate.writeCache(repo, releases) + if type(repo) ~= "string" or repo == "" then return false end + local ok = pcall(function() + local SaveData = require("src.core.SaveData") + local opts = cacheStore() + local best = ModUpdate.pickBest(releases) + -- Persist a lean copy: enough to paint the UI and reinstall without + -- re-fetching within the TTL. + local lean = {} + for i, rel in ipairs(releases or {}) do + lean[i] = { + version = rel.version, + tag = rel.tag, + name = rel.name, + prerelease = rel.prerelease == true, + body = type(rel.body) == "string" and rel.body or "", + zip = rel.zip and { + name = rel.zip.name, + url = rel.zip.url, + size = rel.zip.size, + } or nil, + } + end + opts.modUpdateCache[repo] = { + checkedAt = os.time(), + latest = best and best.version or nil, + releases = lean, + } + SaveData.saveOptions(opts) + end) + return ok +end + +-- Status vs an installed version using a cache entry / release list. +-- Returns "available" | "current" | "unknown". +function ModUpdate.statusFor(installedVersion, releases) + local best = ModUpdate.pickBest(releases) + if not best then return "unknown", nil end + if ModUpdate.isNewer(installedVersion, best.version) then + return "available", best + end + return "current", best +end + +-- ------- host I/O (curl) + +local function shq(s) + s = tostring(s) + if love and love.system and love.system.getOS + and love.system.getOS() == "Windows" then + return '"' .. s:gsub('"', '') .. '"' + end + return "'" .. s:gsub("'", "'\\''") .. "'" +end + +local function curlCapture(url) + local HostShell = require("src.core.HostShell") + local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 " + .. "-H " .. shq("User-Agent: gen1recomp-mod-updater") .. " " + .. "-H " .. shq("Accept: application/vnd.github+json") .. " " + .. shq(url) + local pipeOk, pipe = pcall(HostShell.popen, cmd) + if not pipeOk or not pipe then return nil, "could not run curl" end + local readOk, out = pcall(function() return pipe:read("*a") end) + pcall(function() pipe:close() end) + if not readOk then return nil, "curl read failed: " .. tostring(out) end + if not out or out == "" then return nil, "empty response from GitHub" end + return out +end + +function ModUpdate.haveCurl() + local HostShell = require("src.core.HostShell") + local pipeOk, pipe = pcall(HostShell.popen, "curl --version") + if not pipeOk or not pipe then return false end + local readOk, out = pcall(function() return pipe:read("*a") end) + pcall(function() pipe:close() end) + return readOk and out ~= nil and out:find("curl", 1, true) ~= nil +end + +-- Fetch release list. opts.force bypasses the 6h cache. +-- Returns releases, err, meta where meta = { fromCache = bool }. +function ModUpdate.fetchReleases(repo, modId, opts) + opts = opts or {} + if type(repo) ~= "string" or repo == "" then + return nil, "missing github repo" + end + if not opts.force then + local cached = ModUpdate.readCache(repo) + if ModUpdate.cacheFresh(cached) then + return cached.releases, nil, { fromCache = true } + end + end + if not ModUpdate.haveCurl() then + -- Stale cache is better than nothing when offline + local cached = ModUpdate.readCache(repo) + if cached and cached.releases then + return cached.releases, nil, { fromCache = true, stale = true } + end + return nil, "curl is not available on this platform" + end + local body, curlErr = curlCapture(ModUpdate.apiReleasesUrl(repo)) + if not body then + local cached = ModUpdate.readCache(repo) + if cached and cached.releases then + return cached.releases, nil, { fromCache = true, stale = true } + end + return nil, curlErr + end + local list, parseErr = ModUpdate.parseReleases(body, modId) + if not list then return nil, parseErr end + ModUpdate.writeCache(repo, list) + return list, nil, { fromCache = false } +end + +function ModUpdate.downloadZip(url, destName) + if type(url) ~= "string" or url == "" then + return nil, "missing download url" + end + if not (love and love.filesystem) then + return nil, "download needs LOVE" + end + if not ModUpdate.haveCurl() then + return nil, "curl is not available on this platform" + end + local HostShell = require("src.core.HostShell") + local name = destName or ("mod_update_" .. tostring(os.time()) .. ".zip") + name = tostring(name):gsub("[/\\]", "_") + local saveOk, saveDir = pcall(function() + return love.filesystem.getSaveDirectory() + end) + if not saveOk or not saveDir or saveDir == "" then + return nil, "no save directory" + end + local abs = saveDir .. "/" .. name + local cmd = "curl -fsSL --connect-timeout 15 --max-time 300 -o " + .. shq(abs) .. " " .. shq(url) + local pipeOk, pipe = pcall(HostShell.popen, cmd) + if not pipeOk or not pipe then return nil, "could not start download" end + pcall(function() pipe:read("*a") end) + pcall(function() pipe:close() end) + local infoOk, info = pcall(love.filesystem.getInfo, name) + if not infoOk or not info or (info.size or 0) == 0 then + pcall(love.filesystem.remove, name) + return nil, "download failed" + end + return name +end + +return ModUpdate diff --git a/tests/engine/launcher_mods_tests.lua b/tests/engine/launcher_mods_tests.lua index 2f4044c5..d2a2a5f0 100644 --- a/tests/engine/launcher_mods_tests.lua +++ b/tests/engine/launcher_mods_tests.lua @@ -60,6 +60,25 @@ do eq(m.aaa.statusDetail, "Ready", "ok detail reads Ready") end +-- ------- experimental defaults to disabled; github surfaces on the row + +do + local manifests = { + mf({ id = "lab", name = "Lab", version = "1.2.0", entry = "m.lua", + experimental = true, github = "acme/lab" }), + mf({ id = "lab_on", name = "Lab On", version = "1.0.0", entry = "m.lua", + experimental = true }), + } + local m = byId(LauncherMods.deriveList(manifests, { + mods = { lab_on = true }, + })) + check(not m.lab.enabled, "experimental with no options entry stays off") + check(m.lab_on.enabled, "experimental can still be explicitly enabled") + eq(m.lab.badge, "EXPERIMENTAL", "experimental badge overrides category") + eq(m.lab.github, "acme/lab", "github is exposed on the panel row") + check(m.lab.experimental, "experimental flag is exposed on the panel row") +end + -- ------- conflict: only when this mod is enabled and the other is too do diff --git a/tests/engine/mod_update_tests.lua b/tests/engine/mod_update_tests.lua new file mode 100644 index 00000000..759d2f18 --- /dev/null +++ b/tests/engine/mod_update_tests.lua @@ -0,0 +1,111 @@ +-- Pure coverage for src/mods/ModUpdate.lua (zip picking, release parse, isNewer). +-- luajit tests/engine/mod_update_tests.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +local ModUpdate = require("src.mods.ModUpdate") +local Json = require("src.link.Json") + +do + local assets = { + { name = "readme.txt", browser_download_url = "https://x/r" }, + { name = "other-1.0.0.zip", browser_download_url = "https://x/o", size = 10 }, + { name = "coolmod-1.2.0.zip", browser_download_url = "https://x/c", size = 20 }, + } + local pick = ModUpdate.pickZipAsset(assets, "coolmod", "1.2.0") + eq(pick.name, "coolmod-1.2.0.zip", "prefers id-version.zip") + eq(pick.url, "https://x/c", "returns the download URL") +end + +do + local assets = { + { name = "payload.zip", browser_download_url = "https://x/p" }, + } + local pick = ModUpdate.pickZipAsset(assets, "coolmod", "1.0.0") + eq(pick.name, "payload.zip", "falls back to the first .zip") +end + +do + local body = Json.encode({ + { + tag_name = "v1.2.0", + name = "1.2.0", + prerelease = false, + assets = { + { name = "demo-1.2.0.zip", browser_download_url = "https://x/d.zip", + size = 99 }, + }, + }, + { + tag_name = "v1.1.0", + assets = { + { name = "demo-1.1.0.zip", browser_download_url = "https://x/old.zip" }, + }, + }, + { + tag_name = "notes-only", + assets = {}, + }, + }) + local list = ModUpdate.parseReleases(body, "demo") + eq(#list, 2, "releases without a zip are dropped") + eq(list[1].version, "1.2.0", "keeps GitHub array order") + eq(list[1].zip.url, "https://x/d.zip", "zip url is preserved") +end + +check(ModUpdate.isNewer("1.0.0", "1.0.1"), "patch bump is newer") +check(not ModUpdate.isNewer("1.2.0", "1.1.9"), "older candidate is not newer") +check(not ModUpdate.isNewer("1.0.0", "1.0.0"), "same version is not newer") + +eq(ModUpdate.apiLatestUrl("acme/mod"), + "https://api.github.com/repos/acme/mod/releases/latest", + "latest API URL") + +-- soft-fail paths: garbage input must never throw +do + local list, err = ModUpdate.parseReleases("{{{not json", "demo") + check(list == nil and err ~= nil, "bad json returns nil, err") + list, err = ModUpdate.parseReleases('{"message":"Not Found"}', "demo") + check(list == nil and tostring(err):find("Not Found", 1, true), + "GitHub error payload surfaces the message") + list, err = ModUpdate.fetchReleases("", "demo") + check(list == nil and err ~= nil, "empty repo soft-fails") + local path, dlErr = ModUpdate.downloadZip("", "x.zip") + check(path == nil and dlErr ~= nil, "empty url soft-fails") +end + +do + local body = Json.encode({ + tag_name = "v2.0.0", + body = "## Changes\n\n- **fixed** the [thing](http://x)\n\n", + assets = { + { name = "demo-2.0.0.zip", browser_download_url = "https://x/d.zip" }, + }, + }) + local list = ModUpdate.parseReleases(body, "demo") + eq(list[1].body:sub(1, 2), "##", "release body is kept raw") + local cleaned = ModUpdate.cleanBody(list[1].body, 200) + check(cleaned:find("fixed", 1, true) and cleaned:find("thing", 1, true), + "cleanBody keeps readable text") + check(not cleaned:find("http://", 1, true), "cleanBody strips link urls") + check(not cleaned:find("