Files
gen1recomp/tests/engine/mod_targets_tests.lua
bryanthaboi ae6cac89e1 G2 support
2026-08-11 11:53:30 -04:00

209 lines
8.6 KiB
Lua

-- Which games a mod is for: the manifest `games` key, the legacy gen2compat
-- reading of it, the per-game enable overlay both mod surfaces resolve
-- through, and the profile that carries a per-game set between installs.
-- luajit tests/engine/mod_targets_tests.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local GameVersion = require("src.core.GameVersion")
local Manifest = require("src.mods.Manifest")
local ModProfile = require("src.mods.ModProfile")
local ModTargets = require("src.mods.ModTargets")
local SaveData = require("src.core.SaveData")
local function mf(raw)
raw.id = raw.id or "m"
raw.name = raw.name or "M"
raw.version = raw.version or "1.0.0"
raw.entry = raw.entry or "main.lua"
return Manifest.validate(raw)
end
local function list(m)
return table.concat(ModTargets.versions(m), ",")
end
-- ------- tokens expand off GameVersion, never a literal list
do
eq(table.concat(ModTargets.expand("red"), ","), "red",
"a version id names exactly that game")
eq(table.concat(ModTargets.expand("GEN1"), ","), "red,blue,yellow",
"gen1 is every Gen 1 game, case-insensitive")
eq(table.concat(ModTargets.expand("gen2"), ","), "gold",
"gen2 is every Gen 2 game")
eq(table.concat(ModTargets.expand("all"), ","),
table.concat(GameVersion.ORDER, ","), "all is the launcher order itself")
eq(ModTargets.expand("silver"), nil, "a game this engine has no cache for")
eq(ModTargets.expand("gen9"), nil, "a generation with no games is unknown")
eq(ModTargets.expand(7), nil, "a non-string token is not a game")
end
do
local versions, unknown = ModTargets.normalize({ "gold", "red", "red" })
eq(table.concat(versions, ","), "red,gold",
"normalize dedupes and sorts into GameVersion.ORDER")
eq(#unknown, 0, "known tokens leave nothing unreported")
local _, bad = ModTargets.normalize({ "crystal", "gen1" })
eq(#bad, 1, "an unknown token comes back for the caller to report")
eq(bad[1], "crystal", "by name")
end
-- ------- the legacy reading: gen2compat only ever ADDS Gen 2
do
eq(list(mf({})), "red,blue,yellow",
"a manifest with no games key is Gen 1, which is what it was tested as")
eq(list(mf({ gen2compat = true })), "red,blue,yellow,gold",
"gen2compat keeps Gen 1 and adds Gen 2")
eq(mf({}).gen2compat, false, "and the derived flag agrees")
eq(mf({ gen2compat = true }).gen2compat, true, "both ways")
end
-- ------- games declares it directly, and the loader's gate reads the derived
-- gen2compat, so no Loader change is needed to honour the new key
do
local gen2 = mf({ games = { "gen2" } })
eq(list(gen2), "gold", "games can name Gen 2 alone")
eq(gen2.gen2compat, true, "which IS the gen2compat claim the gate reads")
local both = mf({ games = { "gen1", "gen2" } })
eq(list(both), "red,blue,yellow,gold", "or both generations")
local one = mf({ games = { "blue" } })
eq(list(one), "blue", "or one single game")
eq(one.gen2compat, false, "a Gen 1 game is not a Gen 2 claim")
eq(list(mf({ games = { "red" }, gen2compat = true })), "red,gold",
"an old gen2compat beside a new games list still adds its game")
end
do
-- vocabulary: api 1 warns and keeps loading, api 2 refuses, exactly like
-- every other manifest vocabulary (Manifest.violation)
local lenient = mf({ games = { "crystal", "red" } })
eq(list(lenient), "red", "api 1 drops the unknown game and keeps the rest")
check(not pcall(mf, { api = 2, games = { "crystal" } }),
"api 2 refuses a game it does not have")
check(not pcall(mf, { games = "gen1" }),
"games must be an array, not a bare string")
eq(list(mf({ games = {} })), "red,blue,yellow",
"an empty games list falls back rather than orphaning the mod")
end
-- ------- supports / runsHere: the claim, then what actually happens
do
local gen1 = mf({ id = "one" })
local gen2 = mf({ id = "two", games = { "gen2" } })
check(ModTargets.supports(gen1, "red"), "a Gen 1 mod supports Red")
check(not ModTargets.supports(gen1, "gold"), "and not Gold")
check(ModTargets.supports(gen2, "gold"), "a Gen 2 mod supports Gold")
check(not ModTargets.supports(gen2, "red"), "and not Red")
check(ModTargets.supports(gen1, nil, 1), "a generation can be asked directly")
check(not ModTargets.supports(gen1, nil, 2), "and answers the same way")
check(not ModTargets.runsHere(gen1, "gold"), "no claim, no run")
check(ModTargets.runsHere(gen1, "gold", nil, true),
"the player's override is what forces one anyway")
end
-- ------- one label, both surfaces
do
eq(ModTargets.label(mf({})), "Gen 1", "whole generations read as generations")
eq(ModTargets.label(mf({ games = { "all" } })), "Gen 1+2", "both of them")
eq(ModTargets.label(mf({ games = { "gen2" } })), "Gen 2", "or just the one")
eq(ModTargets.label(mf({ games = { "red", "gold" } })), "Red/Gold",
"part of a generation reads as the games themselves")
eq(ModTargets.chip(mf({})), "GEN 1", "the chip is the same label, uppercased")
eq(ModTargets.detail(mf({}), "gold"), "For Gen 1, not Gold",
"and the launcher line names both sides")
end
-- ------- per-game enable overlay: absent means the shared flag, which is
-- what every options.lua written before this key holds
do
local opts = { mods = { a = false, b = true } }
eq(SaveData.modEnabled(opts, "a", "gold"), false,
"no overlay entry falls through to the shared flag")
eq(SaveData.modEnabled(opts, "b"), true, "with or without a game")
eq(SaveData.modEnabled(opts, "ghost", "gold"), nil,
"an unanswered mod is nil, so the caller owns the default")
opts.modsByVersion = { gold = { a = true } }
eq(SaveData.modEnabled(opts, "a", "gold"), true, "the game's own answer wins")
eq(SaveData.modEnabled(opts, "a", "red"), false, "for that game only")
eq(SaveData.modEnabled(opts, "a"), false, "and the shared view is untouched")
end
do
local opts = { mods = {} }
SaveData.setModEnabled(opts, "a", false, "gold")
eq(opts.modsByVersion.gold.a, false, "a per-game write lands in that game")
eq(opts.mods.a, nil, "and never in the shared flag")
SaveData.setModEnabled(opts, "a", false)
eq(opts.mods.a, false, "a shared write lands in the shared flag")
SaveData.setModEnabled(opts, "a", false, "gold")
eq(opts.modsByVersion.gold.a, nil,
"a per-game answer that agrees with the shared one is dropped, not stored")
eq(SaveData.modEnabled(opts, "a", "gold"), false, "and still resolves the same")
local fresh = { mods = {} }
SaveData.setModEnabled(fresh, "b", true, "gold")
eq(fresh.modsByVersion.gold.b, nil,
"no shared flag reads as enabled, so agreeing with it stores nothing")
end
do
-- the write scope is gated on the loader honouring it, so no surface can
-- promise a per-game set the boot would ignore
eq(SaveData.modScope("gold"), SaveData.PER_VERSION_MODS and "gold" or nil,
"modScope follows the PER_VERSION_MODS switch")
end
-- ------- a profile carries the per-game half of a setup
do
local available = {
{ id = "a", enabled = true }, { id = "b", enabled = false },
}
local byVersion = { gold = { a = false }, red = {} }
local p = ModProfile.capture(available, {}, byVersion)
eq(p.enabledByVersion.gold.a, false, "capture takes the per-game answers")
eq(p.enabledByVersion.red, nil, "an empty game is not carried")
p.name = "PROF"
local back = ModProfile.decode(ModProfile.encode(p))
eq(back.enabledByVersion.gold.a, false, "and they survive an export")
local opts = { mods = {} }
ModProfile.restoreVersions(back, opts)
eq(opts.modsByVersion.gold.a, false, "applying a profile restores them")
check(ModProfile.matchesVersions(back, opts),
"a restored setup still reads as that profile")
opts.modsByVersion.gold.a = true
check(not ModProfile.matchesVersions(back, opts),
"and drifts to ad-hoc as soon as one game differs")
local untouched = { modsByVersion = { gold = { z = true } } }
ModProfile.restoreVersions({ enabledByVersion = {} }, untouched)
eq(untouched.modsByVersion.gold.z, true,
"a profile that names no game blanks none")
end
do
-- a shared .g1rmodlist is untrusted input; the manager indexes it straight
-- into options.modsByVersion
local bad = ModProfile.decode(require("src.core.SaveSerializer").encode({
format = "g1rmodlist", formatVersion = 1,
profile = { name = "P", enabledByVersion = {
gold = { a = true }, silver = { a = true }, red = "nope" } },
}))
eq(bad.enabledByVersion.gold.a, true, "a shared file's known game is kept")
eq(bad.enabledByVersion.silver, nil, "an unknown game is dropped on read")
eq(bad.enabledByVersion.red, nil, "and so is a bucket that is not a table")
end
T.finish("mod_targets")