mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
mod manager now supports auto updates
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 
|
||||
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
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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"' }),
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user