-- World & maps v2: the de-Kanto'd literals replayed against their old
-- values, authoring a new map/tileset/encounter table through the
-- registries, the encounter/palette/collision/warp hooks, the map events,
-- MapLoader invalidation, and the mod.world runtime services.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")

local Collision = require("src.world.Collision")
local Data = require("src.core.Data")
local Encounter = require("src.world.Encounter")
local Events = require("src.mods.Events")
local FieldDefaults = require("src.world.FieldDefaults")
local Hooks = require("src.mods.Hooks")
local Loader = require("src.mods.Loader")
local Map = require("src.world.Map")
local MapLoader = require("src.world.MapLoader")
local Merge = require("src.mods.Merge")
local OW = require("src.world.OverworldController")
local Registry = require("src.mods.Registry")
local Runtime = require("src.mods.Runtime")
local Schemas = require("src.mods.Schemas")
local Warp = require("src.world.Warp")
local WorldAPI = require("src.world.WorldAPI")

local S = require("tests.harness").suite("world & maps v2")
local check = S.check

if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end

-- the same upvalue rewire the parity suites use: OverworldState's methods
-- close over a module-local Game that only a real boot assigns
local function bindGame(fn, game)
  local i = 1
  while true do
    local name = debug.getupvalue(fn, i)
    if not name then return false end
    if name == "Game" then debug.setupvalue(fn, i, game) return true end
    i = i + 1
  end
end

-- ------- the literal-lift oracles

-- paletteNameFor before this milestone, verbatim, as the oracle
local TOWN_PALS = {
  PALLET_TOWN = "PALLET", VIRIDIAN_CITY = "VIRIDIAN",
  PEWTER_CITY = "PEWTER", CERULEAN_CITY = "CERULEAN",
  LAVENDER_TOWN = "LAVENDER", VERMILION_CITY = "VERMILION",
  CELADON_CITY = "CELADON", FUCHSIA_CITY = "FUCHSIA",
  CINNABAR_ISLAND = "CINNABAR", INDIGO_PLATEAU = "INDIGO",
  SAFFRON_CITY = "SAFFRON",
}
local function oldPaletteNameFor(def, lastOutdoorId)
  local ts, id = def.tileset, def.id
  if ts == "CEMETERY" then return "GRAYMON"
  elseif ts == "CAVERN" then return "CAVE"
  elseif id == "LORELEIS_ROOM" then return "PALLET"
  elseif id == "BRUNOS_ROOM" then return "CAVE"
  elseif TOWN_PALS[id] or id:match("^ROUTE_") then
    return TOWN_PALS[id] or "ROUTE"
  end
  local last = lastOutdoorId or "PALLET_TOWN"
  return TOWN_PALS[last] or "ROUTE"
end

do
  local fakeGame = { data = Data }
  check(bindGame(OW.paletteNameFor, fakeGame), "paletteNameFor binds Game")
  -- every map, with no outdoor memory and with each of the outdoor maps
  -- remembered, must land on the palette the literals used to pick
  -- "" stands for no outdoor memory at all
  local lasts = { "", "PALLET_TOWN", "ROUTE_1", "INDIGO_PLATEAU", "CELADON_CITY" }
  local mapIds = {}
  for id in pairs(Data.maps) do mapIds[#mapIds + 1] = id end
  table.sort(mapIds)
  local compared = 0
  for _, lastId in ipairs(lasts) do
    local last = lastId ~= "" and { id = lastId } or nil
    local state = setmetatable({ lastOutdoor = last }, { __index = OW })
    for _, id in ipairs(mapIds) do
      local def = Data.maps[id]
      local want = oldPaletteNameFor(def, last and last.id)
      local got = state:paletteNameFor({ id = id, def = def })
      check(got == want, ("palette parity %s (last %s): got %s, want %s")
        :format(id, tostring(last and last.id), tostring(got), tostring(want)))
      compared = compared + 1
    end
  end
  check(compared >= #mapIds * 2, "palette oracle covered every map")
end

do
  -- Map:isWaterCell against the pre-change literals, over every cell of
  -- the largest map of each tileset (SHIP_PORT's $32 exception included)
  local byTileset, area = {}, {}
  for id, def in pairs(Data.maps) do
    local size = def.width * def.height
    if size > (area[def.tileset] or -1) then
      area[def.tileset], byTileset[def.tileset] = size, id
    end
  end
  local cells = 0
  for tileset, mapId in pairs(byTileset) do
    local map = MapLoader.load(Data, mapId)
    for cy = 0, map.heightCells - 1 do
      for cx = 0, map.widthCells - 1 do
        local t = map:cellTile(cx, cy)
        local want = t == 0x14
          or (tileset ~= "SHIP_PORT" and (t == 0x32 or t == 0x48))
        check(map:isWaterCell(cx, cy) == want,
          ("water parity %s (%d,%d) tile %02x"):format(mapId, cx, cy, t))
        cells = cells + 1
      end
    end
  end
  check(cells > 10000, "water oracle walked a real sample of cells")
end

do
  -- outdoor / outside / region / ghost / pushable, replayed per map
  for id, def in pairs(Data.maps) do
    check(Map.isOutdoor(def) == (def.tileset == "OVERWORLD"),
      "outdoor parity " .. id)
    check(Map.isOutside(def) ==
      (def.tileset == "OVERWORLD" or def.tileset == "PLATEAU"),
      "outside parity " .. id)
    check(Map.inRegion(def, "SAFARI", "SAFARI_ZONE")
      == (id:find("SAFARI_ZONE", 1, true) == 1), "safari region parity " .. id)
    local ghost = Map.ghostBattles(def)
    check((ghost ~= nil) == (id:find("POKEMON_TOWER", 1, true) == 1),
      "ghost battle parity " .. id)
    for _, obj in ipairs(def.objects or {}) do
      check(Map.isPushable(obj) == (obj.sprite == "SPRITE_BOULDER"),
        "pushable parity " .. id)
    end
  end
  -- the properties win over the fallbacks, which is how a new map opts in
  check(Map.isOutdoor({ id = "X", tileset = "CAVERN", outdoor = true }),
    "map.outdoor overrides the tileset fallback")
  check(not Map.isOutdoor({ id = "X", tileset = "OVERWORLD", outdoor = false }),
    "map.outdoor = false is honored, not treated as absent")
  check(Map.inRegion({ id = "MY_ZONE", region = "SAFARI" }, "SAFARI", "SAFARI_ZONE"),
    "map.region reaches the safari rules without a Kanto name")
  check(not Map.inRegion({ id = "SAFARI_ZONE_X", region = "OTHER" }, "SAFARI",
    "SAFARI_ZONE"), "an explicit region beats the id prefix")
  check(Map.isPushable({ sprite = "SPRITE_OAK", pushable = true }),
    "obj.pushable makes any sprite a boulder")
end

do
  -- the Route 22 Gate LAST_MAP rewrite, now a table
  local rewrite = FieldDefaults.field(Data, "lastMapRewrites").ROUTE_22_GATE
  for y = 0, 8 do
    local want = y < 4 and "ROUTE_23" or "ROUTE_22"
    check(OW.rewrittenLastMap(rewrite, 0, y) == want,
      "last-map rewrite parity at y=" .. y)
  end
  -- ordered rules, first match wins, and the x axis works the same
  local custom = { axis = "x", rules = { { below = 2, map = "A" },
                                         { atLeast = 6, map = "C" },
                                         { map = "B" } } }
  check(OW.rewrittenLastMap(custom, 1, 0) == "A", "x-axis rewrite low")
  check(OW.rewrittenLastMap(custom, 4, 0) == "B", "x-axis rewrite default row")
  check(OW.rewrittenLastMap(custom, 7, 0) == "C", "x-axis rewrite atLeast")
end

do
  -- badge gates dispatch on the record's shape, not the map id, and the
  -- Route 22 gate keeps its pre-v2 save flag spelling
  check(FieldDefaults.fieldValue(Data, "badgeGates", "ROUTE_22_GATE", "passedFlag")
    == "PASSED_ROUTE22_GATE", "the vanilla gate keeps its save flag name")
  check(FieldDefaults.fieldValue(Data, "badgeGates", "MY_GATE", "passedFlag")
    == nil, "a gate a mod adds falls through to PASSED_<mapId>")
  check(Data.field.badgeGates.ROUTE_22_GATE.coords ~= nil,
    "the Route 22 gate record is the coords shape")
  check(Data.field.badgeGates.ROUTE_23.guards ~= nil,
    "the Route 23 record is the guards shape")
end

do
  -- the ledge rows keep their OVERWORLD-only reach until a row says otherwise
  local rows = 0
  for _, ledge in ipairs(Data.field.ledges) do
    check(ledge.tileset == nil, "vanilla ledge rows carry no tileset")
    rows = rows + 1
  end
  check(rows > 0, "ledge rows extracted")
end

-- ------- encounter buckets

do
  check(FieldDefaults.constant(Data, "encounterBuckets")[10] == 256,
    "encounterBuckets seeded, last bucket 256")
  local seq, i = { 0, 20 }, 0
  local function rng() i = i + 1 return seq[i] end
  -- rate 30 > 0 so the first draw bites; pick 20 lands in slot 1 (< 51)
  local def = { grass = { rate = 30, slots = {
    { species = "RATTATA", level = 3 }, { species = "PIDGEY", level = 5 } } } }
  local enc = Encounter.roll(def, rng)
  check(enc and enc.species == "RATTATA", "vanilla buckets pick slot 1")
  -- a per-def bucket array of any length reshapes the odds
  i = 0
  def.grass.buckets = { 10, 256 }
  enc = Encounter.roll(def, rng)
  check(enc and enc.species == "PIDGEY", "per-def buckets reshape the slot pick")
end

-- ------- fixture + inline mods

local function memfs(files)
  return {
    read = function(path) return files[path] end,
    getInfo = function(path)
      if files[path] then return { type = "file" } end
      local prefix = path .. "/"
      for key in pairs(files) do
        if key:sub(1, #prefix) == prefix then return { type = "directory" } end
      end
      return nil
    end,
    load = function(path)
      if not files[path] then return nil, "no file: " .. path end
      return load(files[path], path)
    end,
    getDirectoryItems = function(path)
      local seen, items = {}, {}
      local prefix = path .. "/"
      for key in pairs(files) do
        if key:sub(1, #prefix) == prefix then
          local child = key:sub(#prefix + 1):match("^[^/]+")
          if child and not seen[child] then
            seen[child] = true
            items[#items + 1] = child
          end
        end
      end
      table.sort(items)
      return items
    end,
  }
end

local function manifestJson(id)
  return ([[{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua","api":2}]])
    :format(id, id)
end

-- a private slice of the real dataset: these tests merge into their data
local function fixture()
  local maps = {}
  for _, id in ipairs({ "PALLET_TOWN", "ROUTE_21", "ROUTE_22_GATE",
                        "SEAFOAM_ISLANDS_B4F", "VERMILION_GYM" }) do
    maps[id] = Merge.deepCopy(Data.maps[id])
  end
  local pokemon = {}
  for _, id in ipairs({ "TANGELA", "MAGIKARP", "GOLDEEN", "POLIWAG" }) do
    pokemon[id] = Merge.deepCopy(Data.pokemon[id])
  end
  return {
    maps = maps,
    pokemon = pokemon,
    tilesets = Merge.deepCopy(Data.tilesets),
    items = Merge.deepCopy(Data.items),
    moves = Merge.deepCopy(Data.moves),
    sprites = Merge.deepCopy(Data.sprites),
    encounters = {},
    constants = Merge.deepCopy(Data.constants),
    field = FieldDefaults.seed({ field = Merge.deepCopy(Data.field) }).field,
  }
end

local function withMod(id, source)
  local loader = Loader.new({ fs = memfs({
    ["mods/" .. id .. "/manifest.json"] = manifestJson(id),
    ["mods/" .. id .. "/main.lua"] = source,
  }) })
  local data = fixture()
  local ok = loader:load(data)
  return data, loader, ok
end

-- ------- seeding is fill-only and idempotent

do
  local data = { field = { palettes = { byMap = { MY_TOWN = "MINE" } } },
                 constants = { world = { stepFrames = 4 } } }
  FieldDefaults.seed(data)
  check(data.field.palettes.byMap.MY_TOWN == "MINE",
    "seed never overwrites a stamped value")
  check(data.field.palettes.byMap.PALLET_TOWN == "PALLET",
    "seed fills the missing siblings of a stamped record")
  check(data.constants.world.stepFrames == 4, "seed leaves a stamped constant")
  check(data.constants.world.turnFrames == 4, "seed fills the missing siblings")
  check(data.field.fishing.OLD_ROD.always.species == "MAGIKARP",
    "seed installs the whole missing key")
  local before = data.field.fishing
  FieldDefaults.seed(data)
  check(data.field.fishing == before, "seed is idempotent")
  -- the vanilla dataset is untouched by any of this
  check(Data.field.source ~= nil, "the real field table still loads")
end

-- ------- authoring a new map, tileset, connection and encounter table

local SABLE = [[
return function(mod)
  -- a mod-local atlas is just a path; this fixture borrows the vanilla
  -- sheet so the headless renderer has real dimensions to quad up
  mod.content.tilesets:register("SABLE_TILES", {
    id = "SABLE_TILES",
    image = "assets/generated/tilesets/overworld.png",
    imageWidth = 128, imageHeight = 48, tilesPerRow = 16,
    blocks = (function()
      local blocks = {}
      for b = 1, 4 do
        local row = {}
        -- block 0 is all grass, block 3 all water; 1 and 2 are spare
        for i = 1, 16 do row[i] = (b == 1) and 0x01 or 0x14 end
        blocks[b] = row
      end
      return blocks
    end)(),
    walkable = { 0x01 },
    waterTiles = { 0x14 },
    shoreTiles = {},
    grassTile = 0x01,
  })
  mod.content.maps:register("SABLE_COVE", {
    id = "SABLE_COVE", label = "SableCove",
    tileset = "SABLE_TILES", width = 4, height = 4,
    blocks = (function()
      local b = {}
      for i = 1, 16 do b[i] = (i <= 8) and 0 or 3 end
      return b
    end)(),
    borderBlock = 3,
    warps = {}, signs = {}, objects = {},
    connections = { north = { map = "ROUTE_21", offset = 0 } },
    outdoor = true, palette = "WATER",
  })
  mod.content.maps:patch("ROUTE_21", {
    connections = { south = { map = "SABLE_COVE", offset = 0 } },
  })
  mod.content.encounters:register("SABLE_COVE", {
    grass = { rate = 200, slots = (function()
      local s = {}
      for i = 1, 10 do s[i] = { level = 22, species = "TANGELA" } end
      return s
    end)() },
  })
  mod.content.field:patch("hiddenItems", {
    SABLE_COVE = { { x = 3, y = 3, item = "NUGGET" } },
  })
  mod.content.field:patch("flyWarps", { SABLE_COVE = { x = 2, y = 2 } })
  mod.content.field:patch("flyOrder", { __append = { "SABLE_COVE" } })
  mod.content.field:patch("townMap", {
    locations = { SABLE_COVE = { x = 4, y = 17, name = "SABLE COVE" } },
    cursorOrder = { __append = { "SABLE_COVE" } },
  })
  mod.content.field:patch("palettes", { byMap = { SABLE_COVE = "WATER" } })
end
]]

do
  local data, loader, ok = withMod("sable_cove", SABLE)
  check(ok, "the new-map mod loads: " .. tostring((loader.errors or {})[1]))
  check(data.maps.SABLE_COVE ~= nil, "the authored map reaches data.maps")
  check(data.tilesets.SABLE_TILES ~= nil, "the authored tileset reaches data")
  check(data.maps.ROUTE_21.connections.south.map == "SABLE_COVE",
    "a connections patch lands on a base map")
  check(data.maps.ROUTE_21.connections.north ~= nil,
    "the base map keeps its other connections")

  -- the map builds and walks: MapLoader resolves both records lazily
  MapLoader.invalidate("SABLE_COVE")
  local cove = MapLoader.load(data, "SABLE_COVE")
  check(cove.widthCells == 8 and cove.heightCells == 8, "authored map geometry")
  check(cove:isWalkableCell(0, 0), "authored walkable tile")
  check(cove:isGrassCell(0, 0), "authored grass tile")
  check(cove:isWaterCell(0, 7), "authored water tile via tileset.waterTiles")
  check(not cove:isWaterCell(0, 0), "authored land tile is not water")
  check(Map.isOutdoor(cove.def), "the authored map declares itself outdoor")

  -- neighbors compose off the patched connection, both ways
  local fromCove = OW.computeNeighbors(data.maps, "SABLE_COVE", 1)
  check(#fromCove == 1 and fromCove[1].id == "ROUTE_21",
    "the authored map connects north to Route 21")
  local fromRoute = OW.computeNeighbors(data.maps, "ROUTE_21", 1)
  local sawCove = false
  for _, n in ipairs(fromRoute) do
    if n.id == "SABLE_COVE" then sawCove = true end
  end
  check(sawCove, "Route 21 connects back south to the authored map")

  -- the encounter table rolls the authored species
  local seq, i = { 0, 0 }, 0
  local enc = Encounter.roll(data.encounters.SABLE_COVE,
    function() i = i + 1 return seq[i] or 0 end)
  check(enc and enc.species == "TANGELA", "the authored encounter table rolls")

  -- the field patches added to Kanto instead of replacing it
  check(data.field.hiddenItems.SABLE_COVE[1].item == "NUGGET",
    "a map-dict field patch adds the mod's map")
  check(data.field.hiddenItems.VIRIDIAN_FOREST ~= nil,
    "the map-dict keeps every vanilla entry")
  check(data.field.flyWarps.SABLE_COVE.x == 2, "a fly warp is one patch")
  check(data.field.flyWarps.PALLET_TOWN ~= nil, "the vanilla fly warps survive")
  check(data.field.flyOrder[#data.field.flyOrder] == "SABLE_COVE",
    "__append puts the new map at the end of the fly order")
  check(#data.field.flyOrder == #Data.field.flyOrder + 1,
    "__append extends the list instead of replacing it")
  check(data.field.townMap.locations.SABLE_COVE.name == "SABLE COVE",
    "the town-map square is one patch")
  check(data.field.townMap.locations.PALLET_TOWN ~= nil,
    "the vanilla town-map squares survive")
  check(data.field.palettes.byMap.SABLE_COVE == "WATER",
    "a palettes patch adds a map")
  check(data.field.palettes.byMap.PALLET_TOWN == "PALLET",
    "the vanilla palette table survives the patch")
  MapLoader.invalidate("SABLE_COVE")
end

-- ------- town map and fly order as data

do
  local data = fixture()
  data.field.flyOrder = { "PALLET_TOWN", "ROUTE_21", "PALLET_TOWN" }
  data.maps.ROUTE_21.outdoor = false
  local game = { data = data, save = { visited = { PALLET_TOWN = true,
                                                   ROUTE_21 = true } } }
  local menu = require("src.ui.FlyMenu").new(game)
  local labels = {}
  for _, item in ipairs(menu.items or {}) do labels[#labels + 1] = item.value end
  check(#labels == 1 and labels[1] == "PALLET_TOWN",
    "the fly menu reads flyOrder, dedupes, and honors map.outdoor")
end

-- ------- MapLoader invalidation

do
  local data = fixture()
  local before = MapLoader.load(data, "PALLET_TOWN")
  local rendererBefore = before.renderer
  check(MapLoader.load(data, "PALLET_TOWN") == before, "the cache returns one instance")
  check(MapLoader.cached("PALLET_TOWN") == before, "cached() sees it")

  -- an un-invalidated map keeps its instance even after its record changes
  data.maps.PALLET_TOWN.blocks[1] = 0x0B
  check(MapLoader.load(data, "PALLET_TOWN") == before,
    "a record change alone does not reach a cached map")

  check(MapLoader.invalidate("PALLET_TOWN"), "invalidate reports the drop")
  check(MapLoader.cached("PALLET_TOWN") == nil, "the entry is gone")
  local after = MapLoader.load(data, "PALLET_TOWN")
  check(after ~= before, "the next load builds a fresh Map")
  check(after.renderer ~= rendererBefore, "and a fresh TileRenderer")
  check(after:blockAt(0, 0) == 0x0B, "blockAt reflects the patched record")
  check(not MapLoader.invalidate("NO_SUCH_MAP"), "invalidating a cold map is false")
  MapLoader.invalidateAll()
  check(MapLoader.cached("PALLET_TOWN") == nil, "invalidateAll clears everything")
end

-- ------- hooks: empty chains are the vanilla path

local function withBuses(fn)
  local events, hooks = Events.new(), Hooks.new()
  local prevE, prevH = Runtime.events, Runtime.hooks
  Runtime.install(events, hooks)
  local ok, err = pcall(fn, events, hooks)
  Runtime.events, Runtime.hooks = prevE, prevH
  if not ok then error(err, 0) end
end

do
  check(not Runtime.wantsHook("encounter.roll"),
    "no chain means no encounter ctx is ever built")
  check(not Runtime.wants("world.stepped"),
    "no listener means no world.stepped payload is ever built")
end

do
  -- encounter.roll suppresses; encounter.species transforms; unhooked is vanilla
  local data = fixture()
  data.encounters.PALLET_TOWN = { grass = { rate = 255, slots = (function()
    local s = {}
    for i = 1, 10 do s[i] = { level = 5, species = "TANGELA" } end
    return s
  end)() } }
  -- rollEncounter needs no Game: it reads the def it is handed and the map
  -- id off self, which is what keeps the hot path free of a Data lookup
  local state = setmetatable({ map = MapLoader.load(data, "PALLET_TOWN") },
    { __index = OW })

  local vanilla = state:rollEncounter(data.encounters.PALLET_TOWN, "grass")
  check(vanilla and vanilla.species == "TANGELA",
    "with no wrapper the roll is the vanilla pick")

  withBuses(function(_, hooks)
    hooks:wrap("encounter.roll", function() return nil end, 0, "suppressor")
    local suppressed = 0
    for _ = 1, 200 do
      if state:rollEncounter(data.encounters.PALLET_TOWN, "grass") == nil then
        suppressed = suppressed + 1
      end
    end
    check(suppressed == 200, "an encounter.roll wrapper suppresses every encounter")
  end)

  withBuses(function(_, hooks)
    local sawCtx
    hooks:wrap("encounter.species", function(next_, enc, ctx)
      sawCtx = ctx
      enc = next_(enc, ctx)
      enc.species = "MAGIKARP"
      return enc
    end, 0, "transformer")
    local enc = state:rollEncounter(data.encounters.PALLET_TOWN, "grass")
    check(enc and enc.species == "MAGIKARP",
      "an encounter.species wrapper transforms the roll")
    check(sawCtx.mapId == "PALLET_TOWN" and sawCtx.terrain == "grass",
      "the encounter ctx carries the documented fields")
  end)

  -- the chain sees the def and may force a pick without calling next
  withBuses(function(_, hooks)
    hooks:wrap("encounter.roll", function()
      return { species = "GOLDEEN", level = 40 }
    end, 0, "forcer")
    local enc = state:rollEncounter(nil, "water")
    check(enc and enc.species == "GOLDEEN" and enc.level == 40,
      "a wrapper that skips next forces its own encounter")
  end)
  MapLoader.invalidateAll()
end

do
  -- the rod tables are field.fishing now, and encounter.fishing wraps the
  -- catch with the resolved pool in hand
  local fishing = FieldDefaults.field(Data, "fishing")
  check(fishing.OLD_ROD.always.species == "MAGIKARP",
    "the Old Rod's fixed catch is data")
  check(#fishing.GOOD_ROD.pool == 2 and fishing.GOOD_ROD.pool[1].species == "GOLDEEN",
    "the Good Rod's pool is data")
  check(fishing.SUPER_ROD.perMap == "superRod",
    "the Super Rod points at the per-map field key")
  check(Data.field.superRod ~= nil, "and that key is the extracted table")
end

do
  -- movement.collision: the verdict flips, the reason rides ctx
  local map = MapLoader.load(Data, "PALLET_TOWN")
  local mover = { cellX = 5, cellY = 6, facing = "up" }
  local blocked, why = Collision.canMove(map, { mover }, mover, "left")
  check(blocked == true, "the plaza step is legal with no wrapper")
  withBuses(function(_, hooks)
    local seen
    hooks:wrap("movement.collision", function(next_, allowed, ctx)
      seen = ctx
      allowed = next_(allowed, ctx)
      ctx.reason = "warded"
      return false
    end, 0, "warder")
    local ok2, reason = Collision.canMove(map, { mover }, mover, "left")
    check(ok2 == false and reason == "warded",
      "a movement.collision wrapper blocks a legal step")
    check(seen.map == map and seen.mover == mover and seen.dir == "left"
      and seen.fromX == 5 and seen.fromY == 6 and seen.toX == 4 and seen.toY == 6,
      "the collision ctx carries the documented fields")
  end)
  -- (4,4) is a house wall: the vanilla reason survives the unwrapped path
  local walled = { cellX = 4, cellY = 5, facing = "up" }
  blocked, why = Collision.canMove(map, { walled }, walled, "up")
  check(blocked == false and why == "tile", "a walled step still reports 'tile'")
end

do
  -- fieldmove.eligibility: next_ is the whole vanilla partyKnows check,
  -- so a wrapper can widen field-move rules without forking the engine
  local save = {
    inventory = { SOULBADGE = 1, THUNDERBADGE = 1, CASCADEBADGE = 1 },
    party = { { species = "SQUIRTLE", moves = {} },
              { species = "PIDGEY", moves = { { id = "FLY" } } } },
  }
  local game = { save = save, data = Data }
  check(bindGame(OW.partyKnows, game), "partyKnows binds a test Game")
  check(OW:partyKnows("SURF") == nil, "no hook, no surf without a knower")
  check(OW:partyKnows("FLY") == save.party[2], "the knower wins unwrapped")
  withBuses(function(_, hooks)
    local seen
    hooks:wrap("fieldmove.eligibility", function(next_, moveId, ctx)
      seen = { moveId = moveId, ctx = ctx }
      local mon = next_(moveId, ctx)
      if mon then return mon end -- vanilla first
      if moveId == "SURF" then return ctx.save.party[1] end
      return nil
    end, 0, "rental")
    check(OW:partyKnows("SURF") == save.party[1],
      "the hook can offer a field move vanilla denies")
    check(OW:partyKnows("FLY") == save.party[2],
      "a vanilla knower still beats the hook")
    check(OW:partyKnows("CUT") == nil,
      "next_ still yields nil when the hook declines")
    check(seen.moveId == "CUT" and seen.ctx.save == save
      and seen.ctx.data == Data,
      "the hook ctx carries moveId, save, and data")
  end)
end

do
  -- issue #320: the surf blink sits UNDER the "got on" text (pokered's
  -- GBPalWhiteOutWithDelay3 runs while the text is up), and the surfing
  -- sprite swap waits for the actual step onto the water
  require("src.render.Font").load(Data)
  local save = {
    flags = {},
    inventory = { SOULBADGE = 1 },
    party = { { species = "SQUIRTLE", moves = { { id = "SURF" } } } },
    player = { name = "RED" },
  }
  local stack = { states = {} }
  function stack:push(s) self.states[#self.states + 1] = s end
  function stack:pop() return table.remove(self.states) end
  function stack:top() return self.states[#self.states] end
  local game = { data = Data, save = save, stack = stack }
  check(bindGame(OW.trySurf, game), "trySurf binds a test Game")
  check(bindGame(OW.partyKnows, game), "partyKnows binds the same Game")
  local ow = setmetatable({ player = { facing = "down" } }, { __index = OW })
  ow.stepForwardOrCrossEdge = function(_, dir) ow.stepped = dir end

  local closed = false
  ow:trySurf(10, 10, function() closed = true end)
  local top = stack.states[1]
  check(#stack.states == 1 and top and top.pages ~= nil,
    "the surf text prints alone, over the party menu (#385)")
  check(not ow.player.surfing, "no surfing sprite while the text is up")

  top.onDone()
  check(closed, "the party menu closes when the text ends")
  check(ow.player.surfing == true, "surfing applies with the blink")
  local blink = stack.states[#stack.states]
  check(blink and blink.frames ~= nil, "the blink follows the text")
  blink.onDone()
  check(ow.stepped == "down", "the step onto the water fires")
end

do
  -- warp.destination reroutes one door without owning the warp table
  local warpDef = Data.maps.PALLET_TOWN.warps[1]
  local m1 = Warp.destination(Data, warpDef)
  withBuses(function(_, hooks)
    hooks:wrap("warp.destination", function(next_, mapId, x, y, ctx)
      check(ctx.warp == warpDef, "warp ctx carries the warp record")
      local _, nx, ny = next_(mapId, x, y, ctx)
      return "ROUTE_21", nx, ny
    end, 0, "rerouter")
    local m2, x2 = Warp.destination(Data, warpDef)
    check(m2 == "ROUTE_21", "a warp.destination wrapper reroutes the door")
    check(x2 ~= nil, "and the landing cell still resolves")
  end)
  check(Warp.destination(Data, warpDef) == m1,
    "unwrapping restores the vanilla destination")
end

do
  -- map.palette recolors without touching the table
  local state = setmetatable({}, { __index = OW })
  check(bindGame(OW.paletteNameFor, { data = Data }), "paletteNameFor rebinds")
  local map = { id = "PALLET_TOWN", def = Data.maps.PALLET_TOWN }
  check(state:paletteNameFor(map) == "PALLET", "vanilla palette unhooked")
  withBuses(function(_, hooks)
    hooks:wrap("map.palette", function(next_, name, m)
      check(m == map, "the palette hook sees the map")
      return next_(name, m) .. "_NIGHT"
    end, 0, "night")
    check(state:paletteNameFor(map) == "PALLET_NIGHT",
      "a map.palette wrapper transforms the name")
  end)
end

-- ------- events

do
  local data = fixture()
  withBuses(function(events)
    local seen = {}
    events:on("world.block_replaced", function(ev) seen.block = ev end, 0, "spy")
    events:on("map.reloaded", function(ev) seen.reload = ev end, 0, "spy")
    local map = MapLoader.load(data, "PALLET_TOWN")
    local state = setmetatable({ map = map, neighbors = {} }, { __index = OW })
    state:replaceBlock(1, 1, 7)
    check(seen.block and seen.block.mapId == "PALLET_TOWN"
      and seen.block.bx == 1 and seen.block.by == 1 and seen.block.block == 7,
      "world.block_replaced fires with the documented payload")

    check(bindGame(OW.reloadMap, { data = data }), "reloadMap binds Game")
    state.map = nil -- not the active map: pure cache drop
    state:reloadMap("ROUTE_21", "hot_reload")
    check(seen.reload and seen.reload.mapId == "ROUTE_21"
      and seen.reload.reason == "hot_reload",
      "map.reloaded carries the reason")
  end)
  MapLoader.invalidateAll()
end

-- ------- a live world, driven headlessly

-- setMap and onStepComplete close over the same two module-locals a real
-- boot fills in; with both rewired the world runs without a Game:load
local function liveWorld(data)
  local StateStack = require("src.core.StateStack")
  local SaveData = require("src.core.SaveData")
  require("src.render.Font").load(data)
  local stack = setmetatable({ states = {} }, { __index = StateStack })
  local game = { data = data, save = SaveData.newGame(), stack = stack,
                 input = { isDown = function() return false end },
                 renderer = { worldViewSize = function() return 160, 144 end } }
  check(bindGame(OW.setMap, game), "setMap binds Game")
  local i = 1
  while true do
    local name = debug.getupvalue(OW.setMap, i)
    if not name then break end
    if name == "mapScripts" then
      debug.setupvalue(OW.setMap, i, require("data.scripts.init"))
    end
    i = i + 1
  end
  local state = setmetatable({ camera = require("src.render.Camera").new(),
                               scriptMoves = {}, npcPool = {} },
                             { __index = OW })
  stack.states[1] = state
  game.overworld = state
  return state, game
end

do
  local data = fixture()
  data.maps = Merge.deepCopy(Data.maps) -- setMap walks the connection graph
  data.encounters = Merge.deepCopy(Data.encounters)
  data.audio = Data.audio
  data.text = Data.text
  data.font = Data.font
  local state, game = liveWorld(data)

  withBuses(function(events)
    local seen = {}
    events:on("map.entered", function(ev) seen.entered = ev end, 0, "spy")
    events:on("map.exited", function(ev) seen.exited = ev end, 0, "spy")
    events:on("world.stepped", function(ev) seen.stepped = ev end, 0, "spy")

    state:setMap("PALLET_TOWN", 5, 6, "down", { via = "boot" })
    check(seen.entered and seen.entered.mapId == "PALLET_TOWN"
      and seen.entered.via == "boot" and seen.entered.fromMapId == nil
      and seen.entered.map == state.map,
      "map.entered fires at boot with the documented payload")
    check(seen.exited == nil, "map.exited does not fire when no map was loaded")

    state:setMap("ROUTE_21", 5, 6, "down")
    check(seen.exited and seen.exited.mapId == "PALLET_TOWN"
      and seen.exited.toMapId == "ROUTE_21",
      "map.exited names both sides of the change")
    check(seen.entered.via == "warp" and seen.entered.fromMapId == "PALLET_TOWN",
      "map.entered reports the previous map and how it was reached")

    state:setMap("PALLET_TOWN", 5, 6, "down", { seamless = true })
    check(seen.entered.via == "connection", "a seamless crossing reports 'connection'")

    pcall(state.onStepComplete, state)
    check(seen.stepped and seen.stepped.mapId == "PALLET_TOWN"
      and seen.stepped.x == 5 and seen.stepped.y == 6
      and type(seen.stepped.tile) == "number",
      "world.stepped fires with mapId, cell and tile")
  end)

  -- WorldAPI against the live world: invalidateMap reloads in place
  withBuses(function(events)
    local reloaded
    events:on("map.reloaded", function(ev) reloaded = ev end, 0, "spy")
    local api = WorldAPI.new(game, "tester")
    local snapshot = api:current()
    check(snapshot.mapId == "PALLET_TOWN" and snapshot.x == 5,
      "current() finds the world through the stack marker")

    local before = MapLoader.cached("PALLET_TOWN")
    local pool = state.npcPool
    data.maps.PALLET_TOWN.blocks[1] = 0x0B
    check(api:invalidateMap("PALLET_TOWN") == true, "invalidateMap succeeds")
    check(reloaded and reloaded.mapId == "PALLET_TOWN"
      and reloaded.reason == "invalidate", "map.reloaded fires for the caller")
    check(MapLoader.cached("PALLET_TOWN") ~= before, "the map was rebuilt")
    check(state.map:blockAt(0, 0) == 0x0B, "the reloaded map sees the new record")
    check(state.player.cellX == 5 and state.player.cellY == 6,
      "the player keeps its cell across the reload")
    check(state.npcPool == pool, "the NPC pool identity survives the reload")

    -- warping through the facade lands the player on the new map
    check(api:warpTo("ROUTE_21", 4, 4, "up") == true, "warpTo starts the warp")
    check(state.transitioning == true, "and the world is mid-transition")
  end)

  -- walking the authored map triggers a wild battle from its own table
  do
    local moddedData, _, loaded = withMod("sable_cove", SABLE)
    check(loaded, "the authored map mod loads for the walk")
    moddedData.maps.ROUTE_21 = Merge.deepCopy(Data.maps.ROUTE_21)
    moddedData.audio, moddedData.text = data.audio, data.text
    moddedData.font, moddedData.trainer_headers = data.font, {}
    local walker, walkerGame = liveWorld(moddedData)
    walkerGame.save.party = { require("src.pokemon.Pokemon").new(moddedData,
                                                                 "TANGELA", 30) }
    walker:setMap("SABLE_COVE", 0, 0, "down", { via = "boot" })
    check(walker.map:isGrassCell(0, 0), "the player stands in authored grass")
    local caught
    walker.pushBattle = function(_, battle) caught = battle end
    -- rate 200/256 per step: no reseed, so the shared RNG stream the rest
    -- of the runner depends on is left exactly where it was
    for _ = 1, 200 do
      pcall(walker.onStepComplete, walker)
      if caught then break end
    end
    check(caught ~= nil, "a wild battle fires on the authored map")
    check(caught.enemy and caught.enemy.mon
      and caught.enemy.mon.species == "TANGELA",
      "and it is the species the authored encounter table names")

    -- Trainer battles do not arm the wild-encounter cooldown.
    walker.wildEncounterGraceSteps = 0
    walker:afterBattle("win", { kind = "trainer" })
    check(walker.wildEncounterGraceSteps == 0,
      "trainer battles do not start the wild encounter grace period")

    -- pokered grants three completed steps after a wild battle before the
    -- next random battle can start (end_of_battle.asm + home/overworld.asm).
    local finishedWild = caught
    walker:afterBattle("run", finishedWild)
    caught = nil
    withBuses(function(_, hooks)
      hooks:wrap("encounter.roll", function()
        return { species = "TANGELA", level = 5 }
      end, 0, "grace-period")
      for step = 1, 3 do
        pcall(walker.onStepComplete, walker)
        check(caught == nil, "wild encounter grace period blocks step " .. step)
      end
      pcall(walker.onStepComplete, walker)
      check(caught ~= nil, "wild encounter is eligible on step 4")
    end)

    -- the same walk with an encounter.roll wrapper never starts a battle
    withBuses(function(_, hooks)
      hooks:wrap("encounter.roll", function() return nil end, 0, "nuzlocke")
      caught = nil
      for _ = 1, 1000 do
        pcall(walker.onStepComplete, walker)
        check(caught == nil, "encounter.roll suppression holds for the whole walk")
      end
    end)
    MapLoader.invalidateAll()
  end

  MapLoader.invalidateAll()
end

-- ------- a map record that omits the optional subtables

do
  -- warps/signs/objects are all f.opt in the maps schema, so a record
  -- authored without them has to survive the entered-map spawn loop and
  -- the neighbor ghost loop, not just Map.new
  local data = fixture()
  data.maps = Merge.deepCopy(Data.maps)
  data.encounters = Merge.deepCopy(Data.encounters)
  data.audio, data.text, data.font = Data.audio, Data.text, Data.font
  data.trainer_headers = {}

  local bare = Merge.deepCopy(Data.maps.ROUTE_21)
  bare.id, bare.label = "BARE_COVE", "BareCove"
  bare.warps, bare.signs, bare.objects = nil, nil, nil
  bare.connections = { north = { map = "ROUTE_21", offset = 0 } }
  data.maps.BARE_COVE = bare
  data.maps.ROUTE_21.connections.south = { map = "BARE_COVE", offset = 0 }

  local state = liveWorld(data)
  local ok, err = pcall(state.setMap, state, "BARE_COVE", 1, 1, "down",
                        { via = "boot" })
  check(ok, "entering a map with no objects table does not throw: "
    .. tostring(err))
  check(#state.npcs == 0, "and it simply spawns no NPCs")

  -- the same record reached as a rendered neighbor of the active map
  ok, err = pcall(state.setMap, state, "ROUTE_21", 5, 5, "down")
  check(ok, "a neighbor with no objects table does not throw: "
    .. tostring(err))
  local sawBare = false
  for _, nb in ipairs(state.neighbors) do
    if nb.map.id == "BARE_COVE" then sawBare = true end
  end
  check(sawBare, "and that neighbor really was in the drawn set")

  MapLoader.invalidateAll()
end

-- ------- mod.world

do
  local api = WorldAPI.new({ data = Data, stack = { states = {} } }, "tester")
  local value, err = api:current()
  check(value == nil and err == "no overworld", "current() off the world")
  value, err = api:warpTo("PALLET_TOWN", 5, 6)
  check(value == nil and err == "no overworld", "warpTo() off the world")
  value, err = api:replaceBlock(0, 0, 1)
  check(value == nil and err == "no overworld", "replaceBlock() off the world")
  value, err = api:spawnNpc("PALLET_TOWN", { sprite = "SPRITE_OAK" })
  check(value == nil and err == "no overworld", "spawnNpc() off the world")
  value, err = api:npc("PALLET_TOWN", 1)
  check(value == nil and err == "no overworld", "npc() off the world")
  value, err = api:queueScript({})
  check(value == nil and err == "no overworld", "queueScript() off the world")
  value, err = api:startWildBattle("PIDGEY", 5)
  check(value == nil and err == "no overworld", "startWildBattle() off the world")
end

-- startWildBattle: what regresses is the handoff, not the battle.  A mod that
-- builds a BattleState and pushes it itself still fights and still levels; it
-- silently loses onFinish -> afterBattle (evolutions, blackout-on-loss) and
-- pushBattle (entry wipe, battle theme).  Shipped mods have hit exactly this.
do
  -- the real dataset, not fixture(): a battle reaches for type_chart, items,
  -- battle_anims and more, and this block only reads
  local data = Data
  local state, game = liveWorld(data)
  -- the handoff runs through these three, so each needs the live game
  for _, fn in ipairs({ "pushBattle", "isDungeonTransitionMap", "afterBattle" }) do
    check(bindGame(OW[fn], game), fn .. " binds Game")
  end
  state:setMap("PALLET_TOWN", 5, 6, "down", { via = "boot" })

  local Pokemon = require("src.pokemon.Pokemon")
  local api = WorldAPI.new(game, "tester")

  local value, err = api:startWildBattle("NOT_A_MON", 5)
  check(value == nil and err:find("unknown species", 1, true),
    "an unknown species refuses and names it")
  -- Pokemon.new writes the level through into the stat calc and the exp curve
  -- verbatim, so a fraction has to be refused rather than rounded downstream
  for _, lv in ipairs({ 0, 101, "nope", 5.5 }) do
    check(api:startWildBattle("PIDGEY", lv) == nil,
      "level " .. tostring(lv) .. " refuses")
  end

  local caterpie = Pokemon.new(data, "CATERPIE", 6)
  game.save.party = { caterpie }
  check(api:startWildBattle("PIDGEY", 25) == true, "a wild battle starts")

  -- pushBattle pushes the entry transition, which pushes the battle from its
  -- own callback; awardExp is the BattleState marker (screenId would not work,
  -- only Screens.push stamps that and pushBattle pushes the battle directly)
  check(game.stack:top() ~= nil and game.stack:top().awardExp == nil,
    "the entry transition goes on first")
  -- overworld() resolves the world from UNDER the battle, so a second call
  -- while one is up has to refuse rather than stack another
  check(api:startWildBattle("PIDGEY", 5) == nil,
    "a battle already running refuses")

  local battle
  for _ = 1, 400 do
    local t = game.stack:top()
    if t and t.awardExp then battle = t break end
    if t and t.update then t:update(1 / 60) else break end
  end
  check(battle ~= nil, "the transition hands off to the battle")

  battle.participants = { [caterpie] = true }
  battle:awardExp()
  check(caterpie.level >= 7, "the mon levels past its evolution threshold")
  check(battle.leveledUp and battle.leveledUp[caterpie],
    "awardExp records the level-up for EvolveAfterBattle")

  -- EndOfBattle evolves on the battle screen (end_of_battle.asm:42-45),
  -- so drive finish() rather than calling onFinish by hand
  battle.result = "win"
  battle:finish()
  -- the "is evolving!" box types out and holds DelayFrames 50 before the
  -- movie (evos_moves.asm:120-134) (#1596), so drive frames to reach it
  game.input.wasPressed = function() return false end
  local evoTop
  for _ = 1, 900 do
    local t = game.stack:top()
    if not t then break end
    if t.screenId == "EvolutionState" then evoTop = t break end
    if t.update then t:update(1 / 60) else break end
  end
  check(evoTop ~= nil, "the win reaches the evolution screen")
end

do
  local data = fixture()
  local save = { flags = {}, objectToggles = {}, party = {} }
  local map = MapLoader.load(data, "PALLET_TOWN")
  local state = setmetatable({ map = map, npcs = {}, entities = {}, npcPool = {},
                               neighbors = {}, player = { cellX = 5, cellY = 6,
                                                          facing = "down" } },
                             { __index = OW })
  local game = { data = data, save = save,
                 stack = { states = { state } } }
  check(bindGame(OW.addRuntimeObject, game), "addRuntimeObject binds Game")
  local api = WorldAPI.new(game, "tester")
  local other = WorldAPI.new(game, "intruder")

  local snapshot = api:current()
  check(snapshot.mapId == "PALLET_TOWN" and snapshot.x == 5 and snapshot.y == 6
    and snapshot.facing == "down", "current() snapshots the live world")

  -- flags
  check(api:setFlag("mod:tester:hello", true), "setFlag writes")
  check(api:getFlag("mod:tester:hello") == true, "getFlag reads back")
  check(save.flags["mod:tester:hello"] == true, "the flag lands in the save")

  -- object toggles emit and persist
  withBuses(function(events)
    local seen
    events:on("world.object_toggled", function(ev) seen = ev end, 0, "spy")
    -- an inactive map takes the plain save-write path
    check(api:toggleObject("ROUTE_21", "SOMEONE", false), "toggleObject writes")
    check(save.objectToggles.ROUTE_21.SOMEONE == false, "the toggle persists")
    check(seen and seen.mapId == "ROUTE_21" and seen.objName == "SOMEONE"
      and seen.visible == false, "world.object_toggled fires")
  end)

  -- spawnNpc / removeNpc, with ownership enforced
  local before = #data.maps.PALLET_TOWN.objects
  local npcId = api:spawnNpc("PALLET_TOWN",
    { x = 6, y = 6, sprite = "SPRITE_OAK", movement = "STAY", range = "DOWN" })
  check(type(npcId) == "string", "spawnNpc returns an id: " .. tostring(npcId))
  check(#data.maps.PALLET_TOWN.objects == before + 1,
    "the runtime object joins the map record")
  local spawned = data.maps.PALLET_TOWN.objects[before + 1]
  check(spawned.runtime == true and spawned.owner == "tester",
    "the runtime object records its owner")
  check(#state.npcs == 1 and state.npcs[1].id == npcId,
    "the NPC is instantiated on the active map")
  check(#state.entities == 1, "and joins the collision entities")

  check(bindGame(OW.removeRuntimeObject, game), "removeRuntimeObject binds Game")
  value, err = other:removeNpc(npcId)
  check(value == nil and err:find("not owned", 1, true),
    "removeNpc refuses another mod's object")
  check(#data.maps.PALLET_TOWN.objects == before + 1, "and changes nothing")
  check(api:removeNpc(npcId) == true, "the owner may remove it")
  check(#data.maps.PALLET_TOWN.objects == before, "the record is clean again")
  check(#state.npcs == 0 and #state.entities == 0, "and so is the live world")

  -- imported objects are never removable through this door
  local importedId = "PALLET_TOWN_obj_" .. data.maps.PALLET_TOWN.objects[1].index
  value, err = api:removeNpc(importedId)
  check(value == nil and err:find("no runtime object", 1, true),
    "removeNpc refuses an imported object")

  -- a handle onto a live NPC
  npcId = api:spawnNpc("PALLET_TOWN",
    { x = 7, y = 6, sprite = "SPRITE_OAK", movement = "STAY", range = "DOWN" })
  local handle = api:npc("PALLET_TOWN", npcId)
  check(handle ~= nil, "npc() resolves a handle by id")
  state.scriptMoves = {}
  check(handle:face("left"), "the handle turns the NPC")
  local hx, hy = handle:position()
  check(hx == 7 and hy == 6, "the handle reports the NPC cell")
  check(handle:scriptMove("left", 1), "the handle queues a scripted move")
  check(#state.scriptMoves == 1, "and the move reached the queue")
  api:removeNpc(npcId)

  -- spawnNpc on a map the dataset does not have
  value, err = api:spawnNpc("NO_SUCH_MAP", { sprite = "SPRITE_OAK" })
  check(value == nil and err:find("unknown map", 1, true),
    "spawnNpc refuses an unknown map")
  value, err = api:warpTo("NO_SUCH_MAP", 0, 0)
  check(value == nil and err:find("unknown map", 1, true),
    "warpTo validates against the merged maps")

  -- invalidateMap on the live map keeps the player and the pool identity
  check(bindGame(OW.reloadMap, game), "reloadMap rebinds")
  check(bindGame(OW.setMap, game), "setMap binds Game")
  check(bindGame(OW.healPoint, game), "healPoint binds Game")
  MapLoader.invalidateAll()
end

-- ------- no-mod parity of the seeded tables

do
  -- FieldDefaults never reaches into Data at require time
  local before = Merge.deepCopy(Data.field.ledges)
  local _ = FieldDefaults.field(Data, "palettes")
  local same = true
  for i, row in ipairs(Data.field.ledges) do
    for k, v in pairs(row) do if before[i][k] ~= v then same = false end end
  end
  check(same, "reading a default never mutates the dataset")
  check(Data.field.hiddenExtras.trashCans.map == "VERMILION_GYM",
    "a key the importer stamps is left as the importer wrote it")
end

-- ------- the boot path seeds, so a mod's patch folds over Kanto

do
  -- Data:load runs seedDefaults, which pulls these in.  Without them the
  -- registry's base for the key is nil and the first patch replaces Kanto
  -- wholesale instead of merging into it.
  check(Data.field.palettes.byMap.PALLET_TOWN == "PALLET",
    "the boot path seeded field.palettes")
  check(Data.field.playerSprites.walk == "SPRITE_RED",
    "the boot path seeded field.playerSprites")
  check(Data.field.playerSprites.surf == "SPRITE_SEEL",
    "the surf sprite defaults to the Seel")
  check(Data.field.playerSprites.surfPikachu == "SPRITE_SURFING_PIKACHU",
    "the surfing-Pikachu sprite defaults to SPRITE_SURFING_PIKACHU (RFC 0001; Yellow ride)")
  check(Data.field.badgeGates.ROUTE_22_GATE.passedFlag == "PASSED_ROUTE22_GATE",
    "the boot path filled the gaps in a stamped key")
  check(Data.constants.world.stepFrames == 16,
    "the boot path seeded constants.world")
  check(Data.constants.encounterBuckets[10] == 256,
    "the boot path seeded constants.encounterBuckets")

  local registry = Registry.new("field", Schemas.REGISTRIES.field)
  registry.base = function() return Data.field end
  registry:patch("palettes", { byMap = { SABLE_COVE = "WATER" } }, "somemod")
  local merged = registry:get("palettes")
  check(merged.byMap.SABLE_COVE == "WATER", "a field patch lands")
  check(merged.byMap.PALLET_TOWN == "PALLET", "and Kanto survives it")
  check(merged.default == "ROUTE", "including the fallthrough")
end

-- ------- mod.world, the Gen 2 arm
--
-- src/world/gen2/WorldAPI.lua is the other half of the same facade name.  The
-- three verbs tested here were reported as unsupported and are the ones a
-- placement/encounter mod actually needs: spawn an actor, drive a wild battle,
-- and get a NAMED refusal for anything the Gen 2 arm has no home for.
do
  local Gen2Api = require("src.world.gen2.WorldAPI")

  -- a World stand-in: the fields the facade reads, nothing else
  local maps = { NEW_BARK_TOWN = { id = "NEW_BARK_TOWN", objects = {
    { index = 0, name = "ELM", sprite = "SPRITE_ELM", x = 1, y = 1 },
  } } }
  local rebuilt, battled = 0, nil
  local world = {
    map = { id = "NEW_BARK_TOWN", def = maps.NEW_BARK_TOWN },
    maps = maps,
    npcPool = {},
    player = { cellX = 3, cellY = 4, facing = "down" },
    -- the two tables src/battle/gen2/Mon.lua reads to build a wild mon
    game = { save = { party = {} }, data = {
      moves = {},
      growthRates = {},
      pokemon = { FIXMON = { name = "FIXMON", types = { "NORMAL" },
        baseStats = { hp = 50, attack = 50, defense = 50, speed = 50,
                      specialAttack = 50, specialDefense = 50 },
        growthRate = "MEDIUM_FAST", learnset = {} } },
    } },
    rebuildPeople = function() rebuilt = rebuilt + 1 end,
    startBattle = function(_, opts, onDone)
      battled = opts
      if onDone then onDone("win") end
    end,
  }
  local World2 = require("src.world.gen2.World")
  world.addRuntimeObject = World2.addRuntimeObject
  world.removeRuntimeObject = World2.removeRuntimeObject
  local api = Gen2Api.new({ world = world }, "tester")

  local id = api:spawnNpc("NEW_BARK_TOWN", { sprite = "SPRITE_LASS", x = 5, y = 5 })
  check(id == "NEW_BARK_TOWN_obj_1", "Gen 2 spawnNpc returns a handle id")
  check(#maps.NEW_BARK_TOWN.objects == 2, "and the object joins the map def")
  check(maps.NEW_BARK_TOWN.objects[2].runtime == true, "marked runtime")
  check(maps.NEW_BARK_TOWN.objects[2].owner == "tester", "and attributed")
  check(rebuilt == 1, "the active map's people are rebuilt around it")
  check(api:removeNpc(id) == true, "Gen 2 removeNpc drops it again")
  check(#maps.NEW_BARK_TOWN.objects == 1, "and the map def is back to vanilla")
  local gone, why = Gen2Api.new({ world = world }, "intruder")
    :removeNpc("NEW_BARK_TOWN_obj_9")
  check(gone == nil and why ~= nil, "an unknown runtime object is refused")

  -- queueScript: the wild-battle verb, which is how a spawn mod starts a fight
  local ok = api:queueScript({ { "start_battle", "wild", "FIXMON", 7 } })
  check(ok == true, "Gen 2 queueScript runs a start_battle row")
  check(battled ~= nil and battled.wild ~= nil, "and Gold's own startBattle ran")
  check(battled.wild.level == 7, "with the row's level")

  -- and a verb with no Gen 2 home is refused BY NAME before anything runs
  local refused, reason = api:queueScript({ { "text", "hi" }, { "wait", 30 } })
  check(refused == nil, "a queue with an unsupported verb is refused whole")
  check(reason ~= nil and reason:find("wait", 1, true) ~= nil,
    "and the refusal names the verb")
end

S.finish()
