mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-21 05:00:43 +02:00
Export versioned mod option schemas
This commit is contained in:
@@ -0,0 +1,36 @@
|
|||||||
|
# Mod option schema export
|
||||||
|
|
||||||
|
`mod_option_schemas.json` is an optional runtime snapshot written beside
|
||||||
|
`options.lua` after the mod loader finishes. It gives a native launcher a
|
||||||
|
data-only description of mod settings without requiring the launcher to run
|
||||||
|
untrusted mod entry code before boot.
|
||||||
|
|
||||||
|
Version 1 has this shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"mods": {
|
||||||
|
"example": [
|
||||||
|
{"key":"enabled","type":"toggle","label":"Enabled","default":true},
|
||||||
|
{"key":"mode","type":"choice","label":"Mode","default":"safe",
|
||||||
|
"choices":[["Safe","safe"],["Fast","fast"]]},
|
||||||
|
{"key":"rate","type":"number","label":"Rate","default":5,
|
||||||
|
"min":0,"max":10,"step":1},
|
||||||
|
{"key":"name","type":"text","label":"Name","default":"","maxLen":12}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Only enabled, successfully loaded mods are included. A boot with no schemas
|
||||||
|
writes `{"schema_version":1,"mods":{}}` when an older snapshot exists, so a
|
||||||
|
disabled or failed mod cannot leave stale settings rows behind. A filesystem
|
||||||
|
that cannot write is tolerated, and a fresh mod-free boot does not create the
|
||||||
|
file.
|
||||||
|
|
||||||
|
The supported row types are `toggle`, `choice`, `number`, and `text`. Native
|
||||||
|
consumers may ignore unknown future row types. Consumers must accept a
|
||||||
|
missing `schema_version` as legacy version 1 and ignore newer versions rather
|
||||||
|
than guessing at their shape. Producers must bump the version when changing
|
||||||
|
the document shape.
|
||||||
@@ -19,6 +19,8 @@ local Loader = {}
|
|||||||
Loader.__index = Loader
|
Loader.__index = Loader
|
||||||
|
|
||||||
local MOD_STATE_FILE = "mod_state.lua" -- legacy migration only
|
local MOD_STATE_FILE = "mod_state.lua" -- legacy migration only
|
||||||
|
local OPTION_SCHEMAS_FILENAME = "mod_option_schemas.json"
|
||||||
|
local OPTION_SCHEMAS_VERSION = 1
|
||||||
|
|
||||||
-- walk a dotted target path without creating anything; the base view a
|
-- walk a dotted target path without creating anything; the base view a
|
||||||
-- registry folds against must never perturb Data on a mod-free boot
|
-- registry folds against must never perturb Data on a mod-free boot
|
||||||
@@ -191,6 +193,43 @@ function Loader:_saveState()
|
|||||||
SaveData.saveOptions(options, self.fs)
|
SaveData.saveOptions(options, self.fs)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Export the runtime option schemas after mod entry chunks have run. This
|
||||||
|
-- is an optional, data-only handoff for native launchers: they must not run
|
||||||
|
-- arbitrary mod code before boot just to discover settings. The snapshot is
|
||||||
|
-- deliberately written beside options.lua so every platform's native shell
|
||||||
|
-- can use the same filesystem contract.
|
||||||
|
function Loader:_writeOptionSchemas()
|
||||||
|
if not self.fs.write then return end
|
||||||
|
|
||||||
|
local mods = {}
|
||||||
|
for id, schema in pairs(self.optionSchemas) do
|
||||||
|
if self.mods[id] and self.mods[id].enabled and not self.mods[id].failed then
|
||||||
|
mods[id] = schema
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Do not create storage on a fresh mod-free boot, but do overwrite an old
|
||||||
|
-- snapshot when the current boot has no schemas so disabled/failed mods do
|
||||||
|
-- not leave stale native settings rows behind.
|
||||||
|
if next(mods) == nil
|
||||||
|
and not (self.fs.getInfo and self.fs.getInfo(OPTION_SCHEMAS_FILENAME)) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok, encoded = pcall(Json.encode, {
|
||||||
|
schema_version = OPTION_SCHEMAS_VERSION,
|
||||||
|
mods = mods,
|
||||||
|
})
|
||||||
|
if not ok then
|
||||||
|
Logger.warn("mod option schema export: failed to encode: %s", tostring(encoded))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local written, err = self.fs.write(OPTION_SCHEMAS_FILENAME, encoded)
|
||||||
|
if not written then
|
||||||
|
Logger.warn("mod option schema export: failed to write: %s", tostring(err))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
function Loader:setEnabled(id, enabled)
|
function Loader:setEnabled(id, enabled)
|
||||||
if not self.mods[id] then return false end
|
if not self.mods[id] then return false end
|
||||||
self.disabled[id] = not enabled
|
self.disabled[id] = not enabled
|
||||||
@@ -1089,6 +1128,7 @@ function Loader:load(data)
|
|||||||
-- which resolves every path to itself (14 §asset resolution).
|
-- which resolves every path to itself (14 §asset resolution).
|
||||||
Assets.installLoader(self)
|
Assets.installLoader(self)
|
||||||
self.events:emit("mods.loaded", { loader = self, data = data })
|
self.events:emit("mods.loaded", { loader = self, data = data })
|
||||||
|
self:_writeOptionSchemas()
|
||||||
self.initialized = true
|
self.initialized = true
|
||||||
return #self.errors == 0
|
return #self.errors == 0
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -303,6 +303,95 @@ do
|
|||||||
"with the env var unset, the saved disable is left alone")
|
"with the env var unset, the saved disable is left alone")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ------- runtime option schema export
|
||||||
|
-- The optional native-launcher contract is written only after enabled mods
|
||||||
|
-- have successfully run, and stale snapshots are cleared when the load set
|
||||||
|
-- no longer contains schema-bearing mods.
|
||||||
|
do
|
||||||
|
local Json = require("src.link.Json")
|
||||||
|
local schemaFiles = {
|
||||||
|
["options.lua"] = "return { mods = { quiet = false } }",
|
||||||
|
["mods/loud/manifest.json"] = manifestJson("loud"),
|
||||||
|
["mods/loud/main.lua"] = [[
|
||||||
|
return function(mod)
|
||||||
|
mod.options:define({
|
||||||
|
{ key = "hardcore", type = "toggle", label = "Hardcore", default = false },
|
||||||
|
{ key = "difficulty", type = "choice", label = "Difficulty", default = "normal",
|
||||||
|
choices = { { "Easy", "easy" }, { "Normal", "normal" } } },
|
||||||
|
{ key = "rate", type = "number", label = "Rate", default = 10,
|
||||||
|
min = 0, max = 100, step = 5 },
|
||||||
|
{ key = "nickname", type = "text", label = "Nickname", default = "", maxLen = 7 },
|
||||||
|
})
|
||||||
|
end
|
||||||
|
]],
|
||||||
|
["mods/quiet/manifest.json"] = manifestJson("quiet"),
|
||||||
|
["mods/quiet/main.lua"] = [[
|
||||||
|
return function(mod)
|
||||||
|
mod.options:define({ { key = "shh", type = "toggle", default = true } })
|
||||||
|
end
|
||||||
|
]],
|
||||||
|
}
|
||||||
|
local writes = {}
|
||||||
|
local fs = memfs(schemaFiles)
|
||||||
|
fs.write = function(path, contents)
|
||||||
|
writes[path] = contents
|
||||||
|
schemaFiles[path] = contents
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local loader = Loader.new({ fs = fs })
|
||||||
|
check(loader:load({ pokemon = {} }) == true,
|
||||||
|
"schema export fixture boots clean")
|
||||||
|
local decoded = writes["mod_option_schemas.json"]
|
||||||
|
and Json.decode(writes["mod_option_schemas.json"])
|
||||||
|
check(decoded and decoded.schema_version == 1,
|
||||||
|
"schema export has an explicit version")
|
||||||
|
check(decoded and decoded.mods and decoded.mods.loud ~= nil,
|
||||||
|
"enabled mod schema is exported")
|
||||||
|
check(decoded and decoded.mods and decoded.mods.quiet == nil,
|
||||||
|
"disabled mod schema is not exported")
|
||||||
|
local rows = decoded and decoded.mods.loud or {}
|
||||||
|
local byKey = {}
|
||||||
|
for _, row in ipairs(rows) do byKey[row.key] = row end
|
||||||
|
check(byKey.hardcore and byKey.hardcore.type == "toggle",
|
||||||
|
"toggle row round-trips")
|
||||||
|
check(byKey.difficulty and byKey.difficulty.choices
|
||||||
|
and byKey.difficulty.choices[1][1] == "Easy"
|
||||||
|
and byKey.difficulty.choices[1][2] == "easy",
|
||||||
|
"choice row round-trips")
|
||||||
|
check(byKey.rate and byKey.rate.min == 0 and byKey.rate.max == 100
|
||||||
|
and byKey.rate.step == 5, "number bounds round-trip")
|
||||||
|
check(byKey.nickname and byKey.nickname.maxLen == 7,
|
||||||
|
"text length round-trips")
|
||||||
|
|
||||||
|
local readOnlyLoader = Loader.new({ fs = memfs(schemaFiles) })
|
||||||
|
check(readOnlyLoader:load({ pokemon = {} }) == true,
|
||||||
|
"read-only filesystems tolerate schema export")
|
||||||
|
|
||||||
|
-- A schema captured before an entry failure is rolled back and must not
|
||||||
|
-- leak into the native snapshot.
|
||||||
|
schemaFiles["mods/broken/manifest.json"] = manifestJson("broken")
|
||||||
|
schemaFiles["mods/broken/main.lua"] = [[
|
||||||
|
return function(mod)
|
||||||
|
mod.options:define({ { key = "ghost", type = "toggle", default = true } })
|
||||||
|
error("broken entry")
|
||||||
|
end
|
||||||
|
]]
|
||||||
|
local failedLoader = Loader.new({ fs = fs })
|
||||||
|
check(failedLoader:load({ pokemon = {} }) == false,
|
||||||
|
"a failing entry is reported")
|
||||||
|
local afterFailure = Json.decode(writes["mod_option_schemas.json"])
|
||||||
|
check(afterFailure and afterFailure.mods and afterFailure.mods.broken == nil,
|
||||||
|
"a failed mod schema is not exported")
|
||||||
|
|
||||||
|
loader:setEnabled("loud", false)
|
||||||
|
loader:_writeOptionSchemas()
|
||||||
|
local cleared = Json.decode(writes["mod_option_schemas.json"])
|
||||||
|
check(cleared and cleared.schema_version == 1 and next(cleared.mods) == nil,
|
||||||
|
"disabling the only schema-bearing mod clears the snapshot")
|
||||||
|
|
||||||
|
end
|
||||||
|
|
||||||
-- leave shared singletons the way we found them for later chained tests
|
-- leave shared singletons the way we found them for later chained tests
|
||||||
local StateStack = require("src.core.StateStack")
|
local StateStack = require("src.core.StateStack")
|
||||||
while StateStack:top() do StateStack:pop() end
|
while StateStack:top() do StateStack:pop() end
|
||||||
|
|||||||
Reference in New Issue
Block a user