Files
gen1recomp/tests/gen2_whirlpool_test.lua
T
bryanthaboi 1905261c5b Spider Car Unleashed
CLOSES #1483, CLOSES #1610, CLOSES #1615, CLOSES #1646, CLOSES #1649, CLOSES #1651, CLOSES #1653, CLOSES #1656, CLOSES #1683, CLOSES #1685, CLOSES #1686, CLOSES #1687, CLOSES #1688, CLOSES #1689, CLOSES #1690, CLOSES #1693, CLOSES #1694, CLOSES #1695, CLOSES #1696, CLOSES #1702, CLOSES #1704, CLOSES #1705, CLOSES #1706, CLOSES #1707, CLOSES #1708, CLOSES #1710, CLOSES #1711, CLOSES #1712, CLOSES #1713, CLOSES #1716, CLOSES #1717, CLOSES #1718, CLOSES #1719, CLOSES #1720, CLOSES #1721, CLOSES #1725, CLOSES #1732, CLOSES #1745, CLOSES #1748, CLOSES #1749, CLOSES #1751, CLOSES #1754
2026-08-24 07:52:05 -04:00

230 lines
9.8 KiB
Lua

-- Whirlpool tiles force-turn the player: DoPlayerMovement's .CheckTile, the
-- CheckWhirlpoolTile arm that sits ABOVE the nybble ladder.
--
-- luajit tests/gen2_whirlpool_test.lua
--
-- COLL_WHIRLPOOL is tested first and takes PLAYERMOVEMENT_FORCE_TURN
-- (engine/overworld/player_movement.asm:117-123), which runs Script_ForcedMovement
-- (engine/events/forced_movement.asm:1-51): step_dig 16, turn_in <back>,
-- step_dig 16, turn_head <back>, step_end. So a whirlpool is entered, whirled
-- on, and left again the way the player came, with the d-pad ignored throughout.
--
-- The port had no arm at all -- only the HI_NYBBLE_CURRENT and HI_NYBBLE_WARPS
-- ones -- so a whirlpool cell was plain water and a surfer swam straight over
-- it, past the HM06 + GLACIERBADGE gate on Route 41 and Route 27. Two decoding
-- gaps sat under that: turn_away / turn_in / turn_waterfall all `jp TurningStep`
-- (engine/overworld/movement.asm:483-513), which is a real one-cell STEP under
-- OBJECT_ACTION_SPIN, and $4f step_dig was not modelled at all. #1716
--
-- The whirl itself is drawing, so what is asserted here is the state the
-- drawing reads (Player.spinFrames) and the grid the player ends on.
-- tests/drivers/gold_whirlpool_forceturn_bug1716_test.lua is the other half.
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 whirlpool")
local check, eq, same = S.check, S.eq, S.same
local Movement = require("src.script.gen2.Movement")
local Permissions = require("src.world.gen2.Permissions")
local World = require("src.world.gen2.World")
local Player = require("src.world.gen2.Player")
local FieldMoves = require("src.world.gen2.FieldMoves")
-- ---- the tile --------------------------------------------------------------
--
-- CheckWhirlpoolTile is the two-entry compare COLL_WHIRLPOOL $24 / COLL_WHIRLPOOL_1 $2c.
local COLL_WATER, COLL_WHIRLPOOL, COLL_WHIRLPOOL_1 = 0x29, 0x24, 0x2c
check(Permissions.isWhirlpool(COLL_WHIRLPOOL), "COLL_WHIRLPOOL $24")
check(Permissions.isWhirlpool(COLL_WHIRLPOOL_1), "COLL_WHIRLPOOL_1 $2c")
check(not Permissions.isWhirlpool(COLL_WATER), "plain water is not one")
check(not Permissions.isWhirlpool(0x33), "and neither is a waterfall")
-- The arm is above the nybble ladder, not part of it: the tile's PERMISSION is
-- still WATER_TILE, which is what lets a surfing player step onto it at all.
check(Permissions.isWater(COLL_WHIRLPOOL),
"a whirlpool is still WATER_TILE, so .CheckSurfable lets the step happen")
eq(Permissions.currentDirection(COLL_WHIRLPOOL), nil,
"and it is not a HI_NYBBLE_CURRENT tile")
-- ---- Script_ForcedMovement's stream ----------------------------------------
--
-- .MovementData_up is `step_dig 16 / turn_in DOWN / step_dig 16 /
-- turn_head DOWN / step_end`, i.e. every byte points BACK the way the player
-- came. turn_head is the $00 family, turn_in the $24 one, step_dig $4f with
-- its frame count in the byte after it (macros/scripts/movement.asm:163-167).
eq(Movement.STEP_DIG, 0x4f, "$4f step_dig")
eq(Movement.STEP_DIG_FRAMES, 16, "and the 16 frames the stream asks it for")
same(Movement.forcedMovementBytes("down"),
{ 0x4f, 16, 0x24, 0x4f, 16, 0x00, 0x47 },
"thrown DOWN: step_dig 16, turn_in DOWN, step_dig 16, turn_head DOWN, end")
same(Movement.forcedMovementBytes("up"),
{ 0x4f, 16, 0x25, 0x4f, 16, 0x01, 0x47 }, "thrown UP")
same(Movement.forcedMovementBytes("left"),
{ 0x4f, 16, 0x26, 0x4f, 16, 0x02, 0x47 }, "thrown LEFT")
same(Movement.forcedMovementBytes("right"),
{ 0x4f, 16, 0x27, 0x4f, 16, 0x03, 0x47 }, "thrown RIGHT")
-- The middle byte is the one the port used to read as a facing change, which
-- is why the bounce could not exist: turn_in never moved anybody.
for byte, dir in pairs({ [0x24] = "down", [0x25] = "up",
[0x26] = "left", [0x27] = "right" }) do
local act = Movement.decodeByte(byte)
eq(act.kind, "step", ("$%02x turn_in crosses a cell"):format(byte))
eq(act.dir, dir, ("$%02x turn_in direction"):format(byte))
eq(act.spin, true, ("$%02x turn_in spins on the way"):format(byte))
end
eq(Movement.decodeByte(0x20).kind, "step", "$20 turn_away is a step too")
eq(Movement.decodeByte(0x28).kind, "step", "$28 turn_waterfall as well")
-- The neighbours must not have picked the spin up.
eq(Movement.decodeByte(0x0c).spin, nil, "$0c step is a plain step")
eq(Movement.decodeByte(0x10).spin, nil, "$10 big_step too")
eq(Movement.decodeByte(0x00).kind, "turn", "$00 turn_head is still a turn")
-- ---- the step --------------------------------------------------------------
--
-- One whirlpool at (5,5) in an open pond, the way Route 41's four sit: water on
-- every side of them (data/generated/maps.lua, ROUTE_41 (22,12)).
local WHIRL_X, WHIRL_Y = 5, 5
local function whirlWorld(px, py, facing, coll)
local game = {
data = { items = {}, moves = {}, pokemon = {} },
save = { player = { name = "GOLD", badges = {} }, party = {},
inventory = {} },
}
local world = World.new(game)
game.world = world
local cells = {}
for y = 0, 9 do
for x = 0, 9 do cells[y * 100 + x] = COLL_WATER end
end
cells[WHIRL_Y * 100 + WHIRL_X] = coll or COLL_WHIRLPOOL
local map
map = {
id = "ROUTE_41",
width = 5, height = 5,
def = { objects = {}, bgEvents = {}, environment = "WATER",
tileset = "TILESET_JOHTO", width = 5, height = 5 },
cellCollision = function(_, x, y)
return cells[y * 100 + x] or COLL_WATER
end,
inBounds = function(_, x, y)
return x >= 0 and y >= 0 and x < 10 and y < 10
end,
isWalkable = function(_, x, y)
return Permissions.isWalkable(map:cellCollision(x, y))
end,
warpAt = function() return nil end,
connection = function() return nil end,
}
world.map = map
world.maps = { ROUTE_41 = map.def }
world.player = Player.new(px, py, facing)
world.entities = { world.player }
world.npcs = {}
world.playerState = FieldMoves.PLAYER_SURF
world.encounters = {}
world.pollTimeOfDay = function() end
-- Nothing here draws, and nothing here is about the wild roll.
world.noWildEncounters = true
world.updatePeople = function() end
return world, game
end
-- Swim at the whirlpool from `dir` for `frames` with the d-pad held the whole
-- way, the way a player leaning on it does, and report what happened.
local function swimInto(dir, frames)
local DELTA = { up = { 0, -1 }, down = { 0, 1 },
left = { -1, 0 }, right = { 1, 0 } }
local d = DELTA[dir]
-- Two cells short of the whirlpool, so the approach is an ordinary swim.
local world = whirlWorld(WHIRL_X - d[1] * 2, WHIRL_Y - d[2] * 2, dir)
local p = world.player
local r = { onWhirlpool = false, spun = false, busy = false, past = false }
for _ = 1, frames or 220 do
world.heldDir = dir
world:step()
if p.cellX == WHIRL_X and p.cellY == WHIRL_Y then r.onWhirlpool = true end
if p.spinFrames then r.spun = true end
if world:busy() then r.busy = true end
-- One cell past the whirlpool, on the far side.
if p.cellX == WHIRL_X + d[1] and p.cellY == WHIRL_Y + d[2] then
r.past = true
end
end
r.x, r.y, r.facing = p.cellX, p.cellY, p.facing
return r, world
end
-- The cart lets the player land on the whirlpool: FORCE_TURN is answered from
-- the tile UNDERFOOT, so the step onto it happens and the bounce follows.
local BACK = { up = "down", down = "up", left = "right", right = "left" }
for _, dir in ipairs({ "up", "down", "left", "right" }) do
local r = swimInto(dir)
check(r.onWhirlpool,
("swimming %s reaches the whirlpool cell"):format(dir))
check(not r.past,
("holding %s never carries the player through it"):format(dir))
check(r.busy,
("the forced movement owns the world while it runs (%s)"):format(dir))
check(r.spun,
("step_dig put the player under OBJECT_ACTION_SPIN (%s)"):format(dir))
eq(r.facing, BACK[dir],
("turn_head leaves the player facing away from it (%s)"):format(dir))
end
-- COLL_WHIRLPOOL_1 is the same arm, not a second tile that fell through.
do
local world = whirlWorld(WHIRL_X, WHIRL_Y + 2, "up", COLL_WHIRLPOOL_1)
local p = world.player
local past = false
for _ = 1, 220 do
world.heldDir = "up"
world:step()
if p.cellY < WHIRL_Y then past = true end
end
check(not past, "$2c force-turns exactly like $24")
end
-- The negative control, and the reason the bug was invisible: the same cell as
-- plain water is swum straight over.
do
local world = whirlWorld(WHIRL_X, WHIRL_Y + 2, "up", COLL_WATER)
local p = world.player
for _ = 1, 220 do
world.heldDir = "up"
world:step()
end
check(p.cellY < WHIRL_Y, "plain water in the same cell still lets us through")
eq(p.spinFrames, nil, "and nothing spins over it")
end
-- The turn is answered from the tile the player is STANDING on, not from a
-- neighbour: swimming past a whirlpool one cell to the side is untouched.
do
local world = whirlWorld(WHIRL_X - 1, WHIRL_Y + 2, "up")
local p = world.player
for _ = 1, 220 do
world.heldDir = "up"
world:step()
end
eq(p.cellX, WHIRL_X - 1, "the column beside the whirlpool is ordinary water")
check(p.cellY < WHIRL_Y, "and it is swum up without a bounce")
end
-- The stream is Script_ForcedMovement's own, byte for byte, and it is queued
-- through the same World:beginMovement every applymovement uses -- so the
-- freeze, the follower and the step-end bookkeeping all come along with it.
do
local world = whirlWorld(WHIRL_X, WHIRL_Y, "up")
check(world:runForcedMovement(), "standing on one starts the stream")
check(world.moveState ~= nil, "through an applymovement, not a bespoke path")
same(world.moveState.bytes, Movement.forcedMovementBytes("down"),
"and the bytes are the ones facing UP asks for")
check(world:busy(), "which is what closes the overworld to input")
-- It does not start a second copy over itself.
check(not world:runForcedMovement(), "one stream at a time")
end
S.finish()