mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 11:11:10 +02:00
Validate a map object's pokemon field against the pokemon registry
R.maps.objects was f.opt(f.list(f.any)): a static wild encounter's species (OverworldController.lua's d.pokemon, handed straight to BattleState.newWild) went completely unchecked at load time, unlike an encounter slot's species. A typo'd or removed id sat in a loaded mod and only surfaced as a crash the moment a player reached that object. Objects share one array across every kind -- NPCs, signs, warps and static encounters all coexist with no field the loader could use to tell them apart ahead of time -- so a strict f.rec covering the whole shape would reject every kind this schema does not enumerate. Added f.partial, an open counterpart to f.rec: it type-checks (and, through collectRefs, cross-reference-checks) only the fields it is given and leaves everything else on the value alone, the same extensibility f.rec already grants at a record's top level but nowhere further in. R.maps.objects now types just `pokemon` through it, so a bad species id is a load-time "unresolved reference" error instead of a runtime crash, while an NPC object's sprite/movement/range/... fields -- never named in this schema -- still pass through untouched.
This commit is contained in:
+49
-2
@@ -91,6 +91,28 @@ function f.rec(fields, opts)
|
||||
desc = "{" .. table.concat(parts, ", ") .. "}" }
|
||||
end
|
||||
|
||||
-- An open record: the listed fields are typed (including f.id
|
||||
-- cross-references) and everything else on the value passes through
|
||||
-- unexamined, unlike f.rec's nested shapes, which reject any key they do
|
||||
-- not name. Map objects are why this exists -- NPCs, signs, items, warps
|
||||
-- and static encounters all share one array, and only a full union of
|
||||
-- every kind's shape could describe it as f.rec; that is a lot of surface
|
||||
-- to keep in sync with the loader for fields nothing here needs to check.
|
||||
-- f.partial types just the field that actually names another registry and
|
||||
-- leaves every kind-specific field around it alone.
|
||||
function f.partial(fields)
|
||||
local names = {}
|
||||
for name in pairs(fields) do names[#names + 1] = name end
|
||||
table.sort(names)
|
||||
local parts = {}
|
||||
for _, name in ipairs(names) do
|
||||
local ft = fields[name]
|
||||
parts[#parts + 1] = name .. (ft.kind == "opt" and "?" or "")
|
||||
end
|
||||
return { kind = "partial", fields = fields,
|
||||
desc = "{" .. table.concat(parts, ", ") .. ", ...}" }
|
||||
end
|
||||
|
||||
function f.union(alts)
|
||||
local parts = {}
|
||||
for _, alt in ipairs(alts) do parts[#parts + 1] = alt.desc end
|
||||
@@ -197,6 +219,23 @@ checkValue = function(t, value, path, patchMode, errors, top)
|
||||
end
|
||||
return
|
||||
end
|
||||
if kind == "partial" then
|
||||
-- the open counterpart of "rec": listed fields are checked exactly
|
||||
-- like a rec's, and any key not listed is left alone rather than
|
||||
-- flagged, so a heterogeneous blob (map objects) can have one field
|
||||
-- typed without every other shape sharing the array being rejected
|
||||
if type(value) ~= "table" then return fail(errors, path, t.desc, value) end
|
||||
for key, ft in pairs(t.fields) do
|
||||
local sub = value[key]
|
||||
if sub ~= nil then
|
||||
checkValue(ft, sub, path .. "." .. tostring(key), patchMode, errors)
|
||||
elseif ft.kind ~= "opt" and not patchMode then
|
||||
errors[#errors + 1] = ("%s.%s: missing required field (%s)")
|
||||
:format(path, key, ft.desc)
|
||||
end
|
||||
end
|
||||
return
|
||||
end
|
||||
if kind == "union" then
|
||||
for _, alt in ipairs(t.alts) do
|
||||
local scratch = {}
|
||||
@@ -299,7 +338,7 @@ collectRefs = function(t, value, path, out)
|
||||
for k, v in pairs(value) do
|
||||
collectRefs(t.value, v, path .. "." .. tostring(k), out)
|
||||
end
|
||||
elseif kind == "rec" and type(value) == "table" then
|
||||
elseif (kind == "rec" or kind == "partial") and type(value) == "table" then
|
||||
for key, ft in pairs(t.fields) do
|
||||
collectRefs(ft, value[key], path .. "." .. tostring(key), out)
|
||||
end
|
||||
@@ -895,7 +934,15 @@ R.maps = {
|
||||
destMap = f.str, destWarp = f.int(0),
|
||||
destGroup = f.opt(f.int(0)),
|
||||
destMapNum = f.opt(f.int(0)) })),
|
||||
objects = f.opt(f.list(f.any)),
|
||||
-- NPCs, signs, items, warps and static wild encounters all share this
|
||||
-- one array, with no field the loader could use to tell them apart
|
||||
-- ahead of time -- an f.rec strict enough to describe every kind would
|
||||
-- reject the others. f.partial types only `pokemon` (the static
|
||||
-- encounter's species, OverworldController.lua's `d.pokemon` ->
|
||||
-- BattleState.newWild) so a bad id is a load-time error, the same as
|
||||
-- an encounter slot's species, instead of the crash newWild has no
|
||||
-- guard against. Every other object field passes through untouched.
|
||||
objects = f.opt(f.list(f.partial{ pokemon = f.opt(f.id("pokemon")) })),
|
||||
signs = f.opt(f.list(f.any)),
|
||||
connections = f.opt(f.map(f.enum{ "north", "south", "east", "west" }, f.any)),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
-- A map object's `pokemon` field (the static wild encounter kind --
|
||||
-- OverworldController.lua's `d.pokemon`, handed straight to
|
||||
-- BattleState.newWild with no existence check of its own) used to go
|
||||
-- completely unchecked: R.maps.objects was f.opt(f.list(f.any)), so a
|
||||
-- typo'd species sat in a loaded mod and only surfaced as a crash the
|
||||
-- moment a player stepped up to that object. Every other kind sharing the
|
||||
-- objects array (NPCs, signs-as-objects, warps) has fields this schema
|
||||
-- still does not know about, which is what f.partial is for: it types only
|
||||
-- `pokemon` and leaves the rest of an object's shape alone.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
|
||||
local function manifest(id)
|
||||
return ([[{
|
||||
"id": "%s", "name": "%s", "version": "1.0.0",
|
||||
"entry": "main.lua", "api": 2
|
||||
}]]):format(id, id)
|
||||
end
|
||||
|
||||
-- ------- a bad species id is caught as a load error, not left to crash
|
||||
|
||||
local BAD = {
|
||||
["mods/bad_static_encounter/manifest.json"] = manifest("bad_static_encounter"),
|
||||
["mods/bad_static_encounter/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.content.maps:patch("FIX_ROUTE", {
|
||||
objects = {
|
||||
{ pokemon = "NOT_A_SPECIES", level = 30, text = "Gyaoo!" },
|
||||
},
|
||||
})
|
||||
]],
|
||||
}
|
||||
|
||||
do
|
||||
local run = T.sdk.loadMods({ "mods/bad_static_encounter" },
|
||||
{ fs = T.sdk.memfs(BAD) })
|
||||
local dangling = {}
|
||||
for _, message in ipairs(run.errors) do
|
||||
if message:match("unresolved reference") then
|
||||
dangling[#dangling + 1] = message
|
||||
end
|
||||
end
|
||||
T.eq(#dangling, 1,
|
||||
"a bad static-encounter species is reported once ("
|
||||
.. table.concat(dangling, "; ") .. ")")
|
||||
T.check(dangling[1] and dangling[1]:match("maps%.FIX_ROUTE%.objects")
|
||||
and dangling[1]:match("pokemon"),
|
||||
"the report names the map, the objects field and the pokemon registry: "
|
||||
.. tostring(dangling[1]))
|
||||
run.release()
|
||||
end
|
||||
|
||||
-- ------- a real species resolves, and an NPC-shaped object beside it (no
|
||||
-- pokemon field at all, and fields this schema never named -- sprite,
|
||||
-- movement, range) is untouched
|
||||
|
||||
local GOOD = {
|
||||
["mods/good_static_encounter/manifest.json"] = manifest("good_static_encounter"),
|
||||
["mods/good_static_encounter/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.content.maps:patch("FIX_ROUTE", {
|
||||
objects = {
|
||||
{ index = 1, name = "FIXROUTE_TRAINER", sprite = "SPRITE_FIX_NPC",
|
||||
movement = "STAY", range = "NONE", text = "TEXT_FIXROUTE_TRAINER",
|
||||
x = 5, y = 9 },
|
||||
{ pokemon = "FIXMON_A", level = 30, text = "Gyaoo!" },
|
||||
},
|
||||
})
|
||||
]],
|
||||
}
|
||||
|
||||
do
|
||||
local run = T.sdk.loadMods({ "mods/good_static_encounter" },
|
||||
{ fs = T.sdk.memfs(GOOD) })
|
||||
T.eq(#run.errors, 0,
|
||||
"a real species and an untyped NPC object both load clean ("
|
||||
.. tostring(run.errors[1]) .. ")")
|
||||
local objects = run.data.maps.FIX_ROUTE.objects
|
||||
T.eq(#objects, 2, "both objects landed on the map")
|
||||
T.eq(objects[1].sprite, "SPRITE_FIX_NPC",
|
||||
"the NPC object's untyped fields passed through unexamined")
|
||||
T.eq(objects[2].pokemon, "FIXMON_A",
|
||||
"the static encounter's species field passed through too")
|
||||
run.release()
|
||||
end
|
||||
|
||||
T.finish()
|
||||
Reference in New Issue
Block a user