diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index 9be13727..dfa2fb97 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -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)), }, diff --git a/tests/engine/map_object_pokemon_ref_test.lua b/tests/engine/map_object_pokemon_ref_test.lua new file mode 100644 index 00000000..905f400d --- /dev/null +++ b/tests/engine/map_object_pokemon_ref_test.lua @@ -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()