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
+8 -6
View File
@@ -130,18 +130,20 @@ before `Game:load`, so **it never loads a mod's entry chunk**; only
duplicate) and returns one row per mod: duplicate) and returns one row per mod:
`{id, name, version, badge, description, enabled, status, statusDetail}`. `{id, name, version, badge, description, enabled, status, statusDetail}`.
`badge` is the manifest's `category`, falling back to `profile`, then `badge` is the manifest's `category`, falling back to `profile`, then
`"MOD"`, uppercased. `enabled` reads `options.mods[id]` (missing means `"MOD"`, uppercased. `enabledByVersion` contains an answer for each game;
enabled, matching the loader's own default). missing entries default to enabled (except experimental mods), matching the
loader. On the first run with per-game controls, legacy shared choices are
copied to every installed game's answer.
- `status` is `"ok"`, `"warn"`, or `"conflict"`, computed by the pure - `status` is `"ok"`, `"warn"`, or `"conflict"`, computed by the pure
`LauncherMods.deriveList`/`statusFor` against `ManagerState.resolveToggle` `LauncherMods.deriveList`/`statusFor` against `ManagerState.resolveToggle`
and the validated manifests: `conflict` when enabling this mod collides and the validated manifests: `conflict` when enabling this mod collides
with another enabled one; `warn` for an out-of-range `game_version` or an with another enabled one; `warn` for an out-of-range `game_version` or an
absent/disabled/wrong-version hard dependency; `ok` otherwise. Having no absent/disabled/wrong-version hard dependency; `ok` otherwise. Having no
`love.*` calls, this half is table-driven by the test suite on its own. `love.*` calls, this half is table-driven by the test suite on its own.
- `LauncherMods.setEnabled(id, bool)` persists `options.mods[id]` as a plain - `LauncherMods.setEnabled(id, bool, version)` persists the selected game's
boolean, the exact shape `Loader:_saveState` writes, so the running game answer, so the running game and the in-game `ManagerState` see the change on
and the in-game `ManagerState` see the change on next boot. The mods panel next boot. The MODS panel renders a coloured checkbox for Red, Blue, Yellow,
calls this on every toggle and re-derives the list right away and Gold on every row and re-derives the list right away
(`RomImporter:_refreshMods`) so a status change (e.g. a new conflict) (`RomImporter:_refreshMods`) so a status change (e.g. a new conflict)
shows without waiting for a reload. shows without waiting for a reload.
- `LauncherMods.installZip(path)` mounts the archive with - `LauncherMods.installZip(path)` mounts the archive with
+8 -8
View File
@@ -96,14 +96,14 @@ The launcher asks the same question of a mod's dependencies: one whose hard
dependency does not run on the selected game reads `Needs <id> (not for Gold)`, dependency does not run on the selected game reads `Needs <id> (not for Gold)`,
matching the loader's contagious skip. matching the loader's contagious skip.
A separate overlay, `options.modsByVersion[version][id]`, is where a per-game A separate overlay, `options.modsByVersion[version][id]`, holds each game's
enable flag will live. It is a preview: `SaveData.PER_VERSION_MODS` is `false`, enable flag. The launcher shows a coloured Red / Blue / Yellow / Gold checkbox
so `SaveData.modScope` answers nil for every caller and the launcher panel, the for every installed mod, and the loader and in-game manager read the same
in-game manager and the loader all read *and* write the one shared game-specific answer on the next boot. On the first launch after this feature,
`options.mods` flag. Nothing consults the overlay for enablement until that the existing shared state is copied to every game, so a mod that was enabled
flips, which is deliberate: the overlay is plantable from an imported remains enabled everywhere; after that, changing one checkbox affects only
`.g1rmodlist`, and a reader scoped differently from the writers would show a that game. New mods still default to enabled on every game (experimental mods
mod set no boot would honour. retain their explicit opt-in default).
That is deliberate. Gold reimplements the battle engine, the overworld, the That is deliberate. Gold reimplements the battle engine, the overworld, the
script VM and the save format, so a Gen 1 mod dropped into a Gold boot would script VM and the save format, so a Gen 1 mod dropped into a Gold boot would
+7 -12
View File
@@ -260,18 +260,13 @@ selected game reads `Needs <id> (not for Gold)` rather than `Ready`.
### One limit worth knowing ### One limit worth knowing
**Per-game enable flags are still a preview.** The overlay **Enablement is per game.** The overlay
`options.modsByVersion[version][id]` exists and every surface goes through `options.modsByVersion[version][id]` is read and written through
`SaveData.modEnabled` / `SaveData.setModEnabled`, but `SaveData.modEnabled` / `SaveData.setModEnabled` by the launcher, in-game
`SaveData.PER_VERSION_MODS` is `false` manager, and loader. Existing shared settings are copied to every game the
(`src/core/SaveData.lua:489`). While it is false, `SaveData.modScope` answers first time this version sees the installed mods; from then on, each coloured
nil for every caller, so the launcher panel, the in-game manager *and* the game checkbox changes only that game's next boot. Nothing about this affects a
loader all read and write the one shared `options.mods` flag and the overlay is mod author; it affects what a player can express.
not consulted for enablement anywhere. That matters because the overlay is
plantable from an imported `.g1rmodlist`: keeping every reader on the same
scope as every writer is what stops a stored per-game flag from showing a mod
set no boot would honour. Nothing about this affects a mod author; it affects
what a player can currently express.
Targeting is a different question from enablement and *is* enforced per game, Targeting is a different question from enablement and *is* enforced per game,
as above. The two do not share a switch. as above. The two do not share a switch.
+60 -7
View File
@@ -304,6 +304,10 @@ function SaveData.defaultOptions()
-- options.lua written before this key keeps its exact meaning. Read and -- options.lua written before this key keeps its exact meaning. Read and
-- written through SaveData.modEnabled / SaveData.setModEnabled. -- written through SaveData.modEnabled / SaveData.setModEnabled.
modsByVersion = {}, 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 -- Named setups the player can switch between (#593; src/mods/ModProfile.lua
-- owns the shape, src/mods/ManagerState.lua the UI): each row is -- owns the shape, src/mods/ManagerState.lua the UI): each row is
-- { name, enabled = {id=bool}, options = {id={k=v}}, slots = {version=slotId} }. -- { 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 -- 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. -- 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 -- Per-game answers are live. Every reader and writer goes through modScope,
-- flags once, before any entry chunk (src/mods/Loader.lua _loadState), so this -- so a choice made in the launcher is the choice the next boot loads.
-- flips on with that read and not before: until then every writer keeps to the SaveData.PER_VERSION_MODS = true
-- shared flag and no surface promises what the boot does not do.
SaveData.PER_VERSION_MODS = false
-- The version a write should be scoped to: the game asked for once per-game -- The version a write is scoped to: per-game controls name one game; a nil
-- flags are live, nil (the shared flag) while they are only a preview. -- caller still addresses the legacy shared fallback.
function SaveData.modScope(version) function SaveData.modScope(version)
if SaveData.PER_VERSION_MODS then return version end if SaveData.PER_VERSION_MODS then return version end
return nil return nil
end 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 -- true/false as chosen for `version`, else the shared flag, else nil -- the
-- caller owns the default (the loader enables, the launcher keeps -- caller owns the default (the loader enables, the launcher keeps
-- experimental mods off until asked). -- experimental mods off until asked).
+51 -22
View File
@@ -1139,6 +1139,28 @@ local function drawCheck(x, y, size, color)
love.graphics.pop() love.graphics.pop()
end 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) local function buildModsPanel(imp, x, y, w, availH, m)
imp:_ensureMods() imp:_ensureMods()
local ModUpdate = require("src.mods.ModUpdate") local ModUpdate = require("src.mods.ModUpdate")
@@ -1225,15 +1247,14 @@ local function buildModsPanel(imp, x, y, w, availH, m)
mods = sorted mods = sorted
end end
-- A mod row is a fixed height: name line, version + status line, one line -- A mod row is a fixed height: its details first, then a dedicated second
-- of description, and an action row. Fixed because a page of uniform rows -- line of per-game checkboxes. Fixed because a page of uniform rows is
-- is what lets perPage come from the viewport. -- what lets perPage come from the viewport.
local chipH = math.max(Kit.tapMin(), math.floor(30 * m.s)) local togH = math.floor(26 * m.s)
-- Text block on the left, chips right-aligned beside it: one row, not a local gamesLabel = Strings("Enable for:")
-- text block with a button strip stacked under it.
local textH = Kit.textHeight("button") + math.floor(4 * m.s) local textH = Kit.textHeight("button") + math.floor(4 * m.s)
+ Kit.textHeight("small") + math.floor(2 * m.s) + Kit.textHeight("small") + 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) + math.floor(8 * m.s)
local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s)) local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s))
local listH = availH - (cy - y) - pagerH - gap 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 px, inner = x + pad, w - 2 * pad
local ly = ry + math.floor(10 * m.s) local ly = ry + math.floor(10 * m.s)
local togW = math.floor(56 * m.s) local togGap = math.floor(4 * m.s)
local togH = math.floor(26 * m.s)
local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id) local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id)
local togKey = "mod-toggle-" .. mod.id -- These answer separate games, not a single shared install flag. The
-- The toggle reports its own new value, but the importer owns the state: -- importer receives the game id so an experimental confirmation also
-- queue the flip and let _toggleMod (which may raise an experimental-mod -- applies only to the checkbox the player pressed.
-- confirm) decide what actually happens. local flipped = false
local _, flipped = Kit.toggle(px + inner - togW, local gamesY = ry + math.floor(8 * m.s) + textH + math.floor(8 * m.s)
ry + (rowH - togH) / 2, togW, togH, mod.enabled, togKey) Kit.text("micro", gamesLabel, px,
if flipped then gamesY + (togH - Kit.textHeight("micro")) / 2, PAL.muted)
queueAction(imp, togKey, function() imp:_toggleMod(mod.id) end) 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 end
-- The toggle sits inside the row's rect, so its press also passes the tx = tx + togH + togGap
-- row's hit test; `flipped` gates the row action to everywhere else. end
-- 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 if not flipped
and (Kit.press(x, ry, w, rowH) or Kit._activateId == rowKey) then and (Kit.press(x, ry, w, rowH) or Kit._activateId == rowKey) then
local id = mod.id local id = mod.id
queueAction(imp, rowKey, function() imp._modActions = id end) queueAction(imp, rowKey, function() imp._modActions = id end)
end end
local chipsW = togW + math.floor(6 * m.s) local textW = inner
local textW = inner - chipsW - math.floor(12 * m.s)
local badgeW = Kit.textWidth("micro", mod.badge) + math.floor(12 * m.s) 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 -- 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 elseif c.kind == "importOversize" then
imp:_importSave(c.version, c.source, true) imp:_importSave(c.version, c.source, true)
else else
imp:_toggleMod(c.id, true) imp:_toggleMod(c.id, true, c.version)
end end
end, end,
}) })
+10 -6
View File
@@ -3064,15 +3064,19 @@ function RomImporter:_modUpdateInfo(id)
return self.modUpdateInfo and self.modUpdateInfo[id] or nil return self.modUpdateInfo and self.modUpdateInfo[id] or nil
end end
-- Flip a mod's enabled flag (persisted via LauncherMods.setEnabled) and relist -- Flip one game's mod flag (persisted via LauncherMods.setEnabled) and relist
-- so the toggle, count, and every status chip reflect the new resolution. -- so that game's checkbox and status chips reflect the new resolution.
-- Enabling an experimental mod arms a confirm first. -- Enabling an experimental mod arms a confirmation for that same game.
function RomImporter:_toggleMod(id, confirmed) function RomImporter:_toggleMod(id, confirmed, version)
local LauncherMods = require("src.mods.LauncherMods") local LauncherMods = require("src.mods.LauncherMods")
local cur, experimental = false, false local cur, experimental = false, false
for _, m in ipairs(self.mods or {}) do for _, m in ipairs(self.mods or {}) do
if m.id == id then if m.id == id then
if version and m.enabledByVersion then
cur = m.enabledByVersion[version] == true
else
cur = m.enabled cur = m.enabled
end
experimental = m.experimental == true experimental = m.experimental == true
break break
end end
@@ -3080,7 +3084,7 @@ function RomImporter:_toggleMod(id, confirmed)
local want = not cur local want = not cur
if want and experimental and not confirmed then if want and experimental and not confirmed then
self._modConfirm = { self._modConfirm = {
kind = "experimental", id = id, kind = "experimental", id = id, version = version,
title = "Experimental mod", title = "Experimental mod",
yesLabel = "Enable", yesLabel = "Enable",
lines = { lines = {
@@ -3092,7 +3096,7 @@ function RomImporter:_toggleMod(id, confirmed)
return return
end end
self._modConfirm = nil self._modConfirm = nil
LauncherMods.setEnabled(id, want, self.modScope) LauncherMods.setEnabled(id, want, version or self.modScope)
self:_refreshMods() self:_refreshMods()
end end
+43 -10
View File
@@ -36,6 +36,7 @@ local ModTargets = require("src.mods.ModTargets")
local Semver = require("src.mods.Semver") local Semver = require("src.mods.Semver")
local Version = require("src.core.Version") local Version = require("src.core.Version")
local SaveData = require("src.core.SaveData") local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local CacheFs = require("src.import.CacheFs") local CacheFs = require("src.import.CacheFs")
local LauncherMods = {} local LauncherMods = {}
@@ -107,8 +108,9 @@ end
-- deriveList(manifests, options [, version]) -> the panel row list, pure. -- deriveList(manifests, options [, version]) -> the panel row list, pure.
-- manifests is an array of validated manifests (Manifest.validate output); -- manifests is an array of validated manifests (Manifest.validate output);
-- options is the options table (options.mods, options.modsByVersion and -- options is the options table (options.mods, options.modsByVersion and
-- options.modsGen2 are read). `version` is the game the panel is showing: -- 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. -- 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. -- Rows come back sorted by id so the panel order is stable.
function LauncherMods.deriveList(manifests, options, version) function LauncherMods.deriveList(manifests, options, version)
local ordered = {} local ordered = {}
@@ -126,8 +128,7 @@ function LauncherMods.deriveList(manifests, options, version)
-- this game's choice, then the shared flag, then the default: enabled, -- this game's choice, then the shared flag, then the default: enabled,
-- matching the loader -- except experimental mods, which stay off until -- matching the loader -- except experimental mods, which stay off until
-- the player opts in. Scoped through modScope, so this reads exactly what -- the player opts in. Scoped through modScope, so this reads exactly what
-- setEnabled writes and the loader loads: while per-game flags are a -- setEnabled writes and the loader loads for the selected game.
-- preview the shared flag is the whole answer on every surface.
local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version)) local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version))
if decided == nil then decided = not m.experimental end if decided == nil then decided = not m.experimental end
if decided then enabledSet[m.id] = true end if decided then enabledSet[m.id] = true end
@@ -152,6 +153,14 @@ function LauncherMods.deriveList(manifests, options, version)
badge = badge, badge = badge,
description = m.description or "", description = m.description or "",
enabled = enabled, 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, status = status,
statusDetail = detail, statusDetail = detail,
github = m.github, github = m.github,
@@ -281,7 +290,14 @@ end
function LauncherMods.list(version) function LauncherMods.list(version)
local ok, result = pcall(function() local ok, result = pcall(function()
local options = SaveData.loadOptions() 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) end)
if not ok then if not ok then
-- a single bad options/mod file must not blank the launcher -- a single bad options/mod file must not blank the launcher
@@ -364,10 +380,8 @@ function LauncherMods.translationStrings()
return merged return merged
end end
-- setEnabled(id, enabled [, version]): persist options.mods[id] in the exact -- setEnabled(id, enabled [, version]): with a game, persist just that game's
-- shape Loader:_saveState writes (a plain boolean), so the running game and -- answer. The loader and the in-game manager use the same scope on next boot.
-- the in-game ManagerState pick it up unchanged. With `version` the choice
-- lands in that game's overlay instead and no other game moves.
function LauncherMods.setEnabled(id, enabled, version) function LauncherMods.setEnabled(id, enabled, version)
local options = SaveData.loadOptions() local options = SaveData.loadOptions()
SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version)) SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version))
@@ -384,7 +398,15 @@ function LauncherMods.setAllEnabled(ids, enabled, version)
local options = SaveData.loadOptions() local options = SaveData.loadOptions()
local scope = SaveData.modScope(version) local scope = SaveData.modScope(version)
for _, id in ipairs(ids or {}) do for _, id in ipairs(ids or {}) do
if scope then
SaveData.setModEnabled(options, id, enabled, scope) 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 end
SaveData.saveOptions(options) SaveData.saveOptions(options)
return true return true
@@ -843,7 +865,7 @@ end
-- uninstall(id) -> true | nil, errString -- uninstall(id) -> true | nil, errString
-- Removes mods/<id>/ from wherever it was installed (the portable game folder -- 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. -- so the loader and in-game manager no longer see it. Rejects missing ids.
-- Does not touch other mods' enable state. -- Does not touch other mods' enable state.
function LauncherMods.uninstall(id) 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 -- Drop the enable flag so a reinstall of the same id starts from the
-- loader's default (enabled) rather than a stale false. -- loader's default (enabled) rather than a stale false.
local options = SaveData.loadOptions() local options = SaveData.loadOptions()
local changed = false
if options.mods and options.mods[id] ~= nil then if options.mods and options.mods[id] ~= nil then
options.mods[id] = nil 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) SaveData.saveOptions(options)
end end
return true return true
+20 -4
View File
@@ -270,8 +270,7 @@ function Loader:_targetVersion()
return version return version
end end
-- The version an enable flag is read and written under: this game once -- The version an enable flag is read and written under: this running game.
-- per-game flags are live, nil (the shared flag) while they are a preview.
-- Reads and writes go through the same answer so the two can never drift. -- Reads and writes go through the same answer so the two can never drift.
function Loader:_enableScope() function Loader:_enableScope()
return SaveData.modScope(self:_targetVersion()) return SaveData.modScope(self:_targetVersion())
@@ -1306,13 +1305,30 @@ function Loader:load(data)
require("src.mods.Builtins").install(self.content, data, self.generation) require("src.mods.Builtins").install(self.content, data, self.generation)
self:_loadState() self:_loadState()
self:_discover() 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 -- Experimental mods stay off until the player opts in: a missing
-- options.mods entry normally means enabled, but experimental flips that. -- options.mods entry normally means enabled, but experimental flips that.
do do
local options = SaveData.loadOptions(self.fs) local options = SaveData.loadOptions(self.fs)
local modsOpt = options.mods or {} local scope = self:_enableScope()
for id, mod in pairs(self.mods) do 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 and mod.manifest.experimental then
self.disabled[id] = true self.disabled[id] = true
end end
+1 -2
View File
@@ -735,8 +735,7 @@ function ManagerState:toggleGen2Force(m)
}, apply) }, apply)
end end
-- Where the loader persisted an enable flag: this game's slot once it keeps -- Where the loader persists an enable flag: this running game's slot.
-- them per game, the shared flag until then (SaveData.modScope).
function ManagerState:enableScope() function ManagerState:enableScope()
return SaveData.modScope((self:targetGame())) return SaveData.modScope((self:targetGame()))
end end
+18 -6
View File
@@ -56,10 +56,24 @@ do
local m = byId(LauncherMods.deriveList(manifests, { mods = { bbb = false } })) local m = byId(LauncherMods.deriveList(manifests, { mods = { bbb = false } }))
check(m.aaa.enabled, "a mod with no options entry defaults to enabled") check(m.aaa.enabled, "a mod with no options entry defaults to enabled")
check(not m.bbb.enabled, "an explicit false disables the mod") check(not m.bbb.enabled, "an explicit false disables the mod")
check(m.aaa.enabledByVersion.red and m.aaa.enabledByVersion.gold,
"every row exposes its enabled answer for each game")
eq(m.aaa.status, "ok", "a healthy enabled mod is ok") eq(m.aaa.status, "ok", "a healthy enabled mod is ok")
eq(m.aaa.statusDetail, "Ready", "ok detail reads Ready") eq(m.aaa.statusDetail, "Ready", "ok detail reads Ready")
end end
do
local manifests = {
mf({ id = "one", name = "One", version = "1.0.0", entry = "m.lua" }),
}
local row = byId(LauncherMods.deriveList(manifests, {
mods = { one = true }, modsByVersion = { gold = { one = false } },
}, "gold")).one
check(row.enabledByVersion.red, "the Red checkbox keeps the shared answer")
check(not row.enabledByVersion.gold, "the Gold checkbox reads Gold's answer")
check(not row.enabled, "the selected game's row state matches its checkbox")
end
-- ------- experimental defaults to disabled; github surfaces on the row -- ------- experimental defaults to disabled; github surfaces on the row
do do
@@ -291,10 +305,9 @@ end
-- ------- enable flags: the panel reads exactly what the switch writes -- ------- enable flags: the panel reads exactly what the switch writes
-- --
-- One scope for both halves (SaveData.modScope). While per-game flags are a -- One scope for both halves (SaveData.modScope). Per-game flags are live, so
-- preview the shared flag is the whole answer, so a modsByVersion overlay -- -- a modsByVersion answer -- including one restored from a .g1rmodlist -- is
-- which an imported .g1rmodlist can plant, ModProfile.restoreVersions -- can -- both what the launcher shows and what the next boot reads.
-- never leave the switch showing an answer no writer can reach.
local SaveData = require("src.core.SaveData") local SaveData = require("src.core.SaveData")
@@ -309,9 +322,8 @@ do
} }
local planted = { mods = { one = true }, local planted = { mods = { one = true },
modsByVersion = { gold = { one = false } } } modsByVersion = { gold = { one = false } } }
local expected = SaveData.PER_VERSION_MODS and false or true
eq(byId(LauncherMods.deriveList(manifests, planted, "gold")).one.enabled, eq(byId(LauncherMods.deriveList(manifests, planted, "gold")).one.enabled,
expected, "the overlay is read exactly when a write can reach it") false, "the overlay is read exactly when a write can reach it")
-- the round trip, the thing the dead switch failed: flip it, re-derive -- the round trip, the thing the dead switch failed: flip it, re-derive
local options = { mods = {} } local options = { mods = {} }
+26
View File
@@ -162,6 +162,32 @@ do
"modScope follows the PER_VERSION_MODS switch") "modScope follows the PER_VERSION_MODS switch")
end end
do
-- First launch after per-game controls shipped: preserve every old answer
-- and make the old implicit defaults explicit for the installed set.
local opts = {
mods = { old_on = true, old_off = false },
modsByVersion = { gold = { old_on = false } },
}
check(SaveData.migrateModEnablement(opts, {
{ id = "old_on" }, { id = "old_off" }, { id = "implicit" },
{ id = "lab", experimental = true },
}), "legacy mod state is migrated once")
check(opts.modsByVersionMigrated, "the migration is marked complete")
eq(SaveData.modEnabled(opts, "old_on", "red"), true,
"an old enabled mod is enabled for Red")
eq(SaveData.modEnabled(opts, "old_on", "gold"), false,
"an already-stored preview answer is preserved")
eq(SaveData.modEnabled(opts, "old_off", "blue"), false,
"an old disabled mod stays disabled for every game")
eq(SaveData.modEnabled(opts, "implicit", "yellow"), true,
"an old implicit default is enabled for every game")
eq(SaveData.modEnabled(opts, "lab", "red"), false,
"an experimental mod keeps its existing opt-in default")
check(not SaveData.migrateModEnablement(opts, { { id = "newer" } }),
"a later mod install does not rerun the legacy migration")
end
-- ------- a profile carries the per-game half of a setup -- ------- a profile carries the per-game half of a setup
do do
+4 -2
View File
@@ -1017,8 +1017,10 @@ press(ms, "select")
check(avail[1].enabled == false, "SELECT quick-toggles the focused mod") check(avail[1].enabled == false, "SELECT quick-toggles the focused mod")
check(ms:isStaged(avail[1]), "a flip against boot state is staged") check(ms:isStaged(avail[1]), "a flip against boot state is staged")
check(ms:glyphFor(avail[1]) == ".", "staged mods show the staged glyph") check(ms:glyphFor(avail[1]) == ".", "staged mods show the staged glyph")
check(mgame.save.options.mods.badmod == false, local managerScope = ms:enableScope()
"the live options table mirrors the flip") check(managerScope and mgame.save.options.modsByVersion
and mgame.save.options.modsByVersion[managerScope].badmod == false,
"the live options table mirrors the flip for this game")
check(ms.restartPending, "staged changes arm the apply screen") check(ms.restartPending, "staged changes arm the apply screen")
ms:discardChanges() ms:discardChanges()
check(avail[1].enabled == true and not ms.restartPending, check(avail[1].enabled == true and not ms.restartPending,