mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
3344 lines
151 KiB
Lua
3344 lines
151 KiB
Lua
-- Gen 2 COLL_* permissions + New Bark warp/connection facts against a Gold
|
|
-- cache. Self-contained: `luajit tests/gen2_world_test.lua`; also dofile'd
|
|
-- by tests/run_tests.lua. Map checks SKIP when no gold cache is present.
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
|
|
local S = require("tests.harness").suite("gen2 world")
|
|
local check, eq = S.check, S.eq
|
|
|
|
local Permissions = require("src.world.gen2.Permissions")
|
|
local Map = require("src.world.gen2.Map")
|
|
local NPC = require("src.world.gen2.Npc")
|
|
local Events = require("src.world.gen2.Events")
|
|
|
|
-- Object visibility: flag SET hides; 0xFFFF always shown.
|
|
local ev = Events.new({ 1794 })
|
|
check(ev:get(1794), "initial flag applied")
|
|
check(not ev:objectVisible(1794), "set flag hides object")
|
|
check(ev:objectVisible(nil), "nil eventFlag always visible")
|
|
check(ev:objectVisible(0xFFFF), "0xFFFF eventFlag always visible")
|
|
check(ev:objectVisible(1600), "unset flag leaves object visible")
|
|
|
|
|
|
check(Permissions.isWalkable(0x00), "COLL_FLOOR walkable")
|
|
check(not Permissions.isWalkable(0x07), "COLL_WALL blocked")
|
|
check(Permissions.isWater(0x29), "COLL_WATER is water")
|
|
check(Permissions.isWarpCollision(0x71), "COLL_DOOR is warp")
|
|
check(Permissions.isImmediateWarp(0x71), "COLL_DOOR immediate")
|
|
check(not Permissions.isImmediateWarp(0x70), "carpet not immediate")
|
|
eq(Permissions.carpetDirection(0x70), "down", "carpet down")
|
|
eq(Permissions.of(0xff), Permissions.WALL, "missing coll → wall")
|
|
check(not Permissions.isWalkable(0xff), "sentinel $ff not walkable")
|
|
|
|
-- ---------------------------------------------------------------- field
|
|
-- The two encounter systems that need an INPUT path: a rod used from the PACK
|
|
-- and an A press on a headbutt tree. Both are wired here rather than called
|
|
-- directly, because the wiring is the thing under test -- a stub input drives
|
|
-- PackMenu, a stub map drives World:interact, and the world's own text /
|
|
-- choice / battle seams are recorded so the ORDER the cart's script imposes
|
|
-- (ask, then shake, then roll) is checkable.
|
|
|
|
local World = require("src.world.gen2.World")
|
|
local PackMenu = require("src.ui.gen2.PackMenu")
|
|
|
|
-- Collisions the two paths key off, straight out of the table above:
|
|
-- COLL_FLOOR walkable land, COLL_WATER, COLL_HEADBUTT_TREE.
|
|
local COLL_FLOOR, COLL_WATER, COLL_TREE = 0x00, 0x29, 0x15
|
|
|
|
check(World.isHeadbuttTree(COLL_TREE), "COLL_HEADBUTT_TREE is a tree")
|
|
check(World.isHeadbuttTree(0x1d), "COLL_HEADBUTT_TREE_1D alias is a tree")
|
|
check(not World.isHeadbuttTree(COLL_FLOOR), "floor is not a tree")
|
|
check(not World.isHeadbuttTree(nil), "no tile is not a tree")
|
|
check(Permissions.isWall(COLL_TREE), "a headbutt tree blocks a step")
|
|
|
|
-- ItemNames indices out of constants/item_constants.asm.
|
|
local ITEMS = {
|
|
OLD_ROD = { id = "OLD_ROD", name = "OLD ROD", pocket = "KEY_ITEM",
|
|
index = 0x3a },
|
|
GOOD_ROD = { id = "GOOD_ROD", name = "GOOD ROD", pocket = "KEY_ITEM",
|
|
index = 0x3b },
|
|
SUPER_ROD = { id = "SUPER_ROD", name = "SUPER ROD", pocket = "KEY_ITEM",
|
|
index = 0x3d },
|
|
BICYCLE = { id = "BICYCLE", name = "BICYCLE", pocket = "KEY_ITEM",
|
|
index = 0x06 },
|
|
-- A key item with no field handler of its own, which is what the PACK's
|
|
-- onChoose fallthrough needs: every other key item here is a rod or the
|
|
-- BICYCLE, and both of those are claimed by useFieldItem.
|
|
COIN_CASE = { id = "COIN_CASE", name = "COIN CASE", pocket = "KEY_ITEM",
|
|
index = 0x47 },
|
|
REPEL = { id = "REPEL", name = "REPEL", pocket = "ITEM", index = 0x14,
|
|
canSelect = true },
|
|
SUPER_REPEL = { id = "SUPER_REPEL", name = "SUPER REPEL", pocket = "ITEM",
|
|
index = 0x2a },
|
|
MAX_REPEL = { id = "MAX_REPEL", name = "MAX REPEL", pocket = "ITEM",
|
|
index = 0x2b },
|
|
-- ITEMATTR_PERMISSIONS: a plain field item with no CANT_SELECT_F.
|
|
POTION = { id = "POTION", name = "POTION", pocket = "ITEM", index = 0x02,
|
|
canSelect = true },
|
|
-- A TM/HM's CheckSelectableItem.CheckTMHM always refuses (RegisterItem's
|
|
-- .cant_register), which the extractor already answers as canSelect = false.
|
|
HM_CUT = { id = "HM_CUT", name = "HM01", pocket = "TM_HM", index = 0x32,
|
|
canSelect = false },
|
|
}
|
|
check(World.isRod("OLD_ROD", ITEMS), "OLD ROD is a rod")
|
|
check(World.isRod("GOOD_ROD", ITEMS), "GOOD ROD is a rod")
|
|
check(World.isRod("SUPER_ROD", ITEMS), "SUPER ROD is a rod")
|
|
check(not World.isRod("BICYCLE", ITEMS), "BICYCLE is not a rod")
|
|
check(not World.isRod("OLD_ROD", { OLD_ROD = { index = 7 } }),
|
|
"a rod at the wrong ItemNames index is refused")
|
|
|
|
local DATA = {
|
|
items = ITEMS,
|
|
moves = {
|
|
HEADBUTT = { name = "HEADBUTT", pp = 15 },
|
|
SPLASH = { name = "SPLASH", pp = 40 },
|
|
TACKLE = { name = "TACKLE", pp = 35 },
|
|
},
|
|
pokemon = {
|
|
MAGIKARP = {
|
|
name = "MAGIKARP", types = { "WATER", "WATER" },
|
|
baseStats = { hp = 20, attack = 10, defense = 55, speed = 80,
|
|
specialAttack = 15, specialDefense = 20 },
|
|
levelMoves = { { level = 1, move = "SPLASH" } },
|
|
},
|
|
HOOTHOOT = {
|
|
name = "HOOTHOOT", types = { "NORMAL", "FLYING" },
|
|
baseStats = { hp = 60, attack = 30, defense = 30, speed = 50,
|
|
specialAttack = 36, specialDefense = 56 },
|
|
levelMoves = { { level = 1, move = "TACKLE" } },
|
|
},
|
|
},
|
|
}
|
|
|
|
-- One always-hit row per list, so the roll is not what these assert.
|
|
local ENCOUNTERS = {
|
|
fishGroups = {
|
|
FISHGROUP_POND = {
|
|
old = { { chance = 256, species = "MAGIKARP", level = 10 } },
|
|
good = { { chance = 256, species = "MAGIKARP", level = 20 } },
|
|
super = { { chance = 256, species = "MAGIKARP", level = 40 } },
|
|
},
|
|
},
|
|
trees = { TEST_MAP = "TREEMON_SET_TEST" },
|
|
treeSets = {
|
|
TREEMON_SET_TEST = {
|
|
common = { { chance = 100, species = "HOOTHOOT", level = 12 } },
|
|
rare = { { chance = 100, species = "HOOTHOOT", level = 12 } },
|
|
},
|
|
},
|
|
}
|
|
|
|
-- A 100-wide strip of collisions, keyed y*100+x, plus the empty event lists
|
|
-- World:interact walks. `opts` fills in the parts of a real Map the field
|
|
-- moves read: the block grid CUT and WHIRLPOOL edit, the tileset that decides
|
|
-- which replacement block they use, and the environment the encounter gate
|
|
-- branches on.
|
|
local MAP_W, MAP_H = 10, 10
|
|
|
|
local function fakeMap(cells, opts)
|
|
opts = opts or {}
|
|
local blocks = {}
|
|
for i = 1, MAP_W * MAP_H do blocks[i] = 0 end
|
|
for index, id in pairs(opts.blocks or {}) do blocks[index] = id end
|
|
local map
|
|
map = {
|
|
id = "TEST_MAP",
|
|
width = MAP_W, height = MAP_H,
|
|
blocks = blocks,
|
|
def = {
|
|
bgEvents = {}, objects = {}, blocks = blocks,
|
|
width = MAP_W, height = MAP_H,
|
|
tileset = opts.tileset or "TILESET_JOHTO",
|
|
environment = opts.environment or "ROUTE",
|
|
palette = opts.palette or "PALETTE_AUTO",
|
|
},
|
|
cellCollision = function(_, x, y) return cells[y * 100 + x] or COLL_FLOOR end,
|
|
inBounds = function(_, x, y)
|
|
return x >= 0 and y >= 0 and x < MAP_W * 2 and y < MAP_H * 2
|
|
end,
|
|
isWalkable = function(_, x, y)
|
|
if not map:inBounds(x, y) then return false end
|
|
return Permissions.isWalkable(map:cellCollision(x, y))
|
|
end,
|
|
warpAt = function() return nil end,
|
|
}
|
|
return map
|
|
end
|
|
|
|
local function fakePlayer(x, y, facing)
|
|
return {
|
|
cellX = x, cellY = y, px = x * 16, py = y * 16,
|
|
facing = facing, moving = false, turnArmed = true,
|
|
update = function() return false end,
|
|
-- Player:setSprite, which World:applyPlayerState calls every time
|
|
-- wPlayerState changes. Nothing here draws, so it only has to exist.
|
|
setSprite = function() end,
|
|
}
|
|
end
|
|
|
|
-- A world with the render half replaced: no love here, so the seams that would
|
|
-- push a TextBox, a ChoiceBox or the battle screen record instead. `log` is
|
|
-- the script trace the assertions read.
|
|
local function fakeWorld(cells, player, party, fishGroup, opts)
|
|
opts = opts or {}
|
|
local game = {
|
|
data = DATA,
|
|
save = { player = { name = "GOLD", badges = opts.badges or {} },
|
|
party = party or {},
|
|
inventory = { OLD_ROD = 1, BICYCLE = 1, COIN_CASE = 1 } },
|
|
}
|
|
local world = World.new(game)
|
|
game.world = world
|
|
world.map = fakeMap(cells, opts)
|
|
-- maps[id] IS the map's def in the real world, and World:restoreBlocks
|
|
-- reaches for it that way when a cut tree has to grow back.
|
|
world.map.def.fishGroup = fishGroup or "FISHGROUP_POND"
|
|
world.maps = { TEST_MAP = world.map.def }
|
|
world.player = player
|
|
world.encounters = ENCOUNTERS
|
|
world.vm = { running = function() return false end, update = function() end }
|
|
-- The palette poll re-bakes map canvases through love.graphics; nothing in
|
|
-- these assertions is about colour.
|
|
world.pollTimeOfDay = function() end
|
|
|
|
local log = {}
|
|
world.log = log
|
|
world.showText = function(self, body, onDone)
|
|
log[#log + 1] = body
|
|
self.textbox = true
|
|
self.pendingText = function()
|
|
self.textbox = nil
|
|
if onDone then onDone() end
|
|
end
|
|
end
|
|
world.askYesNo = function(self, onChoose)
|
|
log[#log + 1] = "<yesno>"
|
|
self.choicebox = true
|
|
self.pendingChoice = function(yes)
|
|
self.choicebox = nil
|
|
onChoose(yes)
|
|
end
|
|
end
|
|
world.startBattle = function(_, opts)
|
|
log[#log + 1] = "<battle:" .. tostring(opts.wild and opts.wild.species) .. ">"
|
|
return true
|
|
end
|
|
return world, game
|
|
end
|
|
|
|
-- The A that closes a text box, and the YES/NO answer.
|
|
local function advanceText(world)
|
|
local fn = world.pendingText
|
|
world.pendingText = nil
|
|
if fn then fn() end
|
|
end
|
|
|
|
local function answerYesNo(world, yes)
|
|
local fn = world.pendingChoice
|
|
world.pendingChoice = nil
|
|
if fn then fn(yes) end
|
|
end
|
|
|
|
local function runFrames(world, n)
|
|
for _ = 1, n do world:step() end
|
|
end
|
|
|
|
local function stubInput()
|
|
local input = { queued = {} }
|
|
function input:press(button) self.queued[button] = true end
|
|
function input:wasPressed(button)
|
|
if self.queued[button] then
|
|
self.queued[button] = nil
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
function input:isDown() return false end
|
|
return input
|
|
end
|
|
|
|
-- A stack that only has to answer the one question PackMenu asks of it.
|
|
local function stubStack()
|
|
return { cleared = 0, clear = function(self) self.cleared = self.cleared + 1 end }
|
|
end
|
|
|
|
-- A on a field-PACK row does not use the item: it opens the item submenu
|
|
-- (.ItemBallsKey_LoadSubmenu, engine/items/pack.asm:243) whose first row is
|
|
-- USE, so "use the highlighted row" is two A presses through that menu -- which
|
|
-- is why every use below presses A twice. The chooser packs (the mart's SELL,
|
|
-- the item PC's DEPOSIT, the DUDE's) have no submenu and still answer the first
|
|
-- press; see PackMenu:hasSubmenu. Written out at each site rather than as a
|
|
-- helper because this file is already at the 200-local ceiling.
|
|
|
|
-- ---- A. the rod ----------------------------------------------------------
|
|
-- Facing land: .TryFish falls to $3 .FailFish, so UseItem's .Oak prints inside
|
|
-- the PACK and the PACK stays open.
|
|
local landWorld, landGame = fakeWorld({}, fakePlayer(5, 5, "down"))
|
|
landGame.input = stubInput()
|
|
landGame.stack = stubStack()
|
|
local chosen = nil
|
|
local landPack = PackMenu.new(landGame, {
|
|
pocket = "KEY_ITEM",
|
|
onChoose = function(id) chosen = id end,
|
|
onClose = function() chosen = "<closed>" end,
|
|
})
|
|
eq(landPack.rows[1].id, "BICYCLE", "key items sort by ItemNames index")
|
|
eq(landPack.rows[2].id, "OLD_ROD", "the rod is the second key item")
|
|
landPack.index = 2
|
|
landGame.input:press("a")
|
|
landPack:update(0)
|
|
landGame.input:press("a")
|
|
landPack:update(0)
|
|
check(landPack.message ~= nil, "rod on land keeps the PACK open with a message")
|
|
eq(landPack.message[1], "OAK: {PLAYER}!", "and the message is Oak's")
|
|
eq(landGame.stack.cleared, 0, "rod on land does not quit the PACK")
|
|
eq(chosen, nil, "rod on land never reaches onChoose")
|
|
check(landWorld.fishing == nil, "rod on land starts no cast")
|
|
landGame.input:press("a")
|
|
landPack:update(0)
|
|
check(landPack.message == nil, "a button clears the message")
|
|
|
|
-- An item World claims nothing for still falls through to the PACK's own
|
|
-- onChoose (TM teaching).
|
|
landPack.index = 3
|
|
landGame.input:press("a")
|
|
landPack:update(0)
|
|
landGame.input:press("a")
|
|
landPack:update(0)
|
|
eq(chosen, "COIN_CASE", "an unhandled item reaches onChoose untouched")
|
|
eq(landGame.stack.cleared, 0, "and does not quit the PACK either")
|
|
|
|
-- Facing water: the roll lands on $2 .FishGotSomething, the PACK quits
|
|
-- (PACKSTATE_QUITRUNSCRIPT) and Script_FishCastRod's cast owns the world.
|
|
local waterCells = { [4 * 100 + 5] = COLL_WATER }
|
|
local seaWorld, seaGame = fakeWorld(waterCells, fakePlayer(5, 5, "up"))
|
|
seaGame.input = stubInput()
|
|
seaGame.stack = stubStack()
|
|
local seaPack = PackMenu.new(seaGame, { pocket = "KEY_ITEM" })
|
|
seaPack.index = 2
|
|
seaGame.input:press("a")
|
|
seaPack:update(0)
|
|
seaGame.input:press("a")
|
|
seaPack:update(0)
|
|
check(seaPack.message == nil, "rod on water prints no refusal")
|
|
eq(seaGame.stack.cleared, 1, "rod on water quits the PACK")
|
|
check(seaWorld.fishing ~= nil, "rod on water starts the cast")
|
|
eq(seaWorld.fishing.outcome, "battle", "and the roll already hooked something")
|
|
check(seaWorld:busy(), "the cast holds the world")
|
|
-- pause 40, the bite, pause 40, then RodBiteText.
|
|
runFrames(seaWorld, 41)
|
|
eq(seaWorld.fishing.phase, "bite", "the cast runs out into the bite")
|
|
eq(#seaWorld.log, 0, "and says nothing until the rod comes back")
|
|
runFrames(seaWorld, 41)
|
|
eq(seaWorld.log[1], "Oh!\nA bite!", "RodBiteText lands after the second pause")
|
|
check(seaWorld.log[2] == nil, "and the battle waits on the button")
|
|
advanceText(seaWorld)
|
|
eq(seaWorld.log[2], "<battle:MAGIKARP>", "the hooked mon is the battle")
|
|
check(seaWorld.fishing == nil, "the cast is over")
|
|
check(not seaWorld:busy(), "and the world is free again")
|
|
|
|
-- FISHGROUP_NONE is $4 .FishNoFish: the PACK still quits, the rod still casts,
|
|
-- and RodNothingText is what comes back.
|
|
local dryWorld = fakeWorld(waterCells, fakePlayer(5, 5, "up"), nil,
|
|
"FISHGROUP_NONE")
|
|
eq(dryWorld:useRod("OLD_ROD"), "nofish", "no fish group is .FishNoFish")
|
|
runFrames(dryWorld, 41)
|
|
eq(dryWorld.log[1], "Not even a nibble!", "and RodNothingText is the line")
|
|
advanceText(dryWorld)
|
|
check(dryWorld.fishing == nil, "the rod goes away after the nibble line")
|
|
|
|
-- A rod is ITEMMENU_NOUSE in battle, and a running script owns the world.
|
|
local busyWorld = fakeWorld(waterCells, fakePlayer(5, 5, "up"))
|
|
busyWorld.battleActive = true
|
|
eq(busyWorld:useRod("OLD_ROD"), "nowhere", "no fishing from inside a battle")
|
|
busyWorld.battleActive = nil
|
|
busyWorld.vm = { running = function() return true end, update = function() end }
|
|
eq(busyWorld:useRod("OLD_ROD"), "nowhere", "no fishing while a script runs")
|
|
check(busyWorld:useFieldItem("COIN_CASE") == nil,
|
|
"useFieldItem passes an unhandled item back to the PACK")
|
|
|
|
-- ---- A2. REPEL / SUPER REPEL / MAX REPEL ----------------------------------
|
|
-- UseRepel (engine/items/item_effects.asm): the step count is the only thing
|
|
-- that differs between the three items, wRepelEffect already set refuses
|
|
-- without touching either the counter or the bag, and only the success arm
|
|
-- goes through UseDisposableItem.
|
|
--
|
|
-- Scoped in its own `do` block: the file is already brushing Lua's 200-local
|
|
-- ceiling, and this test needs its own world/game/pack rather than reusing
|
|
-- the rod fixtures above.
|
|
do
|
|
local repelWorld, repelGame = fakeWorld({}, fakePlayer(5, 5, "down"))
|
|
repelGame.save.inventory.REPEL = 2
|
|
repelGame.save.inventory.SUPER_REPEL = 1
|
|
eq(repelWorld:useRepel("REPEL"), "repel_used", "a fresh REPEL is used")
|
|
eq(repelGame.save.repelSteps, 100, "REPEL sets the counter to 100")
|
|
eq(repelGame.save.inventory.REPEL, 1, "and one REPEL leaves the bag")
|
|
|
|
eq(repelWorld:useRepel("SUPER_REPEL"), "repel_active",
|
|
"a REPEL already ticking refuses a SUPER REPEL")
|
|
eq(repelGame.save.repelSteps, 100, "the counter is untouched by the refusal")
|
|
eq(repelGame.save.inventory.SUPER_REPEL, 1,
|
|
"and the SUPER REPEL is not taken from the bag")
|
|
|
|
repelGame.save.repelSteps = 0
|
|
eq(repelWorld:useRepel("SUPER_REPEL"), "repel_used",
|
|
"a SUPER REPEL is used once the counter reaches zero")
|
|
eq(repelGame.save.repelSteps, 200, "SUPER REPEL sets the counter to 200")
|
|
check(repelGame.save.inventory.SUPER_REPEL == nil,
|
|
"the last SUPER REPEL is gone from the bag")
|
|
|
|
-- Battle refuses a REPEL exactly like a rod (ITEMMENU_NOUSE): the world is
|
|
-- shared with BattleState:openPack, so the guard has to live here too.
|
|
repelWorld.battleActive = true
|
|
repelGame.save.repelSteps = 0
|
|
eq(repelWorld:useRepel("MAX_REPEL"), "nowhere",
|
|
"no REPEL from inside a battle")
|
|
eq(repelGame.save.repelSteps, 0, "and the refusal never sets the counter")
|
|
repelWorld.battleActive = nil
|
|
|
|
-- The PACK: World already claims the item, so onChoose is never reached, and
|
|
-- the still-in-effect message keeps the row list (and the bag) untouched.
|
|
local packGame = { data = DATA, save = repelGame.save,
|
|
stack = stubStack(), input = stubInput(), world = repelWorld }
|
|
local repelPack = PackMenu.new(packGame, { pocket = "ITEM" })
|
|
local chosenRepel
|
|
repelPack.onChoose = function(id) chosenRepel = id end
|
|
for i, row in ipairs(repelPack.rows) do
|
|
if row.id == "REPEL" then repelPack.index = i end
|
|
end
|
|
packGame.input:press("a")
|
|
repelPack:update(0)
|
|
packGame.input:press("a")
|
|
repelPack:update(0)
|
|
check(repelPack.message ~= nil, "using a REPEL keeps the PACK open with a message")
|
|
eq(repelPack.message[1], "{PLAYER} used the", "the message is ItemUsedText")
|
|
eq(repelPack.message[2], "REPEL.", "naming the item that was used")
|
|
eq(packGame.stack.cleared, 0, "and the PACK is never quit")
|
|
eq(chosenRepel, nil, "onChoose never sees a REPEL the world already claimed")
|
|
eq(repelGame.save.repelSteps, 100, "the PACK path set the counter too")
|
|
|
|
packGame.input:press("a")
|
|
repelPack:update(0)
|
|
check(repelPack.message == nil, "a button clears ItemUsedText")
|
|
|
|
for i, row in ipairs(repelPack.rows) do
|
|
if row.id == "SUPER_REPEL" then repelPack.index = i end
|
|
end
|
|
repelGame.save.inventory.SUPER_REPEL = 1
|
|
repelPack:rebuild()
|
|
for i, row in ipairs(repelPack.rows) do
|
|
if row.id == "SUPER_REPEL" then repelPack.index = i end
|
|
end
|
|
packGame.input:press("a")
|
|
repelPack:update(0)
|
|
packGame.input:press("a")
|
|
repelPack:update(0)
|
|
eq(repelPack.message[1], "The REPEL used",
|
|
"a REPEL already active refuses a second item with the static text")
|
|
eq(repelPack.message[3], "in effect.", "the third line of the fixed text")
|
|
end
|
|
|
|
-- ---- A3. SACRED ASH -------------------------------------------------------
|
|
-- SacredAshEffect / _SacredAsh (engine/items/item_effects.asm,
|
|
-- engine/events/sacred_ash.asm). CheckAnyFaintedMon is the whole gate: no
|
|
-- fainted (non-egg) party member means no carry, wItemEffectSucceeded stays
|
|
-- 0, and UseItem's .Field falls to .Oak with the Ash untouched -- "nowhere",
|
|
-- same answer PackMenu already gives a rod cast nowhere useful. A hit queues
|
|
-- SacredAshScript (special HealParty, three Pokecenter fade cycles, the
|
|
-- "all healed" line) and only then removes the one Ash.
|
|
do
|
|
local order = { "HealParty", "FadeOutToWhite", "FadeInFromWhite" }
|
|
|
|
local faintedParty = {
|
|
{ species = "MAGIKARP", nickname = "KARP", hp = 0, maxHp = 20,
|
|
moves = { { id = "SPLASH", pp = 0, maxPp = 40 } } },
|
|
}
|
|
local ashWorld, ashGame = fakeWorld({}, fakePlayer(5, 5, "down"), faintedParty)
|
|
ashWorld.constants = { specialOrder = order }
|
|
ashGame.save.inventory.SACRED_ASH = 1
|
|
|
|
eq(ashWorld:useFieldItem("SACRED_ASH"), "sacredash",
|
|
"a fainted party takes the Ash through useFieldItem")
|
|
eq(ashGame.save.inventory.SACRED_ASH, nil, "and the last Ash leaves the bag")
|
|
|
|
local script = ashWorld.queuedScript
|
|
check(script ~= nil, "the effect queues SacredAshScript")
|
|
eq(script[1].op, "special", "special HealParty opens the script")
|
|
eq(script[1].id, 0, "HealParty resolves through specialOrder, not a bare index")
|
|
local fades = 0
|
|
for _, cmd in ipairs(script) do
|
|
if cmd.op == "special" and cmd.id == 1 then fades = fades + 1 end
|
|
end
|
|
eq(fades, 3, "three FadeOutToWhite cycles, same as a Pokecenter warp")
|
|
eq(script[#script].op, "end", "the script ends")
|
|
eq(script[#script - 1].op, "closetext", "and closes the text box first")
|
|
|
|
-- A fully healthy party never sets carry: refused, nothing spent.
|
|
local healthyParty = {
|
|
{ species = "MAGIKARP", nickname = "KARP", hp = 20, maxHp = 20 },
|
|
}
|
|
local healthyWorld, healthyGame =
|
|
fakeWorld({}, fakePlayer(5, 5, "down"), healthyParty)
|
|
healthyWorld.constants = { specialOrder = order }
|
|
healthyGame.save.inventory.SACRED_ASH = 1
|
|
eq(healthyWorld:useSacredAsh(), "nowhere", "no fainted mon refuses the Ash")
|
|
eq(healthyGame.save.inventory.SACRED_ASH, 1,
|
|
"and nothing is taken from the bag")
|
|
check(healthyWorld.queuedScript == nil, "no script queued either")
|
|
|
|
-- An egg's hp field is the box struct underneath, not a real total that can
|
|
-- be "fainted"; CheckAnyFaintedMon's `cp EGG / jr z, .next` skips it.
|
|
local eggOnlyParty = { { species = "EGG", isEgg = true, hp = 0, maxHp = 0 } }
|
|
local eggWorld, eggGame = fakeWorld({}, fakePlayer(5, 5, "down"), eggOnlyParty)
|
|
eggWorld.constants = { specialOrder = order }
|
|
eggGame.save.inventory.SACRED_ASH = 1
|
|
eq(eggWorld:useSacredAsh(), "nowhere", "an egg-only party has nothing to revive")
|
|
|
|
-- ITEMMENU_NOUSE in battle, the same guard every field item gets.
|
|
ashWorld.battleActive = true
|
|
eq(ashWorld:useSacredAsh(), "nowhere", "no Sacred Ash from inside a battle")
|
|
ashWorld.battleActive = nil
|
|
end
|
|
|
|
-- Ho-Oh's own end: BATTLETYPE_FORCEITEM (InitEnemyMon's `.WildItem`) is what
|
|
-- actually puts a SACRED_ASH on the mon the player catches -- the effect
|
|
-- above is reachable only once something stamps the held item on, and until
|
|
-- this the port never did. The general 25%/8% wild-item roll stays
|
|
-- unmodeled (Mon.new's own note); only the unconditional FORCEITEM path is.
|
|
do
|
|
local Mon = require("src.battle.gen2.Mon")
|
|
local realMonNew = Mon.new
|
|
Mon.new = function(_, id, level) return { species = id, level = level } end
|
|
|
|
local forceWorld, forceGame = fakeWorld({}, fakePlayer(5, 5, "down"))
|
|
forceGame.data = { pokemon = {
|
|
HO_OH = { index = 250, items = { "SACRED_ASH", "SACRED_ASH" } },
|
|
} }
|
|
local captured
|
|
forceWorld.startBattle = function(_, opts) captured = opts return true end
|
|
|
|
forceWorld.scriptVars[0x03] = 10 -- VAR_BATTLETYPE, BATTLETYPE_FORCEITEM
|
|
forceWorld:startScriptedBattle(nil, { species = 250, level = 70 },
|
|
function() end)
|
|
eq(captured and captured.wild and captured.wild.item, "SACRED_ASH",
|
|
"BATTLETYPE_FORCEITEM hands Ho-Oh's Item1 to the wild mon unconditionally")
|
|
|
|
captured = nil
|
|
forceWorld:startScriptedBattle(nil, { species = 250, level = 70 },
|
|
function() end)
|
|
check(captured and captured.wild and captured.wild.item == nil,
|
|
"without FORCEITEM armed again, no held item is stamped on")
|
|
|
|
Mon.new = realMonNew
|
|
end
|
|
|
|
-- ---- B. the tree ---------------------------------------------------------
|
|
local treeCells = { [4 * 100 + 5] = COLL_TREE }
|
|
|
|
-- No party mon knows HEADBUTT: TryHeadbuttOW returns nc and the A press is
|
|
-- dropped entirely (.noevent), text included.
|
|
local bareWorld = fakeWorld(treeCells, fakePlayer(5, 5, "up"), {
|
|
{ species = "MAGIKARP", nickname = "KARP",
|
|
moves = { { id = "SPLASH", pp = 40 } } },
|
|
})
|
|
check(not bareWorld:interact(), "a tree with no HEADBUTT mon refuses the press")
|
|
eq(#bareWorld.log, 0, "and says nothing at all")
|
|
|
|
-- With one, AskHeadbuttScript opens; NO closes it without a roll.
|
|
local noWorld = fakeWorld(treeCells, fakePlayer(5, 5, "up"), {
|
|
{ species = "HOOTHOOT", nickname = "OWL",
|
|
moves = { { id = "HEADBUTT", pp = 15 } } },
|
|
})
|
|
check(noWorld:interact(), "a tree with a HEADBUTT mon takes the press")
|
|
eq(noWorld.log[1], "A POKéMON could be\nin this tree.\fWant to HEADBUTT\nit?",
|
|
"AskHeadbuttText is the question")
|
|
advanceText(noWorld)
|
|
eq(noWorld.log[2], "<yesno>", "and it is a yesorno")
|
|
answerYesNo(noWorld, false)
|
|
eq(#noWorld.log, 2, "NO ends the script there")
|
|
check(noWorld.headbutt == nil, "NO shakes nothing")
|
|
|
|
-- YES: nickname, UseHeadbuttText, the 32-frame shake, and only then the roll.
|
|
local hitWorld, hitGame = fakeWorld(treeCells, fakePlayer(5, 5, "up"), {
|
|
{ species = "HOOTHOOT", nickname = "OWL",
|
|
moves = { { id = "HEADBUTT", pp = 15 } } },
|
|
})
|
|
check(hitWorld:interact(), "the ask opens again")
|
|
advanceText(hitWorld)
|
|
answerYesNo(hitWorld, true)
|
|
eq(hitGame.stringBuffer, "OWL", "GetPartyNickname fills wStringBuffer2")
|
|
eq(hitWorld.log[3], "{STRBUF} did a\nHEADBUTT!", "UseHeadbuttText names the mon")
|
|
check(hitWorld.headbutt == nil, "the shake waits for the text")
|
|
advanceText(hitWorld)
|
|
check(hitWorld.headbutt ~= nil, "then ShakeHeadbuttTree starts")
|
|
eq(hitWorld.headbutt.timer, 32, "wFrameCounter is 32")
|
|
check(hitWorld:busy(), "the shake holds the world")
|
|
runFrames(hitWorld, 32)
|
|
eq(hitWorld.log[4], nil, "nothing is rolled mid-shake")
|
|
runFrames(hitWorld, 1)
|
|
eq(hitWorld.log[4], "<battle:HOOTHOOT>", "TreeMonEncounter runs after the shake")
|
|
check(hitWorld.headbutt == nil, "and the shake is over")
|
|
|
|
-- A map with no treemon set is the cart's other no_battle: the tree still
|
|
-- shakes, and "Nope. Nothing…" is what it gives up.
|
|
local emptyWorld = fakeWorld(treeCells, fakePlayer(5, 5, "up"), {
|
|
{ species = "HOOTHOOT", nickname = "OWL",
|
|
moves = { { id = "HEADBUTT", pp = 15 } } },
|
|
})
|
|
emptyWorld.encounters = { fishGroups = ENCOUNTERS.fishGroups }
|
|
check(emptyWorld:interact(), "the ask opens on the bare map too")
|
|
advanceText(emptyWorld)
|
|
answerYesNo(emptyWorld, true)
|
|
advanceText(emptyWorld)
|
|
runFrames(emptyWorld, 33)
|
|
eq(emptyWorld.log[4], "Nope. Nothing…", "HeadbuttNothingText closes it out")
|
|
|
|
-- A tree the player is not facing is somebody else's press.
|
|
local pastWorld = fakeWorld(treeCells, fakePlayer(5, 5, "down"), {
|
|
{ species = "HOOTHOOT", nickname = "OWL",
|
|
moves = { { id = "HEADBUTT", pp = 15 } } },
|
|
})
|
|
check(not pastWorld:interact(), "facing away from the tree does nothing")
|
|
|
|
-- ---- C. the cave encounter gate ------------------------------------------
|
|
-- CanEncounterWildMon (engine/overworld/events.asm): a CAVE or DUNGEON map
|
|
-- skips CheckGrassCollision entirely, so any non-ice walkable tile rolls.
|
|
local FieldMoves = require("src.world.gen2.FieldMoves")
|
|
local Palettes = require("src.world.gen2.Palettes")
|
|
|
|
local COLL_TALL_GRASS, COLL_ICE = 0x18, 0x23
|
|
local COLL_CUT_TREE, COLL_WHIRLPOOL, COLL_WATERFALL = 0x12, 0x24, 0x33
|
|
|
|
local function canEncounter(env, coll, noWild)
|
|
return FieldMoves.canEncounterWildMon(env, coll, noWild)
|
|
end
|
|
|
|
check(canEncounter("ROUTE", COLL_TALL_GRASS), "route + tall grass rolls")
|
|
check(not canEncounter("ROUTE", COLL_FLOOR), "route + bare floor does not")
|
|
check(canEncounter("CAVE", COLL_FLOOR), "CAVE floor rolls: no grass check")
|
|
check(canEncounter("DUNGEON", COLL_FLOOR), "DUNGEON floor rolls too")
|
|
check(not canEncounter("CAVE", COLL_ICE), "ice in a cave still refuses")
|
|
check(not canEncounter("ROUTE", COLL_ICE), "ice on a route refuses")
|
|
check(canEncounter("ROUTE", COLL_WATER), "COLL_WATER is in CheckGrassCollision")
|
|
check(not canEncounter("CAVE", COLL_FLOOR, true), "wildoff beats the cave arm")
|
|
check(not canEncounter("ROUTE", COLL_TALL_GRASS, true), "wildoff beats grass")
|
|
check(not canEncounter("INDOOR", COLL_FLOOR), "a house floor is not a cave")
|
|
-- The unused $10 / $1c grass aliases are NOT in the cart's array.
|
|
check(not canEncounter("ROUTE", 0x10), "COLL_TALL_GRASS_10 is not in the array")
|
|
check(Permissions.isGrass(0x10), "...even though it reads as grass")
|
|
eq(FieldMoves.encounterTable(COLL_WATER), "water", "water tile, water list")
|
|
eq(FieldMoves.encounterTable(COLL_TALL_GRASS), "grass", "grass tile, grass list")
|
|
eq(FieldMoves.encounterTable(COLL_FLOOR), "grass", "cave floor, grass list")
|
|
|
|
-- ...and the same thing through World:tryWildEncounter, which is where the
|
|
-- bug actually bit: Dark Cave and Union Cave gave nothing at all.
|
|
local ALWAYS = {
|
|
grass = {
|
|
TEST_MAP = {
|
|
rates = { MORN = 256, DAY = 256, NITE = 256 },
|
|
slots = {
|
|
DAY = {}, MORN = {}, NITE = {},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
for _, key in ipairs({ "DAY", "MORN", "NITE" }) do
|
|
for i = 1, 7 do
|
|
ALWAYS.grass.TEST_MAP.slots[key][i] =
|
|
{ species = "HOOTHOOT", level = 5 }
|
|
end
|
|
end
|
|
|
|
local PARTY_ONE = { { species = "MAGIKARP", nickname = "KARP",
|
|
moves = { { id = "SPLASH", pp = 40 } } } }
|
|
|
|
local caveWorld = fakeWorld({}, fakePlayer(5, 5, "down"), PARTY_ONE, nil,
|
|
{ environment = "CAVE" })
|
|
caveWorld.encounters = ALWAYS
|
|
check(caveWorld:tryWildEncounter(), "a cave floor step rolls an encounter")
|
|
eq(caveWorld.log[1], "<battle:HOOTHOOT>", "and it is the grass list")
|
|
|
|
-- CheckWildEncounterCooldown (engine/overworld/events.asm:357-365), the first
|
|
-- thing RandomEncounter runs: EnterMap arms it with 5, and only the step that
|
|
-- ticks it to zero may roll.
|
|
caveWorld.wildCooldown = 5
|
|
for _ = 1, 4 do
|
|
check(not caveWorld:tryWildEncounter(), "the five-step cooldown blocks a step")
|
|
end
|
|
check(caveWorld:tryWildEncounter(), "the fifth step rolls again")
|
|
|
|
local routeWorld = fakeWorld({}, fakePlayer(5, 5, "down"), PARTY_ONE, nil,
|
|
{ environment = "ROUTE" })
|
|
routeWorld.encounters = ALWAYS
|
|
check(not routeWorld:tryWildEncounter(), "the same step on a route does not")
|
|
|
|
local iceWorld = fakeWorld({ [5 * 100 + 5] = COLL_ICE },
|
|
fakePlayer(5, 5, "down"), PARTY_ONE, nil, { environment = "CAVE" })
|
|
iceWorld.encounters = ALWAYS
|
|
check(not iceWorld:tryWildEncounter(), "ice inside a cave rolls nothing")
|
|
|
|
local offWorld = fakeWorld({}, fakePlayer(5, 5, "down"), PARTY_ONE, nil,
|
|
{ environment = "CAVE" })
|
|
offWorld.encounters = ALWAYS
|
|
offWorld.noWildEncounters = true
|
|
check(not offWorld:tryWildEncounter(), "wildoff stops the cave roll")
|
|
|
|
-- ---- D. badges and party moves -------------------------------------------
|
|
local function saveWith(badges)
|
|
return { player = { name = "GOLD", badges = badges } }
|
|
end
|
|
|
|
check(FieldMoves.hasBadge(saveWith({ HIVE = true }), "HIVE"),
|
|
"a badge keyed by name is owned")
|
|
check(FieldMoves.hasBadge(saveWith({ [2] = true }), "HIVE"),
|
|
"...and so is one keyed by its wJohtoBadges position")
|
|
check(not FieldMoves.hasBadge(saveWith({ ZEPHYR = true }), "HIVE"),
|
|
"a different badge is not the HIVEBADGE")
|
|
check(not FieldMoves.hasBadge({}, "HIVE"), "a save with no badges owns none")
|
|
eq(FieldMoves.BADGE.CUT, "HIVE", "CUT is the HIVEBADGE")
|
|
eq(FieldMoves.BADGE.FLASH, "ZEPHYR", "FLASH is the ZEPHYRBADGE")
|
|
eq(FieldMoves.BADGE.SURF, "FOG", "SURF is the FOGBADGE")
|
|
eq(FieldMoves.BADGE.STRENGTH, "PLAIN", "STRENGTH is the PLAINBADGE")
|
|
eq(FieldMoves.BADGE.FLY, "STORM", "FLY is the STORMBADGE")
|
|
eq(FieldMoves.BADGE.WHIRLPOOL, "GLACIER", "WHIRLPOOL is the GLACIERBADGE")
|
|
eq(FieldMoves.BADGE.WATERFALL, "RISING", "WATERFALL is the RISINGBADGE")
|
|
|
|
local CUTTER = { { species = "HOOTHOOT", nickname = "OWL",
|
|
moves = { { id = "CUT", pp = 30 }, { id = "FLASH", pp = 20 } } } }
|
|
local mon, slot = FieldMoves.partyMoveUser(CUTTER, "CUT")
|
|
check(mon ~= nil, "CheckPartyMove finds the cutter")
|
|
eq(slot, 1, "and leaves its slot in wCurPartyMon")
|
|
check(FieldMoves.partyMoveUser(CUTTER, "SURF") == nil, "no SURF in the party")
|
|
check(FieldMoves.partyMoveUser(
|
|
{ { egg = true, moves = { { id = "CUT" } } } }, "CUT") == nil,
|
|
"an EGG slot is skipped")
|
|
|
|
-- ---- E. the seven, from the party submenu --------------------------------
|
|
-- MonMenu_*: the badge is checked with the noisy CheckBadge, so every refusal
|
|
-- here has a line, and every success is QUEUED rather than run.
|
|
local ALL_BADGES = {
|
|
ZEPHYR = true, HIVE = true, PLAIN = true, FOG = true,
|
|
STORM = true, MINERAL = true, GLACIER = true, RISING = true,
|
|
}
|
|
local T = FieldMoves.TEXT
|
|
|
|
-- One johto tree block in the block the facing cell (5,4) sits in: bx 2, by 2
|
|
-- of a 10-wide grid, so blocks[2 * 10 + 2 + 1].
|
|
local TREE_BLOCK_INDEX = 2 * MAP_W + 2 + 1
|
|
|
|
local function fieldWorld(cells, party, opts)
|
|
opts = opts or {}
|
|
opts.badges = opts.badges == nil and ALL_BADGES or opts.badges
|
|
local world, game = fakeWorld(cells, fakePlayer(5, 5, "up"), party, nil, opts)
|
|
world.encounters = nil
|
|
return world, game
|
|
end
|
|
|
|
-- CUT, refused: no HIVEBADGE.
|
|
local noHive = fieldWorld({ [4 * 100 + 5] = COLL_CUT_TREE }, CUTTER,
|
|
{ badges = { ZEPHYR = true },
|
|
blocks = { [TREE_BLOCK_INDEX] = 0x5b } })
|
|
local res = noHive:useFieldMove("CUT", CUTTER[1])
|
|
check(not res.ok, "CUT with no HIVEBADGE is refused")
|
|
eq(res.badge, "HIVE", "and CheckBadge names the badge it wanted")
|
|
eq(noHive.log[1], T.BADGE_REQUIRED, "BadgeRequiredText is the line")
|
|
check(noHive.queuedFieldMove == nil, "nothing is queued")
|
|
|
|
-- CUT, refused: badge in hand, nothing in front worth cutting.
|
|
local nothingToCut = fieldWorld({}, CUTTER)
|
|
check(not nothingToCut:useFieldMove("CUT", CUTTER[1]).ok, "bare floor: no cut")
|
|
eq(nothingToCut.log[1], T.CUT_NOTHING, "CutNothingText is the line")
|
|
|
|
-- CUT, done. The block swap waits for the text box, and the tree is a whole
|
|
-- BLOCK, not a tile.
|
|
local cutWorld = fieldWorld({ [4 * 100 + 5] = COLL_CUT_TREE }, CUTTER,
|
|
{ blocks = { [TREE_BLOCK_INDEX] = 0x5b } })
|
|
local cutRes = cutWorld:useFieldMove("CUT", CUTTER[1])
|
|
check(cutRes.ok, "CUT with the badge and a tree in front succeeds")
|
|
eq(#cutWorld.log, 0, "QueueScript says nothing while the menu is still up")
|
|
check(cutWorld.queuedFieldMove ~= nil, "the script is queued")
|
|
runFrames(cutWorld, 1)
|
|
eq(cutWorld.log[1], T.USE_CUT, "the queued script runs once the world is back")
|
|
eq(cutWorld.game.stringBuffer, "OWL", "GetPartyNickname filled {STRBUF}")
|
|
eq(cutWorld.map.def.blocks[TREE_BLOCK_INDEX], 0x5b, "the tree is still standing")
|
|
advanceText(cutWorld)
|
|
eq(cutWorld.map.def.blocks[TREE_BLOCK_INDEX], 0x3c,
|
|
"CutDownTreeOrGrass swaps block $5b for $3c")
|
|
-- LoadMapAttributes refills the buffer from ROM: the tree grows back.
|
|
cutWorld:restoreBlocks()
|
|
eq(cutWorld.map.def.blocks[TREE_BLOCK_INDEX], 0x5b, "a map load regrows it")
|
|
|
|
-- CUT mows grass too, and picks that tileset's own replacement.
|
|
eq(select(1, FieldMoves.blockReplacement(
|
|
FieldMoves.CUT_BLOCKS, "TILESET_JOHTO", 0x03)), 0x02, "johto grass -> $02")
|
|
eq(select(2, FieldMoves.blockReplacement(
|
|
FieldMoves.CUT_BLOCKS, "TILESET_JOHTO", 0x03)), 1, "grass takes animation 1")
|
|
eq(select(2, FieldMoves.blockReplacement(
|
|
FieldMoves.CUT_BLOCKS, "TILESET_JOHTO", 0x5b)), 0, "a tree takes animation 0")
|
|
check(FieldMoves.blockReplacement(
|
|
FieldMoves.CUT_BLOCKS, "TILESET_CAVE", 0x03) == nil,
|
|
"a tileset with no CutTreeBlockPointers row cuts nothing")
|
|
|
|
-- FLASH: the ZEPHYRBADGE, and only on a DARKNESS_PALSET map.
|
|
local FLASHER = { { species = "HOOTHOOT", nickname = "OWL",
|
|
moves = { { id = "FLASH", pp = 20 } } } }
|
|
local litWorld = fieldWorld({}, FLASHER, { environment = "CAVE" })
|
|
check(not litWorld:useFieldMove("FLASH", FLASHER[1]).ok,
|
|
"FLASH in a lit cave is refused")
|
|
eq(litWorld.log[1], T.CANT_USE_HERE, "and it is FieldMoveFailed's line")
|
|
|
|
local darkWorld = fieldWorld({}, FLASHER,
|
|
{ environment = "CAVE", palette = "PALETTE_DARK" })
|
|
check(not darkWorld.flashUsed, "a dark map starts unflashed")
|
|
eq(Palettes.daytimeFor(darkWorld.map.def, 12, false), "DARK",
|
|
"PALETTE_DARK resolves to the DARK palette row")
|
|
check(darkWorld:useFieldMove("FLASH", FLASHER[1]).ok, "FLASH in the dark works")
|
|
runFrames(darkWorld, 1)
|
|
eq(darkWorld.log[1], T.BLINDING_FLASH, "BlindingFlashText")
|
|
advanceText(darkWorld)
|
|
check(darkWorld.flashUsed, "BlindingFlash sets STATUSFLAGS_FLASH_F")
|
|
eq(Palettes.daytimeFor(darkWorld.map.def, 12, true), "NITE",
|
|
"and a flashed dark map reads as NITE")
|
|
check(not darkWorld:useFieldMove("FLASH", FLASHER[1]).ok,
|
|
"a second FLASH in the same cave is refused")
|
|
|
|
local noZephyr = fieldWorld({}, FLASHER,
|
|
{ badges = {}, environment = "CAVE", palette = "PALETTE_DARK" })
|
|
eq(noZephyr:useFieldMove("FLASH", FLASHER[1]).badge, "ZEPHYR",
|
|
"FLASH without the ZEPHYRBADGE is refused first")
|
|
|
|
-- FLY: outdoors only.
|
|
local FLYER = { { species = "HOOTHOOT", nickname = "OWL",
|
|
moves = { { id = "FLY", pp = 15 } } } }
|
|
local indoorFly = fieldWorld({}, FLYER, { environment = "INDOOR" })
|
|
check(not indoorFly:useFieldMove("FLY", FLYER[1]).ok, "no FLY indoors")
|
|
eq(indoorFly.log[1], T.CANT_USE_HERE, "and it is the generic refusal")
|
|
check(not fieldWorld({}, FLYER, { environment = "CAVE" })
|
|
:useFieldMove("FLY", FLYER[1]).ok, "no FLY in a cave either")
|
|
check(fieldWorld({}, FLYER, { environment = "TOWN" })
|
|
:useFieldMove("FLY", FLYER[1]).ok, "FLY works in a town")
|
|
check(fieldWorld({}, FLYER, { environment = "ROUTE" })
|
|
:useFieldMove("FLY", FLYER[1]).ok, "...and on a route")
|
|
eq(fieldWorld({}, FLYER, { badges = {}, environment = "ROUTE" })
|
|
:useFieldMove("FLY", FLYER[1]).badge, "STORM", "FLY wants the STORMBADGE")
|
|
|
|
-- Flypoints: Johto's twelve rows, filtered by what has been visited.
|
|
local LANDMARKS = {
|
|
landmarks = {
|
|
LANDMARK_NEW_BARK_TOWN = { index = 1, name = "NEW BARK\nTOWN" },
|
|
LANDMARK_VIOLET_CITY = { index = 5, name = "VIOLET CITY" },
|
|
LANDMARK_INDIGO_PLATEAU = { index = 63, name = "INDIGO\nPLATEAU" },
|
|
},
|
|
spawns = {
|
|
SPAWN_NEW_BARK = { map = "NEW_BARK_TOWN", x = 4, y = 5 },
|
|
SPAWN_VIOLET = { map = "VIOLET_CITY", x = 9, y = 21 },
|
|
},
|
|
}
|
|
local flySave = { player = { badges = ALL_BADGES },
|
|
visitedSpawns = { SPAWN_NEW_BARK = true } }
|
|
local points = FieldMoves.flyPoints(flySave, LANDMARKS, "johto")
|
|
eq(#points, 1, "only visited flypoints are offered")
|
|
eq(points[1].spawn, "SPAWN_NEW_BARK", "and it is the one that was visited")
|
|
flySave.visitedSpawns.SPAWN_VIOLET = true
|
|
points = FieldMoves.flyPoints(flySave, LANDMARKS, "johto")
|
|
eq(#points, 2, "a second visit adds a second row")
|
|
eq(points[2].landmark, "LANDMARK_VIOLET_CITY", "in Flypoints order")
|
|
-- Kanto is withheld until Indigo Plateau is on the record, or the picker has
|
|
-- no legal cursor position at all.
|
|
eq(#FieldMoves.flyPoints(flySave, LANDMARKS, "kanto"), 2,
|
|
"standing in Kanto with no Indigo flypoint shows the Johto map")
|
|
flySave.visitedSpawns.SPAWN_INDIGO = true
|
|
eq(#FieldMoves.flyPoints(flySave, LANDMARKS, "kanto"), 1,
|
|
"with Indigo visited the Kanto half takes over")
|
|
eq(FieldMoves.FLYPOINTS[FieldMoves.KANTO_FLYPOINT].spawn, "SPAWN_PALLET",
|
|
"KANTO_FLYPOINT is the Pallet Town row")
|
|
eq(#FieldMoves.FLYPOINTS, 24, "Flypoints has 24 rows")
|
|
|
|
-- The real path: MAPCALLBACK_NEWMAP's `setflag ENGINE_FLYPOINT_*` lands on
|
|
-- save.engineFlags[id] (Vm.lua's setflag), and that is what the menu now
|
|
-- reads first -- visitedSpawns is only the fallback for a save that predates
|
|
-- this.
|
|
local engineSave = { player = { badges = ALL_BADGES }, engineFlags = {} }
|
|
eq(#FieldMoves.flyPoints(engineSave, LANDMARKS, "johto"), 0,
|
|
"no engine flags set yet: nothing offered")
|
|
engineSave.engineFlags[64] = true -- ENGINE_FLYPOINT_NEW_BARK
|
|
local enginePoints = FieldMoves.flyPoints(engineSave, LANDMARKS, "johto")
|
|
eq(#enginePoints, 1, "the engine flag alone is enough")
|
|
eq(enginePoints[1].spawn, "SPAWN_NEW_BARK", "and it is the right spawn")
|
|
-- A save with BOTH an engine flag and stale visitedSpawns trusts the engine
|
|
-- flag, even when it disagrees -- an explicit false beats a leftover true.
|
|
engineSave.visitedSpawns = { SPAWN_VIOLET = true }
|
|
engineSave.engineFlags[66] = false -- ENGINE_FLYPOINT_VIOLET, explicitly unset
|
|
eq(#FieldMoves.flyPoints(engineSave, LANDMARKS, "johto"), 1,
|
|
"an explicit engine-flag false overrides a stale visitedSpawns true")
|
|
|
|
-- STRENGTH from the menu: the badge and nothing else.
|
|
local LIFTER = { { species = "HOOTHOOT", nickname = "OWL",
|
|
moves = { { id = "STRENGTH", pp = 15 } } } }
|
|
local strWorld = fieldWorld({}, LIFTER)
|
|
check(strWorld:useFieldMove("STRENGTH", LIFTER[1]).ok,
|
|
"STRENGTH off the menu only wants the PLAINBADGE")
|
|
runFrames(strWorld, 1)
|
|
check(strWorld.strengthActive, "SetStrengthFlag runs before the text")
|
|
eq(strWorld.log[1], T.USE_STRENGTH, "UseStrengthText")
|
|
advanceText(strWorld)
|
|
check(strWorld.fieldMove ~= nil, "then `pause 3`")
|
|
runFrames(strWorld, 4)
|
|
eq(strWorld.log[2], T.MOVE_BOULDER, "MoveBoulderText follows the pause")
|
|
eq(fieldWorld({}, LIFTER, { badges = {} })
|
|
:useFieldMove("STRENGTH", LIFTER[1]).badge, "PLAIN", "STRENGTH wants PLAIN")
|
|
|
|
-- WATERFALL: facing UP at one, and only then.
|
|
local CLIMBER = { { species = "MAGIKARP", nickname = "KARP",
|
|
moves = { { id = "WATERFALL", pp = 15 } } } }
|
|
local fallCells = { [4 * 100 + 5] = COLL_WATERFALL, [3 * 100 + 5] = COLL_WATERFALL }
|
|
local flatWorld = fieldWorld({}, CLIMBER)
|
|
check(not flatWorld:useFieldMove("WATERFALL", CLIMBER[1]).ok,
|
|
"no waterfall above: refused")
|
|
local sideWorld = fieldWorld(fallCells, CLIMBER)
|
|
sideWorld.player.facing = "left"
|
|
check(not sideWorld:useFieldMove("WATERFALL", CLIMBER[1]).ok,
|
|
"facing away from the waterfall: refused")
|
|
check(FieldMoves.waterfallContinues(COLL_WATERFALL),
|
|
"the climb continues while the tile underfoot is a waterfall")
|
|
check(FieldMoves.waterfallContinues(0x3b),
|
|
"COLL_CURRENT_DOWN counts as a waterfall tile too")
|
|
check(not FieldMoves.waterfallContinues(COLL_WATER), "plain water ends it")
|
|
|
|
-- WHIRLPOOL: the block table, not just the collision.
|
|
local SPINNER = { { species = "MAGIKARP", nickname = "KARP",
|
|
moves = { { id = "WHIRLPOOL", pp = 15 } } } }
|
|
local poolWorld = fieldWorld({ [4 * 100 + 5] = COLL_WHIRLPOOL }, SPINNER,
|
|
{ blocks = { [TREE_BLOCK_INDEX] = 0x07 } })
|
|
check(poolWorld:useFieldMove("WHIRLPOOL", SPINNER[1]).ok, "whirlpool cleared")
|
|
runFrames(poolWorld, 1)
|
|
eq(poolWorld.log[1], T.USE_WHIRLPOOL, "UseWhirlpoolText")
|
|
advanceText(poolWorld)
|
|
eq(poolWorld.map.def.blocks[TREE_BLOCK_INDEX], 0x36,
|
|
"DisappearWhirlpool swaps block $07 for $36")
|
|
local wrongBlock = fieldWorld({ [4 * 100 + 5] = COLL_WHIRLPOOL }, SPINNER)
|
|
check(not wrongBlock:useFieldMove("WHIRLPOOL", SPINNER[1]).ok,
|
|
"a whirlpool collision over the wrong block is refused")
|
|
|
|
-- A move the port has no routine for still answers the way the cart does.
|
|
check(not fieldWorld({}, CUTTER):useFieldMove("DIG", CUTTER[1]).ok,
|
|
"DIG is not ported and lands on FieldMoveFailed")
|
|
|
|
-- ---- E2. SWEET_SCENT -------------------------------------------------------
|
|
-- SweetScentFromMenu (engine/events/sweet_scent.asm): no badge, no facing
|
|
-- tile test at all -- QueueScript always succeeds, and whether anything is
|
|
-- home is answered by the queued script once the menus are gone, same as
|
|
-- HEADBUTT's shake.
|
|
-- `do`/`end`-scoped: the file is one big chunk and LuaJIT's main function
|
|
-- caps at 200 live locals, so a fresh block of world/game fixtures has to
|
|
-- free its slots at `end` rather than pile onto the running total.
|
|
do
|
|
local SCENTER = { { species = "HOOTHOOT", nickname = "OWL",
|
|
moves = { { id = "SWEET_SCENT", pp = 20 } } } }
|
|
local NOTHING_HERE = "Looks like there's\nnothing here…"
|
|
|
|
local bareScent, bareGame = fieldWorld({}, SCENTER)
|
|
bareScent.encounters = nil
|
|
check(bareScent:useFieldMove("SWEET_SCENT", SCENTER[1]).ok,
|
|
"SWEET SCENT always queues")
|
|
eq(#bareScent.log, 0, "QueueScript says nothing while the menu is still up")
|
|
runFrames(bareScent, 1)
|
|
eq(bareScent.log[1], "{STRBUF} used\nSWEET SCENT!",
|
|
"UseSweetScentText names the mon")
|
|
eq(bareGame.stringBuffer, "OWL", "GetPartyNickname fills wStringBuffer2")
|
|
advanceText(bareScent)
|
|
eq(bareScent.log[2], NOTHING_HERE,
|
|
"no encounter table on this map: SweetScentNothingText")
|
|
|
|
-- A grass tile with a nonzero rate: ChooseWildEncounter runs unconditionally,
|
|
-- with no percentage roll of its own -- the whole point of the move.
|
|
local grassScent = fieldWorld({ [5 * 100 + 5] = COLL_TALL_GRASS }, SCENTER)
|
|
grassScent.encounters = ALWAYS
|
|
check(grassScent:useFieldMove("SWEET_SCENT", SCENTER[1]).ok)
|
|
runFrames(grassScent, 1)
|
|
advanceText(grassScent)
|
|
eq(grassScent.log[2], "<battle:HOOTHOOT>",
|
|
"SweetScentEncounter forces a battle off the current tile's table")
|
|
|
|
-- Bare floor on a ROUTE: CanEncounterWildMon still refuses (no grass, no
|
|
-- water) -- SWEET SCENT does not walk through that gate, it only skips the
|
|
-- roll behind it.
|
|
local floorScent = fieldWorld({}, SCENTER)
|
|
floorScent.encounters = ALWAYS
|
|
check(floorScent:useFieldMove("SWEET_SCENT", SCENTER[1]).ok)
|
|
runFrames(floorScent, 1)
|
|
advanceText(floorScent)
|
|
eq(floorScent.log[2], NOTHING_HERE, "bare ROUTE floor still refuses the roll")
|
|
|
|
-- wildoff (STATUSFLAGS_NO_WILD_ENCOUNTERS_F) beats SWEET SCENT the same way
|
|
-- it beats a step.
|
|
local offScent = fieldWorld({ [5 * 100 + 5] = COLL_TALL_GRASS }, SCENTER)
|
|
offScent.encounters = ALWAYS
|
|
offScent.noWildEncounters = true
|
|
check(offScent:useFieldMove("SWEET_SCENT", SCENTER[1]).ok)
|
|
runFrames(offScent, 1)
|
|
advanceText(offScent)
|
|
eq(offScent.log[2], NOTHING_HERE, "wildoff refuses too")
|
|
|
|
-- ENGINE_BUG_CONTEST_TIMER: farsjump's straight past GetMapEncounterRate to
|
|
-- the park's own table, same branch RandomEncounter takes on a normal step.
|
|
-- A one-row ContestMons list so the pick is deterministic without adding
|
|
-- CATERPIE and friends to the shared DATA fixture every other case here uses.
|
|
local contestScent = fieldWorld({ [5 * 100 + 5] = COLL_TALL_GRASS }, SCENTER)
|
|
contestScent.encounters = ALWAYS
|
|
contestScent.game.save.bugContest = { active = true }
|
|
contestScent.game.data = {
|
|
items = DATA.items, moves = DATA.moves, pokemon = DATA.pokemon,
|
|
encounters = { bugContest = {
|
|
{ chance = 1000, species = "HOOTHOOT", min = 5, max = 5 },
|
|
} },
|
|
}
|
|
check(contestScent:useFieldMove("SWEET_SCENT", SCENTER[1]).ok)
|
|
runFrames(contestScent, 1)
|
|
advanceText(contestScent)
|
|
check(contestScent.log[2]:match("^<battle:"),
|
|
"the contest's own table runs, not the map's grass list")
|
|
end
|
|
|
|
-- ---- F. the same seven, from an A press ----------------------------------
|
|
-- Try*OW: the MOVE is checked first and the badge silently, so the refusals
|
|
-- are the tile's own lines, not "a new BADGE is required".
|
|
local owNoCut = fieldWorld({ [4 * 100 + 5] = COLL_CUT_TREE },
|
|
{ { species = "MAGIKARP", moves = { { id = "SPLASH" } } } },
|
|
{ blocks = { [TREE_BLOCK_INDEX] = 0x5b } })
|
|
check(owNoCut:interact(), "a cut tree takes the A press even with no cutter")
|
|
eq(owNoCut.log[1], T.CAN_CUT, "CanCutText, not the badge line")
|
|
|
|
local owNoBadge = fieldWorld({ [4 * 100 + 5] = COLL_CUT_TREE }, CUTTER,
|
|
{ badges = {}, blocks = { [TREE_BLOCK_INDEX] = 0x5b } })
|
|
check(owNoBadge:interact(), "and with the mon but no badge it still answers")
|
|
eq(owNoBadge.log[1], T.CAN_CUT, "CheckEngineFlag is the silent check")
|
|
|
|
local owCut = fieldWorld({ [4 * 100 + 5] = COLL_CUT_TREE }, CUTTER,
|
|
{ blocks = { [TREE_BLOCK_INDEX] = 0x5b } })
|
|
check(owCut:interact(), "the A press opens AskCutScript")
|
|
eq(owCut.log[1], T.ASK_CUT, "AskCutText")
|
|
advanceText(owCut)
|
|
eq(owCut.log[2], "<yesno>", "and it is a yesorno")
|
|
answerYesNo(owCut, false)
|
|
eq(#owCut.log, 2, "NO closes it and cuts nothing")
|
|
eq(owCut.map.def.blocks[TREE_BLOCK_INDEX], 0x5b, "the tree is untouched")
|
|
|
|
local owCut2 = fieldWorld({ [4 * 100 + 5] = COLL_CUT_TREE }, CUTTER,
|
|
{ blocks = { [TREE_BLOCK_INDEX] = 0x5b } })
|
|
owCut2:interact()
|
|
advanceText(owCut2)
|
|
answerYesNo(owCut2, true)
|
|
eq(owCut2.log[3], T.USE_CUT, "YES runs Script_Cut on the spot (CallScript)")
|
|
advanceText(owCut2)
|
|
eq(owCut2.map.def.blocks[TREE_BLOCK_INDEX], 0x3c, "and the tree comes down")
|
|
|
|
-- WHIRLPOOL, from the water.
|
|
local owPool = fieldWorld({ [4 * 100 + 5] = COLL_WHIRLPOOL }, SPINNER,
|
|
{ blocks = { [TREE_BLOCK_INDEX] = 0x07 } })
|
|
check(owPool:interact(), "a whirlpool takes the press")
|
|
eq(owPool.log[1], T.ASK_WHIRLPOOL, "AskWhirlpoolText")
|
|
local owPoolNo = fieldWorld({ [4 * 100 + 5] = COLL_WHIRLPOOL },
|
|
{ { species = "MAGIKARP", moves = { { id = "SPLASH" } } } },
|
|
{ blocks = { [TREE_BLOCK_INDEX] = 0x07 } })
|
|
check(owPoolNo:interact(), "and refuses with a line rather than silence")
|
|
eq(owPoolNo.log[1], T.MAY_PASS_WHIRLPOOL, "MayPassWhirlpoolText")
|
|
|
|
-- WATERFALL, from the bottom.
|
|
local owFall = fieldWorld(fallCells, CLIMBER)
|
|
check(owFall:interact(), "a waterfall takes the press")
|
|
eq(owFall.log[1], T.ASK_WATERFALL, "AskWaterfallText")
|
|
local owFallNo = fieldWorld(fallCells,
|
|
{ { species = "MAGIKARP", moves = { { id = "SPLASH" } } } })
|
|
check(owFallNo:interact(), "with no climber it still answers")
|
|
eq(owFallNo.log[1], T.HUGE_WATERFALL, "HugeWaterfallText")
|
|
|
|
-- The climb itself: Script_UsedWaterfall loops one turn_waterfall UP step at a
|
|
-- time and stops only once the tile UNDERFOOT is no longer a waterfall tile.
|
|
local PlayerModule = require("src.world.gen2.Player")
|
|
local climbCells = {
|
|
[4 * 100 + 5] = COLL_WATERFALL,
|
|
[3 * 100 + 5] = COLL_WATERFALL,
|
|
[2 * 100 + 5] = COLL_WATER,
|
|
}
|
|
local climb = fakeWorld(climbCells, PlayerModule.new(5, 5, "up"), CLIMBER, nil,
|
|
{ badges = ALL_BADGES })
|
|
climb.encounters = nil
|
|
check(climb:interact(), "the waterfall takes the press")
|
|
advanceText(climb)
|
|
answerYesNo(climb, true)
|
|
eq(climb.log[3], T.USE_WATERFALL, "UseWaterfallText")
|
|
advanceText(climb)
|
|
check(climb.fieldMove ~= nil, "and then the climb owns the world")
|
|
check(climb:busy(), "nothing else runs during it")
|
|
runFrames(climb, 60)
|
|
eq(climb.player.cellY, 2, "the climb stops on the first non-waterfall tile")
|
|
check(climb.fieldMove == nil, "and hands the world back")
|
|
check(not climb:busy(), "the world is free again")
|
|
|
|
-- ---- G. SURF, the player state -------------------------------------------
|
|
local RealPlayer = require("src.world.gen2.Player")
|
|
local SURFER = { { species = "MAGIKARP", nickname = "KARP",
|
|
moves = { { id = "SURF", pp = 15 } } } }
|
|
|
|
check(not FieldMoves.isSurfing(FieldMoves.PLAYER_NORMAL), "normal is not surf")
|
|
check(FieldMoves.isSurfing(FieldMoves.PLAYER_SURF), "PLAYER_SURF is")
|
|
check(FieldMoves.isSurfing(FieldMoves.PLAYER_SURF_PIKA), "so is the Pika one")
|
|
eq(FieldMoves.surfType({ species = "PIKACHU" }), FieldMoves.PLAYER_SURF_PIKA,
|
|
"GetSurfType gives PIKACHU its own state")
|
|
eq(FieldMoves.surfType({ species = "LAPRAS" }), FieldMoves.PLAYER_SURF,
|
|
"and everything else the ordinary one")
|
|
eq(FieldMoves.STATE_SPRITE[FieldMoves.PLAYER_SURF], "SPRITE_SURF",
|
|
"ChrisStateSprites maps PLAYER_SURF to SPRITE_SURF")
|
|
|
|
local seaCells = {
|
|
[4 * 100 + 5] = COLL_WATER,
|
|
[3 * 100 + 5] = COLL_WATER,
|
|
}
|
|
|
|
-- No SURF mon: TrySurfOW's every failure is `.quit`, so the press is silent.
|
|
local dryShore = fakeWorld(seaCells, fakePlayer(5, 5, "up"),
|
|
{ { species = "HOOTHOOT", moves = { { id = "TACKLE" } } } }, nil,
|
|
{ badges = ALL_BADGES })
|
|
dryShore.encounters = nil
|
|
check(not dryShore:interact(), "water with no SURF mon drops the press")
|
|
eq(#dryShore.log, 0, "and says nothing at all")
|
|
|
|
local noFog = fakeWorld(seaCells, fakePlayer(5, 5, "up"), SURFER, nil,
|
|
{ badges = { ZEPHYR = true } })
|
|
noFog.encounters = nil
|
|
check(not noFog:interact(), "no FOGBADGE is just as silent")
|
|
eq(#noFog.log, 0, "CheckEngineFlag never prints")
|
|
|
|
local sea = fakeWorld(seaCells, RealPlayer.new(5, 5, "up"), SURFER, nil,
|
|
{ badges = ALL_BADGES })
|
|
sea.encounters = nil
|
|
check(sea:interact(), "with the badge and the mon, AskSurfScript opens")
|
|
eq(sea.log[1], T.ASK_SURF, "AskSurfText")
|
|
advanceText(sea)
|
|
answerYesNo(sea, true)
|
|
eq(sea.log[3], T.USED_SURF, "UsedSurfText")
|
|
eq(sea.playerState, FieldMoves.PLAYER_NORMAL, "still on foot behind the text")
|
|
advanceText(sea)
|
|
eq(sea.playerState, FieldMoves.PLAYER_SURF, "then wPlayerState becomes SURF")
|
|
check(sea.player.moving, "SurfStartStep walks into the water")
|
|
check(sea:busy(), "and the world is frozen for it")
|
|
runFrames(sea, 20)
|
|
eq(sea.player.cellY, 4, "the step lands on the water tile")
|
|
check(not sea:busy(), "and the world comes back")
|
|
|
|
-- NO at the shore leaves the player on foot.
|
|
local declined = fakeWorld(seaCells, RealPlayer.new(5, 5, "up"), SURFER, nil,
|
|
{ badges = ALL_BADGES })
|
|
declined.encounters = nil
|
|
check(declined:interact(), "the ask opens again")
|
|
advanceText(declined)
|
|
answerYesNo(declined, false)
|
|
eq(declined.playerState, FieldMoves.PLAYER_NORMAL, "NO stays on the bank")
|
|
eq(declined.player.cellY, 5, "and takes no step")
|
|
|
|
-- Surfing, a step across open water is an ordinary step...
|
|
eq(sea:movePlayer("up"), "moved", "a surfing step onto water is allowed")
|
|
for _ = 1, 16 do sea.player:update() end
|
|
eq(sea.player.cellY, 3, "and it lands")
|
|
eq(sea.playerState, FieldMoves.PLAYER_SURF, "still surfing")
|
|
|
|
-- ...and a step onto LAND is .ExitWater, which puts the state back BEFORE the
|
|
-- step rather than after it.
|
|
sea.player.facing = "down"
|
|
sea.player.turnArmed = false
|
|
eq(sea:movePlayer("down"), "moved", "and back down over the water")
|
|
for _ = 1, 16 do sea.player:update() end
|
|
eq(sea.player.cellY, 4, "one cell short of the beach")
|
|
eq(sea.playerState, FieldMoves.PLAYER_SURF, "still afloat over water")
|
|
eq(sea:movePlayer("down"), "moved", "a surfing step onto land is allowed too")
|
|
eq(sea.playerState, FieldMoves.PLAYER_NORMAL,
|
|
"GetOutOfWater runs before .DoStep")
|
|
|
|
-- A wall is neither land nor water: .CheckSurfPerms bumps.
|
|
local walled = fakeWorld({ [4 * 100 + 5] = COLL_WATER,
|
|
[3 * 100 + 5] = 0x07 }, RealPlayer.new(5, 4, "up"), SURFER, nil,
|
|
{ badges = ALL_BADGES })
|
|
walled.encounters = nil
|
|
walled.playerState = FieldMoves.PLAYER_SURF
|
|
eq(walled:movePlayer("up"), "blocked", "a wall stops a surfing step")
|
|
eq(Permissions.surfable(COLL_WATER), "water", "water keeps you surfing")
|
|
eq(Permissions.surfable(COLL_FLOOR), "land", "land is .ExitWater")
|
|
check(Permissions.surfable(0x07) == nil, "a wall is neither")
|
|
|
|
-- Already surfing, the menu says so rather than trying again.
|
|
local afloat = fieldWorld(seaCells, SURFER)
|
|
afloat.playerState = FieldMoves.PLAYER_SURF
|
|
eq(afloat:useFieldMove("SURF", SURFER[1]).text, T.ALREADY_SURFING,
|
|
"AlreadySurfingText comes before the tile check")
|
|
afloat.playerState = FieldMoves.PLAYER_NORMAL
|
|
local ashore = fieldWorld({}, SURFER)
|
|
eq(ashore:useFieldMove("SURF", SURFER[1]).text, T.CANT_SURF,
|
|
"and dry land is CantSurfText")
|
|
|
|
-- Encounters roll while surfing: the tile underfoot is water, so the gate
|
|
-- passes on CheckGrassCollision's COLL_WATER row and the WATER list is rolled.
|
|
local surfEnc = fakeWorld({ [5 * 100 + 5] = COLL_WATER },
|
|
fakePlayer(5, 5, "down"), PARTY_ONE, nil, { environment = "ROUTE" })
|
|
surfEnc.playerState = FieldMoves.PLAYER_SURF
|
|
surfEnc.encounters = {
|
|
water = { TEST_MAP = { rate = 256,
|
|
slots = { { species = "MAGIKARP", level = 20 },
|
|
{ species = "MAGIKARP", level = 20 },
|
|
{ species = "MAGIKARP", level = 20 } } } },
|
|
}
|
|
check(surfEnc:tryWildEncounter(), "a surfing step rolls")
|
|
eq(surfEnc.log[1], "<battle:MAGIKARP>", "off the water list")
|
|
|
|
-- ...and fishing does not: .TryFish reads wPlayerState first.
|
|
local rodAfloat = fakeWorld({ [4 * 100 + 5] = COLL_WATER },
|
|
fakePlayer(5, 5, "up"), SURFER)
|
|
rodAfloat.playerState = FieldMoves.PLAYER_SURF
|
|
eq(rodAfloat:useRod("OLD_ROD"), "nowhere", "no fishing from the water")
|
|
|
|
-- ---- H. STRENGTH and the boulder -----------------------------------------
|
|
local function fakeBoulder(x, y)
|
|
return {
|
|
def = { index = 1, movement = 0x19 }, -- SPRITEMOVEDATA_STRENGTH_BOULDER
|
|
cellX = x, cellY = y, px = x * 16, py = y * 16,
|
|
moving = false, facing = "down",
|
|
scriptStep = function(self, dir)
|
|
local d = Map.DELTA[dir]
|
|
self.facing = dir
|
|
self.targetX, self.targetY = self.cellX + d[1], self.cellY + d[2]
|
|
self.moving = true
|
|
return true
|
|
end,
|
|
update = function() end,
|
|
}
|
|
end
|
|
|
|
check(World.isStrengthBoulder({ def = { movement = 0x19 } }),
|
|
"SPRITEMOVEDATA_STRENGTH_BOULDER is a boulder")
|
|
check(not World.isStrengthBoulder({ def = { movement = 0x18 } }),
|
|
"SPRITEMOVEDATA_SMASHABLE_ROCK is not")
|
|
|
|
local function boulderWorld(party, badges)
|
|
local world = fieldWorld({}, party, { badges = badges })
|
|
local rock = fakeBoulder(5, 4)
|
|
world.npcs = { rock }
|
|
world.entities = { world.player, rock }
|
|
return world, rock
|
|
end
|
|
|
|
-- Talking to a boulder with no STRENGTH mon: BouldersMayMoveText.
|
|
local rockNoMon, _ = boulderWorld(
|
|
{ { species = "MAGIKARP", moves = { { id = "SPLASH" } } } })
|
|
check(rockNoMon:interact(), "a boulder answers the A press")
|
|
eq(rockNoMon.log[1], T.BOULDERS_MAY_MOVE, "BouldersMayMoveText")
|
|
|
|
-- With the mon and the badge, AskStrengthScript.
|
|
local rockWorld, rock = boulderWorld(LIFTER)
|
|
check(rockWorld:interact(), "and offers STRENGTH")
|
|
eq(rockWorld.log[1], T.ASK_STRENGTH, "AskStrengthText")
|
|
advanceText(rockWorld)
|
|
answerYesNo(rockWorld, true)
|
|
check(rockWorld.strengthActive, "YES sets BIKEFLAGS_STRENGTH_ACTIVE")
|
|
eq(rockWorld.log[3], T.USE_STRENGTH, "UseStrengthText")
|
|
advanceText(rockWorld)
|
|
runFrames(rockWorld, 4)
|
|
eq(rockWorld.log[4], T.MOVE_BOULDER, "MoveBoulderText")
|
|
advanceText(rockWorld)
|
|
|
|
-- Already on: the third of TryStrengthOW's three answers.
|
|
check(rockWorld:interact(), "a second press still answers")
|
|
eq(rockWorld.log[5], T.BOULDERS_MOVE, "BouldersMoveText once STRENGTH is on")
|
|
|
|
-- The push itself. The boulder moves; the player bumps.
|
|
check(not rock.moving, "the boulder is standing")
|
|
check(rockWorld:tryPushBoulder("up", 5, 4), "walking into it pushes it")
|
|
eq(rock.targetY, 3, "one cell in the walking direction")
|
|
eq(rockWorld.player.cellY, 5, "and the player does not move")
|
|
check(not rockWorld:tryPushBoulder("up", 5, 4), "a moving boulder is not pushed")
|
|
|
|
-- No STRENGTH, no push.
|
|
local coldWorld, coldRock = boulderWorld(LIFTER)
|
|
check(not coldWorld:tryPushBoulder("up", 5, 4),
|
|
"without BIKEFLAGS_STRENGTH_ACTIVE nothing moves")
|
|
check(not coldRock.moving, "the boulder stays put")
|
|
|
|
-- A boulder against a wall does not move (CanObjectMoveInDirection).
|
|
local pinned = fieldWorld({ [3 * 100 + 5] = 0x07 }, LIFTER)
|
|
local pinnedRock = fakeBoulder(5, 4)
|
|
pinned.npcs = { pinnedRock }
|
|
pinned.entities = { pinned.player, pinnedRock }
|
|
pinned.strengthActive = true
|
|
check(not pinned:tryPushBoulder("up", 5, 4), "a boulder against a wall holds")
|
|
|
|
-- ---- I. the party submenu's field-move row -------------------------------
|
|
-- MonMenu_Cut's $2 / $3 return, through the screen a player actually uses:
|
|
-- the row is picked, the world queues the script, and the party list plus the
|
|
-- START menu behind it are torn down so the overworld can run it.
|
|
local PartyMenu = require("src.ui.gen2.PartyMenu")
|
|
|
|
local menuWorld, menuGame = fieldWorld({ [4 * 100 + 5] = COLL_CUT_TREE },
|
|
CUTTER, { blocks = { [TREE_BLOCK_INDEX] = 0x5b } })
|
|
menuGame.input = stubInput()
|
|
menuGame.stack = stubStack()
|
|
local list = PartyMenu.new(menuGame, { party = CUTTER, submenu = true })
|
|
local rows = list:submenuItems(CUTTER[1])
|
|
eq(rows[1].id, "CUT", "a mon that knows CUT gets a CUT row first")
|
|
check(rows[1].fieldMove, "and it is flagged as a field move")
|
|
eq(rows[2].id, "FLASH", "FIELD_MOVES order, not the mon's move order")
|
|
menuGame.input:press("a")
|
|
list:update(0)
|
|
check(list.submenu ~= nil, "A on the mon opens PokemonActionSubmenu")
|
|
menuGame.input:press("a")
|
|
list:update(0)
|
|
check(menuWorld.queuedFieldMove ~= nil, "CUT queues its script")
|
|
eq(menuGame.stack.cleared, 1, "and the $2 return clears the menus off")
|
|
|
|
-- A refusal is the $3 return: the line prints and the list stays.
|
|
local menuFail, failGame = fieldWorld({}, CUTTER)
|
|
failGame.input = stubInput()
|
|
failGame.stack = stubStack()
|
|
local failList = PartyMenu.new(failGame, { party = CUTTER, submenu = true })
|
|
failGame.input:press("a")
|
|
failList:update(0)
|
|
failGame.input:press("a")
|
|
failList:update(0)
|
|
check(menuFail.queuedFieldMove == nil, "nothing to cut queues nothing")
|
|
eq(menuFail.log[1], T.CUT_NOTHING, "the refusal prints over the list")
|
|
eq(failGame.stack.cleared, 0, "and the party list stays open")
|
|
|
|
-- ---- J. the dark cave -----------------------------------------------------
|
|
-- There is no vision mask in Gen 2: the whole map resolves to near-black
|
|
-- through the ordinary bake, and FlickeringCaveEntrancePalette blinks
|
|
-- PAL_BG_YELLOW's color 0 between its own two colours on a four-frame cycle.
|
|
eq(Palettes.PAL_BG_YELLOW, 5, "PAL_BG_YELLOW is the fifth 1-based slot")
|
|
eq(Palettes.caveFlickerSource(0), 1, "frame 0 leaves color 0 alone")
|
|
eq(Palettes.caveFlickerSource(1), 1, "...and so does frame 1")
|
|
eq(Palettes.caveFlickerSource(2), 2, "frame 2 copies color 1 into it")
|
|
eq(Palettes.caveFlickerSource(3), 2, "...and so does frame 3")
|
|
eq(Palettes.FLICKER_PERIOD, 4, "the cycle is four frames long")
|
|
|
|
local DARK_SET = {}
|
|
for slot = 1, 8 do
|
|
DARK_SET[slot] = { { 8, 8, 16 }, { 0, 0, 0 }, { 0, 0, 0 }, { 0, 0, 0 } }
|
|
end
|
|
DARK_SET[Palettes.PAL_BG_YELLOW] =
|
|
{ { 247, 247, 90 }, { 0, 0, 0 }, { 0, 0, 0 }, { 0, 0, 0 } }
|
|
local lit = Palettes.withCaveFlicker(DARK_SET, 1)
|
|
eq(lit[Palettes.PAL_BG_YELLOW][1][1], 247, "phase 1 keeps the entrance lit")
|
|
local unlit = Palettes.withCaveFlicker(DARK_SET, 2)
|
|
eq(unlit[Palettes.PAL_BG_YELLOW][1][1], 0, "phase 2 blinks it out")
|
|
eq(unlit[1][1][1], 8, "and no other palette moves")
|
|
eq(DARK_SET[Palettes.PAL_BG_YELLOW][1][1], 247, "the source set is untouched")
|
|
check(Palettes.isDarkness({ palette = "PALETTE_DARK" }, 12, false),
|
|
"an unflashed PALETTE_DARK map is DARKNESS_PALSET")
|
|
check(not Palettes.isDarkness({ palette = "PALETTE_DARK" }, 12, true),
|
|
"...and a flashed one is not")
|
|
check(not Palettes.isDarkness({ palette = "PALETTE_NITE" }, 12, false),
|
|
"a merely dim cave is not DARKNESS_PALSET")
|
|
|
|
-- ResetFlashIfOutOfCave: the flag survives cave-to-cave and dies outdoors.
|
|
eq(Permissions.isCutTree(0x12), true, "COLL_CUT_TREE")
|
|
eq(Permissions.isCutTree(0x1a), true, "COLL_CUT_TREE_1A alias")
|
|
check(not Permissions.isCutTree(0x15), "a headbutt tree is not a cut tree")
|
|
check(Permissions.isWhirlpool(0x24) and Permissions.isWhirlpool(0x2c),
|
|
"both whirlpool collisions")
|
|
check(Permissions.isWaterfall(0x33) and Permissions.isWaterfall(0x3b),
|
|
"COLL_WATERFALL and COLL_CURRENT_DOWN")
|
|
check(Permissions.isIce(0x23) and Permissions.isIce(0x2b), "both ice tiles")
|
|
check(Permissions.isCuttable(0x18) and Permissions.isCuttable(0x12),
|
|
"CUT swings at grass and at trees")
|
|
check(not Permissions.isCuttable(0x15), "but not at a headbutt tree")
|
|
|
|
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 mapsPath = cache .. "/data/generated/maps.lua"
|
|
local mapsFile = io.open(mapsPath, "r")
|
|
if not mapsFile then
|
|
check(true, "gold cache absent : permission checks only (SKIP map facts)")
|
|
S.finish()
|
|
return
|
|
end
|
|
mapsFile:close()
|
|
|
|
local function loadLua(rel)
|
|
local path = cache .. "/" .. rel
|
|
return assert(loadfile(path))()
|
|
end
|
|
|
|
local maps = loadLua("data/generated/maps.lua")
|
|
local tilesets = loadLua("data/generated/tilesets.lua")
|
|
local town = Map.new(maps.NEW_BARK_TOWN, tilesets[maps.NEW_BARK_TOWN.tileset])
|
|
|
|
eq(town.widthCells, 20, "New Bark width cells")
|
|
eq(town.heightCells, 18, "New Bark height cells")
|
|
check(town:isWalkable(13, 6), "south of player house walkable")
|
|
check(not town:isWalkable(0, 0), "corner wall blocked")
|
|
eq(town:cellCollision(6, 3), 0x71, "Elm door COLL_DOOR")
|
|
eq(town:warpAt(6, 3).def.destMap, "ELMS_LAB", "Elm door → ELMS_LAB")
|
|
eq(town:warpAt(13, 5).def.destMap, "PLAYERS_HOUSE_1F", "house door")
|
|
|
|
-- The four cell predicates a mod binds to, shared with src/world/Map.lua so one
|
|
-- placement routine reads either generation's map (src/world/gen2/Map.lua).
|
|
-- Without them a mod guarding on `map.isWalkableCell and ...` silently decides
|
|
-- every cell is walkable, dry and grassless.
|
|
do
|
|
local Permissions = require("src.world.gen2.Permissions")
|
|
local walkAgrees, waterAgrees, grassAgrees = true, true, true
|
|
local sawGrass = false
|
|
for cy = 0, town.heightCells - 1 do
|
|
for cx = 0, town.widthCells - 1 do
|
|
local coll = town:cellCollision(cx, cy)
|
|
if town:isWalkableCell(cx, cy) ~= town:isWalkable(cx, cy) then
|
|
walkAgrees = false
|
|
end
|
|
if town:isWaterCell(cx, cy) ~= Permissions.isWater(coll) then
|
|
waterAgrees = false
|
|
end
|
|
if town:isGrassCell(cx, cy) ~= Permissions.isGrass(coll) then
|
|
grassAgrees = false
|
|
end
|
|
end
|
|
end
|
|
check(walkAgrees, "isWalkableCell tracks isWalkable")
|
|
check(waterAgrees, "isWaterCell tracks the WATER permission")
|
|
check(grassAgrees, "isGrassCell tracks the grass collisions")
|
|
-- and it actually answers true somewhere: New Bark Town is paved, so the
|
|
-- route next door is where a mod's placement code would look for grass
|
|
local route29 = Map.new(maps.ROUTE_29, tilesets[maps.ROUTE_29.tileset])
|
|
for cy = 0, route29.heightCells - 1 do
|
|
for cx = 0, route29.widthCells - 1 do
|
|
if route29:isGrassCell(cx, cy) then sawGrass = true end
|
|
end
|
|
end
|
|
check(sawGrass, "ROUTE_29 has grass cells")
|
|
eq(town:cellTile(6, 3), town:cellCollision(6, 3), "cellTile is the COLL_ byte")
|
|
check(not town:isGrassCell(-1, -1), "off-map cells are never grass")
|
|
check(not town:isWaterCell(-1, -1), "off-map cells are never water")
|
|
end
|
|
|
|
-- src/world/gen2/CmdQueue.lua's hand-ported stone tables name warps, object ids
|
|
-- and event flags by NUMBER, because the callback they live behind is not
|
|
-- extracted. Pin the four numbers the cache actually emits, so a re-import that
|
|
-- shifts any of them fails here rather than by a boulder quietly not falling.
|
|
do
|
|
local CmdQueue = require("src.world.gen2.CmdQueue")
|
|
local ice = maps.ICE_PATH_B1F
|
|
local iceMap = Map.new(ice, tilesets[ice.tileset])
|
|
for i, row in ipairs(CmdQueue.STONE_TABLES.ICE_PATH_B1F) do
|
|
local obj = ice.objects[row.object - 1]
|
|
eq(obj and obj.movement, CmdQueue.BOULDER_MOVEDATA,
|
|
("Ice Path row %d names a STRENGTH_BOULDER object"):format(i))
|
|
eq(obj.eventFlag, 1800 + i,
|
|
("and it is EVENT_BOULDER_IN_ICE_PATH_%d"):format(i))
|
|
local warp = ice.warps[row.warp]
|
|
check(warp ~= nil, ("row %d names a real warp"):format(i))
|
|
eq(iceMap:cellCollision(warp.x, warp.y), 0x60,
|
|
("and the tile under it is COLL_PIT (%d,%d)"):format(warp.x, warp.y))
|
|
eq(row.script[2].event, 1804 + i,
|
|
("it clears EVENT_BOULDER_IN_ICE_PATH_%dA"):format(i))
|
|
end
|
|
local below = maps.ICE_PATH_B2F_MAHOGANY_SIDE
|
|
for i = 1, 4 do
|
|
eq(below.objects[i].eventFlag, 1804 + i,
|
|
("which is the boulder one floor down (%d)"):format(i))
|
|
end
|
|
local gym = maps.BLACKTHORN_GYM_2F
|
|
for i, row in ipairs(CmdQueue.STONE_TABLES.BLACKTHORN_GYM_2F) do
|
|
local obj = gym.objects[row.object - 1]
|
|
eq(obj and obj.movement, CmdQueue.BOULDER_MOVEDATA,
|
|
("Blackthorn row %d names a STRENGTH_BOULDER object"):format(i))
|
|
check(gym.warps[row.warp] ~= nil,
|
|
("Blackthorn row %d names a real warp"):format(i))
|
|
end
|
|
end
|
|
|
|
-- Map callbacks. maps.lua now carries the map script header's second half
|
|
-- (`def_callbacks` / `callback TYPE, script`), which is what makes the two
|
|
-- stone tables reachable without hand-porting them. The counts are the
|
|
-- decomp's: `grep -h "^\tcallback " ../pokegold/maps/*.asm | sort | uniq -c`.
|
|
do
|
|
local seen, total = {}, 0
|
|
for id, def in pairs(maps) do
|
|
if type(def) == "table" and def.callbacks then
|
|
for _, cb in ipairs(def.callbacks) do
|
|
seen[cb.callback] = (seen[cb.callback] or 0) + 1
|
|
total = total + 1
|
|
check(type(cb.scriptKey) == "string",
|
|
("%s callback names a script key"):format(id))
|
|
end
|
|
end
|
|
end
|
|
if total == 0 then
|
|
check(true, "cache predates map callbacks (SKIP)")
|
|
else
|
|
eq(total, 84, "every map callback in the game")
|
|
eq(seen.MAPCALLBACK_TILES, 19, "MAPCALLBACK_TILES")
|
|
eq(seen.MAPCALLBACK_OBJECTS, 24, "MAPCALLBACK_OBJECTS")
|
|
eq(seen.MAPCALLBACK_CMDQUEUE, 2, "MAPCALLBACK_CMDQUEUE")
|
|
eq(seen.MAPCALLBACK_NEWMAP, 39, "MAPCALLBACK_NEWMAP")
|
|
-- MAPCALLBACK_SPRITES is in the enum and no map uses it; a count here
|
|
-- would mean the type byte is being read one row off.
|
|
eq(seen.MAPCALLBACK_SPRITES, nil, "no map uses MAPCALLBACK_SPRITES")
|
|
end
|
|
end
|
|
|
|
-- The extracted stone tables against the hand-ported ones. These are the
|
|
-- SAME data by two routes -- CmdQueue.STONE_TABLES was transcribed from
|
|
-- pokegold, the rows below were followed out of the ROM -- so any drift means
|
|
-- one of them is wrong.
|
|
do
|
|
local CmdQueue = require("src.world.gen2.CmdQueue")
|
|
local scripts = loadLua("data/generated/scripts.lua")
|
|
local function extractedRows(mapId)
|
|
for _, cb in ipairs(maps[mapId].callbacks or {}) do
|
|
if cb.callback == "MAPCALLBACK_CMDQUEUE" then
|
|
for _, cmd in ipairs(scripts[cb.scriptKey] or {}) do
|
|
if cmd.op == "writecmdqueue" then return cmd.queue end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
local ice = extractedRows("ICE_PATH_B1F")
|
|
if not ice then
|
|
check(true, "cache predates writecmdqueue following (SKIP)")
|
|
else
|
|
eq(ice.queue, "CMDQUEUE_STONETABLE", "Ice Path writes a stone table")
|
|
for mapId, hand in pairs(CmdQueue.STONE_TABLES) do
|
|
local got = CmdQueue.fromExtracted(extractedRows(mapId), mapId)
|
|
check(got ~= nil, ("%s converts to a queue entry"):format(mapId))
|
|
eq(#got.rows, #hand, ("%s row count"):format(mapId))
|
|
for i, row in ipairs(hand) do
|
|
eq(got.rows[i].warp, row.warp, ("%s row %d warp"):format(mapId, i))
|
|
eq(got.rows[i].object, row.object, ("%s row %d object"):format(mapId, i))
|
|
-- The hand-ported row inlines its command list; the extracted one
|
|
-- names a scripts.lua key, and the script behind it must exist or the
|
|
-- boulder disappears into a missing key.
|
|
check(scripts[got.rows[i].script] ~= nil,
|
|
("%s row %d script is in scripts.lua"):format(mapId, i))
|
|
end
|
|
end
|
|
-- The boulder script the cart runs, which the hand-port had to write out
|
|
-- as `rawtext` because nothing pointed at its string.
|
|
local first = CmdQueue.fromExtracted(ice, "ICE_PATH_B1F").rows[1]
|
|
local body = scripts[first.script]
|
|
eq(body[1].op, "disappear", "the boulder disappears first")
|
|
eq(body[1].object, 2, "and it is ICEPATHB1F_BOULDER1")
|
|
eq(body[2].op, "clearevent", "then clears its twin one floor down")
|
|
eq(body[2].event, 1805, "EVENT_BOULDER_IN_ICE_PATH_1A")
|
|
end
|
|
end
|
|
|
|
local west = town:connection("west")
|
|
check(west and west.mapId == "ROUTE_29", "west connection Route 29")
|
|
local east = town:connection("east")
|
|
check(east and east.mapId == "ROUTE_27", "east connection Route 27")
|
|
|
|
local r29 = maps.ROUTE_29
|
|
local lx, ly = Map.connectionLanding(r29, west, "left", 0, 8)
|
|
eq(lx, r29.width * 2 - 1, "west edge → Route 29 x")
|
|
eq(ly, 8, "west edge → Route 29 y")
|
|
|
|
-- Neighbor strip placement (RBY-style): Route 29 sits flush on New Bark's west.
|
|
local nbs = World.computeNeighbors(maps, "NEW_BARK_TOWN", 2)
|
|
local found29
|
|
for _, n in ipairs(nbs) do
|
|
if n.id == "ROUTE_29" then found29 = n break end
|
|
end
|
|
check(found29 ~= nil, "computeNeighbors includes ROUTE_29")
|
|
if found29 then
|
|
eq(found29.ox, -r29.width * 32, "Route 29 west strip ox")
|
|
eq(found29.oy, 0, "Route 29 west strip oy")
|
|
end
|
|
local found27
|
|
for _, n in ipairs(nbs) do
|
|
if n.id == "ROUTE_27" then found27 = n break end
|
|
end
|
|
check(found27 ~= nil, "computeNeighbors includes ROUTE_27")
|
|
|
|
local lab = Map.new(maps.ELMS_LAB, tilesets[maps.ELMS_LAB.tileset])
|
|
eq(maps.ELMS_LAB.tileset, "TILESET_LAB", "lab uses indoor tileset (no roofs)")
|
|
eq(lab:cellCollision(4, 11), 0x70, "lab exit carpet")
|
|
eq(Permissions.carpetDirection(lab:cellCollision(4, 11)), "down",
|
|
"lab carpet faces down")
|
|
eq(lab:warpAt(4, 11).def.destMap, "NEW_BARK_TOWN", "lab carpet → town")
|
|
-- Officer on the starter table is hidden by InitializeEventsScript at boot.
|
|
local labObjs = maps.ELMS_LAB.objects
|
|
eq(#labObjs, 6, "Elms Lab has 6 object_events")
|
|
eq(labObjs[6].sprite, "SPRITE_OFFICER", "lab object 6 is the cop")
|
|
eq(labObjs[6].x, 5, "cop x on starter table")
|
|
eq(labObjs[6].y, 3, "cop y on starter table")
|
|
local initPath = cache .. "/data/generated/initial_events.lua"
|
|
local initFile = io.open(initPath, "r")
|
|
if initFile then
|
|
initFile:close()
|
|
local initial = loadLua("data/generated/initial_events.lua")
|
|
local copFlag = labObjs[6].eventFlag
|
|
local hidden = false
|
|
for _, id in ipairs(initial.flags or {}) do
|
|
if id == copFlag then hidden = true break end
|
|
end
|
|
check(hidden, "InitializeEventsScript hides lab cop (flag "
|
|
.. tostring(copFlag) .. ")")
|
|
else
|
|
check(true, "initial_events.lua absent : re-import Gold")
|
|
end
|
|
|
|
-- Overworld sprites (Chris + New Bark NPCs) when the cache is post-sprite extract.
|
|
local spritesPath = cache .. "/data/generated/sprites.lua"
|
|
local spritesFile = io.open(spritesPath, "r")
|
|
if spritesFile then
|
|
spritesFile:close()
|
|
local sprites = loadLua("data/generated/sprites.lua")
|
|
local chris = sprites.SPRITE_CHRIS
|
|
check(chris ~= nil, "SPRITE_CHRIS extracted")
|
|
if chris then
|
|
eq(chris.frames, 6, "Chris walking sheet has 6 frames")
|
|
check(chris.walker, "Chris is a walker")
|
|
eq(chris.image, "assets/generated/sprites/chris.png", "Chris image path")
|
|
end
|
|
check(sprites.SPRITE_TEACHER ~= nil, "SPRITE_TEACHER extracted")
|
|
check(sprites.SPRITE_RIVAL ~= nil, "SPRITE_RIVAL extracted")
|
|
local objs = maps.NEW_BARK_TOWN.objects
|
|
eq(#objs, 3, "New Bark has 3 object_events")
|
|
eq(objs[1].sprite, "SPRITE_TEACHER", "New Bark teacher sprite")
|
|
eq(objs[3].sprite, "SPRITE_RIVAL", "New Bark rival sprite")
|
|
|
|
-- Anim paths from SPRITEMOVEDATA_* (no SpriteRenderer : headless luajit).
|
|
local kind, dirs = NPC.patternFor(NPC.MOVE.SPINRANDOM_SLOW)
|
|
eq(kind, "spin", "teacher movement is spin")
|
|
kind, dirs = NPC.patternFor(NPC.MOVE.WALK_UP_DOWN)
|
|
eq(kind, "walk", "fisher movement is walk")
|
|
check(dirs and dirs[1] == "up" and dirs[2] == "down", "fisher walks up/down")
|
|
kind = NPC.patternFor(NPC.MOVE.STANDING_RIGHT)
|
|
eq(kind, "stand", "rival stands")
|
|
eq(select(1, NPC.patternFor(objs[1].movement)), "spin", "New Bark teacher spins")
|
|
eq(select(1, NPC.patternFor(objs[2].movement)), "walk", "New Bark fisher walks")
|
|
eq(select(1, NPC.patternFor(objs[3].movement)), "stand", "New Bark rival stands")
|
|
|
|
-- Fisher radius (0,1): home±1 in Y stays in radius, X step does not.
|
|
local fisher = setmetatable({
|
|
homeX = objs[2].x, homeY = objs[2].y,
|
|
radiusX = (objs[2].radius and objs[2].radius.x) or 0,
|
|
radiusY = (objs[2].radius and objs[2].radius.y) or 0,
|
|
}, NPC)
|
|
eq(fisher.radiusY, 1, "fisher radius Y")
|
|
check(fisher:inRadius(fisher.homeX, fisher.homeY + 1), "fisher +Y in radius")
|
|
check(not fisher:inRadius(fisher.homeX + 1, fisher.homeY), "fisher +X out of radius")
|
|
else
|
|
check(true, "sprites.lua absent : skip OW sprite facts (re-import Gold)")
|
|
end
|
|
|
|
-- ------------------------------------------------------- the script VM hooks
|
|
--
|
|
-- The 81 opcodes the interpreter grew are inert without these: an `appear` that
|
|
-- reaches no World never spawns the object, a `changeblock` never opens the
|
|
-- door, and a `warpcheck` never drops the player through the hole. Every hook
|
|
-- the VM guards with `if self.xFn then` is asserted here against real World
|
|
-- behaviour rather than against the closure that forwards to it.
|
|
--
|
|
-- The world under test is built with World.new and then poked directly: the
|
|
-- constructor is love-free, and each method below is the WORLD half of one
|
|
-- transcribed command, so a stub map and a stub save are the whole rig.
|
|
|
|
local function hookWorld(opts)
|
|
opts = opts or {}
|
|
local game = {
|
|
data = {
|
|
pokemon = {
|
|
MAGIKARP = { name = "MAGIKARP", index = 129 },
|
|
SHUCKLE = { name = "SHUCKLE", index = 213, eggSteps = 20 },
|
|
CHIKORITA = { name = "CHIKORITA", index = 152, eggSteps = 20,
|
|
baseStats = { hp = 45, attack = 49, defense = 65, speed = 45,
|
|
specialAttack = 49, specialDefense = 65 },
|
|
levelMoves = { { level = 1, move = "TACKLE" } } },
|
|
},
|
|
items = {
|
|
POTION = { id = "POTION", name = "POTION", index = 18 },
|
|
COIN_CASE = { id = "COIN_CASE", name = "COIN CASE", index = 0x47 },
|
|
},
|
|
moves = { TACKLE = { name = "TACKLE", pp = 35 } },
|
|
audio = { sfxOrder = { "Sfx_Dummy", "Sfx_Item", "Sfx_WarpTo",
|
|
"Sfx_EnterDoor", "Sfx_ExitBuilding" } },
|
|
},
|
|
save = {
|
|
version = opts.version or "gold",
|
|
player = { name = "GOLD", id = 1234, money = 3000, coins = 40 },
|
|
mom = { savedMoney = 500 },
|
|
party = opts.party or {},
|
|
inventory = opts.inventory or {},
|
|
boxes = {},
|
|
},
|
|
}
|
|
local world = World.new(game)
|
|
world.maps = {
|
|
TEST_MAP = { id = "TEST_MAP", group = 1, map = 2, width = 4, height = 4,
|
|
blocks = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 },
|
|
objects = {}, warps = {}, sceneScripts = { [0] = { scriptKey = "s" } } },
|
|
OTHER_MAP = { id = "OTHER_MAP", group = 3, map = 4, width = 2, height = 2,
|
|
blocks = { 1, 2, 3, 4 }, objects = {}, warps = {} },
|
|
}
|
|
world.map = { id = "TEST_MAP", def = world.maps.TEST_MAP, width = 4,
|
|
height = 4 }
|
|
world.map.blocks = world.maps.TEST_MAP.blocks
|
|
world.constants = { spriteOrder = { "SPRITE_CHRIS", "SPRITE_TEACHER",
|
|
"SPRITE_RIVAL" } }
|
|
world.sprites = { SPRITE_CHRIS = {}, SPRITE_TEACHER = {}, SPRITE_RIVAL = {} }
|
|
world.daytime = opts.daytime or "DAY"
|
|
world.player = fakePlayer(2, 3, "down")
|
|
return world, game
|
|
end
|
|
|
|
-- ---- scene, clock, cartridge ----------------------------------------------
|
|
local hw = hookWorld()
|
|
eq(hw:mapSceneOf(1, 2), 0, "checkmapscene: a map with scene scripts reads 0")
|
|
hw.mapScenes.TEST_MAP = 3
|
|
eq(hw:mapSceneOf(1, 2), 3, "and reads back what setmapscene wrote")
|
|
check(hw:mapSceneOf(3, 4) == nil,
|
|
"a map with NO scene_var row answers nil, which the VM turns into $ff")
|
|
check(hw:mapSceneOf(9, 9) == nil, "and an unresolvable pair is nil too")
|
|
|
|
eq(hw:timeOfDayId(), 1, "DAY is wTimeOfDay 1")
|
|
hw.daytime = "MORN"
|
|
eq(hw:timeOfDayId(), 0, "MORN is 0")
|
|
hw.daytime = "NITE"
|
|
eq(hw:timeOfDayId(), 2, "NITE is 2")
|
|
hw.daytime = "DARK"
|
|
eq(hw:timeOfDayId(), 3, "DARKNESS is 3")
|
|
hw.daytime = "DAY"
|
|
eq(hw:gsVersion(), 0, "checkver: a Gold save is 0")
|
|
eq(hookWorld({ version = "silver" }):gsVersion(), 1, "and a Silver save is 1")
|
|
|
|
-- ---- LoadPlayerData -------------------------------------------------------
|
|
-- engine/menus/save.asm copies sPlayerData straight back over wPlayerData, and
|
|
-- BOTH wEventFlags and the w<Map>SceneID block sit inside that region, so a
|
|
-- reload comes back with every flag the player set and with each map still on
|
|
-- the scene it had reached. World:load runs this before the first setMap;
|
|
-- these are the halves of it on their own.
|
|
--
|
|
-- The seed is InitializeEventsScript's setevent list, which the cache carries
|
|
-- as data/generated/initial_events.lua. Three of its ids stand in for it here:
|
|
-- EVENT_ILEX_FOREST_APPRENTICE 1794, EVENT_EARLS_ACADEMY_EARL 1739, and
|
|
-- EVENT_INITIALIZED_EVENTS 54, which the script sets last and
|
|
-- PlayersHouse2FInitializeRoomCallback checks before running it at all.
|
|
local SEED = { 1739, 1794, 54 }
|
|
do
|
|
local fresh = hookWorld()
|
|
fresh.initialEvents = SEED
|
|
fresh:loadPlayerData(fresh.game.save)
|
|
check(fresh.events:get(1794), "a save with no bitfield gets the seed")
|
|
check(fresh.events:get(54), "including EVENT_INITIALIZED_EVENTS")
|
|
eq(next(fresh.mapScenes), nil, "and no map has been advanced yet")
|
|
fresh:loadPlayerData(nil)
|
|
check(fresh.events:get(1794), "and so does no save at all")
|
|
|
|
-- Play: one flag set (EVENT_TOGEPI_HATCHED 84), one of the seeded flags
|
|
-- CLEARED the way MeetMomScript clears EVENT_PLAYERS_HOUSE_MOM_2 on its way
|
|
-- out, and the map advanced off scene 0. This is what the snapshot writes.
|
|
fresh.events:set(84, true)
|
|
fresh.events:set(1794, false)
|
|
fresh.mapScenes.TEST_MAP = 3
|
|
local written = {
|
|
events = fresh.events:serialize(),
|
|
mapScenes = fresh.mapScenes,
|
|
}
|
|
|
|
local reload = hookWorld()
|
|
reload.initialEvents = SEED
|
|
reload:loadPlayerData(written)
|
|
check(reload.events:get(84), "a flag the player set survives the reload")
|
|
check(not reload.events:get(1794),
|
|
"a CLEARED seed flag stays cleared rather than being seeded again")
|
|
check(reload.events:get(1739), "the rest of the seed is still set")
|
|
eq(reload.mapScenes.TEST_MAP, 3, "and the map is still on its scene")
|
|
eq(reload:mapSceneOf(1, 2), 3, "which is what checkmapscene reads back")
|
|
|
|
-- A bitfield that has flags but has never had the seed -- a save built by
|
|
-- hand, or one written before InitializeEventsScript ran -- keeps its own
|
|
-- flags AND gets the seed, which is the branch the bedroom callback takes.
|
|
local partial = hookWorld()
|
|
partial.initialEvents = SEED
|
|
partial:loadPlayerData({ events = { [10] = 0x01 } })
|
|
check(partial.events:get(80), "an un-seeded bitfield keeps its own flags")
|
|
check(partial.events:get(1794), "and gets the seed on top")
|
|
|
|
-- Objects read the restored bitfield, not the seed: this is the whole point
|
|
-- of the restore happening before the first map load.
|
|
check(not reload.events:objectVisible(84),
|
|
"a restored flag hides the object that names it")
|
|
check(reload.events:objectVisible(1794),
|
|
"and a restored CLEAR shows the object again")
|
|
end
|
|
|
|
-- ---- wPlayerState across a save -------------------------------------------
|
|
-- The third member of the sPlayerData block LoadPlayerData copies back. Until
|
|
-- it rode the save, a reload walked the player off the BICYCLE and off the
|
|
-- water: everything the state decides follows from this one field, so all
|
|
-- three of the things it decides are checked here.
|
|
--
|
|
-- Wrapped in a function for the same reason bikeChecks is, and called on the
|
|
-- spot rather than through a name of its own: block locals count against the
|
|
-- main chunk's 200-local ceiling and this file has none left.
|
|
;(function()
|
|
local Bike = require("src.world.gen2.Bike")
|
|
local Player = require("src.world.gen2.Player")
|
|
local Gen2Save = require("src.core.gen2.Save")
|
|
|
|
-- Save on the bike, reload on the bike: the state itself, ChrisStateSprites'
|
|
-- bike row on the player, and STEP_BIKE under the next step.
|
|
local riding = hookWorld()
|
|
riding.sprites.SPRITE_CHRIS_BIKE = { name = "SPRITE_CHRIS_BIKE" }
|
|
local worn
|
|
riding.player.setSprite = function(_, def) worn = def end
|
|
riding:loadPlayerData({ playerState = "bike" })
|
|
eq(riding.playerState, FieldMoves.PLAYER_BIKE,
|
|
"a save made on the BICYCLE reloads on the BICYCLE")
|
|
eq(worn and worn.name, "SPRITE_CHRIS_BIKE", "wearing the bike's own sprite")
|
|
eq(Bike.stepFrames(riding.playerState, "up", false, 16), 8,
|
|
"and stepping at STEP_BIKE rather than STEP_WALK")
|
|
|
|
-- Save afloat, reload afloat, on the sprite GetSurfType picked at the time.
|
|
local afloat = hookWorld()
|
|
afloat.sprites.SPRITE_SURFING_PIKACHU = { name = "SPRITE_SURFING_PIKACHU" }
|
|
local raft
|
|
afloat.player.setSprite = function(_, def) raft = def end
|
|
afloat:loadPlayerData({ playerState = "surf_pika" })
|
|
eq(afloat.playerState, FieldMoves.PLAYER_SURF_PIKA,
|
|
"a save made surfing reloads surfing")
|
|
eq(raft and raft.name, "SPRITE_SURFING_PIKACHU",
|
|
"on the PIKACHU it was riding, not on a generic Lapras")
|
|
|
|
-- The third thing that follows: .TranslateIntoMovement picks .CheckSurfPerms
|
|
-- off wPlayerState, so the restored state is what lets the reload step back
|
|
-- out onto the water it was saved on. Water at (3,2), the player facing it.
|
|
local sea = fakeWorld({ [2 * 100 + 3] = COLL_WATER })
|
|
sea.player = Player.new(3, 3, "up", nil)
|
|
sea.game.save.playerState = "surf"
|
|
sea:loadPlayerData(sea.game.save)
|
|
eq(sea.playerState, FieldMoves.PLAYER_SURF, "the state comes off the save")
|
|
eq(sea:movePlayer("up"), "moved", "and the step onto the water is legal")
|
|
|
|
-- The same world without the state restored is the bug: on foot, and the sea
|
|
-- the save was made on is a wall.
|
|
local dry = fakeWorld({ [2 * 100 + 3] = COLL_WATER })
|
|
dry.player = Player.new(3, 3, "up", nil)
|
|
dry:loadPlayerData({})
|
|
eq(dry.playerState, FieldMoves.PLAYER_NORMAL,
|
|
"a save with no state at all is on foot")
|
|
check(dry:movePlayer("up") ~= "moved", "and that same step bumps")
|
|
|
|
-- Only a name the save format vouches for is taken back; the two ends of the
|
|
-- round trip share one set, so neither can drift.
|
|
local bogus = hookWorld()
|
|
bogus:loadPlayerData({ playerState = "skateboard" })
|
|
eq(bogus.playerState, FieldMoves.PLAYER_NORMAL,
|
|
"a state no cartridge could write comes back as PLAYER_NORMAL")
|
|
check(Gen2Save.PLAYER_STATES[FieldMoves.PLAYER_SURF_PIKA],
|
|
"and the set World tests against names every state FieldMoves does")
|
|
check(Gen2Save.PLAYER_STATES[FieldMoves.PLAYER_BIKE], "the bike included")
|
|
end)()
|
|
|
|
-- ---- ENGINE_* flags -------------------------------------------------------
|
|
-- A namespace of its own: BADGE_ZEPHYR must never collide with an object's
|
|
-- MAPOBJECT_EVENT_FLAG, and setting one must not respawn anybody.
|
|
check(not hw:engineFlag(26), "an unset engine flag is false")
|
|
hw:setEngineFlag(26, true)
|
|
check(hw:engineFlag(26), "setflag ENGINE_ZEPHYRBADGE reads back")
|
|
-- A BADGE id is the one ENGINE_* that does not live in save.engineFlags.
|
|
-- World:setEngineFlag routes it to save.player.badges instead, because that is
|
|
-- the store hasBadge, VAR_BADGES, the trainer card and the save summary all
|
|
-- read -- and while the two disagreed no field move in the game could be used
|
|
-- (tests/gen2_badges_test.lua). This assertion still expected the old store.
|
|
check(hw.game.save.player.badges[FieldMoves.JOHTO_BADGES[1]],
|
|
"and it lives on the save, in the badge store")
|
|
check(not (hw.game.save.engineFlags or {})[26],
|
|
"not in engineFlags, which nothing reads for badges")
|
|
check(not hw.events:get(26), "and it did NOT touch the wEventFlags array")
|
|
hw:setEngineFlag(26, false)
|
|
check(not hw:engineFlag(26), "clearflag clears it")
|
|
|
|
-- ---- vars -----------------------------------------------------------------
|
|
hw:writeVar(0x03, 10) -- VAR_BATTLETYPE, BATTLETYPE_FORCEITEM
|
|
eq(hw:battleType(), 10, "writevar VAR_BATTLETYPE is read back by startbattle")
|
|
|
|
-- ---- map objects ----------------------------------------------------------
|
|
-- disappear then appear has to be symmetrical BOTH ways: through the event
|
|
-- flag when the object has one, and through the synthetic hide when it does
|
|
-- not. A one-way pair is why a scripted NPC used to vanish for good.
|
|
local objWorld = hookWorld()
|
|
objWorld.maps.TEST_MAP.objects = {
|
|
{ index = 1, sprite = "SPRITE_TEACHER", x = 1, y = 1, eventFlag = 700 },
|
|
{ index = 2, sprite = "SPRITE_RIVAL", x = 2, y = 2, eventFlag = 0xFFFF },
|
|
{ index = 3, sprite = 244, x = 3, y = 3, eventFlag = 0xFFFF },
|
|
}
|
|
objWorld.rebuildPeople = function(self) self.rebuilds = (self.rebuilds or 0) + 1 end
|
|
objWorld:disappearObject(2) -- object_const 2 -> index 1
|
|
check(objWorld.events:get(700), "disappear sets the object's event flag")
|
|
objWorld:appearObject(2)
|
|
check(not objWorld.events:get(700), "and appear clears it again")
|
|
objWorld:disappearObject(3) -- eventFlag $ffff: the mask, with no flag to set
|
|
check(objWorld.objectMasks["TEST_MAP:2"], "a flagless disappear masks by key")
|
|
objWorld:appearObject(3)
|
|
check(objWorld.objectMasks["TEST_MAP:2"] == false,
|
|
"and appear unmasks it, so the pair is not one-way")
|
|
|
|
-- Three object_events SHARING one MAPOBJECT_EVENT_FLAG is ordinary: the
|
|
-- animated Burned Tower beasts all carry EVENT_BURNED_TOWER_B1F_BEASTS_1
|
|
-- (maps/BurnedTowerB1F.asm:152) and ReleaseTheBeasts appears and jumps them
|
|
-- away one at a time. MaskObject writes ONE byte (home/map.asm:1542) and the
|
|
-- flag is only read back at the next LoadObjectMasks, so a `disappear` must
|
|
-- take its own object off the map and leave the other two standing.
|
|
do
|
|
local beasts = hookWorld()
|
|
beasts.maps.TEST_MAP.objects = {
|
|
{ index = 1, sprite = "SPRITE_TEACHER", x = 1, y = 1, eventFlag = 1866 },
|
|
{ index = 2, sprite = "SPRITE_TEACHER", x = 2, y = 2, eventFlag = 1866 },
|
|
{ index = 3, sprite = "SPRITE_TEACHER", x = 3, y = 3, eventFlag = 1866 },
|
|
}
|
|
beasts.pooledNpc = function(_, mapId, obj)
|
|
return { def = obj, id = mapId .. ":" .. tostring(obj.index) }
|
|
end
|
|
local function standing(world)
|
|
local ids = {}
|
|
for _, npc in ipairs(world.npcs) do ids[npc.id] = true end
|
|
return ids
|
|
end
|
|
beasts:loadObjectMasks() -- the map load, with the shared flag clear
|
|
beasts:rebuildPeople()
|
|
eq(#beasts.npcs, 3, "all three spawn while the shared flag is clear")
|
|
beasts:disappearObject(2) -- object_const 2 -> index 1
|
|
beasts:rebuildPeople()
|
|
local left = standing(beasts)
|
|
check(not left["TEST_MAP:1"], "the beast that jumped away is gone")
|
|
check(left["TEST_MAP:2"] and left["TEST_MAP:3"],
|
|
"and the two sharing its event flag are still standing")
|
|
check(beasts.events:get(1866), "the flag is set for the next map load")
|
|
|
|
-- The reveal half, the same way round: a load with the shared flag SET masks
|
|
-- every one of them, and each `appear` unmasks its own (Script_appear ->
|
|
-- UnmaskCopyMapObjectStruct, home/map_objects.asm:309).
|
|
beasts:loadObjectMasks()
|
|
beasts:rebuildPeople()
|
|
eq(#beasts.npcs, 0, "a load with the shared flag set masks all three")
|
|
beasts:appearObject(2)
|
|
beasts:rebuildPeople()
|
|
local shown = standing(beasts)
|
|
check(shown["TEST_MAP:1"], "the first appear puts exactly one on the map")
|
|
check(not (shown["TEST_MAP:2"] or shown["TEST_MAP:3"]),
|
|
"the other two wait for their own appear")
|
|
-- The hour poll stands in for a reload the cart never runs, so it passes
|
|
-- keepScripted: it re-derives the hour windows, but an object a scene masked
|
|
-- with no flag to remember it by ($ffff takes MaskObject's path alone) must
|
|
-- not walk back in at the top of the hour.
|
|
beasts.maps.TEST_MAP.objects[4] =
|
|
{ index = 4, sprite = "SPRITE_TEACHER", x = 1, y = 3, eventFlag = 0xFFFF }
|
|
beasts:loadObjectMasks()
|
|
beasts:disappearObject(5) -- object_const 5 -> index 4
|
|
beasts:loadObjectMasks({ keepScripted = true })
|
|
beasts:rebuildPeople()
|
|
check(not standing(beasts)["TEST_MAP:4"],
|
|
"the hour poll leaves a scripted mask alone")
|
|
end
|
|
|
|
-- moveobject writes the DEF as well as the live NPC, because it nearly always
|
|
-- names an object that has not spawned yet.
|
|
objWorld:moveObject(2, 7, 9)
|
|
eq(objWorld.maps.TEST_MAP.objects[1].x, 7, "moveobject writes the object def x")
|
|
eq(objWorld.maps.TEST_MAP.objects[1].y, 9, "and its y")
|
|
|
|
-- variablesprite: an object whose sprite is a NUMBER is a wVariableSprites
|
|
-- slot, and nothing spawns for it until the slot is filled.
|
|
check(objWorld:resolveSprite(244) == nil,
|
|
"SPRITE_WEIRD_TREE resolves to nothing while its slot is empty")
|
|
objWorld:setVariableSprite(0xf4 - 0xf0, 2)
|
|
eq(objWorld:resolveSprite(244), "SPRITE_TEACHER",
|
|
"and to the sprite the slot names once variablesprite has run")
|
|
eq(objWorld:resolveSprite("SPRITE_RIVAL"), "SPRITE_RIVAL",
|
|
"a named sprite passes straight through")
|
|
check(objWorld:resolveSprite(130) == nil,
|
|
"a SPRITE_POKEMON byte is below SPRITE_VARS and still resolves to nothing")
|
|
|
|
-- Script_appear RESPAWNS the object struct out of the map object
|
|
-- (UnmaskCopyMapObjectStruct -> CopyObjectStruct -> CopyMapObjectToObjectStruct,
|
|
-- engine/overworld/player_object.asm:207-215), so the pooled NPC -- the OLD
|
|
-- struct, still sitting on the old cell -- has to go with it or a
|
|
-- `moveobject` + `appear` pair spawns at the cell the object was already on.
|
|
-- That is Kurt after the Slowpoke Well grunt: moved to (11,6), reappearing at
|
|
-- (16,14).
|
|
objWorld.npcPool = { TEST_MAP_obj_1 = "stale struct",
|
|
TEST_MAP_obj_2 = "another object" }
|
|
objWorld:appearObject(2)
|
|
check(objWorld.npcPool.TEST_MAP_obj_1 == nil,
|
|
"appear drops the pooled struct so the coords are re-read from the def")
|
|
eq(objWorld.npcPool.TEST_MAP_obj_2, "another object",
|
|
"and only that object's")
|
|
|
|
-- ---- the day care ---------------------------------------------------------
|
|
-- data/events/engine_flags.asm:18-20: ENGINE_DAY_CARE_MAN_HAS_EGG (5),
|
|
-- _MAN_HAS_MON (6) and _LADY_HAS_MON (7) ARE bits of wDayCareMan/wDayCareLady,
|
|
-- the bits the deposit, DayCareStep and the withdrawal write. So
|
|
-- Route34EggCheckCallback's `checkflag` reads the deposit state itself and
|
|
-- there is exactly one store; a second copy in save.engineFlags is what kept
|
|
-- EVENT_DAY_CARE_MON_1/2 set forever and the yard empty.
|
|
do
|
|
local dcWorld, dcGame = hookWorld()
|
|
local Breeding = require("src.core.gen2.Breeding")
|
|
check(not dcWorld:engineFlag(6), "an empty day care answers checkflag 6 false")
|
|
check(not dcWorld:engineFlag(7), "and checkflag 7")
|
|
check(not dcWorld:engineFlag(5), "and no egg is waiting")
|
|
Breeding.side(dcGame.save, "man").mon = { species = "CHIKORITA", level = 5 }
|
|
check(dcWorld:engineFlag(6), "a deposit into the man's slot sets checkflag 6")
|
|
check(not dcWorld:engineFlag(7), "without touching the lady's")
|
|
Breeding.side(dcGame.save, "lady").mon = { species = "MAGIKARP", level = 5 }
|
|
check(dcWorld:engineFlag(7), "and the lady's slot sets checkflag 7")
|
|
Breeding.side(dcGame.save, "man").mon = nil
|
|
check(not dcWorld:engineFlag(6), "a withdrawal clears it again")
|
|
-- DayCareStep's `set DAYCAREMAN_HAS_EGG_F` is the only writer of the egg bit
|
|
-- besides DayCareManScript_Outside's clearflag, which is the one cart script
|
|
-- that writes any of the three.
|
|
Breeding.dayCare(dcGame.save).hasEgg = true
|
|
check(dcWorld:engineFlag(5), "a built egg answers checkflag 5")
|
|
dcWorld:setEngineFlag(5, false)
|
|
check(not Breeding.dayCare(dcGame.save).hasEgg,
|
|
"and the script's clearflag writes save.dayCare, not a second store")
|
|
check(dcWorld:engineFlags()[5] == nil,
|
|
"nothing lands in save.engineFlags for an aliased id")
|
|
-- No cart script writes the two HAS_MON bits: the deposit routines own them.
|
|
dcWorld:setEngineFlag(6, true)
|
|
check(not dcWorld:engineFlag(6),
|
|
"a setflag on ENGINE_DAY_CARE_MAN_HAS_MON does not fake a deposit")
|
|
|
|
-- GetMonSprite (engine/overworld/overworld.asm:279-305) tests
|
|
-- SPRITE_DAY_CARE_MON_1/2 ($e0/$e1) BEFORE the SPRITE_VARS range and answers
|
|
-- with LoadOverworldMonIcon of wBreedMon1Species / wBreedMon2Species. Route
|
|
-- 34's two yard objects carry those bytes, so without the arm they resolved
|
|
-- to nothing and the mons never spawned even with the flags right.
|
|
dcGame.data.gen2Icons = {
|
|
species = { CHIKORITA = "ICON_BULBASAUR", MAGIKARP = "ICON_FISH" },
|
|
icons = {
|
|
ICON_BULBASAUR = { id = "ICON_BULBASAUR",
|
|
image = "assets/generated/icons/gen2/bulbasaur.png" },
|
|
ICON_FISH = { id = "ICON_FISH",
|
|
image = "assets/generated/icons/gen2/fish.png" },
|
|
},
|
|
}
|
|
check(dcWorld:resolveSprite(0xe0) == nil,
|
|
"an empty man's slot leaves the yard object unresolved, so it stays hidden")
|
|
Breeding.side(dcGame.save, "man").mon = { species = "CHIKORITA", level = 5 }
|
|
local def = dcWorld:resolveSprite(0xe0)
|
|
check(type(def) == "table", "a deposited mon resolves to a built sprite def")
|
|
eq(def and def.species, "CHIKORITA", "for the species that was deposited")
|
|
eq(def and def.image, "assets/generated/icons/gen2/bulbasaur.png",
|
|
"pointed at that mon's own menu icon")
|
|
eq(def and def.spriteType, "POKEMON_SPRITE",
|
|
"in the shape extractMonSprites emits")
|
|
local lady = dcWorld:resolveSprite(0xe1)
|
|
eq(lady and lady.species, "MAGIKARP", "and $e1 reads the lady's slot")
|
|
end
|
|
|
|
-- ---- map blocks -----------------------------------------------------------
|
|
local blockWorld = hookWorld()
|
|
blockWorld.refreshMapImages = function() return true end
|
|
check(blockWorld:changeBlock(1, 1, 99), "changeblock writes one block")
|
|
eq(blockWorld.maps.TEST_MAP.blocks[1 * 4 + 1 + 1], 99,
|
|
"at row * width + column + 1")
|
|
check(not blockWorld:changeBlock(9, 9, 99), "and refuses a block off the grid")
|
|
-- restoreBlocks is LoadMapAttributes' refill: the edit must not survive a map
|
|
-- load, or the shared maps.lua table would carry it into a New Game.
|
|
blockWorld:restoreBlocks()
|
|
eq(blockWorld.maps.TEST_MAP.blocks[6], 6, "and the next map load puts it back")
|
|
|
|
-- changemapblocks repaints the WHOLE map off a second blockdata array. The
|
|
-- operand is a raw ROM bank/pointer, so the test carries the blockdata pair
|
|
-- RomExtractorGen2 now records for every map and checks that the pointer is
|
|
-- placed by the array it lands IN -- offset and all -- rather than by a name.
|
|
do
|
|
local world = hookWorld()
|
|
world.maps.TEST_MAP.blockdata = { bank = 0x60, address = 0x4000 }
|
|
local alt = {}
|
|
for i = 1, 20 do alt[i] = 100 + i end
|
|
world.maps.ALT_BLOCKS = { id = "ALT_BLOCKS", group = 5, map = 6,
|
|
width = 4, height = 5, blocks = alt, objects = {}, warps = {},
|
|
blockdata = { bank = 0x60, address = 0x5000 } }
|
|
-- Enough of the bake path to be real: refreshMapImages does nothing at all
|
|
-- when the world has never baked, and the bake drop is half of what is under
|
|
-- test here.
|
|
world.mapImage = "stale canvas"
|
|
world.mapImages = { ["TEST_MAP|DAY|gbc|1"] = "stale",
|
|
["OTHER_MAP|DAY|gbc|1"] = "keep" }
|
|
world.imageFor = function() return "rebaked" end
|
|
world.rebuildNeighbors = function() end
|
|
|
|
check(not world:changeMapBlocks(0x61, 0x5000),
|
|
"a pointer in another bank places nowhere and changes nothing")
|
|
check(not world:changeMapBlocks(0x60, 0x7000),
|
|
"and so does one no map's blockdata covers")
|
|
-- 4 x 4 is sixteen blocks read as one flat run, so an offset that leaves
|
|
-- fewer than that in the source array would run off the end of it.
|
|
check(not world:changeMapBlocks(0x60, 0x5008),
|
|
"a run that would read past the source array is refused")
|
|
eq(world.maps.TEST_MAP.blocks[1], 1, "none of the three touched a block")
|
|
|
|
check(world:changeMapBlocks(0x60, 0x5000), "and the array itself is copied")
|
|
eq(world.maps.TEST_MAP.blocks[1], 101, "from its first block")
|
|
eq(world.maps.TEST_MAP.blocks[16], 116, "to the sixteenth, width * height")
|
|
eq(alt[17], 117, "without disturbing the source")
|
|
check(world.mapImages["TEST_MAP|DAY|gbc|1"] == nil,
|
|
"the bake taken off the old blocks is dropped, or the screen keeps it")
|
|
eq(world.mapImages["OTHER_MAP|DAY|gbc|1"], "keep",
|
|
"and only that map's bakes go")
|
|
|
|
-- LoadMapAttributes' refill, exactly as for changeblock: a whole-map repaint
|
|
-- is still an edit to the shared maps.lua table.
|
|
world:restoreBlocks()
|
|
eq(world.maps.TEST_MAP.blocks[1], 1, "the next map load puts the map back")
|
|
eq(world.maps.TEST_MAP.blocks[16], 16, "every block of it")
|
|
|
|
-- A pointer part way into an array: the cart's copy starts there, it does not
|
|
-- start at the array's head.
|
|
world.mapImages["TEST_MAP|DAY|gbc|1"] = "stale again"
|
|
check(world:changeMapBlocks(0x60, 0x5004), "a pointer inside an array works")
|
|
eq(world.maps.TEST_MAP.blocks[1], 105, "and the copy starts at the offset")
|
|
check(world.mapImages["TEST_MAP|DAY|gbc|1"] == nil, "dropping the bake again")
|
|
end
|
|
|
|
-- ---- earthquake -----------------------------------------------------------
|
|
-- One byte, two numbers: `earthquake 80` ($50) is amplitude 1 << 1 held for
|
|
-- $50 & $3f = 16 frames, not eighty of anything.
|
|
local quakeWorld = hookWorld()
|
|
quakeWorld:earthquake(80, 16)
|
|
eq(quakeWorld.shake.amplitude, 2, "earthquake 80 shakes by two pixels")
|
|
eq(quakeWorld.shake.left, 16, "for sixteen frames")
|
|
quakeWorld:updateShake()
|
|
check(quakeWorld.shake.phase ~= 0, "and the offset flips off zero at once")
|
|
for _ = 1, 20 do quakeWorld:updateShake() end
|
|
check(quakeWorld.shake == nil, "the shake ends itself")
|
|
quakeWorld:earthquake(0xc1, 1)
|
|
eq(quakeWorld.shake.amplitude, 8, "the top two bits pick 1 << 3 for $c1")
|
|
|
|
-- ---- warps ----------------------------------------------------------------
|
|
local warpWorld = hookWorld()
|
|
local warped = {}
|
|
warpWorld.setMap = function(_, id, x, y, facing)
|
|
warped = { id = id, x = x, y = y, facing = facing }
|
|
return true
|
|
end
|
|
check(warpWorld:warpTo(3, 4, 5, 6, "up"), "warpfacing resolves group/map")
|
|
eq(warped.id, "OTHER_MAP", "to the right map")
|
|
eq(warped.x, 5, "at the raw cell x")
|
|
eq(warped.facing, "up", "facing the byte warpfacing carried")
|
|
check(not warpWorld:warpTo(9, 9, 0, 0), "an unresolvable pair is a no-op")
|
|
|
|
-- warpcheck ARMS a warp; it must not take it, because the commands queued
|
|
-- behind it would run with the map pulled out from under them.
|
|
local checkWorld = hookWorld()
|
|
checkWorld.map.warpAt = function(_, x, y)
|
|
if x == 2 and y == 3 then return { def = { destMap = "OTHER_MAP" } } end
|
|
return nil
|
|
end
|
|
check(checkWorld:armWarpCheck(), "warpcheck finds the warp underfoot")
|
|
check(checkWorld.pendingWarp ~= nil, "and parks it")
|
|
local took = false
|
|
checkWorld.takeWarp = function() took = true return true end
|
|
check(not took, "without warping inside the command")
|
|
checkWorld:takePendingWarp()
|
|
check(took, "the drain is what takes it")
|
|
check(checkWorld.pendingWarp == nil, "and it fires once")
|
|
|
|
-- blackoutmod overrides the SPAWN_* lookup, so losing at sea does not respawn
|
|
-- the player somewhere they cannot leave.
|
|
local boWorld = hookWorld()
|
|
boWorld.landmarks = { spawns = { SPAWN_HOME = { map = "TEST_MAP", x = 1, y = 1 } } }
|
|
local spawned
|
|
boWorld.setMap = function(_, id) spawned = id return true end
|
|
boWorld:warpToSpawn()
|
|
eq(spawned, "TEST_MAP", "with no override the SPAWN_* table wins")
|
|
boWorld:setBlackoutMap(3, 4)
|
|
eq(boWorld.game.save.blackoutMap, "OTHER_MAP", "blackoutmod stores a map id")
|
|
boWorld:warpToSpawn()
|
|
eq(spawned, "OTHER_MAP", "and warpToSpawn prefers it")
|
|
|
|
-- warpmod is stored and nothing more; the elevator that reads it does not
|
|
-- exist yet, which is exactly why storing it is the point.
|
|
boWorld:setWarpMod(2, 3, 4)
|
|
eq(boWorld.game.save.warpMod.map, "OTHER_MAP", "warpmod resolves its map id")
|
|
eq(boWorld.game.save.warpMod.warp, 2, "and keeps the warp number")
|
|
|
|
-- ---- the map setup chain --------------------------------------------------
|
|
-- Walking onto a warp tile is PLAYEREVENT_WARP -> WarpToNewMapScript, which is
|
|
-- `warpsound` then `newloadmap MAPSETUP_DOOR`. MapSetupScript_Door opens on
|
|
-- FadeOutToWhite and falls through into _Train, whose tail is FadeInFromWhite,
|
|
-- so the LOAD SITS IN THE MIDDLE of the script rather than replacing it.
|
|
local doorWorld = hookWorld()
|
|
doorWorld.maps.OTHER_MAP.warps = { { x = 1, y = 1 } }
|
|
local doorSfx = {}
|
|
doorWorld.playSfx = function(_, id) doorSfx[#doorSfx + 1] = id end
|
|
doorWorld.map.cellCollision = function() return 0x71 end -- COLL_DOOR underfoot
|
|
local doorLoad = nil
|
|
doorWorld.setMap = function(self, id, x, y, facing)
|
|
doorLoad = { id = id, x = x, y = y, facing = facing }
|
|
self.fade = nil -- a map load repaints everything, exactly as setMap does
|
|
return true
|
|
end
|
|
check(doorWorld:takeWarp({ destMap = "OTHER_MAP", destWarp = 1 }),
|
|
"takeWarp takes a resolvable warp_event")
|
|
eq(doorSfx[1], 3, "warpsound runs BEFORE the load, off the tile underfoot")
|
|
check(doorLoad == nil, "and the map does not load on the frame it is taken")
|
|
check(doorWorld:busy(),
|
|
"the setup script is a blocking call, so the world is busy for it")
|
|
-- FadeOutToWhite / FadeInFromWhite are `ld b, $4` steps of ConvertTimePals*HL,
|
|
-- each followed by DelayFrames 2: four steps, eight frames, per half.
|
|
for _ = 1, 7 do doorWorld:updateMapSetup() end
|
|
check(doorLoad == nil, "seven frames in, the fade out is still running")
|
|
eq(doorWorld.fade, "white", "and the sheet it fades to is FillWhiteBGColor's")
|
|
doorWorld:updateMapSetup()
|
|
check(doorLoad ~= nil, "the eighth frame is where the load lands")
|
|
eq(doorLoad.id, "OTHER_MAP", "on the destination map")
|
|
eq(doorLoad.x, 1, "at the destination WARP's own cell")
|
|
eq(doorWorld.fadeLevel, 1,
|
|
"with the sheet re-armed, because the load cleared it")
|
|
for _ = 1, 7 do doorWorld:updateMapSetup() end
|
|
check(doorWorld.mapSetup ~= nil, "the fade in takes another eight")
|
|
doorWorld:updateMapSetup()
|
|
check(doorWorld.mapSetup == nil, "and the sixteenth frame ends the chain")
|
|
check(doorWorld.fade == nil, "with nothing left over the world")
|
|
check(not doorWorld:busy(), "and control back")
|
|
|
|
-- MapSetupScript_Connection and _Submenu are the only two rows with no fade at
|
|
-- all: an edge cross must not hitch. Everything else fades back IN at least,
|
|
-- and only DOOR / FALL / TELEPORT fade OUT first (FALL and TELEPORT by
|
|
-- FALLTHROUGH -- neither names a FadeOutToWhite of its own).
|
|
local setupWorld = hookWorld()
|
|
local setupLoads = 0
|
|
setupWorld.setMap = function() setupLoads = setupLoads + 1 return true end
|
|
setupWorld:runMapSetup(0xf7, function() return setupWorld:setMap() end)
|
|
eq(setupLoads, 1, "MAPSETUP_CONNECTION loads on the spot")
|
|
check(setupWorld.mapSetup == nil, "with no chain behind it")
|
|
setupWorld:runMapSetup(0xf1, function() return setupWorld:setMap() end)
|
|
eq(setupLoads, 2, "MAPSETUP_WARP opens on DisableLCD, so it loads at once too")
|
|
eq(setupWorld.mapSetup.phase, "in", "and only fades back in")
|
|
setupWorld.mapSetup = nil
|
|
setupWorld:runMapSetup(0xf6, function() return setupWorld:setMap() end)
|
|
eq(setupLoads, 2, "MAPSETUP_FALL fades out FIRST, by fallthrough into _Door")
|
|
eq(setupWorld.mapSetup.phase, "out", "so its load is still to come")
|
|
|
|
-- Script_newloadmap is four lines and none of them is a PlaySFX; where the cart
|
|
-- wants a sound it writes `warpsound` in front (WarpToNewMapScript is that
|
|
-- pair). Inventing one here rang a door bell over every scripted re-entry.
|
|
local reloadWorld = hookWorld()
|
|
local reloadSfx = 0
|
|
reloadWorld.playSfx = function() reloadSfx = reloadSfx + 1 end
|
|
reloadWorld.setMap = function() return true end
|
|
reloadWorld:newLoadMap(0xf5) -- MAPSETUP_DOOR
|
|
eq(reloadSfx, 0, "newloadmap alone is silent")
|
|
|
|
-- RefreshPlayerSprite: CheckWarpFacingDown against the tile the player ARRIVES
|
|
-- on, then `call c, SpawnInFacingDown`. Anything not in that array keeps the
|
|
-- facing they walked in with, which is why you enter a building still facing up
|
|
-- and step out of one facing the street.
|
|
local faceWorld = hookWorld()
|
|
faceWorld.player.facing = "up"
|
|
faceWorld.map.cellCollision = function() return 0x70 end -- COLL_WARP_CARPET_DOWN
|
|
faceWorld:spawnFacing()
|
|
eq(faceWorld.player.facing, "up", "a doormat inside a building keeps the facing")
|
|
faceWorld.map.cellCollision = function() return 0x71 end -- COLL_DOOR
|
|
faceWorld:spawnFacing()
|
|
eq(faceWorld.player.facing, "down", "a doorway outside one spawns facing down")
|
|
faceWorld.player.facing = "left"
|
|
faceWorld.map.cellCollision = function() return 0x7b end -- COLL_CAVE
|
|
faceWorld:spawnFacing()
|
|
eq(faceWorld.player.facing, "down", "and so does a cave mouth")
|
|
|
|
-- checkWarpOnArrive has to ANSWER whether it warped: DoPlayerEvent hands the
|
|
-- frame to WarpToNewMapScript, so the rest of that frame's overworld loop --
|
|
-- DoPlayerMovement included -- never runs. Falling through to World:movePlayer
|
|
-- gave a still-held direction one free step on the far side of the door, which
|
|
-- put the player a cell too far into Elm's Lab before its scene script started.
|
|
local arriveWorld = hookWorld()
|
|
arriveWorld.map.cellCollision = function() return 0x71 end -- COLL_DOOR
|
|
arriveWorld.map.warpAt = function() return { def = { destMap = "OTHER_MAP" } } end
|
|
arriveWorld.takeWarp = function() return true end
|
|
check(arriveWorld:checkWarpOnArrive(), "an immediate warp answers true")
|
|
arriveWorld.map.cellCollision = function() return 0x00 end
|
|
check(not arriveWorld:checkWarpOnArrive(), "an ordinary tile answers false")
|
|
arriveWorld.map.cellCollision = function() return 0x70 end -- carpet, needs DOWN
|
|
arriveWorld.heldDir = nil
|
|
check(not arriveWorld:checkWarpOnArrive(),
|
|
"and a carpet with nothing held answers false rather than warping")
|
|
arriveWorld.heldDir = "down"
|
|
check(arriveWorld:checkWarpOnArrive(), "a carpet pressed into does warp")
|
|
|
|
-- GetWarpSFX picks by the tile the player STANDS on, not the destination.
|
|
local sfxWorld = hookWorld()
|
|
local played = {}
|
|
sfxWorld.playSfx = function(_, id) played[#played + 1] = id end
|
|
sfxWorld.map.cellCollision = function() return 0x71 end -- COLL_DOOR
|
|
sfxWorld:warpSound()
|
|
eq(played[1], 3, "COLL_DOOR plays Sfx_EnterDoor by NAME, not by a fixed index")
|
|
sfxWorld.map.cellCollision = function() return 0x7c end -- COLL_WARP_PANEL
|
|
sfxWorld:warpSound()
|
|
eq(played[2], 2, "COLL_WARP_PANEL plays Sfx_WarpTo")
|
|
sfxWorld.map.cellCollision = function() return 0x00 end
|
|
sfxWorld:warpSound()
|
|
eq(played[3], 4, "and anything else is Sfx_ExitBuilding")
|
|
|
|
-- ---- encounters -----------------------------------------------------------
|
|
-- StoreSwarmMapIndices falls THROUGH into SetSwarmFlag, so both halves land.
|
|
local swarmWorld = hookWorld()
|
|
swarmWorld:setSwarm(3, 4)
|
|
eq(swarmWorld.game.save.swarmMap, "OTHER_MAP", "swarm stores the map")
|
|
check(swarmWorld.game.save.dailyFlags.swarm,
|
|
"and DAILYFLAGS1_SWARM, which is what CheckSwarmFlag clears it by")
|
|
|
|
-- ---- bag, money, coins ----------------------------------------------------
|
|
local bagWorld = hookWorld({ inventory = { POTION = 3 } })
|
|
check(bagWorld:hasItem(18), "checkitem finds a POTION by its ItemNames index")
|
|
check(not bagWorld:hasItem(0x47), "and does not find one that is not there")
|
|
check(not bagWorld:takeItem(18, 5),
|
|
"takeitem takes NOTHING when the pack holds fewer than asked")
|
|
eq(bagWorld.game.save.inventory.POTION, 3, "so the count is untouched")
|
|
check(bagWorld:takeItem(18, 3), "and takes them all when it can")
|
|
check(bagWorld.game.save.inventory.POTION == nil, "clearing the row")
|
|
|
|
eq(bagWorld:money(0), 3000, "YOUR_MONEY is the player's wallet")
|
|
eq(bagWorld:money(1), 500, "MOMS_MONEY is the savings account")
|
|
bagWorld:setMoney(1, 9000)
|
|
eq(bagWorld.game.save.mom.savedMoney, 9000, "and setMoney writes it back")
|
|
eq(bagWorld:coins(), 40, "the coin case")
|
|
bagWorld:setCoins(9999)
|
|
eq(bagWorld:coins(), 9999, "and its write")
|
|
|
|
-- ---- party ----------------------------------------------------------------
|
|
local partyWorld = hookWorld({ party = { { species = "MAGIKARP" } } })
|
|
check(partyWorld:hasPoke(129), "checkpoke finds MAGIKARP in the party")
|
|
check(not partyWorld:hasPoke(213), "and refuses a species that is not in it")
|
|
check(partyWorld:giveEgg(152, 5), "giveegg builds the egg")
|
|
local egg = partyWorld.game.save.party[2]
|
|
check(egg.isEgg, "and marks the slot as one")
|
|
eq(egg.eggSteps, 20, "with the species' own hatch counter")
|
|
eq(egg.hp, 0, "and DayCare_GiveEgg's zeroed HP")
|
|
for _ = 1, 6 do partyWorld:giveEgg(152, 5) end
|
|
eq(#partyWorld.game.save.party, 6, "a full party refuses the next one")
|
|
|
|
-- ---- fruit trees ----------------------------------------------------------
|
|
-- FruitTreeItems is not extracted (nothing in the bytecode points at it), so
|
|
-- the table is src/core/gen2/Apricorns.lua's and this side turns its item ids
|
|
-- into the indices the VM's giveitem speaks in. An item this stub data table
|
|
-- has no row for still answers 0, which is the "no fruit here" the VM prints
|
|
-- "BERRY" for.
|
|
local treeWorld = hookWorld()
|
|
treeWorld.game.data.items.BERRY = { id = "BERRY", name = "BERRY", index = 173 }
|
|
eq(treeWorld:fruitTreeItem(1), 173, "FRUITTREE_ROUTE_29 is a BERRY")
|
|
eq(treeWorld:fruitTreeItem(0x11), 0,
|
|
"and an item the table has no row for is 0")
|
|
check(not treeWorld:fruitTreePicked(4), "an unpicked tree")
|
|
treeWorld:fruitTreePick(4)
|
|
check(treeWorld:fruitTreePicked(4), "stays picked")
|
|
check(treeWorld.game.save.fruitTrees[4], "on the save")
|
|
-- TryResetFruitTrees, the top of FruitTreeScript: the first tree examined
|
|
-- after the daily rollover refills every one of the thirty.
|
|
check(treeWorld:fruitTreeReset(), "the first look refills them all")
|
|
check(not treeWorld:fruitTreePicked(4), "so the picked one has fruit again")
|
|
check(not treeWorld:fruitTreeReset(),
|
|
"and ENGINE_ALL_FRUIT_TREES stops a second refill the same day")
|
|
|
|
-- ---- music ----------------------------------------------------------------
|
|
-- musicfadeout queues the song UNDER the ramp; it must not start until the
|
|
-- ramp reaches the bottom, or the cross-fade reads as a cut.
|
|
local musicWorld = hookWorld()
|
|
musicWorld.game.data.audio.musicOrder = { "Music_Nothing", "Music_NewBarkTown" }
|
|
musicWorld.game.data.audio.songs = { Music_NewBarkTown = {} }
|
|
-- World captured the Music module by reference, so patching the two entry
|
|
-- points on the table is what keeps this headless: neither has a love-free
|
|
-- path, and neither is what is under test here.
|
|
local Music = require("src.core.Music")
|
|
local realFadeOut, realPlay = Music.fadeOut, Music.play
|
|
local started = {}
|
|
Music.fadeOut = function(control) started.control = control end
|
|
Music.play = function(_, name) started.song = name end
|
|
musicWorld:fadeOutMusic(1, 4)
|
|
eq(started.control, 4, "the ramp is handed the control byte")
|
|
check(musicWorld.pendingMusic ~= nil, "the label is queued")
|
|
eq(musicWorld.pendingMusic.left, 28, "for control * 7 frames of ramp")
|
|
for _ = 1, 27 do musicWorld:updateMusicFade() end
|
|
check(musicWorld.pendingMusic ~= nil, "and is still waiting one frame short")
|
|
musicWorld:updateMusicFade()
|
|
check(musicWorld.pendingMusic == nil, "then starts")
|
|
eq(started.song, "Music_NewBarkTown", "with the label the id named")
|
|
musicWorld:fadeOutMusic(0, 4)
|
|
check(musicWorld.pendingMusic == nil, "MUSIC_NONE queues nothing behind it")
|
|
-- tests/run_tests.lua dofiles every suite into ONE process, so a patched module
|
|
-- table would follow this file into the next one.
|
|
Music.fadeOut, Music.play = realFadeOut, realPlay
|
|
|
|
-- ---- the phone ------------------------------------------------------------
|
|
local phoneWorld = hookWorld()
|
|
-- PHONE_MOM is contact 1 (constants/phone_constants.asm); the table is keyed
|
|
-- by that number, not by the label.
|
|
check(phoneWorld:addPhoneNumber(1), "askforphonenumber stores one")
|
|
check(not phoneWorld:addPhoneNumber(1),
|
|
"and refuses the same contact twice, the way _CheckCellNum does")
|
|
phoneWorld:setSpecialCall(3)
|
|
eq(phoneWorld:specialCall(), 3, "specialphonecall parks its id")
|
|
phoneWorld:setSpecialCall(0)
|
|
eq(phoneWorld:specialCall(), 0, "and SPECIALCALL_NONE clears it")
|
|
|
|
-- ---- roaming legendaries --------------------------------------------------
|
|
--
|
|
-- engine/overworld/wildmons.asm InitRoamMons / CheckEncounterRoamMon /
|
|
-- UpdateRoamMons / JumpRoamMons, and data/wild/roammon_maps.asm. All of it is
|
|
-- pure state, so none of these need a map loaded.
|
|
--
|
|
-- Wrapped in a function because Lua 5.1 caps a chunk at 200 active locals and
|
|
-- this suite is already close to it; the alternative is renaming everything
|
|
-- above, which would make the diff lie about what changed.
|
|
local function roamerAndSwarmSuite()
|
|
|
|
local Encounter = require("src.battle.gen2.Encounter")
|
|
local Roamers = require("src.core.gen2.Roamers")
|
|
|
|
-- A random that hands back a scripted queue of ROM bytes. `% n` because the
|
|
-- module asks for 0..n-1 and the cart masks the same byte down at each use.
|
|
local function seeded(bytes)
|
|
local index = 0
|
|
return function(n)
|
|
index = index + 1
|
|
return (bytes[index] or 0) % n
|
|
end
|
|
end
|
|
|
|
eq(#Roamers.MAPS, Roamers.NUM_MAPS, "RoamMaps has NUM_ROAMMON_MAPS entries")
|
|
eq(Roamers.NUM_MAPS, 16, "...which is 16, the width JumpRoamMon's mask assumes")
|
|
-- Every destination has to be a START map too, or a beast that walks there can
|
|
-- never walk out again: `.Update`'s not-found path leaves it parked forever.
|
|
local strandedFrom = {}
|
|
for _, row in ipairs(Roamers.MAPS) do
|
|
for _, dest in ipairs(row.to) do
|
|
if not Roamers.entryFor(dest) then
|
|
strandedFrom[#strandedFrom + 1] = row.map .. "->" .. dest
|
|
end
|
|
end
|
|
end
|
|
eq(table.concat(strandedFrom, ","), "",
|
|
"every roam destination is itself a roam map")
|
|
-- The two four-way junctions, and the one dead end. `and %11` gives a two-bit
|
|
-- index, so four is the most connections an entry can actually use.
|
|
eq(#Roamers.entryFor("ROUTE_36").to, 4, "ROUTE 36 is a four-way junction")
|
|
eq(#Roamers.entryFor("ROUTE_42").to, 4, "so is ROUTE 42")
|
|
eq(#Roamers.entryFor("ROUTE_39").to, 1, "ROUTE 39 is a dead end")
|
|
local widest = 0
|
|
for _, row in ipairs(Roamers.MAPS) do widest = math.max(widest, #row.to) end
|
|
eq(widest, 4, "no entry has more connections than a two-bit index can reach")
|
|
check(Roamers.entryFor("ROUTE_40") == nil,
|
|
"the water routes are absent: ROUTE 40 is not a roam map")
|
|
check(Roamers.entryFor("ROUTE_41") == nil, "nor is ROUTE 41")
|
|
|
|
-- InitRoamMons, in slot order. The order IS the identity: CheckEncounterRoamMon
|
|
-- indexes the structs by a random 0..2.
|
|
local roamSave = {}
|
|
Roamers.init(roamSave)
|
|
eq(#roamSave.roamers, 3, "three beasts")
|
|
eq(roamSave.roamers[1].species, "RAIKOU", "slot 1 is RAIKOU")
|
|
eq(roamSave.roamers[2].species, "ENTEI", "slot 2 is ENTEI")
|
|
eq(roamSave.roamers[3].species, "SUICUNE", "slot 3 is SUICUNE")
|
|
eq(roamSave.roamers[1].map, "ROUTE_42", "RAIKOU starts on ROUTE 42")
|
|
eq(roamSave.roamers[2].map, "ROUTE_37", "ENTEI starts on ROUTE 37")
|
|
eq(roamSave.roamers[3].map, "ROUTE_38", "SUICUNE starts on ROUTE 38")
|
|
eq(roamSave.roamers[1].level, 40, "all three are level 40")
|
|
eq(roamSave.roamers[1].hp, 0, "with no stats rolled yet")
|
|
roamSave.roamers[1].map = "ROUTE_29"
|
|
Roamers.init(roamSave)
|
|
eq(roamSave.roamers[1].map, "ROUTE_29",
|
|
"a second InitRoamMons does not hand the player three fresh beasts")
|
|
|
|
-- `.Update`, byte by byte. From ROUTE 42 the connection list is
|
|
-- { ROUTE_43, ROUTE_44, ROUTE_37, ROUTE_38 }, and the index is the low two
|
|
-- bits of the byte after it has been masked to five.
|
|
eq(Roamers.moveOne("ROUTE_42", nil, "NEW_BARK_TOWN", seeded({ 2 })),
|
|
"ROUTE_37", "index 2 out of ROUTE 42 is ROUTE 37")
|
|
eq(Roamers.moveOne("ROUTE_42", nil, "NEW_BARK_TOWN", seeded({ 3 })),
|
|
"ROUTE_38", "index 3 is ROUTE 38")
|
|
-- An index at or past the entry's count re-rolls: ROUTE 39 has one connection,
|
|
-- so only a byte whose low two bits are 0 (and which is not itself 0, that
|
|
-- being the jump) can pick it.
|
|
eq(Roamers.moveOne("ROUTE_39", nil, "NEW_BARK_TOWN", seeded({ 1, 4 })),
|
|
"ROUTE_38", "an out-of-range index re-rolls rather than picking nil")
|
|
-- The last-map check: the beast refuses the map the player was on before this
|
|
-- one, which is what stops it shadowing a player pacing two routes.
|
|
eq(Roamers.moveOne("ROUTE_42", "ROUTE_37", "NEW_BARK_TOWN", seeded({ 2, 3 })),
|
|
"ROUTE_38", "a connection equal to the last map re-rolls")
|
|
-- A beast standing somewhere RoamMaps does not list stays put (`cp -1 / ret z`
|
|
-- leaves b and c alone).
|
|
eq(Roamers.moveOne("GOLDENROD_CITY", nil, "NEW_BARK_TOWN", seeded({ 2 })),
|
|
"GOLDENROD_CITY", "an unlisted map leaves the beast where it is")
|
|
|
|
-- The 1-in-32 jump, and the fact that the SAME byte decides both: a byte whose
|
|
-- low five bits are zero jumps instead of stepping.
|
|
eq(Roamers.moveOne("ROUTE_42", nil, "NEW_BARK_TOWN", seeded({ 32, 5 })),
|
|
Roamers.MAPS[6].map, "a masked byte of 0 jumps to a random roam map")
|
|
eq(Roamers.MAPS[6].map, "ROUTE_34", "...which for index 5 is ROUTE 34")
|
|
-- JumpRoamMon re-rolls off the map the PLAYER is standing on.
|
|
eq(Roamers.jumpOne("ROUTE_34", seeded({ 5, 6 })), "ROUTE_35",
|
|
"a jump onto the player's own map re-rolls")
|
|
|
|
-- A seeded walk across connected maps, which is the thing that has to hold
|
|
-- end to end: 42 -> 37 -> 38 -> 39 -> 38.
|
|
local walkSave = {}
|
|
Roamers.init(walkSave)
|
|
local WALK = { 2, 1, 1, 1, 4 }
|
|
local WANT = { "ROUTE_37", "ROUTE_38", "ROUTE_39", "ROUTE_38" }
|
|
local rng = seeded(WALK)
|
|
local where = walkSave.roamers[1].map
|
|
local path = {}
|
|
for _ = 1, 4 do
|
|
where = Roamers.moveOne(where, nil, "NEW_BARK_TOWN", rng)
|
|
path[#path + 1] = where
|
|
end
|
|
eq(table.concat(path, ","), table.concat(WANT, ","),
|
|
"a seeded roamer walks 42 -> 37 -> 38 -> 39 -> 38")
|
|
|
|
-- UpdateRoamMons moves every live beast and then backs up the map indices, so
|
|
-- the NEXT update avoids the map the player has just left.
|
|
local updSave = {}
|
|
Roamers.init(updSave)
|
|
Roamers.update(updSave, "ROUTE_30", seeded({ 2, 1, 1 }))
|
|
eq(updSave.roamerMaps.current, "ROUTE_30", "the player's map becomes Cur")
|
|
check(updSave.roamerMaps.last == nil, "and Last is still empty on the first")
|
|
Roamers.update(updSave, "ROUTE_31", seeded({ 2, 1, 1 }))
|
|
eq(updSave.roamerMaps.last, "ROUTE_30", "Cur shifts into Last on the second")
|
|
eq(updSave.roamerMaps.current, "ROUTE_31", "...and the new map into Cur")
|
|
|
|
-- JumpRoamMons scatters all three (the Teleport map setup script).
|
|
local jumpSave = {}
|
|
Roamers.init(jumpSave)
|
|
Roamers.jumpAll(jumpSave, "NEW_BARK_TOWN", seeded({ 0, 1, 2 }))
|
|
eq(jumpSave.roamers[1].map, "ROUTE_29", "RAIKOU jumped to entry 0")
|
|
eq(jumpSave.roamers[2].map, "ROUTE_30", "ENTEI to entry 1")
|
|
eq(jumpSave.roamers[3].map, "ROUTE_31", "SUICUNE to entry 2")
|
|
|
|
-- CheckEncounterRoamMon's three gates off ONE byte.
|
|
local encSave = {}
|
|
Roamers.init(encSave)
|
|
check(Roamers.checkEncounter(encSave, "ROUTE_42", true, seeded({ 1 })) == nil,
|
|
"surfing refuses before anything else")
|
|
check(Roamers.checkEncounter(encSave, "ROUTE_42", false, seeded({ 100 })) == nil,
|
|
"a byte of 100 or more is no encounter")
|
|
check(Roamers.checkEncounter(encSave, "ROUTE_42", false, seeded({ 4 })) == nil,
|
|
"a byte whose low two bits are 0 is no encounter")
|
|
local met = Roamers.checkEncounter(encSave, "ROUTE_42", false, seeded({ 1 }))
|
|
check(met ~= nil, "byte 1 picks slot 1")
|
|
eq(met and met.species, "RAIKOU", "...which is RAIKOU, and it is on ROUTE 42")
|
|
eq(met and met.level, 40, "...at level 40")
|
|
check(Roamers.checkEncounter(encSave, "ROUTE_42", false, seeded({ 2 })) == nil,
|
|
"byte 2 picks ENTEI, which is on ROUTE 37, so nothing happens")
|
|
check(Roamers.checkEncounter(encSave, "ROUTE_37", false, seeded({ 2 })) ~= nil,
|
|
"...and the same byte on ROUTE 37 does meet it")
|
|
|
|
-- A beast that has been beaten is gone for good.
|
|
local goneSave = {}
|
|
Roamers.init(goneSave)
|
|
Roamers.endBattle(goneSave, 1, "win", 40, "ROUTE_42", seeded({ 2 }))
|
|
check(goneSave.roamers[1].species == nil, "a defeated beast loses its species")
|
|
check(goneSave.roamers[1].map == nil, "...and its map")
|
|
check(Roamers.checkEncounter(goneSave, "ROUTE_42", false, seeded({ 1 })) == nil,
|
|
"...so it is never met again")
|
|
local caughtSave = {}
|
|
Roamers.init(caughtSave)
|
|
Roamers.endBattle(caughtSave, 1, "caught", 40, "ROUTE_42", seeded({ 2 }))
|
|
check(caughtSave.roamers[1].species == nil, "a caught beast goes the same way")
|
|
|
|
-- HP ACROSS TWO ENCOUNTERS, which is the whole point of the roam struct.
|
|
local ROAM_DATA = {
|
|
pokemon = {
|
|
growthRates = {
|
|
GROWTH_SLOW = { numerator = 5, denominator = 4, squared = 0,
|
|
linear = 0, constant = 0 },
|
|
},
|
|
RAIKOU = {
|
|
id = "RAIKOU", index = 243, name = "RAIKOU",
|
|
baseStats = { hp = 90, attack = 85, defense = 75, speed = 115,
|
|
specialAttack = 115, specialDefense = 100 },
|
|
types = { "ELECTRIC", "ELECTRIC" }, catchRate = 3, baseExp = 216,
|
|
growthRate = "GROWTH_SLOW", genderRatio = 0xff,
|
|
levelMoves = { { level = 1, move = "BITE" } },
|
|
evolutions = {},
|
|
},
|
|
},
|
|
moves = { BITE = { id = "BITE", name = "BITE", pp = 25 } },
|
|
}
|
|
|
|
local hpSave = {}
|
|
Roamers.init(hpSave)
|
|
local beast, slot = Roamers.beginBattle(hpSave, 1, ROAM_DATA)
|
|
check(beast ~= nil, "the first encounter builds the beast")
|
|
eq(beast.species, "RAIKOU", "...as RAIKOU")
|
|
eq(beast.level, 40, "...at level 40")
|
|
check(#beast.moves > 0,
|
|
"...through Mon.new, so it has a Gen 2 moveset rather than none")
|
|
eq(slot.hp, beast.maxHp,
|
|
".InitRoamHP banks the full HP the moment the battle starts")
|
|
check(slot.dvs ~= nil, "and the DVs are kept from the first meeting")
|
|
local firstDVs = slot.dvs
|
|
-- The player knocks it down and it flees: DRAW, so the struct keeps the HP.
|
|
beast.hp = 17
|
|
Roamers.endBattle(hpSave, 1, "fled", beast.hp, "ROUTE_42", seeded({ 2 }))
|
|
eq(slot.hp, 17, "a flee banks the damage")
|
|
-- ...and the beast moved on, so it has to be found again.
|
|
check(slot.map ~= nil, "the beast is still out there")
|
|
local again = Roamers.beginBattle(hpSave, 1, ROAM_DATA)
|
|
eq(again.hp, 17, "the second encounter is the SAME hurt beast")
|
|
eq(again.maxHp, beast.maxHp, "...at the same max HP")
|
|
eq(again.dvs.attack, firstDVs.attack, "...and the same individual")
|
|
eq(again.dvs.special, firstDVs.special, "...on every DV")
|
|
|
|
-- The player running away banks the damage the same way (TryToRunAwayFromBattle
|
|
-- writes DRAW too).
|
|
local runSave = {}
|
|
Roamers.init(runSave)
|
|
local runBeast = Roamers.beginBattle(runSave, 1, ROAM_DATA)
|
|
runBeast.hp = 3
|
|
Roamers.endBattle(runSave, 1, "run", runBeast.hp, "ROUTE_42", seeded({ 2 }))
|
|
eq(runSave.roamers[1].hp, 3, "a player run banks the damage as well")
|
|
|
|
-- BattleEnd_HandleRoamMons `.not_roaming`: one wild battle in sixteen moves the
|
|
-- beasts even though you never saw one.
|
|
local driftSave = {}
|
|
Roamers.init(driftSave)
|
|
check(not Roamers.afterWildBattle(driftSave, "ROUTE_30", seeded({ 1 })),
|
|
"fifteen wild battles in sixteen leave the beasts alone")
|
|
check(Roamers.afterWildBattle(driftSave, "ROUTE_30", seeded({ 16, 2, 1, 1 })),
|
|
"and the sixteenth moves them")
|
|
|
|
-- ---- swarms ---------------------------------------------------------------
|
|
--
|
|
-- engine/events/specials.asm StoreSwarmMapIndices / SetSwarmFlag /
|
|
-- CheckSwarmFlag, and _SwarmWildmonCheck's place in front of the normal table.
|
|
|
|
local Swarm = Roamers.Swarm
|
|
|
|
-- A stand-in cache. encounters.swarmGrass is NOT in data/generated today, so
|
|
-- this is also the assertion that the reader will pick it up unchanged when it
|
|
-- arrives: the shape is exactly encounters.grass'.
|
|
local SWARM_ENCOUNTERS = {
|
|
grass = {
|
|
ROUTE_35 = { rates = { MORN = 5, DAY = 5, NITE = 5 },
|
|
slots = { MORN = { { species = "NIDORAN_M", level = 12 } },
|
|
DAY = { { species = "NIDORAN_M", level = 12 } },
|
|
NITE = { { species = "NIDORAN_M", level = 12 } } } },
|
|
},
|
|
water = {},
|
|
swarmGrass = {
|
|
ROUTE_35 = { rates = { MORN = 25, DAY = 25, NITE = 25 },
|
|
slots = { MORN = { { species = "YANMA", level = 12 } },
|
|
DAY = { { species = "YANMA", level = 12 } },
|
|
NITE = { { species = "YANMA", level = 12 } } } },
|
|
},
|
|
swarmWater = {},
|
|
}
|
|
|
|
local swarmSave = {}
|
|
check(not Swarm.active(swarmSave), "no swarm to start with")
|
|
eq(Swarm.tables(swarmSave, SWARM_ENCOUNTERS, "ROUTE_35"), SWARM_ENCOUNTERS,
|
|
"and with none, the encounter tables are handed back untouched")
|
|
|
|
Swarm.set(swarmSave, "ROUTE_35")
|
|
check(Swarm.active(swarmSave),
|
|
"StoreSwarmMapIndices falls through into SetSwarmFlag")
|
|
eq(Swarm.mapId(swarmSave), "ROUTE_35", "and parks the map")
|
|
eq(Swarm.check(swarmSave), 0, "CheckSwarmFlag answers 0 while the flag is up")
|
|
|
|
local view = Swarm.tables(swarmSave, SWARM_ENCOUNTERS, "ROUTE_35")
|
|
check(view ~= SWARM_ENCOUNTERS, "an active swarm builds a view")
|
|
eq(view.grass.ROUTE_35.slots.DAY[1].species, "YANMA",
|
|
"_SwarmWildmonCheck searches the swarm table BEFORE the Johto one")
|
|
eq(view.grass.ROUTE_35.rates.DAY, 25, "...rate and all")
|
|
eq(Encounter.grassSlot(view, "ROUTE_35", "DAY", function() return 0 end)
|
|
.species, "YANMA", "so Encounter rolls the swarm's list unchanged")
|
|
-- The override is one map wide: everywhere else still reads its own table.
|
|
eq(SWARM_ENCOUNTERS.grass.ROUTE_35.slots.DAY[1].species, "NIDORAN_M",
|
|
"and the cache's own table is not mutated")
|
|
eq(Swarm.tables(swarmSave, SWARM_ENCOUNTERS, "ROUTE_32"), SWARM_ENCOUNTERS,
|
|
"a swarm on ROUTE 35 does not touch ROUTE 32")
|
|
-- A swarm map the swarm TABLE does not list falls through to the normal
|
|
-- lookup rather than blanking the map (`jr nc, .noSwarm`).
|
|
local fishOnly = {}
|
|
Swarm.set(fishOnly, "ROUTE_32")
|
|
eq(Swarm.tables(fishOnly, SWARM_ENCOUNTERS, "ROUTE_32"), SWARM_ENCOUNTERS,
|
|
"a swarm map with no swarm rows falls through to the normal table")
|
|
|
|
-- ActivateFishingSwarm sets the fishing flag and lights the same daily flag,
|
|
-- but leaves the map pair alone.
|
|
local fishSave = {}
|
|
Swarm.setFishing(fishSave, Swarm.FISH_QWILFISH)
|
|
check(Swarm.active(fishSave), "a fishing swarm lights DAILYFLAGS1_SWARM too")
|
|
eq(Swarm.fishing(fishSave), Swarm.FISH_QWILFISH, "and records which one")
|
|
check(fishSave.swarmMap == nil, "without touching the map pair")
|
|
|
|
-- EXPIRY. Nothing but the daily reset ends a swarm: CheckDailyResetTimer
|
|
-- zeroes the daily flags, and CheckSwarmFlag -- which runs immediately after
|
|
-- it in CheckTimeEvents -- is what then clears the map pair.
|
|
local dailySave = {}
|
|
Swarm.set(dailySave, "ROUTE_35")
|
|
check(not Swarm.timeEvents(dailySave, 100),
|
|
"the first time check only starts the countdown")
|
|
check(Swarm.active(dailySave), "so the swarm is still on")
|
|
check(not Swarm.timeEvents(dailySave, 100),
|
|
"and the same day does not end it either")
|
|
eq(Swarm.mapId(dailySave), "ROUTE_35", "the map is still parked")
|
|
check(Swarm.timeEvents(dailySave, 101), "the next day ends it")
|
|
check(not Swarm.active(dailySave), "the daily flag is gone")
|
|
check(dailySave.swarmMap == nil, "and CheckSwarmFlag cleared the map pair")
|
|
eq(Swarm.check(dailySave), 1, "CheckSwarmFlag now answers 1")
|
|
eq(Swarm.tables(dailySave, SWARM_ENCOUNTERS, "ROUTE_35"), SWARM_ENCOUNTERS,
|
|
"so ROUTE 35 is back on its own encounter table")
|
|
|
|
-- A fishing swarm expires on the same clock and takes its flag with it.
|
|
local fishExpire = {}
|
|
Swarm.setFishing(fishExpire, Swarm.FISH_REMORAID)
|
|
Swarm.timeEvents(fishExpire, 200)
|
|
Swarm.timeEvents(fishExpire, 201)
|
|
eq(Swarm.fishing(fishExpire), Swarm.FISH_NONE,
|
|
"the fishing flag is cleared with the rest")
|
|
|
|
-- The cache does not carry the swarm or roam tables today. Assert the reader
|
|
-- SHAPE rather than the data, so this suite tells the truth either way.
|
|
check(Roamers.mapTable(nil) == Roamers.MAPS,
|
|
"with no extracted RoamMaps the transcribed table is used")
|
|
local FAKE_ROAM = { { map = "ROUTE_29", to = { "ROUTE_30" } } }
|
|
check(Roamers.mapTable({ roamMaps = FAKE_ROAM }) == FAKE_ROAM,
|
|
"and an extracted encounters.roamMaps takes over the moment it appears")
|
|
|
|
end
|
|
roamerAndSwarmSuite()
|
|
|
|
-- ---- the hook table itself ------------------------------------------------
|
|
-- Every hook the interpreter guards has to exist by the name the VM looks it
|
|
-- up under, or the command it belongs to is silently inert. Reading the Vm
|
|
-- source is the honest check here: it names each one exactly once.
|
|
local vmSource = (function()
|
|
local f = io.open("src/script/gen2/Vm.lua", "r")
|
|
if not f then return nil end
|
|
local body = f:read("*a")
|
|
f:close()
|
|
return body
|
|
end)()
|
|
check(vmSource ~= nil, "the VM source is readable")
|
|
if vmSource then
|
|
local wired = hookWorld():specialHooks()
|
|
check(wired.world ~= nil, "specialHooks hands the world to Specials.lua")
|
|
local wantHooks = {
|
|
"getMapScene", "getTimeOfDay", "gsVersion", "getEngineFlag",
|
|
"setEngineFlag", "writeVar", "callAsm", "appear", "moveObject",
|
|
"variableSprite", "changeBlock", "earthquake", "warpTo", "warpCheck",
|
|
"warpSound", "newLoadMap", "writeCmdQueue", "delCmdQueue",
|
|
"setWarpMod", "setBlackoutMap", "setSwarm",
|
|
"setWildEncounters", "rollWild", "playMapMusic", "fadeOutMusic",
|
|
"dontRestartMapMusic", "hasItem", "takeItem", "getMoney", "setMoney",
|
|
"getCoins", "setCoins", "hasPoke", "giveEgg", "getLandmarkName",
|
|
"fruitTreeItem", "fruitTreePicked", "fruitTreePick", "addPhoneNumber",
|
|
"setSpecialCall", "getSpecialCall", "specials",
|
|
}
|
|
local worldSource = (function()
|
|
local f = io.open("src/world/gen2/World.lua", "r")
|
|
if not f then return "" end
|
|
local body = f:read("*a")
|
|
f:close()
|
|
return body
|
|
end)()
|
|
local unwired = {}
|
|
for _, name in ipairs(wantHooks) do
|
|
-- `hooks.<name>` is how Vm.new reads it, `<name> =` is how World writes it.
|
|
if not (vmSource:find("hooks." .. name, 1, true)
|
|
and worldSource:find("\n " .. name .. " = ", 1, true)) then
|
|
unwired[#unwired + 1] = name
|
|
end
|
|
end
|
|
eq(table.concat(unwired, ","), "", "every VM hook is wired from World")
|
|
end
|
|
|
|
-- ---- readvar: the rest of engine/overworld/variables.asm .VarActionTable --
|
|
-- VAR_WEEKDAY and VAR_FACING are covered above (through the hooks table) and
|
|
-- in gen2_map_callbacks_test.lua; this pins the other eleven live slots a
|
|
-- Gold script reads through readvar.
|
|
local Phone = require("src.core.gen2.Phone")
|
|
local rv, rvGame = hookWorld()
|
|
rv.player.cellX, rv.player.cellY = 7, 9
|
|
rv.map.def.environmentId = 4 -- CAVE (constants/map_data_constants.asm)
|
|
|
|
eq(rv:readVar(0x01), 0, "VAR_PARTYCOUNT: an empty party is 0")
|
|
rvGame.save.party = { {}, {} }
|
|
eq(rv:readVar(0x01), 2, "VAR_PARTYCOUNT reads save.party's length")
|
|
|
|
eq(rv:readVar(0x02), 0, "VAR_BATTLERESULT: no battle fought yet reads 0")
|
|
rv.lastBattleResult = 1
|
|
eq(rv:readVar(0x02), 1, "VAR_BATTLERESULT reads the last battle's outcome")
|
|
|
|
rvGame.save.pokedex = {
|
|
caught = { CHIKORITA = true, MAGIKARP = true },
|
|
seen = { CHIKORITA = true, MAGIKARP = true, SHUCKLE = true },
|
|
}
|
|
eq(rv:readVar(0x05), 2, "VAR_DEXCAUGHT counts save.pokedex.caught")
|
|
eq(rv:readVar(0x06), 3, "VAR_DEXSEEN counts save.pokedex.seen")
|
|
|
|
rvGame.save.player.badges = { BOULDERBADGE = true }
|
|
rvGame.save.player.kantoBadges = { EARTHBADGE = true, VOLCANOBADGE = true }
|
|
eq(rv:readVar(0x07), 3,
|
|
"VAR_BADGES counts BOTH badge cases, the way CountSetBits walks wBadges' two bytes")
|
|
|
|
eq(rv:readVar(0x08), 0, "VAR_MOVEMENT: PLAYER_NORMAL is wPlayerState 0")
|
|
rv.playerState = require("src.world.gen2.FieldMoves").PLAYER_SURF
|
|
eq(rv:readVar(0x08), 4, "VAR_MOVEMENT: PLAYER_SURF is wPlayerState 4")
|
|
|
|
rv.clockHour = 14
|
|
eq(rv:readVar(0x0a), 14, "VAR_HOUR reads the RTC hour")
|
|
|
|
eq(rv:readVar(0x0c), 1, "VAR_MAPGROUP reads the current map's group")
|
|
eq(rv:readVar(0x0d), 2, "VAR_MAPNUMBER reads the current map's number")
|
|
|
|
eq(rv:readVar(0x0f), 4, "VAR_ENVIRONMENT reads the map's raw environmentId")
|
|
|
|
rvGame.save.currentBox = 1
|
|
rvGame.save.boxes = { { {}, {}, {} } }
|
|
eq(rv:readVar(0x10), 17,
|
|
"VAR_BOXSPACE is MONS_PER_BOX (20) minus the current box's 3 mons")
|
|
|
|
rvGame.save.bugContest = { minutes = 15, seconds = 30 }
|
|
eq(rv:readVar(0x11), 15, "VAR_CONTESTMINUTES reads wBugContestMinsRemaining")
|
|
|
|
eq(rv:readVar(0x12), 7, "VAR_XCOORD reads the player's cell x")
|
|
eq(rv:readVar(0x13), 9, "VAR_YCOORD reads the player's cell y")
|
|
|
|
eq(rv:readVar(0x14), 0, "VAR_SPECIALPHONECALL: nothing queued reads 0")
|
|
Phone.queueSpecialCall(rvGame.save, 3)
|
|
eq(rv:readVar(0x14), 3, "VAR_SPECIALPHONECALL reads back what was queued")
|
|
|
|
-- VAR_UNOWNCOUNT (0x0e): CountUnown walks wUnownDex, a per-FORM catch list.
|
|
-- The port's pokedex only ever flags the species UNOWN as one boolean, so
|
|
-- there is no honest count to answer with; it must not silently invent one.
|
|
eq(rv:readVar(0x0e), 0,
|
|
"VAR_UNOWNCOUNT: blocked upstream, no per-letter Unown tracking to read")
|
|
|
|
-- ---- SELECT menu shortcut -------------------------------------------------
|
|
-- engine/overworld/select_menu.asm (SelectMenu / CheckRegisteredItem /
|
|
-- UseRegisteredItem) plus RegisterItem (engine/items/pack.asm). This port
|
|
-- reaches RegisterItem off the one PACK button left unbound (SELECT on a
|
|
-- highlighted row) rather than the cart's own USE/GIVE/TOSS/SEL/QUIT
|
|
-- submenu, which is not built.
|
|
do
|
|
local selWorld, selGame = fakeWorld({}, fakePlayer(5, 5, "down"))
|
|
selGame.data = { items = ITEMS, moves = DATA.moves, pokemon = DATA.pokemon }
|
|
selGame.save.inventory = { POTION = 1, REPEL = 1, HM_CUT = 1 }
|
|
|
|
-- CheckSelectableItem's gate: a TM/HM refuses outright and nothing is set.
|
|
eq(selWorld:registerItem("HM_CUT"), false,
|
|
"RegisterItem.cant_register: a TM/HM cannot be registered")
|
|
check(selGame.save.registeredItem == nil,
|
|
"and the refusal never touches the slot")
|
|
|
|
-- A selectable item registers.
|
|
eq(selWorld:registerItem("POTION"), true, "a plain ITEM pocket item registers")
|
|
eq(selGame.save.registeredItem.id, "POTION", "RegisteredItemText names it")
|
|
eq(selWorld:registeredItemId(), "POTION",
|
|
"CheckRegisteredItem confirms it while the bag still has one")
|
|
|
|
-- No field handler is wired for POTION, so SELECT lands on CantUseItem --
|
|
-- ItemsOakWarningText, the same line a busy world answers.
|
|
eq(selWorld:useSelectItem(), "cant_use",
|
|
"UseRegisteredItem.CantUse: no field handler for this item yet")
|
|
|
|
selWorld.battleActive = true
|
|
eq(selWorld:useSelectItem(), "nowhere",
|
|
"SELECT is refused mid-battle exactly like the PACK's own UseItem")
|
|
selWorld.battleActive = nil
|
|
|
|
-- Registering something else re-runs CheckRegisteredItem.IsSameItem's swap;
|
|
-- REPEL's field handler is useFieldItem's existing UseRepel arm, so SELECT
|
|
-- dispatches through the identical path a PACK UseItem would.
|
|
eq(selWorld:registerItem("REPEL"), true, "REPEL replaces the registered item")
|
|
eq(selWorld:useSelectItem(), "repel_used",
|
|
"SELECT on a registered REPEL runs UseRepel through useFieldItem")
|
|
eq(selGame.save.inventory.REPEL, nil,
|
|
"and the last REPEL leaves the bag the same way a PACK use would")
|
|
|
|
-- CheckRegisteredItem.NotEnoughItems: the registered item running out of the
|
|
-- bag silently clears the slot rather than holding a stale pointer.
|
|
eq(selWorld:registerItem("POTION"), true, "register the POTION again")
|
|
selGame.save.inventory.POTION = nil
|
|
check(selWorld:registeredItemId() == nil,
|
|
"an item gone from the bag no longer answers as registered")
|
|
check(selGame.save.registeredItem == nil,
|
|
"and the slot itself is cleared, not just skipped")
|
|
|
|
-- SelectMenu.NotRegistered: nothing registered prints MayRegisterItemText.
|
|
eq(selWorld:useSelectItem(), "not_registered",
|
|
"SELECT with nothing registered answers not_registered")
|
|
|
|
-- The PACK side: SELECT on a highlighted row is RegisterItem, the one PACK
|
|
-- button this port left unbound.
|
|
selGame.save.inventory.POTION = 3
|
|
selGame.input = stubInput()
|
|
local selPack = PackMenu.new(selGame, { pocket = "ITEM" })
|
|
selPack.index = 1
|
|
check(selPack.rows[1].id == "POTION", "the ITEM pocket row under test")
|
|
selGame.input:press("select")
|
|
selPack:update(0)
|
|
check(selPack.message ~= nil, "SELECT on a row opens RegisteredItemText")
|
|
eq(selGame.save.registeredItem.id, "POTION",
|
|
"and World:registerItem actually ran")
|
|
selGame.input:press("a")
|
|
selPack:update(0)
|
|
check(selPack.message == nil, "A dismisses the confirmation like any prompt")
|
|
|
|
-- SELECT on CANCEL does nothing -- there is no row under the cursor.
|
|
selPack.index = selPack:total()
|
|
selGame.save.registeredItem = nil
|
|
selGame.input:press("select")
|
|
selPack:update(0)
|
|
check(selPack.message == nil, "SELECT on CANCEL registers nothing")
|
|
check(selGame.save.registeredItem == nil, "and the slot stays empty")
|
|
|
|
-- CantRegisterText: a TM/HM row refuses from the PACK too.
|
|
selGame.save.inventory.HM_CUT = 1
|
|
selPack:rebuild()
|
|
local tmPack = PackMenu.new(selGame, { pocket = "TM_HM" })
|
|
tmPack.index = 1
|
|
check(tmPack.rows[1].id == "HM_CUT", "the TM/HM pocket row under test")
|
|
selGame.input:press("select")
|
|
tmPack:update(0)
|
|
check(tmPack.message ~= nil, "SELECT on the HM still opens a message")
|
|
check(selGame.save.registeredItem == nil,
|
|
"CantRegisterText: the HM never becomes the registered item")
|
|
end
|
|
|
|
-- ---- the BICYCLE ----------------------------------------------------------
|
|
--
|
|
-- BikeFunction (engine/events/overworld.asm) end to end: where the bike may be
|
|
-- got on, what the queued script does, and the Cycling Road's two flags.
|
|
--
|
|
-- Wrapped in a function rather than a `do` block: block locals still count
|
|
-- against the main chunk's 200-local ceiling and this file is already near it.
|
|
local function bikeChecks()
|
|
local Bike = require("src.world.gen2.Bike")
|
|
local FieldMoves = require("src.world.gen2.FieldMoves")
|
|
local Player = require("src.world.gen2.Player")
|
|
|
|
-- .CheckEnvironment: CheckOutdoorMap plus CAVE and GATE by name.
|
|
check(Bike.environmentAllows("ROUTE"), "a ROUTE allows the bike")
|
|
check(Bike.environmentAllows("TOWN"), "so does a TOWN")
|
|
check(Bike.environmentAllows("CAVE"), "and a CAVE")
|
|
check(Bike.environmentAllows("GATE"), "and a gatehouse")
|
|
check(not Bike.environmentAllows("INDOOR"), "an INDOOR map refuses")
|
|
check(not Bike.environmentAllows("DUNGEON"), "and so does a DUNGEON")
|
|
|
|
-- The tile half: GetPlayerTilePermission `and $f` has to be LAND_TILE.
|
|
check(Bike.canUseHere("ROUTE", COLL_FLOOR), "plain ground is rideable")
|
|
check(not Bike.canUseHere("ROUTE", COLL_WATER), "water is not")
|
|
-- A door tile IS land in CollisionPermissionTable, which is why the door is
|
|
-- not in this list: the tiles the lo nybble rejects are water and walls.
|
|
check(not Bike.canUseHere("ROUTE", 0x07), "and neither is a wall")
|
|
check(Bike.canUseHere("ROUTE", 0x71), "a door tile is LAND_TILE, so it passes")
|
|
|
|
-- .TryBike's four answers.
|
|
eq(Bike.tryBike({ state = FieldMoves.PLAYER_NORMAL, environment = "ROUTE",
|
|
collision = COLL_FLOOR }), "mount", "walking on a route gets on")
|
|
eq(Bike.tryBike({ state = FieldMoves.PLAYER_BIKE, environment = "ROUTE",
|
|
collision = COLL_FLOOR }), "dismount", "riding on a route gets off")
|
|
eq(Bike.tryBike({ state = FieldMoves.PLAYER_BIKE, environment = "ROUTE",
|
|
collision = COLL_FLOOR, alwaysOnBike = true }), "cant_get_off",
|
|
"ALWAYS_ON_BIKE refuses the dismount")
|
|
check(Bike.tryBike({ state = FieldMoves.PLAYER_SURF, environment = "ROUTE",
|
|
collision = COLL_FLOOR }) == nil, "a surfing player falls to .CannotUseBike")
|
|
check(Bike.tryBike({ state = FieldMoves.PLAYER_NORMAL, environment = "INDOOR",
|
|
collision = COLL_FLOOR }) == nil, "and so does an indoor one")
|
|
|
|
-- CheckUpdatePlayerSprite's biking arms.
|
|
eq(Bike.mapSetupState(FieldMoves.PLAYER_NORMAL, "ROUTE", true),
|
|
FieldMoves.PLAYER_BIKE, ".CheckForcedBiking puts the player on the bike")
|
|
eq(Bike.mapSetupState(FieldMoves.PLAYER_BIKE, "INDOOR", false),
|
|
FieldMoves.PLAYER_NORMAL, "riding indoors takes them off it")
|
|
eq(Bike.mapSetupState(FieldMoves.PLAYER_BIKE, "CAVE", false),
|
|
FieldMoves.PLAYER_BIKE, "a cave keeps them on it")
|
|
eq(Bike.mapSetupState(FieldMoves.PLAYER_SURF, "INDOOR", false),
|
|
FieldMoves.PLAYER_SURF,
|
|
"and with no tile to read, the surf arms are left alone")
|
|
|
|
-- Its surfing arms, which is what a save made afloat reloads through.
|
|
-- .CheckSurfing reads the tile the player is STANDING on, so it is the water
|
|
-- under them that keeps them there.
|
|
eq(Bike.mapSetupState(FieldMoves.PLAYER_SURF, "ROUTE", false, true),
|
|
FieldMoves.PLAYER_SURF, "landing on water while surfing keeps surfing")
|
|
eq(Bike.mapSetupState(FieldMoves.PLAYER_SURF_PIKA, "ROUTE", false, true),
|
|
FieldMoves.PLAYER_SURF_PIKA, "and a PIKACHU rider keeps its own sprite")
|
|
eq(Bike.mapSetupState(FieldMoves.PLAYER_NORMAL, "ROUTE", false, true),
|
|
FieldMoves.PLAYER_SURF, "a load onto water is a surfing load")
|
|
eq(Bike.mapSetupState(FieldMoves.PLAYER_SURF, "ROUTE", false, false),
|
|
FieldMoves.PLAYER_NORMAL,
|
|
".ResetSurfingOrBikingState takes a warp onto land off the Lapras")
|
|
eq(Bike.mapSetupState(FieldMoves.PLAYER_SURF, "ROUTE", true, true),
|
|
FieldMoves.PLAYER_BIKE, "and .CheckForcedBiking still wins outright")
|
|
eq(Bike.mapSetupState(FieldMoves.PLAYER_BIKE, "ROUTE", false, false),
|
|
FieldMoves.PLAYER_BIKE, "a dry outdoor load leaves the bike alone")
|
|
|
|
-- .DoStep and .GetDPad.
|
|
eq(Bike.stepFrames(FieldMoves.PLAYER_NORMAL, "up", false, 16), 16,
|
|
"a walk is a walk")
|
|
eq(Bike.stepFrames(FieldMoves.PLAYER_BIKE, "up", false, 16), 8,
|
|
"STEP_BIKE is half the duration of STEP_WALK")
|
|
eq(Bike.stepFrames(FieldMoves.PLAYER_BIKE, "up", true, 16), 16,
|
|
"downhill and not moving down is back to walking pace")
|
|
eq(Bike.stepFrames(FieldMoves.PLAYER_BIKE, "down", true, 16), 8,
|
|
"downhill and moving down is the fast step")
|
|
eq(Bike.forcedDirection(nil, true), "down",
|
|
"no direction held on a downhill map reads as DOWN")
|
|
eq(Bike.forcedDirection("up", true), "up", "a held direction still wins")
|
|
check(Bike.forcedDirection(nil, false) == nil, "and off the road, nothing")
|
|
|
|
-- The Player really does cross the cell in half the frames.
|
|
local fast = Player.new(3, 3, "down", nil)
|
|
fast.stepFrames = Bike.stepFrames(FieldMoves.PLAYER_BIKE, "down", false,
|
|
Player.STEP_FRAMES)
|
|
eq(fast:tryMove("down", fakeMap({}), nil), "moved", "the bike step starts")
|
|
local frames = 0
|
|
while fast.moving and frames < 40 do fast:update() frames = frames + 1 end
|
|
eq(frames, 8, "and lands after eight")
|
|
eq(fast.cellY, 4, "one cell further down")
|
|
|
|
-- The world path. A ROUTE with the player standing on plain ground.
|
|
local bikeWorld = fakeWorld({}, fakePlayer(5, 5, "down"))
|
|
eq(bikeWorld.playerState, FieldMoves.PLAYER_NORMAL, "the world starts walking")
|
|
eq(bikeWorld:useFieldItem("BICYCLE"), "bike_on",
|
|
"the BICYCLE is claimed by useFieldItem now")
|
|
local mount = bikeWorld.queuedScript
|
|
check(mount ~= nil, "and QueueScript left a script behind")
|
|
local loadvar
|
|
for _, cmd in ipairs(mount) do
|
|
if cmd.op == "loadvar" then loadvar = cmd end
|
|
end
|
|
check(loadvar ~= nil, "Script_GetOnBike is a loadvar")
|
|
eq(loadvar.args[1], Bike.VAR_MOVEMENT, "of VAR_MOVEMENT")
|
|
eq(loadvar.args[2], Bike.PLAYER_BIKE_ID, "with PLAYER_BIKE")
|
|
|
|
-- That loadvar is the whole mount: writeVar is where it lands.
|
|
bikeWorld:writeVar(loadvar.args[1], loadvar.args[2])
|
|
eq(bikeWorld.playerState, FieldMoves.PLAYER_BIKE, "and the player is riding")
|
|
eq(bikeWorld:readVar(Bike.VAR_MOVEMENT), 1,
|
|
"VAR_MOVEMENT reads back PLAYER_BIKE")
|
|
|
|
-- Riding, the same item gets off again.
|
|
eq(bikeWorld:useFieldItem("BICYCLE"), "bike_off", "the second use dismounts")
|
|
local off
|
|
for _, cmd in ipairs(bikeWorld.queuedScript) do
|
|
if cmd.op == "loadvar" then off = cmd end
|
|
end
|
|
eq(off.args[2], Bike.PLAYER_NORMAL_ID, "back to PLAYER_NORMAL")
|
|
|
|
-- A forced stretch refuses, and refuses WITHOUT touching wPlayerState.
|
|
bikeWorld:writeVar(Bike.VAR_MOVEMENT, Bike.PLAYER_BIKE_ID)
|
|
bikeWorld:setEngineFlag(Bike.ENGINE_ALWAYS_ON_BIKE, true)
|
|
eq(bikeWorld:useFieldItem("BICYCLE"), "bike_stuck",
|
|
"ALWAYS_ON_BIKE is Script_CantGetOffBike")
|
|
for _, cmd in ipairs(bikeWorld.queuedScript) do
|
|
check(cmd.op ~= "loadvar", "which never writes VAR_MOVEMENT")
|
|
end
|
|
eq(bikeWorld.playerState, FieldMoves.PLAYER_BIKE, "so the player still rides")
|
|
bikeWorld:setEngineFlag(Bike.ENGINE_ALWAYS_ON_BIKE, false)
|
|
|
|
-- Indoors is .CannotUseBike, which is wFieldMoveSucceeded 0 -- the PACK's own
|
|
-- Oak line, not a queued script.
|
|
local hallWorld = fakeWorld({}, fakePlayer(5, 5, "down"), nil, nil,
|
|
{ environment = "INDOOR" })
|
|
eq(hallWorld:useFieldItem("BICYCLE"), "nowhere", "no cycling indoors")
|
|
check(hallWorld.queuedScript == nil, "and nothing was queued")
|
|
|
|
-- SELECT sets wUsingItemWithSelect, and .CheckIfRegistered swaps in the silent
|
|
-- pair: the same state change with no text at all.
|
|
local selWorld = fakeWorld({}, fakePlayer(5, 5, "down"))
|
|
selWorld.game.save.registeredItem = { id = "BICYCLE" }
|
|
eq(selWorld:useSelectItem(), "bike_on", "SELECT on the BICYCLE gets on")
|
|
for _, cmd in ipairs(selWorld.queuedScript) do
|
|
check(cmd.op ~= "rawtext", "Script_GetOnBike_Register prints nothing")
|
|
end
|
|
check(selWorld.usingItemWithSelect == nil,
|
|
"and wUsingItemWithSelect is cleared behind it")
|
|
end
|
|
bikeChecks()
|
|
|
|
-- ---------------------------------------------------------------- ice slides
|
|
-- CheckStandingOnIce / .CheckForced / STEP_ICE. Wrapped in a function for the
|
|
-- same 200-local ceiling reason as bikeChecks.
|
|
local function iceSlideChecks()
|
|
local Player = require("src.world.gen2.Player")
|
|
local COLL_WALL = 0x07
|
|
local iceCells = {
|
|
[5 * 100 + 3] = COLL_ICE,
|
|
[5 * 100 + 4] = COLL_ICE,
|
|
[5 * 100 + 5] = COLL_ICE,
|
|
[5 * 100 + 6] = COLL_WALL,
|
|
}
|
|
local slide = fakeWorld(iceCells, Player.new(2, 5, "right"))
|
|
slide.player.turnArmed = false
|
|
slide.entities = { slide.player }
|
|
slide.heldDir = "right"
|
|
-- Tap: start the first step, then release. The latch has to carry the rest.
|
|
for _ = 1, 2 do slide:step() end
|
|
check(slide.player.moving or slide.turningDirection == "right",
|
|
"a step onto ice latches FinishFacing")
|
|
slide.heldDir = nil
|
|
local frames = 0
|
|
while frames < 200 do
|
|
slide:step()
|
|
frames = frames + 1
|
|
if not slide.player.moving and not slide.turningDirection then break end
|
|
end
|
|
eq(slide.player.cellX, 5, "the slide rests on the last ice cell")
|
|
eq(slide.player.cellY, 5, "same row")
|
|
check(slide.turningDirection == nil, "and the bump clears the latch")
|
|
|
|
-- Landing on ordinary floor ends the slide without a wall.
|
|
local mixed = {
|
|
[5 * 100 + 3] = COLL_ICE,
|
|
[5 * 100 + 4] = COLL_ICE,
|
|
-- (5,5) is COLL_FLOOR by default
|
|
}
|
|
local stop = fakeWorld(mixed, Player.new(2, 5, "right"))
|
|
stop.player.turnArmed = false
|
|
stop.entities = { stop.player }
|
|
stop.heldDir = "right"
|
|
for _ = 1, 2 do stop:step() end
|
|
stop.heldDir = nil
|
|
frames = 0
|
|
while frames < 200 do
|
|
stop:step()
|
|
frames = frames + 1
|
|
if not stop.player.moving and not stop.turningDirection then break end
|
|
end
|
|
eq(stop.player.cellX, 5, "leaving ice onto floor is the rest position")
|
|
eq(stop.player.cellY, 5, "same row on the floor landing")
|
|
|
|
-- Off ice, a released direction is still a single cell.
|
|
local dry = fakeWorld({}, Player.new(2, 5, "right"))
|
|
dry.player.turnArmed = false
|
|
dry.entities = { dry.player }
|
|
dry.heldDir = "right"
|
|
for _ = 1, 2 do dry:step() end
|
|
dry.heldDir = nil
|
|
frames = 0
|
|
while frames < 40 do
|
|
dry:step()
|
|
frames = frames + 1
|
|
if not dry.player.moving then break end
|
|
end
|
|
eq(dry.player.cellX, 3, "plain floor still walks one cell per press")
|
|
end
|
|
iceSlideChecks()
|
|
|
|
-- ------------------------------------------------- ledges and one-way walls
|
|
-- .TryJump (STEP_LEDGE) and GetMovementPermissions' side-wall arms. Burned
|
|
-- Tower B1F's landing pockets drain only through hop-down ledges, and Ice Path
|
|
-- 1F's HM07 rest chain only exists because a slide may not glide down onto the
|
|
-- COLL_UP_WALL strip -- both were unwalkable while these two mechanics were
|
|
-- missing. Wrapped for the 200-local ceiling like the blocks above.
|
|
local function ledgeOneWayChecks()
|
|
local Player = require("src.world.gen2.Player")
|
|
local COLL_WALL, COLL_HOP_DOWN, COLL_HOP_RIGHT = 0x07, 0xa3, 0xa0
|
|
local COLL_UP_WALL = 0xb2
|
|
|
|
local function press(world, dir, budget)
|
|
world.heldDir = dir
|
|
for _ = 1, 2 do world:step() end
|
|
world.heldDir = nil
|
|
local frames = 0
|
|
while frames < (budget or 60) do
|
|
world:step()
|
|
frames = frames + 1
|
|
if not world.player.moving and not world.turningDirection then break end
|
|
end
|
|
end
|
|
|
|
-- Standing on a HOP_DOWN ledge with the tile past it blocked: the refused
|
|
-- step becomes a two-cell jump.
|
|
local hopCells = {
|
|
[5 * 100 + 4] = COLL_HOP_DOWN, -- player stands here
|
|
[6 * 100 + 4] = COLL_WALL, -- the refused single step
|
|
-- (4,7) floor: the landing
|
|
}
|
|
local hop = fakeWorld(hopCells, Player.new(4, 5, "down"))
|
|
hop.player.turnArmed = false
|
|
hop.entities = { hop.player }
|
|
press(hop, "down")
|
|
eq(hop.player.cellY, 7, "a refused step off a HOP_DOWN ledge jumps two cells")
|
|
eq(hop.player.cellX, 4, "straight down")
|
|
|
|
-- The ledge only fires for its own facings: HOP_DOWN does nothing for a
|
|
-- rightward bump.
|
|
local wrongWay = fakeWorld({
|
|
[5 * 100 + 4] = COLL_HOP_DOWN,
|
|
[5 * 100 + 5] = COLL_WALL,
|
|
}, Player.new(4, 5, "right"))
|
|
wrongWay.player.turnArmed = false
|
|
wrongWay.entities = { wrongWay.player }
|
|
press(wrongWay, "right")
|
|
eq(wrongWay.player.cellX, 4, "HOP_DOWN does not jump a rightward bump")
|
|
|
|
-- HOP_RIGHT jumps rightward bumps (Gold's $a0 is HOP_RIGHT, not Crystal's
|
|
-- HOP_DOWN -- the direction table is the part a wrong port would swap).
|
|
local hopRight = fakeWorld({
|
|
[5 * 100 + 4] = COLL_HOP_RIGHT,
|
|
[5 * 100 + 5] = COLL_WALL,
|
|
}, Player.new(4, 5, "right"))
|
|
hopRight.player.turnArmed = false
|
|
hopRight.entities = { hopRight.player }
|
|
press(hopRight, "right")
|
|
eq(hopRight.player.cellX, 6, "$a0 hops rightward")
|
|
|
|
-- An occupied landing refuses the jump rather than stacking sprites.
|
|
local npc = Player.new(4, 7, "up")
|
|
local blockedLanding = fakeWorld({
|
|
[5 * 100 + 4] = COLL_HOP_DOWN,
|
|
[6 * 100 + 4] = COLL_WALL,
|
|
}, Player.new(4, 5, "down"))
|
|
blockedLanding.player.turnArmed = false
|
|
blockedLanding.entities = { blockedLanding.player, npc }
|
|
press(blockedLanding, "down")
|
|
eq(blockedLanding.player.cellY, 5, "no jump onto an occupied landing")
|
|
|
|
-- COLL_UP_WALL: Gold's neighbour arm blocks stepping DOWN onto it...
|
|
local noEntry = fakeWorld({
|
|
[6 * 100 + 4] = COLL_UP_WALL,
|
|
}, Player.new(4, 5, "down"))
|
|
noEntry.player.turnArmed = false
|
|
noEntry.entities = { noEntry.player }
|
|
press(noEntry, "down")
|
|
eq(noEntry.player.cellY, 5, "no stepping down onto an UP_WALL")
|
|
|
|
-- ...standing on it blocks UP...
|
|
local noLeave = fakeWorld({
|
|
[5 * 100 + 4] = COLL_UP_WALL,
|
|
}, Player.new(4, 5, "up"))
|
|
noLeave.player.turnArmed = false
|
|
noLeave.entities = { noLeave.player }
|
|
press(noLeave, "up")
|
|
eq(noLeave.player.cellY, 5, "standing on an UP_WALL blocks moving up")
|
|
|
|
-- ...and sideways ON the strip stays legal.
|
|
local alongside = fakeWorld({
|
|
[5 * 100 + 4] = COLL_UP_WALL,
|
|
[5 * 100 + 5] = COLL_UP_WALL,
|
|
}, Player.new(4, 5, "right"))
|
|
alongside.player.turnArmed = false
|
|
alongside.entities = { alongside.player }
|
|
press(alongside, "right")
|
|
eq(alongside.player.cellX, 5, "walking along the strip is an ordinary step")
|
|
|
|
-- An ice slide rests on the last ice cell above an UP_WALL strip instead of
|
|
-- gliding onto it -- the rest position Ice Path 1F's HM07 chain is built on.
|
|
local slideCells = {
|
|
[5 * 100 + 3] = COLL_ICE,
|
|
[6 * 100 + 3] = COLL_ICE,
|
|
[7 * 100 + 3] = COLL_ICE,
|
|
[8 * 100 + 3] = COLL_UP_WALL,
|
|
}
|
|
local slide = fakeWorld(slideCells, Player.new(3, 4, "down"))
|
|
slide.player.turnArmed = false
|
|
slide.entities = { slide.player }
|
|
press(slide, "down", 200)
|
|
eq(slide.player.cellY, 7, "the slide rests above the UP_WALL strip")
|
|
end
|
|
ledgeOneWayChecks()
|
|
|
|
-- ---- the IN_GRASS latch and the tileset anim clock ------------------------
|
|
-- UpdateTallGrassFlags only RE-tests while IN_GRASS is already set (engine/
|
|
-- overworld/map_objects.asm:226), so a step INTO grass stays clear until
|
|
-- SetTallGrassFlags runs at the far end of it (:247); a step OUT of grass
|
|
-- clears on the frame it starts.
|
|
local function grassLatchChecks()
|
|
local COLL_GRASS = 0x18
|
|
local PlayerMod = require("src.world.gen2.Player")
|
|
|
|
local into = fakeWorld({ [6 * 100 + 5] = COLL_GRASS },
|
|
PlayerMod.new(5, 5, "down"), PARTY_ONE)
|
|
into.entities = { into.player }
|
|
into.noWildEncounters = true
|
|
local ip = into.player
|
|
ip.turnArmed = false
|
|
check(not ip.inGrass, "standing on a path is not IN_GRASS")
|
|
eq(into:movePlayer("down"), "moved", "the step into grass is taken")
|
|
check(not ip.inGrass, "UpdateTallGrassFlags does not set it mid-step")
|
|
check(ip.grassShake, "NormalStep still spawns the rustle on that step")
|
|
for _ = 1, PlayerMod.STEP_FRAMES + 1 do into:step() end
|
|
check(not ip.moving, "the step lands")
|
|
check(ip.inGrass, "SetTallGrassFlags sets it once the step has landed")
|
|
|
|
local outOf = fakeWorld({ [5 * 100 + 5] = COLL_GRASS },
|
|
PlayerMod.new(5, 5, "down"), PARTY_ONE)
|
|
outOf.entities = { outOf.player }
|
|
outOf.noWildEncounters = true
|
|
local op = outOf.player
|
|
op.turnArmed = false
|
|
op.inGrass = true
|
|
eq(outOf:movePlayer("down"), "moved", "the step out of grass is taken")
|
|
check(not op.inGrass, "the latch clears on the first frame of that step")
|
|
check(not op.grassShake, "and nothing rustles on a path")
|
|
|
|
-- StepFunction_Reset -> SetTallGrassFlags (map_objects.asm:498-511): National
|
|
-- Park's four spinning trainers are IN_GRASS at load (maps/NationalPark.asm:503-506).
|
|
local parked = { kind = "stand", cellX = 5, cellY = 5, inGrass = false }
|
|
NPC.update(parked, outOf.map)
|
|
check(parked.inGrass, "an object spawned in grass is IN_GRASS before it steps")
|
|
|
|
-- applymovement's step_* is the same NormalStep as a walk (movement.asm:
|
|
-- 434-481), so a scripted step rustles the grass it steps into.
|
|
local scripted = fakeWorld({ [6 * 100 + 5] = COLL_GRASS },
|
|
PlayerMod.new(5, 5, "down"), PARTY_ONE)
|
|
scripted.entities = { scripted.player }
|
|
scripted.noWildEncounters = true
|
|
scripted.player.turnArmed = false
|
|
check(scripted.player:scriptStep("down"), "the scripted step starts")
|
|
scripted:step()
|
|
check(scripted.player.grassShake, "a scripted step spawns the rustle too")
|
|
|
|
-- JumpStep res IN_GRASS_F and calls neither UpdateTallGrassFlags nor
|
|
-- ShakeGrass (movement.asm:741-770); the landing re-latches at the far end.
|
|
local hopGrass = fakeWorld({
|
|
[5 * 100 + 5] = 0xa3, -- COLL_HOP_DOWN, the cell hopped FROM
|
|
[6 * 100 + 5] = 0x07, -- COLL_WALL, the refused single step
|
|
[7 * 100 + 5] = COLL_GRASS, -- the landing
|
|
}, PlayerMod.new(5, 5, "down"), PARTY_ONE)
|
|
hopGrass.entities = { hopGrass.player }
|
|
hopGrass.noWildEncounters = true
|
|
local hp = hopGrass.player
|
|
hp.turnArmed = false
|
|
eq(hopGrass:movePlayer("down"), "moved", "the refused step becomes the hop")
|
|
check(hp.jumping, "and it is a jump")
|
|
check(not hp.grassShake, "no rustle spawns for the airborne cells")
|
|
check(not hp.inGrass, "and IN_GRASS is clear for the whole hop")
|
|
for _ = 1, PlayerMod.STEP_FRAMES + 1 do hopGrass:step() end
|
|
check(hp.inGrass, "the landing tile latches it once the hop ends")
|
|
end
|
|
grassLatchChecks()
|
|
|
|
local function tileAnimChecks()
|
|
-- AnimateWaterTile's `wTileAnimationTimer and %110` (engine/tilesets/
|
|
-- tileset_anims.asm:172-174): four frames, each held for two timer ticks.
|
|
local seq = {}
|
|
for timer = 0, 7 do seq[#seq + 1] = World.waterFrameFor(timer) end
|
|
eq(table.concat(seq, ","), "1,1,2,2,3,3,4,4",
|
|
"the water strip cycles a frame every two ticks")
|
|
eq(World.waterFrameFor(8), World.waterFrameFor(0),
|
|
"StandingTileFrame8 wraps the timer at 8")
|
|
|
|
-- _AnimateTileset runs one row per frame and DoneTileAnimation wraps the
|
|
-- index (tileset_anims.asm:11, :48), so a pass costs `period` frames.
|
|
local world = fakeWorld({}, fakePlayer(5, 5, "down"))
|
|
world.tilesets = { TILESET_JOHTO = { anim = { period = 11 } } }
|
|
for _ = 1, 11 * 2 do world:pollTileAnim() end
|
|
eq(world.animTimer, 2, "two passes of an 11-row program tick the timer twice")
|
|
eq(world.animClock, 0, "and the frame counter is back at the top")
|
|
end
|
|
tileAnimChecks()
|
|
|
|
S.finish()
|