mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
209 lines
8.6 KiB
Lua
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")
|