local failures = 0 local function expect(condition, message) if not condition then failures = failures + 1 io.stderr:write("FAIL: " .. message .. "\n") end end local serverCommand local vehicleSpawned Events = { OnServerCommand = { Add = function(callback) serverCommand = callback end }, OnSpawnVehicleEnd = { Add = function(callback) vehicleSpawned = callback end } } isServer = function() return false end local trace = {} local function record(value) trace[#trace + 1] = value end local function script() return { getAttachmentById = function(_, id) if id == "trailer" or id == "trailerfront" then return {} end return nil end, getWheelCount = function() return 0 end, getPhysicsChassisShape = function() return { z = function() return 4 end } end, addAttachment = function() end } end local rigidAdds = 0 local breaks = 0 local towing local towed local function vehicle(id) local value = { id = id, modData = {}, script = script(), towing = nil, towedBy = nil } function value:getId() return self.id end function value:getScript() return self.script end function value:getScriptName() return "Base.TestVehicle" end function value:getModData() return self.modData end function value:transmitModData() end function value:getMass() return 1200 end function value:getBrakingForce() return 25 end function value:getVehicleTowing() return self.towing end function value:getVehicleTowedBy() return self.towedBy end function value:addPointConstraint(_, other, attachmentA, attachmentB, localOnly) rigidAdds = rigidAdds + 1 record("rigid-add") expect(self == towing and other == towed, "rigid rebuild must use the original pair") expect(attachmentA == "trailer" and attachmentB == "trailerfront", "rigid rebuild must preserve endpoints") expect(localOnly == true, "rigid rebuild must be local-only to avoid an attach/detach command race") self.towing = other other.towedBy = self end function value:breakConstraint() breaks = breaks + 1 record("break-native") if self == towing or self == towed then towing.towing = nil towing.towedBy = nil towed.towing = nil towed.towedBy = nil end end return value end towing = vehicle(101) towed = vehicle(202) local vehicles = { [101] = towing, [202] = towed } getVehicleById = function(id) return vehicles[id] end TowBarMod = { Utils = { updateAttachmentsForRigidTow = function() record("rigid-offsets") end }, Hook = { cleanupDetachedTowBar = function() record("cleanup") towing.modData = {} towed.modData = {} end, setVehicleScriptWithTowBarHidden = function(_, scriptName) record("script:" .. tostring(scriptName)) return true end, setVehiclePostAttach = function() record("post-attach") end } } dofile("42.20/media/lua/client/TowBar/TowSyncClient.lua") expect(type(serverCommand) == "function", "client sync must register its server-command handler") -- Begin with the old physical pair so spontaneous cleanup exercises the same -- path as a real Build 42 break notification. towing.towing = towed towed.towedBy = towing towing.modData.towBarTowedVehicleId = towed:getId() towed.modData.towBarTowingVehicleId = towing:getId() serverCommand("towbar", "spontaneousDetachSync", { vehicleA = towing:getId(), vehicleB = towed:getId() }) expect(breaks > 0, "spontaneous cleanup must remove the old native relation") expect(towing:getVehicleTowing() == nil and towed:getVehicleTowedBy() == nil, "spontaneous cleanup must leave the pair unlinked") -- A direct reattach can arrive from the server as a native B42 relationship -- before forceAttachSync reaches the client. That native relationship is the -- rope-like constraint; forceAttachSync must replace it with the rigid path. towing.towing = towed towed.towedBy = towing local breaksBeforeReattach = breaks serverCommand("towbar", "forceAttachSync", { vehicleA = towing:getId(), vehicleB = towed:getId(), attachmentA = "trailer", attachmentB = "trailerfront" }) expect(breaks == breaksBeforeReattach + 1, "direct reattach must replace the native B42 rope relation") expect(rigidAdds == 1, "direct reattach must create exactly one rigid local constraint") local fakeScriptIndex local rigidAddIndex for index, value in ipairs(trace) do if value == "script:notTowingA_Trailer" and not fakeScriptIndex then fakeScriptIndex = index end if value == "rigid-add" and not rigidAddIndex then rigidAddIndex = index end end expect(fakeScriptIndex ~= nil, "rigid reattach must select the fake-trailer script") expect(rigidAddIndex ~= nil and fakeScriptIndex < rigidAddIndex, "fake-trailer selection must precede rigid constraint creation") expect(towing:getVehicleTowing() == towed and towed:getVehicleTowedBy() == towing, "rigid rebuild must finish with a reciprocal tow relation") local rigidAddsAfterFirstSync = rigidAdds local breaksAfterFirstSync = breaks serverCommand("towbar", "forceAttachSync", { vehicleA = towing:getId(), vehicleB = towed:getId(), attachmentA = "trailer", attachmentB = "trailerfront" }) expect(rigidAdds == rigidAddsAfterFirstSync, "a repeated attach snapshot must not layer a second rigid constraint") expect(breaks == breaksAfterFirstSync, "a repeated attach snapshot must adopt the already-rigid pair") -- Streaming can recreate a native relationship with the same runtime IDs -- while the Lua module and its applied cache survive. The spawn notification -- must invalidate that stale cache so the next snapshot replaces the rope. expect(type(vehicleSpawned) == "function", "client sync must observe vehicle streaming") vehicleSpawned(towed) local rigidAddsBeforeStreamRestore = rigidAdds local breaksBeforeStreamRestore = breaks serverCommand("towbar", "forceAttachSync", { vehicleA = towing:getId(), vehicleB = towed:getId(), attachmentA = "trailer", attachmentB = "trailerfront" }) expect(rigidAdds == rigidAddsBeforeStreamRestore + 1, "stream-restored native rope must be rebuilt rigid") expect(breaks == breaksBeforeStreamRestore + 1, "stream-restored native rope must be removed exactly once") if failures > 0 then os.exit(1) end print("PASS: spontaneous break followed by direct attach rebuilds a rigid towbar")