From 941181d31c817525e690bd771649be19f70f1319 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Thu, 13 Aug 2026 13:22:18 -0400 Subject: [PATCH] check bonxes --- docs/launcher.md | 14 +++--- docs/mod-api-gen2-compat.md | 16 +++--- docs/preparing-your-mod-for-gen2.md | 19 +++----- src/core/SaveData.lua | 67 ++++++++++++++++++++++--- src/import/LauncherView.lua | 73 +++++++++++++++++++--------- src/import/RomImporter.lua | 18 ++++--- src/mods/LauncherMods.lua | 55 ++++++++++++++++----- src/mods/Loader.lua | 24 +++++++-- src/mods/ManagerState.lua | 3 +- tests/engine/launcher_mods_tests.lua | 24 ++++++--- tests/engine/mod_targets_tests.lua | 26 ++++++++++ tests/mod_ui_tests.lua | 6 ++- 12 files changed, 258 insertions(+), 87 deletions(-) diff --git a/docs/launcher.md b/docs/launcher.md index 2a0e2ab6..aa087e48 100644 --- a/docs/launcher.md +++ b/docs/launcher.md @@ -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 diff --git a/docs/mod-api-gen2-compat.md b/docs/mod-api-gen2-compat.md index 3dd8fe0e..62ec6316 100644 --- a/docs/mod-api-gen2-compat.md +++ b/docs/mod-api-gen2-compat.md @@ -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 (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 diff --git a/docs/preparing-your-mod-for-gen2.md b/docs/preparing-your-mod-for-gen2.md index 81c8e9ad..c64ae33b 100644 --- a/docs/preparing-your-mod-for-gen2.md +++ b/docs/preparing-your-mod-for-gen2.md @@ -260,18 +260,13 @@ selected game reads `Needs (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. diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index aa03f32a..3f52375d 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -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). diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index b5f35c8a..78ad18bb 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -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, }) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 9984768c..3e20b5ce 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -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 diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index af091c7d..c35adaf2 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -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// 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 diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 6c446095..f1b1561c 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -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 diff --git a/src/mods/ManagerState.lua b/src/mods/ManagerState.lua index 40cf0ab7..d1f12591 100644 --- a/src/mods/ManagerState.lua +++ b/src/mods/ManagerState.lua @@ -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 diff --git a/tests/engine/launcher_mods_tests.lua b/tests/engine/launcher_mods_tests.lua index ff46a1e4..d60870fc 100644 --- a/tests/engine/launcher_mods_tests.lua +++ b/tests/engine/launcher_mods_tests.lua @@ -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 = {} } diff --git a/tests/engine/mod_targets_tests.lua b/tests/engine/mod_targets_tests.lua index f77390e7..e781818b 100644 --- a/tests/engine/mod_targets_tests.lua +++ b/tests/engine/mod_targets_tests.lua @@ -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 diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 86be2093..5867b59d 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -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,