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:
`{id, name, version, badge, description, enabled, status, statusDetail}`.
`badge` is the manifest's `category`, falling back to `profile`, then
`"MOD"`, uppercased. `enabled` reads `options.mods[id]` (missing means
enabled, matching the loader's own default).
`"MOD"`, uppercased. `enabledByVersion` contains an answer for each game;
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
`LauncherMods.deriveList`/`statusFor` against `ManagerState.resolveToggle`
and the validated manifests: `conflict` when enabling this mod collides
with another enabled one; `warn` for an out-of-range `game_version` or an
absent/disabled/wrong-version hard dependency; `ok` otherwise. Having no
`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
boolean, the exact shape `Loader:_saveState` writes, so the running game
and the in-game `ManagerState` see the change on next boot. The mods panel
calls this on every toggle and re-derives the list right away
- `LauncherMods.setEnabled(id, bool, version)` persists the selected game's
answer, so the running game and the in-game `ManagerState` see the change on
next boot. The MODS panel renders a coloured checkbox for Red, Blue, Yellow,
and Gold on every row and re-derives the list right away
(`RomImporter:_refreshMods`) so a status change (e.g. a new conflict)
shows without waiting for a reload.
- `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)`,
matching the loader's contagious skip.
A separate overlay, `options.modsByVersion[version][id]`, is where a per-game
enable flag will live. It is a preview: `SaveData.PER_VERSION_MODS` is `false`,
so `SaveData.modScope` answers nil for every caller and the launcher panel, the
in-game manager and the loader all read *and* write the one shared
`options.mods` flag. Nothing consults the overlay for enablement until that
flips, which is deliberate: the overlay is plantable from an imported
`.g1rmodlist`, and a reader scoped differently from the writers would show a
mod set no boot would honour.
A separate overlay, `options.modsByVersion[version][id]`, holds each game's
enable flag. The launcher shows a coloured Red / Blue / Yellow / Gold checkbox
for every installed mod, and the loader and in-game manager read the same
game-specific answer on the next boot. On the first launch after this feature,
the existing shared state is copied to every game, so a mod that was enabled
remains enabled everywhere; after that, changing one checkbox affects only
that game. New mods still default to enabled on every game (experimental mods
retain their explicit opt-in default).
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
+7 -12
View File
@@ -260,18 +260,13 @@ selected game reads `Needs <id> (not for Gold)` rather than `Ready`.
### One limit worth knowing
**Per-game enable flags are still a preview.** The overlay
`options.modsByVersion[version][id]` exists and every surface goes through
`SaveData.modEnabled` / `SaveData.setModEnabled`, but
`SaveData.PER_VERSION_MODS` is `false`
(`src/core/SaveData.lua:489`). While it is false, `SaveData.modScope` answers
nil for every caller, so the launcher panel, the in-game manager *and* the
loader all read and write the one shared `options.mods` flag and the overlay is
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.
**Enablement is per game.** The overlay
`options.modsByVersion[version][id]` is read and written through
`SaveData.modEnabled` / `SaveData.setModEnabled` by the launcher, in-game
manager, and loader. Existing shared settings are copied to every game the
first time this version sees the installed mods; from then on, each coloured
game checkbox changes only that game's next boot. Nothing about this affects a
mod author; it affects what a player can express.
Targeting is a different question from enablement and *is* enforced per game,
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
-- 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
+18 -6
View File
@@ -56,10 +56,24 @@ do
local m = byId(LauncherMods.deriveList(manifests, { mods = { bbb = false } }))
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(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.statusDetail, "Ready", "ok detail reads Ready")
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
do
@@ -291,10 +305,9 @@ end
-- ------- enable flags: the panel reads exactly what the switch writes
--
-- One scope for both halves (SaveData.modScope). While per-game flags are a
-- preview the shared flag is the whole answer, so a modsByVersion overlay --
-- which an imported .g1rmodlist can plant, ModProfile.restoreVersions -- can
-- never leave the switch showing an answer no writer can reach.
-- One scope for both halves (SaveData.modScope). Per-game flags are live, so
-- a modsByVersion answer -- including one restored from a .g1rmodlist -- is
-- both what the launcher shows and what the next boot reads.
local SaveData = require("src.core.SaveData")
@@ -309,9 +322,8 @@ do
}
local planted = { mods = { one = true },
modsByVersion = { gold = { one = false } } }
local expected = SaveData.PER_VERSION_MODS and false or true
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
local options = { mods = {} }
+26
View File
@@ -162,6 +162,32 @@ do
"modScope follows the PER_VERSION_MODS switch")
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
do
+4 -2
View File
@@ -1017,8 +1017,10 @@ press(ms, "select")
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:glyphFor(avail[1]) == ".", "staged mods show the staged glyph")
check(mgame.save.options.mods.badmod == false,
"the live options table mirrors the flip")
local managerScope = ms:enableScope()
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")
ms:discardChanges()
check(avail[1].enabled == true and not ms.restartPending,