check bonxes

This commit is contained in:
bryanthaboi
2026-08-13 13:22:18 -04:00
parent 9ceb1a8940
commit 941181d31c
12 changed files with 258 additions and 87 deletions
+60 -7
View File
@@ -304,6 +304,10 @@ function SaveData.defaultOptions()
-- options.lua written before this key keeps its exact meaning. Read and
-- written through SaveData.modEnabled / SaveData.setModEnabled.
modsByVersion = {},
-- Set after the first per-game enablement migration. Older options files
-- have only `mods`, so the migration copies each installed mod's current
-- answer to every game before game-specific toggles begin changing it.
modsByVersionMigrated = false,
-- Named setups the player can switch between (#593; src/mods/ModProfile.lua
-- owns the shape, src/mods/ManagerState.lua the UI): each row is
-- { name, enabled = {id=bool}, options = {id={k=v}}, slots = {version=slotId} }.
@@ -563,19 +567,68 @@ end
-- only holds the games the player actually chose for, so a mod set can differ
-- between Red and Gold without either one owning the other's flags.
-- Whether a per-game answer is honoured at boot. The loader reads the enable
-- flags once, before any entry chunk (src/mods/Loader.lua _loadState), so this
-- flips on with that read and not before: until then every writer keeps to the
-- shared flag and no surface promises what the boot does not do.
SaveData.PER_VERSION_MODS = false
-- Per-game answers are live. Every reader and writer goes through modScope,
-- so a choice made in the launcher is the choice the next boot loads.
SaveData.PER_VERSION_MODS = true
-- The version a write should be scoped to: the game asked for once per-game
-- flags are live, nil (the shared flag) while they are only a preview.
-- The version a write is scoped to: per-game controls name one game; a nil
-- caller still addresses the legacy shared fallback.
function SaveData.modScope(version)
if SaveData.PER_VERSION_MODS then return version end
return nil
end
-- Promote an installation that predates per-game flags. `mods` may contain
-- manifest rows ({ id, experimental }) or bare ids. A mod with no old entry
-- had the loader default: on, except for experimental mods. Copy that answer
-- to every game once, preserving any per-game overlay somebody imported before
-- this feature shipped. New installs need no rows here: an absent answer
-- still defaults to enabled for every game.
function SaveData.migrateModEnablement(options, mods)
if type(options) ~= "table" or options.modsByVersionMigrated then return false end
options.mods = type(options.mods) == "table" and options.mods or {}
options.modsByVersion = type(options.modsByVersion) == "table"
and options.modsByVersion or {}
local known = {}
for id in pairs(options.mods) do
if type(id) == "string" and id ~= "" then known[id] = { id = id } end
end
for version, bucket in pairs(options.modsByVersion) do
if GameVersion.VERSIONS[version] and type(bucket) == "table" then
for id in pairs(bucket) do
if type(id) == "string" and id ~= "" then known[id] = known[id] or { id = id } end
end
end
end
for _, mod in ipairs(mods or {}) do
local id = type(mod) == "table" and mod.id or mod
if type(id) == "string" and id ~= "" then
known[id] = type(mod) == "table" and mod or (known[id] or { id = id })
end
end
-- Do not create options.lua just to record an empty migration on a fresh
-- no-mod boot. Keep it pending until there is a real installed or saved
-- mod answer to preserve.
if next(known) == nil then return false end
for id, mod in pairs(known) do
local shared = options.mods[id]
if type(shared) ~= "boolean" then shared = not (mod.experimental == true) end
for _, version in ipairs(GameVersion.ORDER) do
local bucket = options.modsByVersion[version]
if type(bucket) ~= "table" then
bucket = {}
options.modsByVersion[version] = bucket
end
if type(bucket[id]) ~= "boolean" then bucket[id] = shared end
end
end
options.modsByVersionMigrated = true
return true
end
-- true/false as chosen for `version`, else the shared flag, else nil -- the
-- caller owns the default (the loader enables, the launcher keeps
-- experimental mods off until asked).
+51 -22
View File
@@ -1139,6 +1139,28 @@ local function drawCheck(x, y, size, color)
love.graphics.pop()
end
-- One compact coloured checkbox for each game. The cartridge colour carries
-- the game identity even when the row is narrow; the letter keeps an unchecked
-- box legible without relying on colour alone.
local function modGameCheckbox(x, y, size, checked, game, id)
local color = cartColor(game)
local focused = Kit.focusable(id, x, y, size, size)
local hot = focused or Kit.hover(x, y, size, size)
if love.graphics then
if checked then
Theme.fillRounded(x, y, size, size, color, 1)
drawCheck(x, y, size, PAL.inverse)
else
Theme.fillRounded(x, y, size, size, PAL.bg, 1)
Kit.textCenterBold("micro", game:sub(1, 1):upper(), x,
y + (size - Kit.textHeight("micro")) / 2, size, color)
end
Theme.strokeRounded(x, y, size, size, color,
hot and Theme.A.focus or Theme.A.hover, 1)
end
return Kit.press(x, y, size, size) or Kit._activateId == id
end
local function buildModsPanel(imp, x, y, w, availH, m)
imp:_ensureMods()
local ModUpdate = require("src.mods.ModUpdate")
@@ -1225,15 +1247,14 @@ local function buildModsPanel(imp, x, y, w, availH, m)
mods = sorted
end
-- A mod row is a fixed height: name line, version + status line, one line
-- of description, and an action row. Fixed because a page of uniform rows
-- is what lets perPage come from the viewport.
local chipH = math.max(Kit.tapMin(), math.floor(30 * m.s))
-- Text block on the left, chips right-aligned beside it: one row, not a
-- text block with a button strip stacked under it.
-- A mod row is a fixed height: its details first, then a dedicated second
-- line of per-game checkboxes. Fixed because a page of uniform rows is
-- what lets perPage come from the viewport.
local togH = math.floor(26 * m.s)
local gamesLabel = Strings("Enable for:")
local textH = Kit.textHeight("button") + math.floor(4 * m.s)
+ Kit.textHeight("small") + math.floor(2 * m.s) + Kit.textHeight("small")
local rowH = math.floor(8 * m.s) + math.max(textH, chipH)
local rowH = math.floor(8 * m.s) + textH + math.floor(8 * m.s) + togH
+ math.floor(8 * m.s)
local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s))
local listH = availH - (cy - y) - pagerH - gap
@@ -1258,28 +1279,36 @@ local function buildModsPanel(imp, x, y, w, availH, m)
local px, inner = x + pad, w - 2 * pad
local ly = ry + math.floor(10 * m.s)
local togW = math.floor(56 * m.s)
local togH = math.floor(26 * m.s)
local togGap = math.floor(4 * m.s)
local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id)
local togKey = "mod-toggle-" .. mod.id
-- The toggle reports its own new value, but the importer owns the state:
-- queue the flip and let _toggleMod (which may raise an experimental-mod
-- confirm) decide what actually happens.
local _, flipped = Kit.toggle(px + inner - togW,
ry + (rowH - togH) / 2, togW, togH, mod.enabled, togKey)
if flipped then
queueAction(imp, togKey, function() imp:_toggleMod(mod.id) end)
-- These answer separate games, not a single shared install flag. The
-- importer receives the game id so an experimental confirmation also
-- applies only to the checkbox the player pressed.
local flipped = false
local gamesY = ry + math.floor(8 * m.s) + textH + math.floor(8 * m.s)
Kit.text("micro", gamesLabel, px,
gamesY + (togH - Kit.textHeight("micro")) / 2, PAL.muted)
local tx = px + Kit.textWidth("micro", gamesLabel) + math.floor(10 * m.s)
for _, game in ipairs(GameVersion.ORDER) do
local togKey = "mod-toggle-" .. mod.id .. "-" .. game
if modGameCheckbox(tx, gamesY, togH,
mod.enabledByVersion and mod.enabledByVersion[game] == true,
game, togKey) then
local version = game
queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end)
flipped = true
end
tx = tx + togH + togGap
end
-- The toggle sits inside the row's rect, so its press also passes the
-- row's hit test; `flipped` gates the row action to everywhere else.
-- The checkboxes sit inside the row's rect, so their press also passes the
-- row hit test; `flipped` gates the row action to everywhere else.
if not flipped
and (Kit.press(x, ry, w, rowH) or Kit._activateId == rowKey) then
local id = mod.id
queueAction(imp, rowKey, function() imp._modActions = id end)
end
local chipsW = togW + math.floor(6 * m.s)
local textW = inner - chipsW - math.floor(12 * m.s)
local textW = inner
local badgeW = Kit.textWidth("micro", mod.badge) + math.floor(12 * m.s)
-- the games the mod is for, beside its category: the same chip the
@@ -1741,7 +1770,7 @@ local function buildConfirmModal(imp, m)
elseif c.kind == "importOversize" then
imp:_importSave(c.version, c.source, true)
else
imp:_toggleMod(c.id, true)
imp:_toggleMod(c.id, true, c.version)
end
end,
})
+11 -7
View File
@@ -3064,15 +3064,19 @@ 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.
-- Enabling an experimental mod arms a confirm first.
function RomImporter:_toggleMod(id, confirmed)
-- Flip one game's mod flag (persisted via LauncherMods.setEnabled) and relist
-- so that game's checkbox and status chips reflect the new resolution.
-- Enabling an experimental mod arms a confirmation for that same game.
function RomImporter:_toggleMod(id, confirmed, version)
local LauncherMods = require("src.mods.LauncherMods")
local cur, experimental = false, false
for _, m in ipairs(self.mods or {}) do
if m.id == id then
cur = m.enabled
if version and m.enabledByVersion then
cur = m.enabledByVersion[version] == true
else
cur = m.enabled
end
experimental = m.experimental == true
break
end
@@ -3080,7 +3084,7 @@ function RomImporter:_toggleMod(id, confirmed)
local want = not cur
if want and experimental and not confirmed then
self._modConfirm = {
kind = "experimental", id = id,
kind = "experimental", id = id, version = version,
title = "Experimental mod",
yesLabel = "Enable",
lines = {
@@ -3092,7 +3096,7 @@ function RomImporter:_toggleMod(id, confirmed)
return
end
self._modConfirm = nil
LauncherMods.setEnabled(id, want, self.modScope)
LauncherMods.setEnabled(id, want, version or self.modScope)
self:_refreshMods()
end
+44 -11
View File
@@ -36,6 +36,7 @@ local ModTargets = require("src.mods.ModTargets")
local Semver = require("src.mods.Semver")
local Version = require("src.core.Version")
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local CacheFs = require("src.import.CacheFs")
local LauncherMods = {}
@@ -107,8 +108,9 @@ end
-- deriveList(manifests, options [, version]) -> the panel row list, pure.
-- manifests is an array of validated manifests (Manifest.validate output);
-- options is the options table (options.mods, options.modsByVersion and
-- options.modsGen2 are read). `version` is the game the panel is showing:
-- nil keeps the pre-per-game view, where the shared flag is the whole answer.
-- options.modsGen2 are read). `version` is the game the panel is showing;
-- each row also carries its answer for every game so the launcher can render
-- the coloured game checkboxes together.
-- Rows come back sorted by id so the panel order is stable.
function LauncherMods.deriveList(manifests, options, version)
local ordered = {}
@@ -126,8 +128,7 @@ function LauncherMods.deriveList(manifests, options, version)
-- this game's choice, then the shared flag, then the default: enabled,
-- matching the loader -- except experimental mods, which stay off until
-- the player opts in. Scoped through modScope, so this reads exactly what
-- setEnabled writes and the loader loads: while per-game flags are a
-- preview the shared flag is the whole answer on every surface.
-- setEnabled writes and the loader loads for the selected game.
local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version))
if decided == nil then decided = not m.experimental end
if decided then enabledSet[m.id] = true end
@@ -152,6 +153,14 @@ function LauncherMods.deriveList(manifests, options, version)
badge = badge,
description = m.description or "",
enabled = enabled,
enabledByVersion = (function()
local answers = {}
for _, game in ipairs(GameVersion.ORDER) do
local answer = SaveData.modEnabled(options, m.id, game)
answers[game] = answer == true or (answer == nil and not m.experimental)
end
return answers
end)(),
status = status,
statusDetail = detail,
github = m.github,
@@ -281,7 +290,14 @@ end
function LauncherMods.list(version)
local ok, result = pcall(function()
local options = SaveData.loadOptions()
return LauncherMods.deriveList(discover(), options, version)
local manifests = discover()
-- The first build containing game-specific switches turns the old shared
-- state into one explicit answer per installed mod and game. Saving here
-- means users who only visit the launcher still receive the migration.
if SaveData.migrateModEnablement(options, manifests) then
SaveData.saveOptions(options)
end
return LauncherMods.deriveList(manifests, options, version)
end)
if not ok then
-- a single bad options/mod file must not blank the launcher
@@ -364,10 +380,8 @@ function LauncherMods.translationStrings()
return merged
end
-- setEnabled(id, enabled [, version]): persist options.mods[id] in the exact
-- shape Loader:_saveState writes (a plain boolean), so the running game and
-- the in-game ManagerState pick it up unchanged. With `version` the choice
-- lands in that game's overlay instead and no other game moves.
-- setEnabled(id, enabled [, version]): with a game, persist just that game's
-- answer. The loader and the in-game manager use the same scope on next boot.
function LauncherMods.setEnabled(id, enabled, version)
local options = SaveData.loadOptions()
SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version))
@@ -384,7 +398,15 @@ function LauncherMods.setAllEnabled(ids, enabled, version)
local options = SaveData.loadOptions()
local scope = SaveData.modScope(version)
for _, id in ipairs(ids or {}) do
SaveData.setModEnabled(options, id, enabled, scope)
if scope then
SaveData.setModEnabled(options, id, enabled, scope)
elseif SaveData.PER_VERSION_MODS then
for _, game in ipairs(GameVersion.ORDER) do
SaveData.setModEnabled(options, id, enabled, game)
end
else
SaveData.setModEnabled(options, id, enabled)
end
end
SaveData.saveOptions(options)
return true
@@ -843,7 +865,7 @@ 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]
-- or the save directory, CacheFs decides -- #330) and clears every enable flag
-- so the loader and in-game manager no longer see it. Rejects missing ids.
-- Does not touch other mods' enable state.
function LauncherMods.uninstall(id)
@@ -872,8 +894,19 @@ function LauncherMods.uninstall(id)
-- Drop the enable flag so a reinstall of the same id starts from the
-- loader's default (enabled) rather than a stale false.
local options = SaveData.loadOptions()
local changed = false
if options.mods and options.mods[id] ~= nil then
options.mods[id] = nil
changed = true
end
for _, version in ipairs(GameVersion.ORDER) do
local bucket = options.modsByVersion and options.modsByVersion[version]
if type(bucket) == "table" and bucket[id] ~= nil then
bucket[id] = nil
changed = true
end
end
if changed then
SaveData.saveOptions(options)
end
return true
+20 -4
View File
@@ -270,8 +270,7 @@ function Loader:_targetVersion()
return version
end
-- The version an enable flag is read and written under: this game once
-- per-game flags are live, nil (the shared flag) while they are a preview.
-- The version an enable flag is read and written under: this running game.
-- Reads and writes go through the same answer so the two can never drift.
function Loader:_enableScope()
return SaveData.modScope(self:_targetVersion())
@@ -1306,13 +1305,30 @@ function Loader:load(data)
require("src.mods.Builtins").install(self.content, data, self.generation)
self:_loadState()
self:_discover()
-- Existing installs stored one shared answer. Once their manifests are
-- known, split that answer across every game before the next launcher/game
-- toggle can change one independently. _loadState already used the same
-- fallback, so this write cannot change the current boot's result.
do
local options = SaveData.loadOptions(self.fs)
local installed = {}
for id, mod in pairs(self.mods) do
installed[#installed + 1] = {
id = id,
experimental = mod.manifest and mod.manifest.experimental == true,
}
end
if SaveData.migrateModEnablement(options, installed) and self.fs.write then
SaveData.saveOptions(options, self.fs)
end
end
-- 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 {}
local scope = self:_enableScope()
for id, mod in pairs(self.mods) do
if not self.disabled[id] and modsOpt[id] == nil
if not self.disabled[id] and SaveData.modEnabled(options, id, scope) == nil
and mod.manifest.experimental then
self.disabled[id] = true
end
+1 -2
View File
@@ -735,8 +735,7 @@ function ManagerState:toggleGen2Force(m)
}, apply)
end
-- Where the loader persisted an enable flag: this game's slot once it keeps
-- them per game, the shared flag until then (SaveData.modScope).
-- Where the loader persists an enable flag: this running game's slot.
function ManagerState:enableScope()
return SaveData.modScope((self:targetGame()))
end