Files
Towbar/tests/driver-entry-force-reattach-42.20-spec.lua
2026-08-19 12:12:12 -04:00

250 lines
8.5 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 = 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")