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