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 = table.concat({ "42.20/media/lua/client/?.lua", "42.20/media/lua/shared/?.lua", package.path }, ";") isServer = function() return false end isClient = function() return true end local serverCommandCallbacks = {} local enterVehicleCallbacks = {} local spawnVehicleCallbacks = {} local tickCallbacks = {} Events = { OnServerCommand = { Add = function(callback) serverCommandCallbacks[#serverCommandCallbacks + 1] = callback end }, OnEnterVehicle = { Add = function(callback) enterVehicleCallbacks[#enterVehicleCallbacks + 1] = callback end }, OnSwitchVehicleSeat = { Add = function() end }, OnSpawnVehicleEnd = { Add = function(callback) spawnVehicleCallbacks[#spawnVehicleCallbacks + 1] = callback end }, OnTick = { Add = function(callback) tickCallbacks[#tickCallbacks + 1] = callback end } } local localPlayer = { vehicle = nil } function localPlayer:getVehicle() return self.vehicle end local attachmentIds = { trailer = true, trailerfront = true, towbarWreckerHookLow = true, towbarWreckerHookMid = true, towbarWreckerHookHigh = true } local function unlink(vehicleA, vehicleB) vehicleA.towing, vehicleA.towedBy = nil, nil vehicleB.towing, vehicleB.towedBy = nil, nil end local function link(vehicleA, vehicleB) vehicleA.towing, vehicleA.towedBy = vehicleB, nil vehicleB.towing, vehicleB.towedBy = nil, vehicleA end local function newVehicle(id, sqlId) local value = { id = id, sqlId = sqlId, modData = {}, scriptName = "Base.DriverEntry" .. tostring(id), mass = 1000, brakingForce = 20, towing = nil, towedBy = nil, driver = nil, addCalls = 0, breakCalls = 0 } local script = { getAttachmentById = function(_, attachmentId) return attachmentIds[attachmentId] and {} or nil end } function value:getId() return self.id end function value:getSqlId() return self.sqlId end function value:getModData() return self.modData end function value:transmitModData() end function value:getScript() return script end function value:getScriptName() return self.scriptName end function value:setScriptName(scriptName) self.scriptName = scriptName end function value:getMass() return self.mass end function value:setMass(mass) self.mass = mass end function value:getBrakingForce() return self.brakingForce end function value:setBrakingForce(force) self.brakingForce = force end function value:isParkingBrakeOn() return false end function value:setParkingBrakeOn() end function value:getParkingBrake() return false end function value:setParkingBrake() end function value:isHandbrake() return false end function value:setHandbrake() end function value:getVehicleTowing() return self.towing end function value:getVehicleTowedBy() return self.towedBy end function value:isDriver(player) return self.driver == player end function value:attachmentExist(attachmentId) return attachmentIds[attachmentId] == true end function value:addPointConstraint(_, other) self.addCalls = self.addCalls + 1 link(self, other) end function value:breakConstraint() self.breakCalls = self.breakCalls + 1 local other = self.towing or self.towedBy if other then unlink(self, other) end end return value end local portableTowing = newVehicle(101, 1001) local portableTowed = newVehicle(102, 1002) local wrecker = newVehicle(201, 2001) local wreckerTarget = newVehicle(202, 2002) local unrelated = newVehicle(301, 3001) local allVehicles = { portableTowing, portableTowed, wrecker, wreckerTarget, unrelated } local vehiclesById = {} for _, vehicle in ipairs(allVehicles) do vehiclesById[vehicle:getId()] = vehicle end getPlayer = function() return localPlayer end getVehicleById = function(id) return vehiclesById[tonumber(id)] end getCell = function() return { getVehicles = function() return allVehicles end } end TowBarMod = { Utils = { updateAttachmentsForRigidTow = function() end }, Hook = { applyFreeRollingTowState = function() end, setVehiclePostAttach = function() end, setVehicleScriptWithTowBarHidden = function(vehicle, scriptName) vehicle:setScriptName(scriptName) return true end, cleanupDetachedTowBar = function() end }, Wrecker = { getWorldVehicles = function() return allVehicles end, getHeightAttachmentId = function(level) return ({ [0] = "towbarWreckerHookLow", [1] = "towbarWreckerHookMid", [2] = "towbarWreckerHookHigh" })[tonumber(level) or 0] end } } -- Model a fully persisted portable pair whose transient sync cache was lost -- during reload. The exact native relation is deliberately present: entry -- must replace it with one fresh client-owned rigid constraint. portableTowing.modData = { isTowingByTowBar = true, towed = false, towBarTowedVehicleId = portableTowed:getId(), towBarTowedVehicleSqlId = portableTowed:getSqlId(), towBarExpectedAttachment = "trailer" } portableTowed.modData = { isTowingByTowBar = true, towed = true, towBarTowingVehicleId = portableTowing:getId(), towBarTowingVehicleSqlId = portableTowing:getSqlId(), towBarExpectedAttachment = "trailerfront", towBarOriginalScriptName = portableTowed:getScriptName() } link(portableTowing, portableTowed) -- Model the equivalent persisted wrecker pair, also without applied-level -- cache state. Both members carry the reciprocal identity written by the -- authoritative wrecker attach path. wrecker.modData = { wreckerTowActive = true, wreckerTowedVehicleId = wreckerTarget:getId(), wreckerTowedVehicleSqlId = wreckerTarget:getSqlId(), wreckerTargetAttachment = "trailerfront", wreckerHeightLevel = 2 } wreckerTarget.modData = { wreckerTowingVehicleId = wrecker:getId(), wreckerTowingVehicleSqlId = wrecker:getSqlId(), wreckerOriginalScriptName = wreckerTarget:getScriptName() } link(wrecker, wreckerTarget) dofile("42.20/media/lua/client/TowBar/TowSyncClient.lua") dofile("42.20/media/lua/client/TowBar/WreckerSyncClient.lua") expect(next(TowBarMod.Sync.desiredPairs) == nil, "portable regression setup must not depend on transient desired-pair sync") expect(next(TowBarMod.Sync.appliedPairs) == nil, "portable regression setup must begin without an applied-pair cache") expect(next(TowBarMod.WreckerSync.appliedLevels) == nil, "wrecker regression setup must begin without an applied-level cache") local function enterAsDriver(vehicle) for _, candidate in ipairs(allVehicles) do candidate.driver = nil end vehicle.driver = localPlayer localPlayer.vehicle = vehicle for i = 1, #enterVehicleCallbacks do enterVehicleCallbacks[i](localPlayer) end end enterAsDriver(portableTowing) expect(portableTowing.breakCalls == 1 and portableTowing.addCalls == 1, "portable driver entry must force exactly one rebuild from persisted modData") enterAsDriver(portableTowing) expect(portableTowing.breakCalls == 2 and portableTowing.addCalls == 2, "each later portable driver entry must force one new rebuild, not remain cache-idempotent") enterAsDriver(wrecker) expect(wrecker.breakCalls == 1 and wrecker.addCalls == 1, "wrecker driver entry must force exactly one rebuild from persisted modData") enterAsDriver(wrecker) expect(wrecker.breakCalls == 2 and wrecker.addCalls == 2, "each later wrecker driver entry must force one new rebuild, not remain cache-idempotent") local portableCounts = portableTowing.breakCalls + portableTowing.addCalls local wreckerCounts = wrecker.breakCalls + wrecker.addCalls enterAsDriver(unrelated) expect(portableTowing.breakCalls + portableTowing.addCalls == portableCounts and wrecker.breakCalls + wrecker.addCalls == wreckerCounts, "entering an unrelated vehicle must not rebuild any persisted tow pair") expect(#tickCallbacks == 0, "driver-entry recovery must remain event-driven and must not install a tick retry loop") if failures > 0 then os.exit(1) end print("PASS: driver entry force-reattaches persisted portable and wrecker pairs once")