Files
Towbar/tests/portable-mp-driver-ownership-42.20-spec.lua
T
2026-08-19 12:12:12 -04:00

225 lines
8.4 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 = {}
Events = {
OnServerCommand = {
Add = function(callback)
serverCommandCallbacks[#serverCommandCallbacks + 1] = callback
end
},
OnEnterVehicle = {
Add = function(callback)
enterVehicleCallbacks[#enterVehicleCallbacks + 1] = callback
end
},
OnSpawnVehicleEnd = {
Add = function(callback)
spawnVehicleCallbacks[#spawnVehicleCallbacks + 1] = callback
end
}
}
local localPlayer = { onlineId = 7 }
local function attachmentScript()
local attachments = { trailer = {}, trailerfront = {} }
return {
getAttachmentById = function(_, attachmentId)
return attachments[attachmentId]
end
}
end
local function unlink(a, b)
a.towing, a.towedBy = nil, nil
b.towing, b.towedBy = nil, nil
end
local function vehicle(id)
local value = {
id = id,
script = attachmentScript(),
scriptName = "Base.DriverOwnership" .. tostring(id),
modData = {},
driver = nil,
towing = nil,
towedBy = nil,
addCalls = 0,
breakCalls = 0,
freeRolling = false,
freeRollingAtAdd = {}
}
function value:getId() return self.id end
function value:getScript() return self.script end
function value:getScriptName() return self.scriptName end
function value:setScriptName(scriptName) self.scriptName = scriptName end
function value:getModData() return self.modData end
function value:transmitModData() end
function value:getMass() return 1000 end
function value:getBrakingForce() return 20 end
function value:getVehicleTowing() return self.towing end
function value:getVehicleTowedBy() return self.towedBy end
function value:getDriver() return self.driver end
function value:isDriver(player) return self.driver == player end
function value:addPointConstraint(player, other, attachmentA, attachmentB, localOnly)
self.addCalls = self.addCalls + 1
self.freeRollingAtAdd[#self.freeRollingAtAdd + 1] = other.freeRolling
expect(player == nil, "client rigid attach must not impersonate a player")
expect(attachmentA == "trailer" and attachmentB == "trailerfront",
"deferred attach must preserve its authoritative endpoints")
expect(localOnly == true, "deferred attach must remain client-local")
self.towing = other
other.towedBy = self
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 vehicles = {
[101] = vehicle(101),
[102] = vehicle(102),
[201] = vehicle(201),
[202] = vehicle(202)
}
getVehicleById = function(id) return vehicles[tonumber(id)] end
getPlayer = function() return localPlayer end
TowBarMod = {
Utils = { updateAttachmentsForRigidTow = function() end },
Hook = {
setVehicleScriptWithTowBarHidden = function(target, scriptName)
target:setScriptName(scriptName)
return true
end,
setVehiclePostAttach = function(_, target)
-- This stands in for applyFreeRollingTowState(). The ownership
-- handoff must run it before Bullet receives the local constraint,
-- matching the working wrecker order.
target.freeRolling = true
end,
cleanupDetachedTowBar = function() end
}
}
dofile("42.20/media/lua/client/TowBar/TowSyncClient.lua")
local function serverCommand(command, vehicleA, vehicleB)
local args = {
vehicleA = vehicleA:getId(),
vehicleB = vehicleB:getId(),
attachmentA = "trailer",
attachmentB = "trailerfront"
}
for i = 1, #serverCommandCallbacks do
serverCommandCallbacks[i]("towbar", command, args)
end
end
local function enterVehicle(player)
for i = 1, #enterVehicleCallbacks do
enterVehicleCallbacks[i](player)
end
end
local function spawnVehicle(value)
for i = 1, #spawnVehicleCallbacks do
spawnVehicleCallbacks[i](value)
end
end
-- Portable installation finishes while the player is standing beside the two
-- cars. At this point the local client does not own either vehicle's physics.
local towing, towed = vehicles[101], vehicles[102]
-- The server's ordinary towing relation may reach this client before the mod
-- command. It is usable as a visual snap, but replacing it before driver
-- ownership is precisely the multiplayer race this regression covers.
towing.towing = towed
towed.towedBy = towing
serverCommand("forceAttachSync", towing, towed)
expect(type(TowBarMod.Sync.desiredPairs) == "table",
"authoritative sync must retain desired pairs until this client owns the towing vehicle")
expect(towing.addCalls == 0 and towing.breakCalls == 0,
"sync received outside the driver seat must not create or replace local physics")
-- Entering as driver transfers physics ownership to this client. That event is
-- the first safe point to replace the server/native relation with rigid towing.
towing.driver = localPlayer
expect(#enterVehicleCallbacks == 1,
"portable sync must register one driver-entry ownership handoff")
enterVehicle(localPlayer)
expect(towing.addCalls == 1,
"becoming driver must apply the deferred rigid constraint exactly once")
expect(towing.breakCalls == 1,
"driver ownership handoff must replace the pre-existing native relation exactly once")
expect(towing:getVehicleTowing() == towed and towed:getVehicleTowedBy() == towing,
"driver ownership handoff must leave the authoritative pair linked")
expect(towing.freeRollingAtAdd[1] == true,
"towed free-roll state must be applied before the rigid constraint is created")
-- The user explicitly chose driver entry as a forced repair point. A later
-- entry rebuilds once, while the ordinary server snapshot remains idempotent.
enterVehicle(localPlayer)
serverCommand("forceAttachSync", towing, towed)
expect(towing.addCalls == 2 and towing.breakCalls == 2,
"each driver entry must force one rebuild while snapshots do not add another")
-- A detach can race the later driver entry. It must cancel the desired pair so
-- that entering the vehicle cannot resurrect a server-rejected tow.
local canceledTowing, canceledTowed = vehicles[201], vehicles[202]
serverCommand("forceAttachSync", canceledTowing, canceledTowed)
expect(canceledTowing.addCalls == 0 and canceledTowing.breakCalls == 0,
"a second outside-seat sync must also remain pending without touching physics")
-- The dedicated server may already have replicated its native relation. A
-- non-owning client must not break that relation while canceling its handoff.
canceledTowing.towing = canceledTowed
canceledTowed.towedBy = canceledTowing
serverCommand("forceDetachSync", canceledTowing, canceledTowed)
expect(TowBarMod.Sync.desiredPairs["201:202"] == nil,
"detach must remove the pending desired pair before any later seat event")
canceledTowing.driver = localPlayer
enterVehicle(localPlayer)
expect(canceledTowing.addCalls == 0 and canceledTowing.breakCalls == 0,
"detach must cancel pending ownership handoff without a non-owner break")
-- A restart snapshot can arrive before either vehicle has streamed in. Keep
-- that desired state and retry it from vehicle spawn when this player already
-- owns the towing vehicle.
local streamedTowing, streamedTowed = vehicle(301), vehicle(302)
serverCommand("forceAttachSync", streamedTowing, streamedTowed)
expect(TowBarMod.Sync.desiredPairs["301:302"] ~= nil,
"missing streamed peers must not discard the authoritative attach snapshot")
streamedTowing.driver = localPlayer
vehicles[301], vehicles[302] = streamedTowing, streamedTowed
spawnVehicle(streamedTowed)
expect(streamedTowing.addCalls == 1,
"vehicle streaming must apply one saved rigid handoff for an existing local driver")
if failures > 0 then os.exit(1) end
print("PASS: portable multiplayer rigid tow waits for driver physics ownership")