mod manager now supports auto updates

This commit is contained in:
bryanthaboi
2026-07-31 10:19:51 -04:00
parent 6c4c9d14e2
commit 9d3ee6a597
15 changed files with 2002 additions and 95 deletions
+13
View File
@@ -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.
+224
View File
@@ -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
+19
View File
@@ -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"
}
+3
View File
@@ -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
+709 -73
View File
@@ -923,24 +923,38 @@ end
-- or a love DroppedFile.
function RomImporter:_installMod(source)
if self.workState == "working" then return end
self.tab = "mods"
local ok, installed, res = pcall(function()
local LauncherMods = require("src.mods.LauncherMods")
local ok, res = LauncherMods.installZip(source)
if ok then
self:_refreshMods()
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 ok, deleted, res = pcall(function()
local LauncherMods = require("src.mods.LauncherMods")
local ok, res = LauncherMods.uninstall(id)
if ok then
self:_refreshMods()
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
LauncherMods.setEnabled(id, not cur)
end
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
if m.description ~= "" then
-- version + check status line + description under the name
love.graphics.setFont(self.hintFont)
col(PAL.detail)
love.graphics.printf(m.description, nx, ny + nameH + 6 * s, L.leftW, "left")
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
col(PAL.detail)
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
+72 -7
View File
@@ -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 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,13 +527,25 @@ 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
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/...);
-- the mods tree is shared by Red and Blue, so pin the prefix to the root for
@@ -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/<id>/ from wherever it was installed (the portable game folder
-- or the save directory, CacheFs decides -- #330) and clears options.mods[id]
+12
View File
@@ -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"
+19
View File
@@ -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,6 +613,7 @@ function ManagerState:beginToggle(m)
r = ManagerState.resolveToggle(self:manifestMap(), m.id, want,
self:enabledSet())
end
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
@@ -614,6 +621,18 @@ function ManagerState:beginToggle(m)
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)
+49 -2
View File
@@ -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,
+361
View File
@@ -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 "<id>-<version>.zip", then any "<id>*.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
+19
View File
@@ -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
+111
View File
@@ -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<!-- note -->",
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("<!--", 1, true), "cleanBody strips HTML comments")
local status, best = ModUpdate.statusFor("1.0.0", list)
eq(status, "available", "newer release reports available")
eq(best.version, "2.0.0", "best release is the newer one")
eq(ModUpdate.statusFor("2.0.0", list), "current", "matching version is current")
check(ModUpdate.cacheFresh({ checkedAt = os.time() - 10 }),
"fresh cache is within TTL")
check(not ModUpdate.cacheFresh({ checkedAt = os.time() - ModUpdate.CACHE_TTL - 1 }),
"expired cache is not fresh")
local preview = ModUpdate.previewLine(list[1].body, 40)
check(not preview:find("\n", 1, true), "previewLine collapses newlines")
check(#preview <= 41, "previewLine respects maxChars (+ellipsis)")
check(preview:find("Changes", 1, true), "previewLine keeps heading text")
end
print("ok mod_update_tests")
+37
View File
@@ -116,6 +116,28 @@ check(full.conflictSpecs[1].id == "always_noon" and full.conflictSpecs[1].range
check(full.options_schema == "options.lua"
and full.assets_transforms == "transforms.lua", "declared files are kept")
-- ------- github / experimental / incompatible
local gh = Manifest.validate({
id = "gh", name = "GH", version = "1.0.0", entry = "main.lua",
github = "https://github.com/Acme/Cool-Mod.git",
experimental = true,
incompatible = { "rival" },
conflicts = { "rival", "other" },
})
check(gh.github == "Acme/Cool-Mod", "github URL normalizes to owner/repo")
check(gh.experimental == true, "experimental flag is kept")
check(#gh.conflictSpecs == 2, "incompatible merges into conflicts without dupes")
check(gh.conflictSpecs[1].id == "rival" and gh.conflictSpecs[2].id == "other",
"conflicts list order keeps conflicts then new incompatible ids")
check(Manifest.parseGithub(nil) == nil and Manifest.parseGithub("") == nil,
"absent github is nil")
check(not pcall(Manifest.parseGithub, "not a repo"),
"a malformed github value fails")
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")
local v1 = Manifest.validate({
id = "v1", name = "V1", version = "1.0.0", entry = "main.lua",
}, "mods/v1")
@@ -156,6 +178,21 @@ check(not pcall(Manifest.validate, {
dependencies = { "other@nonsense" },
}, "mods/baddep"), "a malformed dependency range fails validation")
-- ------- experimental mods stay disabled until options.mods says otherwise
do
local loader = Loader.new({ fs = memfs({
["mods/lab/manifest.json"] = manifestJson("lab", {
experimental = "true", api = "2", profile = '"content"',
}),
["mods/lab/main.lua"] = "return function(mod) mod.content.items:register('X', {}) end",
}) })
local data = { items = {} }
check(loader:load(data) == true, "experimental-only install still boots")
local st = statusById(loader)
check(st.lab.state == "disabled", "experimental mod is disabled by default")
check(data.items.X == nil, "experimental entry chunk does not run while off")
end
-- ------- game_version against the engine
local versionLoader = Loader.new({ fs = memfs({
["mods/future/manifest.json"] = manifestJson("future", { game_version = '">=2.0"' }),
+200
View File
@@ -0,0 +1,200 @@
name: Release
# Packs the mod into an installable .zip and publishes it as a GitHub Release,
# once per push to main.
#
# Archive layout: every mod file at the archive root, manifest.json included.
# That is one of the two shapes the game accepts on MODS > Import mod .zip
# (src/mods/LauncherMods.lua locateRoot: manifest at the root, or inside a
# single top-level folder). Nothing else is added, so the archive stays
# installable by hand too.
#
# Versioning, first rule that applies wins:
# 1. the "version" input of a manual run,
# 2. "[release X.Y.Z]" anywhere in the commit message,
# 3. manifest.json's own version, when it is ahead of every existing tag,
# so bumping the manifest is the normal way to cut a release,
# 4. otherwise the newest vX.Y.Z tag with its patch incremented
# (0.2.99 rolls over to 0.3.0).
# Whichever wins is written into the manifest.json inside the archive, so a
# shipped mod never reports a different version than the release it came from.
#
# Generated by: python3 tools/modkit.py add-release-workflow <mod-id>
# MOD_ID below is stamped to this mod's id when the file is copied.
on:
push:
branches: [main]
paths-ignore:
- '.github/**'
- '**.md'
workflow_dispatch:
inputs:
version:
description: "Exact version to release (e.g. 0.3.0). Leave blank to auto-resolve."
required: false
default: ""
permissions:
contents: write
concurrency:
group: release
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Determine version
id: ver
env:
DISPATCH_VERSION: ${{ github.event.inputs.version }}
run: |
set -euo pipefail
python3 - <<'PY' >> "$GITHUB_OUTPUT"
import json, os, re, subprocess, sys
SEMVER = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
def sh(*args):
return subprocess.run(args, capture_output=True, text=True).stdout.strip()
def parse(text):
m = SEMVER.match(text)
return tuple(int(p) for p in m.groups()) if m else None
def die(msg):
print(f"::error::{msg}", file=sys.stderr)
raise SystemExit(1)
with open("manifest.json", encoding="utf-8") as fh:
manifest_version = str(json.load(fh).get("version", ""))
released = sorted(
v for v in (parse(tag[1:]) for tag in sh("git", "tag", "-l", "v*").splitlines()) if v
)
latest = released[-1] if released else None
override = os.environ.get("DISPATCH_VERSION", "").strip()
if not override:
found = re.search(r"\[release\s+(\d+\.\d+\.\d+)\]", sh("git", "log", "-1", "--pretty=%B"))
override = found.group(1) if found else ""
manifest_ver = parse(manifest_version)
if override:
version = parse(override) or die(f"invalid version override {override!r} (expected X.Y.Z)")
source = "the override"
elif manifest_ver and (latest is None or manifest_ver > latest):
version = manifest_ver
source = "manifest.json"
elif latest:
major, minor, patch = latest
patch += 1
if patch > 99:
minor, patch = minor + 1, 0
version = (major, minor, patch)
source = "a patch bump on v%d.%d.%d" % latest
else:
die(f"manifest.json version {manifest_version!r} is not X.Y.Z "
"and there is no vX.Y.Z tag to count from")
text = "%d.%d.%d" % version
print(f"Releasing {text}, from {source}.", file=sys.stderr)
print(f"version={text}")
print(f"tag=v{text}")
PY
- name: Refuse to clobber an existing release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.ver.outputs.tag }}
run: |
set -euo pipefail
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
echo "::error::Tag $TAG already exists. Pick a different version."
exit 1
fi
if gh release view "$TAG" >/dev/null 2>&1; then
echo "::error::Release $TAG already exists. Pick a different version."
exit 1
fi
- name: Build the mod .zip
env:
VERSION: ${{ steps.ver.outputs.version }}
MOD_ID: "{{MOD_ID}}"
run: |
set -euo pipefail
staging="$RUNNER_TEMP/pkg"
out="$GITHUB_WORKSPACE/dist"
rm -rf "$staging" "$out"
mkdir -p "$staging" "$out"
git archive HEAD | tar -x -C "$staging"
rm -rf "$staging/.github" "$staging/.gitattributes" \
"$staging/.gitignore" "$staging/.luarc.json"
python3 - "$staging/manifest.json" "$VERSION" <<'PY'
import json, sys
path, version = sys.argv[1], sys.argv[2]
with open(path, encoding="utf-8") as fh:
manifest = json.load(fh)
manifest["version"] = version
with open(path, "w", encoding="utf-8") as fh:
json.dump(manifest, fh, indent=2, ensure_ascii=False)
fh.write("\n")
PY
zip_path="$out/${MOD_ID}-${VERSION}.zip"
(cd "$staging" && zip -qr "$zip_path" .)
unzip -l "$zip_path"
unzip -p "$zip_path" manifest.json > "$RUNNER_TEMP/packed-manifest.json"
python3 - "$RUNNER_TEMP/packed-manifest.json" "$VERSION" <<'PY'
import json, sys
path, expected = sys.argv[1], sys.argv[2]
with open(path, encoding="utf-8") as fh:
version = json.load(fh)["version"]
if version != expected:
raise SystemExit(f"::error::packed manifest says {version}, expected {expected}")
print(f"manifest.json is at the archive root and reports {version}")
PY
(cd "$out" && sha256sum "${MOD_ID}"-*.zip > sha256sums.txt)
cat "$out/sha256sums.txt"
- name: Publish GitHub Release
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.ver.outputs.version }}
TAG: ${{ steps.ver.outputs.tag }}
MOD_ID: "{{MOD_ID}}"
run: |
set -euo pipefail
prev="$(git tag -l 'v*' --sort=-v:refname | grep -v "^${TAG}$" | head -1 || true)"
range="${prev:+${prev}..}$GITHUB_SHA"
changes="$(git log --no-merges --pretty='- %s' "$range" | head -50 || true)"
notes=$'Download the .zip and install it from the game: MODS > Import mod .zip.'
if [ -n "$changes" ]; then
notes+=$'\n\n## Changes\n\n'"$changes"
fi
printf 'Release notes:\n%s\n' "$notes"
gh release create "$TAG" \
--target "$GITHUB_SHA" \
--title "$VERSION" \
--notes "$notes" \
"dist/${MOD_ID}-${VERSION}.zip" \
"dist/sha256sums.txt"
echo "Published release $TAG"
+142 -1
View File
@@ -5,7 +5,7 @@
Subcommands:
scaffold <id> [--profile content|overhaul|total_conversion] [--api 2]
[--dest DIR] [--force]
[--github owner/repo] [--experimental] [--dest DIR] [--force]
translation <id> [--language NAME] [--base auto|fixture|imported]
[--refresh] [--dest DIR]
validate <id|path> [--strict] [--base auto|fixture|imported]
@@ -13,6 +13,8 @@ Subcommands:
pack <mod-dir> [-o out.modpkg]
bounce <song-id|--all> [--seconds N] [--out DIR]
docs [--out DIR]
set-github <id|path> <url> add/update manifest "github" (auto-update)
add-release-workflow <id|path> copy GitHub Actions release.yml into the mod
Global flags: --repo PATH, --json, --quiet.
Exit codes: 0 success, 1 validation/lint failure, 2 usage error.
@@ -330,10 +332,52 @@ MANIFEST_TEMPLATE = """{
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"incompatible": [],
"experimental": {{experimental}},{{github_line}}
"description": "TODO: one line about {{id}}"{{extra}}
}
"""
# owner/repo or https://github.com/owner/repo(.git)
GITHUB_RE = re.compile(
r"^(?:https?://github\.com/)?([\w.\-]+)/([\w.\-]+?)(?:\.git)?/?$"
)
def normalize_github(value):
"""Return 'owner/repo' or None for empty; raise ValueError if malformed."""
if value is None:
return None
text = str(value).strip()
if not text:
return None
match = GITHUB_RE.fullmatch(text)
if not match:
raise ValueError(
"github must be owner/repo or a github.com URL "
f"(got {value!r})")
owner, repo = match.group(1), match.group(2)
if repo.endswith(".git"):
repo = repo[:-4]
return f"{owner}/{repo}"
def check_github_field(manifest):
"""Optional github field: absent is fine (note), present must parse."""
findings, notes = [], []
raw = manifest.get("github")
if raw is None or raw == "":
notes.append(
'optional tip: set "github": "owner/repo" in manifest.json '
"to enable launcher auto-update and Other versions")
return findings, notes
try:
normalize_github(raw)
except ValueError as err:
findings.append(Finding(
"MK001", "error", str(err), "manifest.json"))
return findings, notes
MAIN_CONTENT = """-- {{id}}: a content-profile mod (api 2).
-- The 10-minute loop: edit, save, F5 in a POKEPORT_DEV=1 game, repeat.
return function(mod)
@@ -428,13 +472,25 @@ def cmd_scaffold(args, repo):
next_major = int(engine.split(".")[0]) + 1
name = args.id.replace("_", " ").replace("-", " ").title()
github = ""
if getattr(args, "github", None):
try:
github = normalize_github(args.github) or ""
except ValueError as err:
print(f"modkit: {err}")
return 2
extra = ""
if profile == "total_conversion":
extra = ',\n "assets_transforms": "transforms.lua"'
github_line = f'\n "github": "{github}",' if github else ""
subst = {
"{{id}}": args.id, "{{name}}": name, "{{profile}}": profile,
"{{game_version}}": engine, "{{next_major}}": str(next_major),
"{{extra}}": extra,
"{{github_line}}": github_line,
"{{experimental}}": "true" if getattr(args, "experimental", False)
else "false",
}
def emit(rel, template):
@@ -753,6 +809,9 @@ def cmd_validate(args, repo):
if problem:
findings.append(problem)
else:
gh_findings, gh_notes = check_github_field(manifest)
findings.extend(gh_findings)
notes.extend(gh_notes)
findings.extend(check_permissions(repo, manifest))
run_loader(repo, mod_dir, findings, args.base, notes)
findings.extend(check_requires(repo, mod_dir, manifest))
@@ -762,6 +821,70 @@ def cmd_validate(args, repo):
notes)
def write_manifest(mod_dir, manifest):
path = os.path.join(mod_dir, "manifest.json")
with open(path, "w", encoding="utf-8") as handle:
json.dump(manifest, handle, indent=2, ensure_ascii=False)
handle.write("\n")
def cmd_set_github(args, repo):
"""Add or update the optional github field on an existing manifest."""
mod_dir = resolve_mod_dir(repo, args.mod)
if not mod_dir:
print(f"modkit: no mod at {args.mod!r}")
return 2
manifest, problem = read_manifest(mod_dir)
if problem:
print(problem.line())
return 1
try:
repo_slug = normalize_github(args.url)
except ValueError as err:
print(f"modkit: {err}")
return 2
if not repo_slug:
print("modkit: github url is empty")
return 2
manifest["github"] = repo_slug
write_manifest(mod_dir, manifest)
if not args.quiet:
print(f"set github to {repo_slug!r} in {mod_dir}/manifest.json")
print("launcher auto-update / Other versions will use this repo")
return 0
def cmd_add_release_workflow(args, repo):
"""Copy the standard GitHub Actions release workflow into a mod folder."""
mod_dir = resolve_mod_dir(repo, args.mod)
if not mod_dir:
print(f"modkit: no mod at {args.mod!r}")
return 2
manifest, problem = read_manifest(mod_dir)
if problem:
print(problem.line())
return 1
mod_id = manifest.get("id") or os.path.basename(mod_dir)
template = os.path.join(repo, "tools", "mod_release_workflow.yml")
if not os.path.isfile(template):
print(f"modkit: missing template {template}")
return 2
dest_dir = os.path.join(mod_dir, ".github", "workflows")
dest = os.path.join(dest_dir, "release.yml")
if os.path.exists(dest) and not args.force:
print(f"modkit: {dest} exists (use --force to overwrite)")
return 2
body = open(template, encoding="utf-8").read().replace("{{MOD_ID}}", mod_id)
os.makedirs(dest_dir, exist_ok=True)
with open(dest, "w", encoding="utf-8") as handle:
handle.write(body)
if not args.quiet:
print(f"wrote {dest}")
print("push this mod as its own GitHub repo (with manifest github set) "
"to publish installable .zip releases on every main push")
return 0
# ---------------------------------------------------------------- lint
def ahash(image):
@@ -1636,6 +1759,8 @@ def cmd_translation(args, repo):
"{{next_major}}": str(int(engine_version_.split(".")[0]) + 1),
"{{profile}}": "content",
"{{extra}}": "",
"{{github_line}}": "",
"{{experimental}}": "false",
"{{total}}": str(sum(counts.values())),
"{{table}}": "\n".join(
f"| `lang/{name}.lua` | {counts[name]} |" for name, *_ in catalogs),
@@ -1840,6 +1965,10 @@ def main(argv):
p.add_argument("--profile", default="content",
choices=["content", "overhaul", "total_conversion"])
p.add_argument("--api", type=int, default=2)
p.add_argument("--github", default="",
help="optional owner/repo (enables launcher auto-update)")
p.add_argument("--experimental", action="store_true",
help="mark the mod experimental (off until confirmed)")
p.add_argument("--dest")
p.add_argument("--force", action="store_true")
@@ -1877,6 +2006,16 @@ def main(argv):
p = sub.add_parser("docs", parents=[shared])
p.add_argument("--out")
p = sub.add_parser("set-github", parents=[shared],
help="add github field to an existing mod manifest")
p.add_argument("mod")
p.add_argument("url", help="owner/repo or https://github.com/owner/repo")
p = sub.add_parser("add-release-workflow", parents=[shared],
help="copy GitHub Actions release.yml into the mod")
p.add_argument("mod")
p.add_argument("--force", action="store_true")
args = parser.parse_args(argv)
for dest, fallback in (("repo", None), ("json", False),
("quiet", False)):
@@ -1905,6 +2044,8 @@ def main(argv):
"bounce": cmd_bounce,
"translation": cmd_translation,
"docs": cmd_docs,
"set-github": cmd_set_github,
"add-release-workflow": cmd_add_release_workflow,
}[args.command]
return handler(args, repo)