Files
Towbar/tests/persistence-handlers-42.20-spec.lua
2026-08-19 12:12:12 -04:00

307 lines
11 KiB
Lua

local failures = 0
local function expect(condition, message)
if not condition then
failures = failures + 1
io.stderr:write("FAIL: " .. message .. "\n")
end
end
package.path = "42.20/media/lua/shared/?.lua;" .. package.path
local globalData = {}
ModData = {
getOrCreate = function(name)
globalData[name] = globalData[name] or {}
return globalData[name]
end,
transmit = function() end
}
TowBarMod = {}
require("TowBar/Persistence")
local function collection(items)
return {
iterator = function()
local index = 0
return {
hasNext = function() return index < #items end,
next = function()
index = index + 1
return items[index]
end
}
end
}
end
local function newMetrics()
return {
addConstraint = 0,
breakConstraint = 0,
attachSync = 0,
detachSync = 0,
worldDrops = 0
}
end
local function newVehicle(id, sqlId, metrics)
local vehicle = {
id = id,
sqlId = sqlId,
modData = {},
towing = nil,
towedBy = nil
}
function vehicle:getId() return self.id end
function vehicle:getSqlId() return self.sqlId end
function vehicle:getModData() return self.modData end
function vehicle:transmitModData() end
function vehicle:getVehicleTowing() return self.towing end
function vehicle:getVehicleTowedBy() return self.towedBy end
function vehicle:attachmentExist() return true end
function vehicle:addPointConstraint()
-- Deliberately do not establish the link. Build 42 may acknowledge a
-- new native constraint on a later tick.
metrics.addConstraint = metrics.addConstraint + 1
end
function vehicle:breakConstraint()
metrics.breakConstraint = metrics.breakConstraint + 1
self.towing = nil
self.towedBy = nil
end
function vehicle:getSquare()
return {
AddWorldInventoryItem = function()
metrics.worldDrops = metrics.worldDrops + 1
return {}
end
}
end
return vehicle
end
local function linkPair(towing, towed)
towing.towing = towed
towing.towedBy = nil
towed.towing = nil
towed.towedBy = towing
end
local function unlinkPair(towing, towed)
towing.towing = nil
towing.towedBy = nil
towed.towing = nil
towed.towedBy = nil
end
local function installEvents()
local callbacks = {}
Events = {
OnClientCommand = {
Add = function(callback) callbacks.clientCommand = callback end
},
OnTick = {
Add = function(callback) callbacks.tick = callback end
}
}
return callbacks
end
local function fireTicks(callback, count)
for _ = 1, count do callback() end
end
local function resetRegistry()
globalData["TowBar.PersistentPairsV2"] = { pairs = {} }
end
local function runPortableHandlerSpec()
resetRegistry()
local metrics = newMetrics()
local callbacks = installEvents()
local now = 1000
local towing = newVehicle(101, 1001, metrics)
local towed = newVehicle(202, 2002, metrics)
local loaded = { towing, towed }
local byId = { [101] = towing, [202] = towed }
isClient = function() return false end
isServer = function() return true end
getDebug = function() return false end
getTimestampMs = function() return now end
getCell = function()
return { getVehicles = function() return collection(loaded) end }
end
getVehicleById = function(id) return byId[id] end
sendServerCommand = function(_, command)
if command == "forceAttachSync" then
metrics.attachSync = metrics.attachSync + 1
elseif command == "forceDetachSync" or command == "spontaneousDetachSync" then
metrics.detachSync = metrics.detachSync + 1
end
end
expect(
TowBarMod.Persistence.savePair(
"towbar", towing, towed, "trailer", "trailerfront"
),
"portable setup must persist a stable pair"
)
dofile("42.20/media/lua/server/TowingCommands.lua")
expect(type(callbacks.tick) == "function", "portable server must register its audit tick")
fireTicks(callbacks.tick, 5)
expect(metrics.addConstraint == 1, "portable restart must request one native restore")
expect(metrics.attachSync == 1,
"portable restart must immediately synchronize one wrecker-style rigid restore")
-- The restore is queued before the native call. Once Build 42 exposes the
-- reciprocal relation, the pending confirmation broadcasts exactly once.
linkPair(towing, towed)
now = 1100
fireTicks(callbacks.tick, 1)
expect(metrics.addConstraint == 1,
"portable confirmation must adopt the first native add without re-adding")
expect(metrics.attachSync == 1,
"portable native acknowledgement must not introduce a second sync phase")
now = 1500
fireTicks(callbacks.tick, 4)
expect(metrics.addConstraint == 1, "portable pending restore must not duplicate its constraint")
expect(metrics.attachSync == 1, "portable pending restore must not duplicate its sync")
now = 2000
fireTicks(callbacks.tick, 5)
expect(metrics.addConstraint == 1, "portable delayed acknowledgement must be adopted without re-adding")
expect(towing.modData.towBarTowedVehicleId == towed:getId(), "portable adoption must refresh the towing live ID")
expect(towed.modData.towBarTowingVehicleId == towing:getId(), "portable adoption must refresh the towed live ID")
unlinkPair(towing, towed)
loaded = { towing }
byId[202] = nil
now = 10000
fireTicks(callbacks.tick, 5)
now = 16000
fireTicks(callbacks.tick, 5)
expect(metrics.worldDrops == 0, "an unloaded portable peer must not drop a towbar")
expect(metrics.detachSync == 0, "an unloaded portable peer must not broadcast destructive cleanup")
expect(towing.modData.isTowingByTowBar == true, "an unloaded portable peer must keep active metadata")
expect(towing.modData.towBarTowedVehicleSqlId == towed:getSqlId(), "an unloaded portable peer must keep stable identity")
loaded = { towing, towed }
byId[202] = towed
now = 17000
fireTicks(callbacks.tick, 5)
expect(metrics.addConstraint == 2, "a reloaded portable peer must re-enter recovery")
expect(metrics.attachSync == 2,
"a reloaded portable peer must receive one fresh wrecker-style restore sync")
linkPair(towing, towed)
now = 17100
fireTicks(callbacks.tick, 1)
expect(metrics.addConstraint == 2,
"reloaded portable confirmation must adopt without another native add")
expect(metrics.attachSync == 2,
"reloaded portable native acknowledgement must not duplicate its sync")
now = 17500
fireTicks(callbacks.tick, 4)
expect(metrics.addConstraint == 2,
"confirmed reloaded pair must not duplicate its native add")
expect(metrics.attachSync == 2,
"confirmed reloaded pair must not duplicate its authoritative sync")
expect(metrics.worldDrops == 0, "portable peer reload must not be mistaken for a break")
expect(metrics.breakConstraint == 0, "portable peer reload must not break an unrelated constraint")
end
local function runWreckerHandlerSpec()
resetRegistry()
local metrics = newMetrics()
local callbacks = installEvents()
local now = 1000
local wrecker = newVehicle(303, 3003, metrics)
local target = newVehicle(404, 4004, metrics)
local loaded = { wrecker, target }
local byId = { [303] = wrecker, [404] = target }
TowBarMod.Wrecker = {
getWorldVehicles = function() return collection(loaded) end,
isSupportedWrecker = function(vehicle) return vehicle == wrecker end,
normalizeHeightLevel = function(level)
return math.max(0, math.min(2, tonumber(level) or 0))
end,
getHeightAttachmentId = function(level)
return ({ [0] = "towbarWreckerHookLow", [1] = "towbarWreckerHookMid", [2] = "towbarWreckerHookHigh" })[level]
end
}
isClient = function() return false end
isServer = function() return true end
getTimestampMs = function() return now end
getVehicleById = function(id) return byId[id] end
sendServerCommand = function(_, command)
if command == "wreckerAttachSync" then
metrics.attachSync = metrics.attachSync + 1
elseif command == "wreckerDetachSync" then
metrics.detachSync = metrics.detachSync + 1
end
end
expect(
TowBarMod.Persistence.savePair(
"wrecker", wrecker, target,
"towbarWreckerHookHigh", "trailerfront", 2
),
"wrecker setup must persist a stable pair"
)
dofile("42.20/media/lua/server/WreckerCommands.lua")
expect(type(callbacks.tick) == "function", "wrecker server must register its audit tick")
fireTicks(callbacks.tick, 30)
expect(metrics.addConstraint == 1, "wrecker restart must request one native restore")
expect(metrics.attachSync == 1, "wrecker restart must broadcast one restore")
now = 1500
fireTicks(callbacks.tick, 30)
expect(metrics.addConstraint == 1, "wrecker pending restore must not duplicate its constraint")
expect(metrics.attachSync == 1, "wrecker pending restore must not duplicate its sync")
linkPair(wrecker, target)
now = 2000
fireTicks(callbacks.tick, 30)
expect(metrics.addConstraint == 1, "wrecker delayed acknowledgement must be adopted without re-adding")
expect(wrecker.modData.wreckerHeightLevel == 2, "wrecker adoption must preserve the saved hook height")
expect(wrecker.modData.wreckerTargetAttachment == "trailerfront", "wrecker adoption must preserve the target endpoint")
unlinkPair(wrecker, target)
loaded = { wrecker }
byId[404] = nil
now = 10000
fireTicks(callbacks.tick, 30)
now = 16000
fireTicks(callbacks.tick, 30)
expect(metrics.detachSync == 0, "an unloaded wrecker peer must not broadcast destructive cleanup")
expect(wrecker.modData.wreckerTowActive == true, "an unloaded wrecker peer must keep active metadata")
expect(wrecker.modData.wreckerTowedVehicleSqlId == target:getSqlId(), "an unloaded wrecker peer must keep stable identity")
loaded = { wrecker, target }
byId[404] = target
now = 17000
fireTicks(callbacks.tick, 30)
expect(metrics.addConstraint == 2, "a reloaded wrecker peer must re-enter recovery")
expect(metrics.attachSync == 2, "a reloaded wrecker peer must receive one fresh restore sync")
expect(metrics.detachSync == 0, "wrecker peer reload must not be mistaken for a break")
expect(metrics.worldDrops == 0, "wrecker recovery must never create a portable towbar item")
expect(metrics.breakConstraint == 0, "wrecker peer reload must not break an unrelated constraint")
end
runPortableHandlerSpec()
runWreckerHandlerSpec()
if failures > 0 then os.exit(1) end
print("PASS: production persistence handlers debounce recovery and survive peer unload")