-- The three roaming beasts, WIRED.  `luajit tests/gen2_roamers_test.lua`; also
-- dofile'd by tests/run_tests.lua.  ROM-free.
--
-- src/core/gen2/Roamers.lua has been complete and unit tested for a while; what
-- it did not have was a single caller outside its own test, so `special
-- InitRoamMons` put three structs on the save when the Burned Tower floor gave
-- way and then nothing ever moved them, rolled for them or banked them.  A
-- beast sat on its starting route forever and could not be met even there.
--
-- So this suite is about the four CALL SITES, and each is a place the cart
-- names explicitly:
--
--   MapSetupCommands $26 UpdateRoamMons   the tail of MapSetupScript_Train,
--                                         which _Door and _Fall fall into, and
--                                         a row of _Connection
--   MapSetupCommands $27 JumpRoamMons     the third row of _Teleport, ABOVE
--                                         the load
--   ChooseWildEncounter                   CheckEncounterRoamMon, before the
--                                         map's own slot list
--   BattleEnd_HandleRoamMons              on the way out of every wild battle
package.path = "./?.lua;./?/init.lua;" .. package.path

local S = require("tests.harness").suite("gen2 roamers")
local check, eq = S.check, S.eq

local World = require("src.world.gen2.World")
local Roamers = require("src.core.gen2.Roamers")
local FieldMoves = require("src.world.gen2.FieldMoves")

-- MAPSETUP_* (constants/map_setup_constants.asm), as World names them.
local WARP, RELOADMAP = 0xf1, 0xf3
local TELEPORT, DOOR, FALL = 0xf4, 0xf5, 0xf6
local CONNECTION, TRAIN = 0xf7, 0xf9

local function roamWorld(opts)
  opts = opts or {}
  local game = {
    data = {
      pokemon = {
        RAIKOU = { name = "RAIKOU", index = 243, types = { "ELECTRIC" },
          baseStats = { hp = 90, attack = 85, defense = 75, speed = 115,
            specialAttack = 115, specialDefense = 100 },
          growthRate = 0, levelMoves = { { level = 1, move = "QUICK_ATTACK" } } },
      },
      moves = { QUICK_ATTACK = { name = "QUICK ATTACK", pp = 30 } },
    },
    save = { player = { name = "GOLD", badges = {} }, party = {}, inventory = {} },
  }
  local world = World.new(game)
  world.maps = {
    ROUTE_29 = { id = "ROUTE_29", group = 1, map = 1, width = 2, height = 2,
      blocks = { 1, 2, 3, 4 }, objects = {}, warps = {} },
    ROUTE_30 = { id = "ROUTE_30", group = 1, map = 2, width = 2, height = 2,
      blocks = { 1, 2, 3, 4 }, objects = {}, warps = {} },
  }
  world.map = { id = opts.map or "ROUTE_29", def = world.maps.ROUTE_29,
    width = 2, height = 2 }
  world.encounters = opts.encounters
  -- The load itself is not the subject here; the roam step around it is.
  world.setMap = function(self, id)
    self.map = { id = id, def = self.maps[id] or self.maps.ROUTE_29,
      width = 2, height = 2 }
    return true
  end
  Roamers.init(game.save, { force = true })
  return world, game
end

-- ---------------------------------------------------------------------------
-- The two map setup commands
-- ---------------------------------------------------------------------------

-- Which of the eleven setup scripts carries which command, fallthroughs
-- honoured.  Getting this table wrong is invisible in play -- the beasts just
-- move at the wrong times -- so it is pinned rather than trusted.
do
  local moved = { UPDATE = {}, JUMP = {} }
  local realUpdate, realJump = Roamers.update, Roamers.jumpAll
  Roamers.update = function(...) moved.UPDATE[#moved.UPDATE + 1] = true
    return realUpdate(...) end
  Roamers.jumpAll = function(...) moved.JUMP[#moved.JUMP + 1] = true
    return realJump(...) end

  local function ran(method)
    moved.UPDATE, moved.JUMP = {}, {}
    local world = roamWorld()
    world:runMapSetup(method, function() return world:setMap("ROUTE_30") end)
    -- A fading script parks its load; run the chain out so the tail lands.
    for _ = 1, 40 do
      if not world.mapSetup then break end
      world:updateMapSetup()
    end
    return #moved.UPDATE > 0, #moved.JUMP > 0
  end

  local up, jump = ran(CONNECTION)
  check(up and not jump, "MAPSETUP_CONNECTION names UpdateRoamMons")
  up, jump = ran(TRAIN)
  check(up and not jump, "and so does _Train")
  up, jump = ran(DOOR)
  check(up and not jump, "_Door by falling into _Train")
  up, jump = ran(FALL)
  check(up and not jump, "and _Fall by falling into _Door first")
  up, jump = ran(TELEPORT)
  check(jump and not up,
    "MAPSETUP_TELEPORT names JumpRoamMons, and _Warp below it names neither")
  up, jump = ran(WARP)
  check(not up and not jump, "a plain warp moves nothing")
  up, jump = ran(RELOADMAP)
  check(not up and not jump, "and neither does a reload")

  Roamers.update, Roamers.jumpAll = realUpdate, realJump
end

-- JumpRoamMons runs ABOVE the load and UpdateRoamMons below it, so each sees a
-- different "player's map".  _BackUpMapIndices records whichever it saw, and
-- that is the map the next walk avoids.
do
  local world, game = roamWorld()
  world:runMapSetup(TELEPORT, function() return world:setMap("ROUTE_30") end)
  for _ = 1, 40 do
    if not world.mapSetup then break end
    world:updateMapSetup()
  end
  eq(game.save.roamerMaps.current, "ROUTE_29",
    "JumpRoamMons banked the map being LEFT, because it runs before the load")

  local world2, game2 = roamWorld()
  world2:runMapSetup(CONNECTION, function() return world2:setMap("ROUTE_30") end)
  eq(game2.save.roamerMaps.current, "ROUTE_30",
    "UpdateRoamMons banked the map ARRIVED on, because it is the script's tail")
end

-- No beasts on the save -> nothing to do, and no crash.  This is every game
-- before the Burned Tower.
do
  local world, game = roamWorld()
  game.save.roamers = nil
  check(not world:roamMonsAfterLoad(CONNECTION), "no roamers, no walk")
  check(not world:roamMonsBeforeLoad(TELEPORT), "no roamers, no jump")
  check(not world:roamMonsAfterBattle(nil, "win", 0), "and no battle tail")
  check(not world:roamMonsOnContinue("ROUTE_29"), "and no scatter on CONTINUE")
end

-- ---------------------------------------------------------------------------
-- The third call site: CONTINUE
-- ---------------------------------------------------------------------------
--
-- `farcall JumpRoamMons` sits in the continue path itself
-- (engine/menus/intro_menu.asm, three lines above the wSpawnAfterChampion
-- read), not in any map setup script -- so EVERY load of a save scatters the
-- three beasts before the map comes back.  That is what makes re-finding one
-- the price of reloading after a failed catch; without it a save-scum loop was
-- strictly easier than the cart's.
do
  local world, game = roamWorld()
  for _, slot in ipairs(game.save.roamers) do slot.map = "ROUTE_29" end
  -- JumpRoamMon re-rolls while the entry it lands on is the PLAYER's map, so
  -- a scatter can never drop a beast on top of the file being loaded.
  local calls = 0
  world.roamerRandom = function(n)
    calls = calls + 1
    return (calls * 5) % n
  end
  check(world:roamMonsOnContinue("ROUTE_29"), "loading a save scatters them")
  for index, slot in ipairs(game.save.roamers) do
    check(slot.map ~= "ROUTE_29",
      "beast " .. index .. " is no longer where the file was saved")
  end
  eq(game.save.roamerMaps.current, "ROUTE_29",
    "_BackUpMapIndices banks the saved map, which is what the next walk avoids")
end

-- ---------------------------------------------------------------------------
-- RoamMaps off the cart
-- ---------------------------------------------------------------------------
--
-- data/wild/roammon_maps.asm, which RomExtractorGen2 now emits as
-- encounters.roamMaps.  Roamers.MAPS stays as the fallback for a cache written
-- before it did, so the two have to agree row for row -- ORDER included, since
-- `.Update` picks a connection by a two-bit index and JumpRoamMon picks an
-- entry by a four-bit one.
do
  local cache = os.getenv("GOLD_CACHE")
  if not cache then
    local home = os.getenv("HOME") or ""
    cache = home .. "/Library/Application Support/LOVE/gold-dev/gold"
  end
  local chunk = loadfile(cache .. "/data/generated/encounters.lua")
  local encounters = chunk and chunk()
  if not (encounters and encounters.roamMaps) then
    check(true, "no gold cache: extracted RoamMaps (SKIP)")
  else
    local extracted = encounters.roamMaps
    eq(#extracted, Roamers.NUM_MAPS, "RoamMaps has NUM_ROAMMON_MAPS entries")
    check(Roamers.mapTable(encounters) == extracted,
      "and Roamers.mapTable prefers it over the hand-written fallback")
    local same = true
    for index, row in ipairs(Roamers.MAPS) do
      local got = extracted[index]
      if not got or got.map ~= row.map or #got.to ~= #row.to then
        same = false
      else
        for i, to in ipairs(row.to) do
          if got.to[i] ~= to then same = false end
        end
      end
    end
    check(same, "every row matches the transcribed table, in order")
    -- Routes 40 and 41 are water routes and deliberately absent.
    local seen = {}
    for _, row in ipairs(extracted) do seen[row.map] = true end
    check(not seen.ROUTE_40, "Route 40 is not a roam map")
    check(not seen.ROUTE_41, "and neither is Route 41")
  end
end

-- ---------------------------------------------------------------------------
-- CheckEncounterRoamMon at the top of the wild roll
-- ---------------------------------------------------------------------------

-- WHERE the gate sits matters as much as what it tests.  CheckEncounterRoamMon
-- is the first thing ChooseWildEncounter does, and ChooseWildEncounter is only
-- reached once TryWildEncounter's own `.EncounterRate` roll has passed
-- (engine/overworld/wildmons.asm) -- so a beast needs the map's percentage AND
-- 75/256 AND its own route.  A table with an always-passing rate and no slots
-- is what isolates the beast from the map's own list below.
local ALWAYS = {
  grass = {
    ROUTE_29 = {
      map = "ROUTE_29",
      rates = { MORN = 256, DAY = 256, NITE = 256 },
      slots = { MORN = {}, DAY = {}, NITE = {} },
    },
  },
  water = {
    ROUTE_29 = { map = "ROUTE_29", rate = 256, slots = {} },
  },
}

-- The gate is three tests on ONE random byte: < 100, then `and %11` non-zero,
-- then that value is the slot.  1 picks slot 1, which is Raikou -- and Raikou
-- starts on ROUTE_42, so it only fires on the map the beast is actually on.
do
  local world, game = roamWorld({ encounters = ALWAYS })
  world.roamerRandom = function() return 1 end -- slot 1, past both gates
  game.save.party = { { species = "RAIKOU", level = 5, hp = 10, maxHp = 10 } }
  world.player = { cellX = 0, cellY = 0 }
  world.map.cellCollision = function() return 0x18 end -- COLL_TALL_GRASS
  world.map.def.environment = "ROUTE"

  local started
  world.startBattle = function(_, opts) started = opts return true end

  -- Raikou is on ROUTE_42 and the player is on ROUTE_29: no beast.
  check(not world:tryWildEncounter() or started == nil,
    "a beast on another route does not fire")
  check(started == nil, "and no battle started off it")

  -- Move it under the player's feet.
  game.save.roamers[1].map = "ROUTE_29"
  check(world:tryWildEncounter(), "the beast on this route fires")
  check(started ~= nil, "and it started a battle")
  eq(started.roaming, 1, "carrying the SLOT, which is what banks its HP")
  eq(started.wild.species, "RAIKOU", "with the right beast")
  eq(started.wild.level, 40, "at the roam level")
  check(game.save.pokedex.seen.RAIKOU, "and the #DEX saw it")
  check(game.save.roamers[1].hp > 0,
    ".InitRoamHP banks the full HP on the FIRST meeting, not at the end")
end

-- The outer gate, pinned on its own: the SAME roamer roll that fires above
-- produces nothing at all on a map whose encounter rate is zero, because
-- ChooseWildEncounter is never reached.  Reading these two in the other order
-- (roamer first, rate second) is what made a beast turn up roughly ten times
-- as often per grass step as the cart allows on a 10 percent route.
do
  local world, game = roamWorld({
    encounters = {
      grass = { ROUTE_29 = { map = "ROUTE_29",
        rates = { MORN = 0, DAY = 0, NITE = 0 },
        slots = { MORN = {}, DAY = {}, NITE = {} } } },
    },
  })
  world.roamerRandom = function() return 1 end
  game.save.roamers[1].map = "ROUTE_29"
  game.save.party = { { species = "RAIKOU", level = 5, hp = 10, maxHp = 10 } }
  world.player = { cellX = 0, cellY = 0 }
  world.map.cellCollision = function() return 0x18 end
  world.map.def.environment = "ROUTE"
  local started
  world.startBattle = function(_, opts) started = opts return true end
  check(not world:tryWildEncounter(),
    "a zero encounter rate stops the step before ChooseWildEncounter")
  check(started == nil, "so the beast standing right there never rolls")
end

-- Surfing refuses before anything else, which is what keeps Suicune out of the
-- water on the routes it shares with the sea.
do
  local world, game = roamWorld({ encounters = ALWAYS })
  world.roamerRandom = function() return 1 end
  game.save.roamers[1].map = "ROUTE_29"
  game.save.party = { { species = "RAIKOU", level = 5, hp = 10, maxHp = 10 } }
  world.player = { cellX = 0, cellY = 0 }
  world.map.cellCollision = function() return 0x29 end -- COLL_WATER
  world.map.def.environment = "ROUTE"
  world.playerState = FieldMoves.PLAYER_SURF
  local started
  world.startBattle = function(_, opts) started = opts return true end
  world:tryWildEncounter()
  check(started == nil or not started.roaming,
    "a surfing step never meets a beast")
end

-- ---------------------------------------------------------------------------
-- BattleEnd_HandleRoamMons
-- ---------------------------------------------------------------------------

do
  -- Beating one clears its slot for good: no species, no map.
  local world, game = roamWorld()
  game.save.roamers[1].hp = 90
  check(world:roamMonsAfterBattle(1, "win", 0), "a won roam battle is handled")
  check(not Roamers.active(game.save.roamers[1]), "and the beast is gone")

  -- Catching one is the same clear.
  local w2, g2 = roamWorld()
  g2.save.roamers[2].hp = 90
  w2:roamMonsAfterBattle(2, "caught", 40)
  check(not Roamers.active(g2.save.roamers[2]), "a caught beast is gone too")

  -- Anything else -- it fled, you ran -- banks the HP and moves it.
  local w3, g3 = roamWorld()
  g3.save.roamers[1].map = "ROUTE_29"
  g3.save.roamers[1].hp = 90
  -- 1: `% 32` is non-zero so .Update takes a CONNECTION rather than the
  -- 1-in-32 jump, and `% 4` is 1, which is Route 29's second exit.
  w3.roamerRandom = function() return 1 end
  check(w3:roamMonsAfterBattle(1, "run", 37), "a flee is handled")
  eq(g3.save.roamers[1].hp, 37, "and the damage is banked")
  check(Roamers.active(g3.save.roamers[1]), "with the beast still out there")
  check(g3.save.roamers[1].map ~= "ROUTE_29", "and moved off the player's route")
end

-- The `.not_roaming` tail: ANY other wild battle gives one chance in sixteen
-- that they all move, which is why they drift while you grind.
do
  local world, game = roamWorld()
  local rolls = { 0 } -- 0 % 16 == 0, the one case in sixteen
  world.roamerRandom = function() return table.remove(rolls, 1) or 5 end
  local before = game.save.roamers[1].map
  check(world:roamMonsAfterBattle(nil, "win", 0), "the 1-in-16 roll can move them")
  check(game.save.roamerMaps ~= nil, "and it backs the map indices up")
  local _ = before

  local w2, g2 = roamWorld()
  w2.roamerRandom = function() return 5 end -- 5 % 16 ~= 0
  local was = g2.save.roamers[1].map
  check(not w2:roamMonsAfterBattle(nil, "win", 0), "fifteen times in sixteen it does not")
  eq(g2.save.roamers[1].map, was, "and nothing moved")
end

S.finish()
