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")