chroma_solstice/tests/test_movement.lua
2026-08-11 20:08:58 -04:00

548 lines
18 KiB
Lua

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, sticky, shape, skewer, spiky)
local entity = {
x = x, y = y,
sticky = sticky or false,
skewer = skewer or false,
spiky = spiky or false,
shape = shape or {{x = 0, y = 0}},
}
function entity:canMove() return true end
function entity:canPassOver() return false end
function entity:isSticky() return self.sticky end
function entity:isSkewer() return self.skewer end
function entity:isSpiky() return self.spiky 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, true)
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, true)
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, true)
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, true)
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 shears off during orthogonal movement", function()
-- The regular block moves right; the sticky block above it stays behind.
local regular = movable(3, 4)
local stickyBranch = movable(3, 3, true)
local possible, group = plan({regular, stickyBranch}, {x = 3, y = 4}, {x = 1, y = 0})
assertTrue(possible)
assertIn(group, regular)
assertNotIn(group, stickyBranch)
end)
test("a blocked sheared sticky block does not stop movement", function()
local regular = movable(3, 4)
local stickyBranch = movable(3, 3, true)
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 skewer carries an orthogonal branch", function()
local regular = movable(3, 4)
local skewerBranch = movable(3, 3, false, nil, true)
local possible, group = plan({regular, skewerBranch}, {x = 3, y = 4}, {x = 1, y = 0})
assertTrue(possible)
assertIn(group, regular)
assertIn(group, skewerBranch)
end)
test("a blocked skewer branch stops its rigid movement group", function()
local regular = movable(3, 4)
local skewerBranch = movable(3, 3, false, nil, true)
local blockingWall = wall(4, 3)
local possible = plan({regular, skewerBranch, blockingWall}, {x = 3, y = 4}, {x = 1, y = 0})
assertFalse(possible)
end)
test("a spiky block pulls both orthogonal side blocks", function()
local spiky = movable(4, 4, false, nil, false, true)
local upper = movable(4, 3)
local lower = movable(4, 5)
local possible, group = plan(
{spiky, upper, lower}, {x = 4, y = 4}, {x = 1, y = 0}
)
assertTrue(possible)
assertIn(group, spiky)
assertIn(group, upper)
assertIn(group, lower)
end)
test("a spiky block releases one blocked side while pulling the other", function()
local spiky = movable(4, 4, false, nil, false, true)
local blockedUpper = movable(4, 3)
local freeLower = movable(4, 5)
local blockingWall = wall(5, 3)
local possible, group = plan(
{spiky, blockedUpper, freeLower, blockingWall},
{x = 4, y = 4}, {x = 1, y = 0}
)
assertTrue(possible)
assertIn(group, spiky)
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, true)
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 trailing occupied cell", function()
local tallRegular = movable(3, 3, false, {{x = 0, y = 0}, {x = 0, y = 1}})
local sticky = movable(2, 4, true)
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, true, 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, true, {
{x = 0, y = 0}, {x = 1, y = 0}, {x = 0, y = 1},
})
local pinned = movable(2, 2, false, {{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, true, {
{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 spiky block grabs moveables across several exposed faces", function()
local spikyL = movable(3, 3, false, {
{x = 0, y = 0}, {x = 1, y = 0}, {x = 0, y = 1},
}, false, true)
local above = movable(3, 2)
local below = movable(3, 5)
local behind = movable(2, 3)
local possible, group = plan(
{spikyL, above, below, behind},
{x = 3, y = 3}, {x = 1, y = 0}
)
assertTrue(possible)
assertIn(group, spikyL)
assertIn(group, above)
assertIn(group, below)
assertIn(group, behind)
end)
test("production sticky, skewer, and spiky blocks advertise distinct behavior", 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"
require "generic_sticky"
require "generic_skewer"
require "generic_spiky"
local sprite = {getWidth = function() return 16 end, getHeight = function() return 16 end}
local regular = GenericMoveable:new(2, 2, "red", sprite, "box")
local sticky = GenericSticky:new(3, 2, "red", sprite, "sticky_box")
local skewer = GenericSkewer:new(4, 2, "red", sprite, "skewer_box")
local spiky = GenericSpiky:new(5, 2, "red", sprite, "spiky_box")
assertTrue(regular:canMove())
assertFalse(regular:isSticky())
assertTrue(sticky:canMove())
assertTrue(sticky:isSticky())
assertFalse(sticky:isSkewer())
assertTrue(skewer:canMove())
assertFalse(skewer:isSticky())
assertTrue(skewer:isSkewer())
assertFalse(skewer:isSpiky())
assertTrue(spiky:canMove())
assertFalse(spiky:isSticky())
assertFalse(spiky:isSkewer())
assertTrue(spiky:isSpiky())
end)
local function loadRoomClass()
love = {
audio = {
newSource = function()
return {stop = function() end, play = function() end}
end,
},
}
require "room"
end
local function roomWith(player, entities, width, height)
gridWidth, gridHeight = width or 8, height or 8
local matrix = {}
for y = 1, gridHeight do
matrix[y] = {}
for x = 1, gridWidth do matrix[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},
}, {__index = Room})
room.playerJoinCheck = function() end
room.tick = function() end
return room
end
test("Room applies the sticky-regular-player pull", function()
loadRoomClass()
local sticky = movable(2, 3, true)
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, true)
local concave = movable(1, 3, false, {
{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, true, {
{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 shears a lateral sticky contact", function()
loadRoomClass()
local sticky = movable(3, 2, true)
local regular = movable(3, 3)
local player = movable(4, 3)
local room = roomWith(player, {sticky, regular})
room:movePlayer("right")
assertTrue(sticky.x == 3, "lateral sticky block should shear and stay behind")
assertTrue(regular.x == 4)
assertTrue(player.x == 5)
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, false, nil, true)
local bridgeMiddle = movable(4, 3, false, nil, true)
local bridgeLeft = movable(3, 3, false, nil, true)
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 spiky onward while a blocked side shears off", function()
loadRoomClass()
local player = movable(3, 4)
local spiky = movable(4, 4, false, nil, false, true)
local blockedUpper = movable(4, 3)
local freeLower = movable(4, 5)
local blockingWall = wall(5, 3)
local room = roomWith(player, {spiky, blockedUpper, freeLower, blockingWall})
room:movePlayer("right")
assertTrue(player.x == 4 and player.y == 4)
assertTrue(spiky.x == 5 and spiky.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("every moveable base box gets all three synthetic behavior variants", function()
local BoxVariants = require "box_variants"
local boxSprites = {red = "box_r.png"}
local boxCells = {red = {{x = 1, y = 1}}}
local shapeSprites = {blue = "box_shape_b.png"}
local shapeCells = {blue = {{x = 1, y = 1}, {x = 2, y = 1}}}
local lSprites = {green = "box_l_g.png"}
local lCells = {green = {{x = 1, y = 1}, {x = 1, y = 2}, {x = 2, y = 2}}}
local assets = {
box = {class = "moveable", sprites = boxSprites, cells = boxCells},
["box_2x1_11"] = {class = "moveable", sprites = shapeSprites, cells = shapeCells},
["box_2x2_10-11"] = {class = "moveable", sprites = lSprites, cells = lCells},
box_statue = {class = "immoveable", sprites = {}},
beer_mug = {class = "moveable", sprites = {}},
}
BoxVariants.addTo(assets)
assertTrue(assets.sticky_box.class == "sticky")
assertTrue(assets.sticky_box.directory == "sticky")
assertTrue(assets.sticky_box.synthetic)
assertTrue(assets.sticky_box.goopy)
assertTrue(assets.sticky_box.sprites == boxSprites, "variant should share base sprites")
assertTrue(assets.sticky_box.cells == boxCells, "variant should share collision cells")
assertTrue(assets["sticky_box_2x1_11"].sprites == shapeSprites)
assertTrue(assets["sticky_box_2x1_11"].cells == shapeCells)
assertTrue(assets["sticky_box_2x2_10-11"].sprites == lSprites)
assertTrue(assets["sticky_box_2x2_10-11"].cells == lCells)
assertTrue(assets.skewer_box.class == "skewer")
assertTrue(assets.skewer_box.directory == "skewer")
assertTrue(assets.skewer_box.synthetic)
assertTrue(assets.skewer_box.skewer)
assertTrue(assets.skewer_box.sprites == boxSprites)
assertTrue(assets.skewer_box.cells == boxCells)
assertTrue(assets["skewer_box_2x1_11"].sprites == shapeSprites)
assertTrue(assets["skewer_box_2x2_10-11"].cells == lCells)
assertTrue(assets.spiky_box.class == "spiky")
assertTrue(assets.spiky_box.directory == "spiky")
assertTrue(assets.spiky_box.synthetic)
assertTrue(assets.spiky_box.spiky)
assertTrue(assets.spiky_box.sprites == boxSprites)
assertTrue(assets.spiky_box.cells == boxCells)
assertTrue(assets["spiky_box_2x1_11"].sprites == shapeSprites)
assertTrue(assets["spiky_box_2x2_10-11"].cells == lCells)
assertFalse(assets.sticky_box_statue)
assertFalse(assets.sticky_beer_mug)
assertFalse(assets.skewer_box_statue)
assertFalse(assets.skewer_beer_mug)
assertFalse(assets.spiky_box_statue)
assertFalse(assets.spiky_beer_mug)
end)
print("movement/block-variant tests passed: " .. passed)