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 }, ";") local runtime = "client" isServer = function() return runtime == "server" end isClient = function() return runtime == "client" end getDebug = function() return false end getTimestampMs = function() return 1000 end local serverCommandCallbacks = {} local clientPlayer = { onlineId = 17 } Events = { OnServerCommand = { Add = function(callback) serverCommandCallbacks[#serverCommandCallbacks + 1] = callback end }, OnSpawnVehicleEnd = { Add = function() end } } local function attachmentScript() local attachments = { trailer = {}, trailerfront = {} } return { getAttachmentById = function(_, attachmentId) return attachments[attachmentId] end } end local function vehicle(id, sqlId, acknowledgeConstraint) local value = { id = id, sqlId = sqlId, script = attachmentScript(), scriptName = "Base.MultiplayerRegression" .. tostring(id), modData = {}, towing = nil, towedBy = nil, constraintKind = nil, acknowledgeConstraint = acknowledgeConstraint, constraintCalls = 0 } function value:getId() return self.id end function value:getSqlId() return self.sqlId 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:isDriver(player) return self.driver == player end function value:getTowAttachmentSelf() return nil end function value:attachmentExist(attachmentId) return self.script:getAttachmentById(attachmentId) ~= nil end function value:getX() return 0 end function value:getY() return 0 end function value:addPointConstraint(_, other) self.constraintCalls = self.constraintCalls + 1 if self.acknowledgeConstraint then self.towing = other other.towedBy = self self.constraintKind = runtime == "client" and "rigid" or "native" other.constraintKind = self.constraintKind end end function value:breakConstraint() local other = self.towing or self.towedBy self.towing, self.towedBy = nil, nil self.constraintKind = nil if other then other.towing, other.towedBy = nil, nil other.constraintKind = nil end end return value end local clientVehicles = { [101] = vehicle(101, 1001, true), [102] = vehicle(102, 1002, true), [201] = vehicle(201, 2001, false), [202] = vehicle(202, 2002, false) } clientVehicles[101].driver = clientPlayer clientVehicles[201].driver = clientPlayer local serverVehicles = { -- This models the dedicated-server behavior behind the live regression: -- addPointConstraint is accepted, but the server getters do not expose the -- relation before the authoritative client sync must be sent. [101] = vehicle(101, 1001, false), [102] = vehicle(102, 1002, false) } getVehicleById = function(id) local vehicles = runtime == "server" and serverVehicles or clientVehicles return vehicles[tonumber(id)] end getPlayer = function() return clientPlayer end TowBarMod = { Utils = { updateAttachmentsForRigidTow = function() end }, Hook = { applyFreeRollingTowState = function() end, setVehicleScriptWithTowBarHidden = function(target, scriptName) target:setScriptName(scriptName) return true end, setVehiclePostAttach = function() end, cleanupDetachedTowBar = function() end } } -- Load the real portable client sync and rigid-constraint implementation. dofile("42.20/media/lua/client/TowBar/TowSyncClient.lua") local globalData = {} ModData = { getOrCreate = function(name) globalData[name] = globalData[name] or {} return globalData[name] end, transmit = function() end } local serverCallbacks = {} Events = { OnClientCommand = { Add = function(callback) serverCallbacks.clientCommand = callback end }, OnTick = { Add = function(callback) serverCallbacks.tick = callback end } } runtime = "server" local item = { id = 7001 } function item:getID() return self.id end function item:getFullType() return "TowBar.TowBar" end local inventory = { items = { item } } function inventory:getItemWithID(id) return self.items[1] and self.items[1]:getID() == id and self.items[1] or nil end function inventory:getFirstTypeRecurse(fullType) return self.items[1] and self.items[1]:getFullType() == fullType and self.items[1] or nil end function inventory:contains(candidate) return self.items[1] == candidate end function inventory:Remove(candidate) if self.items[1] == candidate then self.items[1] = nil end end function inventory:AddItem() return nil end local player = { vehicle = serverVehicles[101] } function player:getInventory() return inventory end function player:getVehicle() return self.vehicle end function player:getX() return 0 end function player:getY() return 0 end function player:isPrimaryHandItem() return false end function player:isSecondaryHandItem() return false end function player:removeFromHands() end function player:setPrimaryHandItem() end sendRemoveItemFromContainer = function() end sendAddItemToContainer = function() end sendEquip = function() end local attachSyncCount = 0 local attachSyncPhases = {} sendServerCommand = function(module, command, args) if module == "towbar" and command == "forceAttachSync" then attachSyncCount = attachSyncCount + 1 attachSyncPhases[#attachSyncPhases + 1] = args.phase end local previousRuntime = runtime runtime = "client" for i = 1, #serverCommandCallbacks do serverCommandCallbacks[i](module, command, args) end runtime = previousRuntime end getCell = function() return { getVehicles = function() return nil end } end -- Load the real server command handler after the client event handler has been -- captured. Both halves now communicate through sendServerCommand above. dofile("42.20/media/lua/server/TowingCommands.lua") serverCallbacks.clientCommand("towbar", "attachTowBar", player, { vehicleA = 101, vehicleB = 102, attachmentA = "trailer", attachmentB = "trailerfront", itemId = item:getID() }) expect(serverVehicles[101].modData.isTowingByTowBar == true, "accepted MP attach must publish towbar state for its visual") expect(serverVehicles[101].constraintCalls == 1, "accepted MP attach must still request the server-side logical constraint") expect(attachSyncCount == 1, "accepted MP attach must immediately send one forceAttachSync even when server towing getters lag") expect(attachSyncPhases[1] == nil, "portable MP attach must use the same single unphased sync as the working wrecker") expect(clientVehicles[101]:getVehicleTowing() == clientVehicles[102] and clientVehicles[102]:getVehicleTowedBy() == clientVehicles[101], "the same command that shows the MP towbar visual must create the client rigid constraint") expect(clientVehicles[101].constraintKind == "rigid", "the one-shot sync must create rigid rather than native towing physics") -- Like the working wrecker, server acknowledgement is bookkeeping only. It -- must not introduce a second portable-only attach protocol or rebuild. serverVehicles[101].towing = serverVehicles[102] serverVehicles[102].towedBy = serverVehicles[101] serverCallbacks.tick() serverCallbacks.tick() expect(attachSyncCount == 1, "server acknowledgement must not send a second portable-only attach phase") expect(clientVehicles[101].constraintCalls == 1, "the wrecker-style attach must submit one local rigid constraint") expect(clientVehicles[101].constraintKind == "rigid", "the single attach sync must leave the client using rigid towing physics") sendServerCommand("towbar", "forceAttachSync", { vehicleA = 101, vehicleB = 102, attachmentA = "trailer", attachmentB = "trailerfront" }) expect(clientVehicles[101].constraintCalls == 1, "duplicate snapshots must not repeatedly rebuild rigid physics") -- The local add itself can also precede the engine's towing-getter update. -- Reconciliation must adopt the now-visible exact pair without duplicating -- the working wrecker-style single attach. runtime = "client" local delayedProvisional = { vehicleA = 201, vehicleB = 202, attachmentA = "trailer", attachmentB = "trailerfront" } for i = 1, #serverCommandCallbacks do serverCommandCallbacks[i]("towbar", "forceAttachSync", delayedProvisional) end expect(clientVehicles[201].constraintCalls == 1 and clientVehicles[201]:getVehicleTowing() == nil, "a rigid add may be accepted before its local getters update") clientVehicles[201].towing = clientVehicles[202] clientVehicles[202].towedBy = clientVehicles[201] clientVehicles[201].constraintKind = "rigid" clientVehicles[202].constraintKind = "rigid" for i = 1, #serverCommandCallbacks do serverCommandCallbacks[i]("towbar", "forceAttachSync", delayedProvisional) end expect(clientVehicles[201].constraintCalls == 1, "delayed local getter acknowledgement must make the next snapshot idempotent") clientVehicles[201].acknowledgeConstraint = true for i = 1, #serverCommandCallbacks do serverCommandCallbacks[i]("towbar", "forceAttachSync", delayedProvisional) end expect(clientVehicles[201].constraintCalls == 1 and clientVehicles[201].constraintKind == "rigid", "duplicate sync after delayed local acceptance must remain idempotent") runtime = "server" if failures > 0 then os.exit(1) end print("PASS: multiplayer portable towbar immediately synchronizes its rigid client constraint")