package.path = "./?.lua;" .. package.path local Movement = require "movement" local passed = 0 local function test(name, fn) local ok, err = pcall(fn) if not ok then io.stderr:write("FAIL: " .. name .. "\n" .. err .. "\n") os.exit(1) end passed = passed + 1 end local function assertTrue(value, message) if not value then error(message or "expected true", 2) end end local function assertFalse(value, message) if value then error(message or "expected false", 2) end end local function assertIn(group, entity, message) if group[entity] ~= entity then error(message or "expected entity in movement group", 2) end end local function assertNotIn(group, entity, message) if group[entity] then error(message or "expected entity outside movement group", 2) end end local function movable(x, y, adhesion, shape) local entity = { x = x, y = y, adhesion = adhesion, shape = shape or {{x = 0, y = 0}}, } function entity:canMove() return true end function entity:canPassOver() return false end function entity:isSticky() return self.adhesion == "sticky" end function entity:isGluey() return self.adhesion == "gluey" end function entity:bump() end function entity:move(axis, amount) self[axis] = self[axis] + amount end function entity:getGridPos() return {x = self.x, y = self.y} end function entity:getOccupiedCells(offset) offset = offset or {x = 0, y = 0} local cells = {} for _, cell in ipairs(self.shape) do table.insert(cells, {x = self.x + cell.x + offset.x, y = self.y + cell.y + offset.y}) end return cells end return entity end local function wall(x, y) local entity = movable(x, y) function entity:canMove() return false end return entity end local function world(entities, width, height) width, height = width or 8, height or 8 local cells = {} for _, entity in ipairs(entities) do for _, cell in ipairs(entity:getOccupiedCells()) do cells[cell.y .. ":" .. cell.x] = entity end end return function(cell) return cells[cell.y .. ":" .. cell.x] end, function(cell) return cell.x >= 1 and cell.x <= width and cell.y >= 1 and cell.y <= height end end local function plan(entities, startCell, direction, width, height) local entityAt, inBounds = world(entities, width, height) return Movement.findMovableGroup({startCell}, direction, entityAt, inBounds) end test("ordinary pull only drags the block touching the player", function() -- regular, regular, player; moving right local rear = movable(2, 3) local touching = movable(3, 3) local possible, group = plan({rear, touching}, {x = 3, y = 3}, {x = 1, y = 0}) assertTrue(possible) assertIn(group, touching) assertNotIn(group, rear) end) test("a trailing sticky block joins a horizontal pull", function() -- sticky, regular, player; moving right local sticky = movable(2, 3, "sticky") local regular = movable(3, 3) local possible, group = plan({sticky, regular}, {x = 3, y = 3}, {x = 1, y = 0}) assertTrue(possible) assertIn(group, regular) assertIn(group, sticky) end) test("a moving sticky block pulls a regular block behind it", function() -- regular, sticky, player; moving right local regular = movable(2, 3) local sticky = movable(3, 3, "sticky") local possible, group = plan({regular, sticky}, {x = 3, y = 3}, {x = 1, y = 0}) assertTrue(possible) assertIn(group, sticky) assertIn(group, regular) end) test("sticky pull chains work vertically", function() -- player moves down, pulling the two blocks above it local sticky = movable(4, 2, "sticky") local regular = movable(4, 3) local possible, group = plan({sticky, regular}, {x = 4, y = 3}, {x = 0, y = 1}) assertTrue(possible) assertIn(group, regular) assertIn(group, sticky) end) test("sticky pulls are symmetric in all four directions", function() for _, direction in ipairs({ {x = 1, y = 0}, {x = -1, y = 0}, {x = 0, y = 1}, {x = 0, y = -1}, }) do local player = {x = 4, y = 4} local regular = movable(player.x - direction.x, player.y - direction.y) local sticky = movable(player.x - direction.x * 2, player.y - direction.y * 2, "sticky") local possible, group = plan( {sticky, regular}, {x = regular.x, y = regular.y}, direction ) assertTrue(possible) assertIn(group, regular) assertIn(group, sticky) end end) test("a sticky block grabs a free lateral block during orthogonal movement", function() -- The regular block moves right; the sticky block above it is soft-grabbed on -- that lateral face (sticky pulls on every edge) and follows along. local regular = movable(3, 4) local stickyBranch = movable(3, 3, "sticky") local possible, group = plan({regular, stickyBranch}, {x = 3, y = 4}, {x = 1, y = 0}) assertTrue(possible) assertIn(group, regular) assertIn(group, stickyBranch) end) test("a blocked sticky lateral grab does not stop movement", function() local regular = movable(3, 4) local stickyBranch = movable(3, 3, "sticky") local blockingWall = wall(4, 3) local possible, group = plan({regular, stickyBranch, blockingWall}, {x = 3, y = 4}, {x = 1, y = 0}) assertTrue(possible) assertIn(group, regular) assertNotIn(group, stickyBranch) end) test("a gluey block welds an orthogonal branch", function() local regular = movable(3, 4) local glueyBranch = movable(3, 3, "gluey") local possible, group = plan({regular, glueyBranch}, {x = 3, y = 4}, {x = 1, y = 0}) assertTrue(possible) assertIn(group, regular) assertIn(group, glueyBranch) end) test("a blocked gluey branch stops its rigid movement group", function() local regular = movable(3, 4) local glueyBranch = movable(3, 3, "gluey") local blockingWall = wall(4, 3) local possible = plan({regular, glueyBranch, blockingWall}, {x = 3, y = 4}, {x = 1, y = 0}) assertFalse(possible) end) test("a sticky block pulls both orthogonal side blocks", function() local sticky = movable(4, 4, "sticky") local upper = movable(4, 3) local lower = movable(4, 5) local possible, group = plan( {sticky, upper, lower}, {x = 4, y = 4}, {x = 1, y = 0} ) assertTrue(possible) assertIn(group, sticky) assertIn(group, upper) assertIn(group, lower) end) test("a sticky block releases one blocked side while pulling the other", function() local sticky = movable(4, 4, "sticky") local blockedUpper = movable(4, 3) local freeLower = movable(4, 5) local blockingWall = wall(5, 3) local possible, group = plan( {sticky, blockedUpper, freeLower, blockingWall}, {x = 4, y = 4}, {x = 1, y = 0} ) assertTrue(possible) assertIn(group, sticky) assertNotIn(group, blockedUpper) assertIn(group, freeLower) end) test("ordinary pushing still propagates through regular blocks", function() local first = movable(3, 3) local second = movable(4, 3) local possible, group = plan({first, second}, {x = 3, y = 3}, {x = 1, y = 0}) assertTrue(possible) assertIn(group, first) assertIn(group, second) end) test("diagonal blocks do not stick", function() local regular = movable(3, 3) local diagonalSticky = movable(2, 2, "sticky") local possible, group = plan({regular, diagonalSticky}, {x = 3, y = 3}, {x = 1, y = 0}) assertTrue(possible) assertIn(group, regular) assertNotIn(group, diagonalSticky) end) test("multi-cell sticky blocks pull from every occupied cell", function() local tallRegular = movable(3, 3, nil, {{x = 0, y = 0}, {x = 0, y = 1}}) local sticky = movable(2, 4, "sticky") local possible, group = plan({tallRegular, sticky}, {x = 3, y = 3}, {x = 1, y = 0}) assertTrue(possible) assertIn(group, tallRegular) assertIn(group, sticky) end) test("an L-shaped sticky pulls from its trailing face in every direction", function() local lShape = {{x = 0, y = 0}, {x = 1, y = 0}, {x = 0, y = 1}} local cases = { {direction = {x = 1, y = 0}, trailing = {x = 2, y = 3}}, {direction = {x = -1, y = 0}, trailing = {x = 5, y = 3}}, {direction = {x = 0, y = 1}, trailing = {x = 3, y = 2}}, {direction = {x = 0, y = -1}, trailing = {x = 3, y = 5}}, } for _, case in ipairs(cases) do local stickyL = movable(3, 3, "sticky", lShape) local trailing = movable(case.trailing.x, case.trailing.y) local possible, group = plan( {stickyL, trailing}, {x = 3, y = 3}, case.direction ) assertTrue(possible) assertIn(group, stickyL) assertIn(group, trailing) end end) test("an L-shaped sticky can release one blocked pull while completing another", function() -- Moving right, the two exposed trailing cells touch separate objects. The -- upper object is pinned against a wall; the lower one is free to follow. local stickyL = movable(3, 3, "sticky", { {x = 0, y = 0}, {x = 1, y = 0}, {x = 0, y = 1}, }) local pinned = movable(2, 2, nil, {{x = 0, y = 0}, {x = 0, y = 1}}) local free = movable(2, 4) local blockingWall = wall(3, 2) local possible, group = plan( {stickyL, pinned, free, blockingWall}, {x = 3, y = 3}, {x = 1, y = 0} ) assertTrue(possible) assertIn(group, stickyL) assertIn(group, free) assertNotIn(group, pinned) end) test("a T-shaped sticky pulls independently across a notched trailing face", function() -- Moving up, three exposed lower faces pull into cells vacated by the T. local stickyT = movable(3, 3, "sticky", { {x = 0, y = 0}, {x = 1, y = 0}, {x = 2, y = 0}, {x = 1, y = 1}, }) local trailingLeft = movable(3, 4) local trailingRight = movable(5, 4) local trailingStem = movable(4, 5) local possible, group = plan( {stickyT, trailingLeft, trailingRight, trailingStem}, {x = 3, y = 3}, {x = 0, y = -1} ) assertTrue(possible) assertIn(group, stickyT) assertIn(group, trailingLeft) assertIn(group, trailingRight) assertIn(group, trailingStem) end) test("an L-shaped sticky block grabs moveables across several exposed faces", function() local stickyL = movable(3, 3, "sticky", { {x = 0, y = 0}, {x = 1, y = 0}, {x = 0, y = 1}, }) local above = movable(3, 2) local below = movable(3, 5) local behind = movable(2, 3) local possible, group = plan( {stickyL, above, below, behind}, {x = 3, y = 3}, {x = 1, y = 0} ) assertTrue(possible) assertIn(group, stickyL) assertIn(group, above) assertIn(group, below) assertIn(group, behind) end) test("production moveables advertise adhesion (regular, sticky, gluey)", function() class = require "libs/middleclass" Color = class("TestColor") function Color:initialize(name) self.name = name end function Color:getname() return self.name end require "generic_moveable" local sprite = {getWidth = function() return 16 end, getHeight = function() return 16 end} local regular = GenericMoveable:new(2, 2, "red", sprite, "box") local sticky = GenericMoveable:new(3, 2, "red", sprite, "box", nil, "sticky") local gluey = GenericMoveable:new(4, 2, "red", sprite, "box", nil, "gluey") assertTrue(regular:canMove()) assertFalse(regular:isSticky()) assertFalse(regular:isGluey()) assertTrue(sticky:canMove()) assertTrue(sticky:isSticky()) assertFalse(sticky:isGluey()) assertTrue(gluey:canMove()) assertFalse(gluey:isSticky()) assertTrue(gluey:isGluey()) end) local function loadRoomClass() love = { audio = { newSource = function() return {stop = function() end, play = function() end} end, }, } -- room pulls in note.lua/mark.lua, which call loadSprite at module-load time. -- Provide a default stub so requiring room works; individual tests still -- override loadSprite as needed. loadSprite = loadSprite or function() return {getWidth = function() return 16 end, getHeight = function() return 16 end} end require "room" end local function roomWith(player, entities, width, height) gridWidth, gridHeight = width or 8, height or 8 local matrix = {} local blockerMatrix = {} for y = 1, gridHeight do matrix[y] = {} blockerMatrix[y] = {} for x = 1, gridWidth do matrix[y][x] = false end for x = 1, gridWidth do blockerMatrix[y][x] = false end end for _, entity in ipairs(entities) do for _, cell in ipairs(entity:getOccupiedCells()) do matrix[cell.y][cell.x] = entity end end local room = setmetatable({ activeColors = {red = true}, colorsPlayerHas = {"red"}, player = {red = player}, collidableMatrices = {red = matrix}, playerBlockerMatrices = {red = blockerMatrix}, }, {__index = Room}) room.playerJoinCheck = function() end room.tick = function() end return room end test("Room keeps player-only blockers out of block collision", function() loadRoomClass() local oldAssets, oldLoadSprite = globalAssetProperties, loadSprite local sprite = {getWidth = function() return 16 end, getHeight = function() return 16 end} globalAssetProperties = { player_blocker = { class = "player_blocker", sprites = {red = "player_blocker_r.png"}, cells = {red = {{x = 1, y = 1}}}, }, } loadSprite = function() return sprite end local room = setmetatable({ collideables = {red = {}, green = {}, blue = {}}, playerBlockers = {red = {}, green = {}, blue = {}}, }, {__index = Room}) local blocker = room:createEntity("player_blocker", "red", 4, 3) assertTrue(blocker:blocksPlayer()) assertTrue(#room.playerBlockers.red == 1) assertTrue(#room.collideables.red == 0, "blockers must not enter block collision") globalAssetProperties, loadSprite = oldAssets, oldLoadSprite end) test("Room player-only blockers stop the player but do not stop a box", function() loadRoomClass() local player = movable(2, 3) local box = movable(3, 3) local blocker = movable(4, 3) function blocker:blocksPlayer() return true end local room = roomWith(player, {box}, 8, 8) local blockerMatrix = {} for y = 1, gridHeight do blockerMatrix[y] = {} for x = 1, gridWidth do blockerMatrix[y][x] = false end end blockerMatrix[3][4] = blocker room.playerBlockerMatrices.red = blockerMatrix room:movePlayer("right") assertTrue(box.x == 4, "box should move onto the player-only blocker") assertTrue(player.x == 3, "player should move while pushing the box through it") room:movePlayer("right") assertTrue(player.x == 3, "player should be stopped by the player-only blocker") end) test("Room applies the sticky-regular-player pull", function() loadRoomClass() local sticky = movable(2, 3, "sticky") local regular = movable(3, 3) local player = movable(4, 3) local room = roomWith(player, {sticky, regular}) room:movePlayer("right") assertTrue(sticky.x == 3, "sticky block should move one cell right") assertTrue(regular.x == 4, "regular block should move one cell right") assertTrue(player.x == 5, "player should move one cell right") end) test("Room pulls sticky out of a pinned concave moveable", function() loadRoomClass() -- Before: ___ After: p__ -- px_ sx_ -- sm_ _m_ -- mm_ mm_ local player = movable(1, 2) local wall = wall(2, 2) local sticky = movable(1, 3, "sticky") local concave = movable(1, 3, nil, { {x = 1, y = 0}, {x = 0, y = 1}, {x = 1, y = 1}, }) local room = roomWith(player, {wall, sticky, concave}) room:movePlayer("up") assertTrue(player.x == 1 and player.y == 1) assertTrue(sticky.x == 1 and sticky.y == 2, "sticky should peel away from the concave block") assertTrue(concave.x == 1 and concave.y == 3, "pinned concave block should stay put") assertTrue(wall.x == 2 and wall.y == 2) end) test("Room moves an L-shaped sticky and both available trailing pulls", function() loadRoomClass() local player = movable(3, 2) local stickyL = movable(3, 3, "sticky", { {x = 0, y = 0}, {x = 1, y = 0}, {x = 0, y = 1}, }) local trailingLeft = movable(3, 5) local trailingRight = movable(4, 4) local room = roomWith(player, {stickyL, trailingLeft, trailingRight}) room:movePlayer("up") assertTrue(player.x == 3 and player.y == 1) assertTrue(stickyL.x == 3 and stickyL.y == 2) assertTrue(trailingLeft.x == 3 and trailingLeft.y == 4) assertTrue(trailingRight.x == 4 and trailingRight.y == 3) end) test("Room never moves a component found by push and pull twice", function() loadRoomClass() local player = movable(4, 4) local front = movable(5, 4) local bridgeRight = movable(5, 3, "gluey") local bridgeMiddle = movable(4, 3, "gluey") local bridgeLeft = movable(3, 3, "gluey") local rear = movable(3, 4) local component = {front, bridgeRight, bridgeMiddle, bridgeLeft, rear} local room = roomWith(player, component) room:movePlayer("right") for _, entity in ipairs(component) do assertTrue(entity.x <= 6, "an entity in the shared component moved more than once") end assertTrue(front.x == 6) assertTrue(bridgeRight.x == 6) assertTrue(bridgeMiddle.x == 5) assertTrue(bridgeLeft.x == 4) assertTrue(rear.x == 4) assertTrue(player.x == 5) end) test("Room pushes sticky onward while a blocked side shears off", function() loadRoomClass() local player = movable(3, 4) local sticky = movable(4, 4, "sticky") local blockedUpper = movable(4, 3) local freeLower = movable(4, 5) local blockingWall = wall(5, 3) local room = roomWith(player, {sticky, blockedUpper, freeLower, blockingWall}) room:movePlayer("right") assertTrue(player.x == 4 and player.y == 4) assertTrue(sticky.x == 5 and sticky.y == 4) assertTrue(blockedUpper.x == 4 and blockedUpper.y == 3) assertTrue(freeLower.x == 5 and freeLower.y == 5) assertTrue(blockingWall.x == 5 and blockingWall.y == 3) end) test("legacy box-variant classes load as adhesion on a plain moveable", function() loadRoomClass() local oldAssets, oldLoadSprite = globalAssetProperties, loadSprite local sprite = {getWidth = function() return 16 end, getHeight = function() return 16 end} globalAssetProperties = { box = { class = "moveable", sprites = {red = "box_r.png"}, cells = {red = {{x = 1, y = 1}}} }, } loadSprite = function() return sprite end local room = setmetatable({ collideables = {red = {}, green = {}, blue = {}}, playerBlockers = {red = {}, green = {}, blue = {}}, }, {__index = Room}) -- Old .sav files baked adhesion into the class name; loading must collapse them -- onto the base box carrying the new adhesion property. local fromSpiky = room:createEntity("spiky_box", "red", 2, 2) local fromSkewer = room:createEntity("skewer_box", "red", 3, 2) local fromSticky = room:createEntity("sticky_box", "red", 4, 2) assertTrue(fromSpiky:isSticky(), "legacy spiky_ should load as sticky") assertTrue(fromSkewer:isGluey(), "legacy skewer_ should load as gluey") assertTrue(fromSticky:isSticky(), "legacy sticky_ should load as sticky") assertTrue(fromSpiky.saveClass == "box", "class should normalize to the base box") -- An explicit adhesion in the object data wins and rides through on a plain box. local placed = room:createEntity("box", "red", 5, 2, { adhesion = "gluey" }) assertTrue(placed:isGluey()) assertTrue(placed.saveClass == "box") globalAssetProperties, loadSprite = oldAssets, oldLoadSprite end) print("movement tests passed: " .. passed)