if isClient() then return end local TowingCommands = {} local Commands = {} local TowBarItemType = "TowBar.TowBar" local SyncDelayTicks = 2 local SnapshotIntervalTicks = 120 local BrokenPairAuditIntervalTicks = 5 local pendingSync = {} local snapshotTickCounter = 0 local brokenPairAuditTickCounter = 0 local confirmedTowPairs = {} local giveTowBar TowingCommands.wantNoise = getDebug() or false local noise = function(msg) if TowingCommands.wantNoise then print("TowBarCommands: " .. msg) end end local function queueSync(kind, player, args, reservedTowBar) if not args then return end table.insert(pendingSync, { kind = kind, ticks = SyncDelayTicks, attempts = 0, reservedTowBar = reservedTowBar == true, player = player, args = args }) end local function cancelPendingAttach(vehicleA, vehicleB) if not vehicleA or not vehicleB then return end local vehicleAId = vehicleA:getId() local vehicleBId = vehicleB:getId() local remaining = {} for i = 1, #pendingSync do local item = pendingSync[i] local itemArgs = item.args or {} local isThisAttach = item.kind == "attach" and itemArgs.vehicleA == vehicleAId and itemArgs.vehicleB == vehicleBId if isThisAttach then -- The reserved item is about to become the single ground drop. item.reservedTowBar = false else table.insert(remaining, item) end end pendingSync = remaining end local function hasPendingAttach(vehicleA, vehicleB) if not vehicleA or not vehicleB then return false end local vehicleAId = vehicleA:getId() local vehicleBId = vehicleB:getId() for i = 1, #pendingSync do local item = pendingSync[i] local itemArgs = item.args or {} if item.kind == "attach" and itemArgs.vehicleA == vehicleAId and itemArgs.vehicleB == vehicleBId then return true end end return false end local function resolveAttachmentA(args, vehicleA) if args and args.attachmentA then return args.attachmentA end if vehicleA and vehicleA:getTowAttachmentSelf() then return vehicleA:getTowAttachmentSelf() end return "trailer" end local function resolveAttachmentB(args, vehicleB) if args and args.attachmentB then return args.attachmentB end if vehicleB and vehicleB:getTowAttachmentSelf() then return vehicleB:getTowAttachmentSelf() end return "trailerfront" end local function isLinked(vehicleA, vehicleB) if not vehicleA or not vehicleB then return false end return vehicleA:getVehicleTowing() == vehicleB and vehicleB:getVehicleTowedBy() == vehicleA end local function hasAnyTowLink(vehicle) if not vehicle then return false end return vehicle:getVehicleTowing() ~= nil or vehicle:getVehicleTowedBy() ~= nil end local function getTowBarPairKey(vehicleA, vehicleB) if not vehicleA or not vehicleB then return nil end return tostring(vehicleA:getId()) .. ":" .. tostring(vehicleB:getId()) end local function markTowBarPairConfirmed(vehicleA, vehicleB) local key = getTowBarPairKey(vehicleA, vehicleB) if key then confirmedTowPairs[key] = true end end local function isTowBarPairConfirmed(vehicleA, vehicleB) local key = getTowBarPairKey(vehicleA, vehicleB) return key ~= nil and confirmedTowPairs[key] == true end local function forgetTowBarPair(vehicleA, vehicleB) local key = getTowBarPairKey(vehicleA, vehicleB) if key then confirmedTowPairs[key] = nil end end local function hasTowBarState(vehicle) if not vehicle then return false end local md = vehicle:getModData() if not md then return false end return md["isTowingByTowBar"] == true end local function markExpectedTowBarPair(vehicleA, vehicleB, attachmentA, attachmentB) if not vehicleA or not vehicleB then return end local towingModData = vehicleA:getModData() local towedModData = vehicleB:getModData() if not towingModData or not towedModData then return end towingModData["isTowingByTowBar"] = true towingModData["towed"] = false towingModData["towBarTowedVehicleId"] = vehicleB:getId() towingModData["towBarTowingVehicleId"] = nil towingModData["towBarExpectedAttachment"] = attachmentA towedModData["isTowingByTowBar"] = true towedModData["towed"] = true towedModData["towBarTowedVehicleId"] = nil towedModData["towBarTowingVehicleId"] = vehicleA:getId() towedModData["towBarExpectedAttachment"] = attachmentB vehicleA:transmitModData() vehicleB:transmitModData() end local function clearExpectedTowBarPair(vehicleA, vehicleB) if vehicleA then local towingModData = vehicleA:getModData() if towingModData then towingModData["isTowingByTowBar"] = false towingModData["towed"] = false towingModData["towBarTowedVehicleId"] = nil towingModData["towBarTowingVehicleId"] = nil towingModData["towBarExpectedAttachment"] = nil vehicleA:transmitModData() end end if vehicleB then local towedModData = vehicleB:getModData() if towedModData then towedModData["isTowingByTowBar"] = false towedModData["towed"] = false towedModData["towBarTowedVehicleId"] = nil towedModData["towBarTowingVehicleId"] = nil towedModData["towBarExpectedAttachment"] = nil vehicleB:transmitModData() end end end local function isExpectedTowBarPair(vehicleA, vehicleB) if not vehicleA or not vehicleB then return false end local towingModData = vehicleA:getModData() local towedModData = vehicleB:getModData() if not towingModData or not towedModData then return false end return towingModData["towBarTowedVehicleId"] == vehicleB:getId() and towedModData["towBarTowingVehicleId"] == vehicleA:getId() end local function isLegacyTowBarPair(vehicleA, vehicleB) if not vehicleA or not vehicleB then return false end local towingModData = vehicleA:getModData() local towedModData = vehicleB:getModData() if not towingModData or not towedModData then return false end return hasTowBarState(vehicleA) and hasTowBarState(vehicleB) and towedModData["towed"] == true and towingModData["towBarTowedVehicleId"] == nil and towingModData["towBarTowingVehicleId"] == nil and towedModData["towBarTowedVehicleId"] == nil and towedModData["towBarTowingVehicleId"] == nil end local function resolveExpectedTowBarPair(vehicle) if not vehicle then return nil, nil end local towingVehicle = vehicle:getVehicleTowing() and vehicle or vehicle:getVehicleTowedBy() local towedVehicle = vehicle:getVehicleTowing() or (vehicle:getVehicleTowedBy() and vehicle or nil) if towingVehicle and towedVehicle and (isExpectedTowBarPair(towingVehicle, towedVehicle) or isLegacyTowBarPair(towingVehicle, towedVehicle)) then return towingVehicle, towedVehicle end local modData = vehicle:getModData() if not modData then return nil, nil end if modData["towBarTowedVehicleId"] ~= nil then local towedVehicle = getVehicleById(modData["towBarTowedVehicleId"]) if isExpectedTowBarPair(vehicle, towedVehicle) then return vehicle, towedVehicle end end if modData["towBarTowingVehicleId"] ~= nil then local towingVehicle = getVehicleById(modData["towBarTowingVehicleId"]) if isExpectedTowBarPair(towingVehicle, vehicle) then return towingVehicle, vehicle end end return nil, nil end local function isPlayerAuthorizedForPair(player, vehicleA, vehicleB) if not player or not vehicleA or not vehicleB then return false end local playerVehicle = player:getVehicle() if playerVehicle == vehicleA or playerVehicle == vehicleB then return true end local playerX = player:getX() local playerY = player:getY() local function isNear(vehicle) local deltaX = playerX - vehicle:getX() local deltaY = playerY - vehicle:getY() return deltaX * deltaX + deltaY * deltaY <= 64 end return isNear(vehicleA) or isNear(vehicleB) end local function findTowBarItem(player, itemId) if not player then return nil end local inventory = player:getInventory() if not inventory then return nil end local towBarItem = itemId and inventory:getItemWithID(itemId) or nil if towBarItem and towBarItem:getFullType() ~= TowBarItemType then towBarItem = nil end return towBarItem or inventory:getFirstTypeRecurse(TowBarItemType) end local function removeTowBarItem(player, towBarItem) if not player or not towBarItem then return false end local inventory = player:getInventory() if not inventory or not inventory:contains(towBarItem, true) then return false end local wasPrimary = player:isPrimaryHandItem(towBarItem) local wasSecondary = player:isSecondaryHandItem(towBarItem) player:removeFromHands(towBarItem) inventory:Remove(towBarItem) sendRemoveItemFromContainer(inventory, towBarItem) if wasPrimary or wasSecondary then sendEquip(player) end return true end local function forEachCollectionItem(collection, callback) if not collection then return end local ok, iterator = pcall(function() return collection:iterator() end) if ok and iterator then while iterator:hasNext() do callback(iterator:next()) end return end local size ok, size = pcall(function() return collection:size() end) if not ok or type(size) ~= "number" then return end for i = 0, size - 1 do callback(collection:get(i)) end end local function broadcastAttach(vehicleA, vehicleB, attachmentA, attachmentB) if not vehicleA or not vehicleB then return end sendServerCommand("towbar", "forceAttachSync", { vehicleA = vehicleA:getId(), vehicleB = vehicleB:getId(), attachmentA = attachmentA, attachmentB = attachmentB }) end local function broadcastDetach(vehicleAId, vehicleBId) sendServerCommand("towbar", "forceDetachSync", { vehicleA = vehicleAId, vehicleB = vehicleBId }) end local function broadcastSpontaneousDetach(vehicleA, vehicleB) if not vehicleA or not vehicleB then return end sendServerCommand("towbar", "spontaneousDetachSync", { vehicleA = vehicleA:getId(), vehicleB = vehicleB:getId() }) end local function breakTowBarConstraint(towingVehicle, towedVehicle) if not towingVehicle or not towedVehicle then return end -- Only break a relation between this exact pair. Stale towbar metadata must -- never disconnect either vehicle from a different trailer. local towingReferencesTowed = towingVehicle:getVehicleTowing() == towedVehicle or towingVehicle:getVehicleTowedBy() == towedVehicle local towedReferencesTowing = towedVehicle:getVehicleTowing() == towingVehicle or towedVehicle:getVehicleTowedBy() == towingVehicle if towedReferencesTowing then towedVehicle:breakConstraint(true, false) end if towingReferencesTowed then towingVehicle:breakConstraint(true, false) end end local function dropTowBarOnGround(towingVehicle, towedVehicle) local square = towedVehicle and towedVehicle:getSquare() or nil if not square and towingVehicle then square = towingVehicle:getSquare() end if not square then noise("could not drop broken towbar because neither vehicle has a square") return false end local item = square:AddWorldInventoryItem(TowBarItemType, 0.5, 0.5, 0) if not item then noise("failed to drop broken towbar on the ground") return false end return true end local function finalizeBrokenTowBarPair(towingVehicle, towedVehicle, reason) if not towingVehicle or not towedVehicle then return false end if not isExpectedTowBarPair(towingVehicle, towedVehicle) and not isLegacyTowBarPair(towingVehicle, towedVehicle) then return false end local shouldDrop = hasTowBarState(towingVehicle) or hasTowBarState(towedVehicle) if shouldDrop and not dropTowBarOnGround(towingVehicle, towedVehicle) then -- Keep the reciprocal IDs intact so the audit can retry when a square is available. return false end cancelPendingAttach(towingVehicle, towedVehicle) forgetTowBarPair(towingVehicle, towedVehicle) breakTowBarConstraint(towingVehicle, towedVehicle) clearExpectedTowBarPair(towingVehicle, towedVehicle) if isServer() then broadcastSpontaneousDetach(towingVehicle, towedVehicle) elseif not isClient() and TowBarMod and TowBarMod.Hook and TowBarMod.Hook.cleanupDetachedTowBar then local ok, err = pcall(TowBarMod.Hook.cleanupDetachedTowBar, towingVehicle, towedVehicle) if not ok then noise("single-player broken towbar cleanup failed: " .. tostring(err)) return false end end noise("finalized broken towbar pair " .. tostring(towingVehicle:getId()) .. ":" .. tostring(towedVehicle:getId()) .. " reason=" .. tostring(reason) .. " dropped=" .. tostring(shouldDrop)) return true end local function reconcileBrokenTowBarPairsServer() brokenPairAuditTickCounter = brokenPairAuditTickCounter + 1 if brokenPairAuditTickCounter < BrokenPairAuditIntervalTicks then return end brokenPairAuditTickCounter = 0 local cell = getCell() if not cell then return end local vehicles = cell:getVehicles() if not vehicles then return end forEachCollectionItem(vehicles, function(towingVehicle) local towingModData = towingVehicle and towingVehicle:getModData() or nil local towedVehicleId = towingModData and towingModData["towBarTowedVehicleId"] or nil local towedVehicle = towedVehicleId and getVehicleById(towedVehicleId) or nil if towingVehicle and towedVehicle and isExpectedTowBarPair(towingVehicle, towedVehicle) then if isLinked(towingVehicle, towedVehicle) then markTowBarPairConfirmed(towingVehicle, towedVehicle) elseif isTowBarPairConfirmed(towingVehicle, towedVehicle) and not hasAnyTowLink(towingVehicle) and not hasAnyTowLink(towedVehicle) then finalizeBrokenTowBarPair(towingVehicle, towedVehicle, "physical-link-audit") end end end) end local function processAttachSync(item) local args = item.args or {} local vehicleA = args.vehicleA and getVehicleById(args.vehicleA) or nil local vehicleB = args.vehicleB and getVehicleById(args.vehicleB) or nil if not vehicleA or not vehicleB then noise("attach sync skipped missing vehicles A=" .. tostring(args.vehicleA) .. " B=" .. tostring(args.vehicleB)) return "failed" end local attachmentA = resolveAttachmentA(args, vehicleA) local attachmentB = resolveAttachmentB(args, vehicleB) if not isLinked(vehicleA, vehicleB) then if isTowBarPairConfirmed(vehicleA, vehicleB) then return "broken" end if item.attempts >= 3 then return "failed" end vehicleA:addPointConstraint(item.player, vehicleB, attachmentA, attachmentB) return "retry" end markExpectedTowBarPair(vehicleA, vehicleB, attachmentA, attachmentB) markTowBarPairConfirmed(vehicleA, vehicleB) broadcastAttach(vehicleA, vehicleB, attachmentA, attachmentB) return "complete" end local function failAttachSync(item) local args = item.args or {} local vehicleA = args.vehicleA and getVehicleById(args.vehicleA) or nil local vehicleB = args.vehicleB and getVehicleById(args.vehicleB) or nil if isLinked(vehicleA, vehicleB) then vehicleA:breakConstraint(true, false) end clearExpectedTowBarPair(vehicleA, vehicleB) broadcastDetach(args.vehicleA, args.vehicleB) if item.reservedTowBar then giveTowBar(item.player, true) item.reservedTowBar = false end end local function processDetachSync(item) local args = item.args or {} local vehicleAId = args.towingVehicle or args.vehicleA or args.vehicle local vehicleBId = args.vehicleB or args.vehicle broadcastDetach(vehicleAId, vehicleBId) end local function snapshotActiveTowbarLinksServer() local cell = getCell() if not cell then return end local list = cell:getVehicles() if not list then return end forEachCollectionItem(list, function(towingVehicle) local towedVehicle = towingVehicle and towingVehicle:getVehicleTowing() or nil if towingVehicle and towedVehicle and towedVehicle:getVehicleTowedBy() == towingVehicle then -- Preserve reciprocal modern pairs and clean legacy pairs only. -- Stale modern IDs must not promote an unrelated current trailer. if isExpectedTowBarPair(towingVehicle, towedVehicle) or isLegacyTowBarPair(towingVehicle, towedVehicle) then local attachmentA = resolveAttachmentA(nil, towingVehicle) local towedMd = towedVehicle:getModData() local attachmentB = (towedMd and towedMd["towBarChangedAttachmentId"]) or resolveAttachmentB(nil, towedVehicle) if attachmentA == attachmentB then attachmentA = "trailer" attachmentB = "trailerfront" end markExpectedTowBarPair(towingVehicle, towedVehicle, attachmentA, attachmentB) markTowBarPairConfirmed(towingVehicle, towedVehicle) broadcastAttach(towingVehicle, towedVehicle, attachmentA, attachmentB) end end end) end local function processPendingSync() reconcileBrokenTowBarPairsServer() snapshotTickCounter = snapshotTickCounter + 1 if snapshotTickCounter >= SnapshotIntervalTicks then snapshotTickCounter = 0 snapshotActiveTowbarLinksServer() end if #pendingSync == 0 then return end local remaining = {} for i = 1, #pendingSync do local item = pendingSync[i] item.ticks = item.ticks - 1 if item.ticks <= 0 then if item.kind == "attach" then local status = processAttachSync(item) if status == "retry" and item.attempts < 3 then item.attempts = item.attempts + 1 item.ticks = SyncDelayTicks table.insert(remaining, item) elseif status == "broken" then finalizeBrokenTowBarPair( item.args.vehicleA and getVehicleById(item.args.vehicleA) or nil, item.args.vehicleB and getVehicleById(item.args.vehicleB) or nil, "attach-confirmation-break" ) elseif status ~= "complete" then failAttachSync(item) end elseif item.kind == "detach" then processDetachSync(item) end else table.insert(remaining, item) end end pendingSync = remaining end function Commands.attachTowBar(player, args) if not player or type(args) ~= "table" then return end if type(args.attachmentA) ~= "string" or type(args.attachmentB) ~= "string" then return end local vehicleA = getVehicleById(args.vehicleA) local vehicleB = getVehicleById(args.vehicleB) if not vehicleA then noise("no such vehicle (A) id=" .. tostring(args.vehicleA)) return end if not vehicleB then noise("no such vehicle (B) id=" .. tostring(args.vehicleB)) return end if vehicleA == vehicleB or not isPlayerAuthorizedForPair(player, vehicleA, vehicleB) then noise("rejected invalid or remote towbar attach") return end if hasAnyTowLink(vehicleA) or hasAnyTowLink(vehicleB) then noise("rejected conflicting towbar attach") return end if isExpectedTowBarPair(vehicleA, vehicleB) or isExpectedTowBarPair(vehicleB, vehicleA) or hasPendingAttach(vehicleA, vehicleB) or hasPendingAttach(vehicleB, vehicleA) then noise("rejected duplicate pending towbar attach") return end local towBarItem = findTowBarItem(player, args.itemId) if not towBarItem then noise("rejected towbar attach without item") return end if not removeTowBarItem(player, towBarItem) then noise("rejected towbar attach because item reservation failed") return end -- Constraint creation may complete on a later server tick in multiplayer. -- Record and queue the reserved item first so an immediate Build 42.20 -- spontaneous break can resolve the pair and consume the reservation once. markExpectedTowBarPair(vehicleA, vehicleB, args.attachmentA, args.attachmentB) queueSync("attach", player, args, true) vehicleA:addPointConstraint(player, vehicleB, args.attachmentA, args.attachmentB) if isLinked(vehicleA, vehicleB) then markTowBarPairConfirmed(vehicleA, vehicleB) end end function Commands.detachTowBar(player, args) local towingVehicle = args.towingVehicle and getVehicleById(args.towingVehicle) or nil local towedVehicle = args.vehicle and getVehicleById(args.vehicle) or nil if not towingVehicle and towedVehicle then towingVehicle = towedVehicle:getVehicleTowedBy() end if not towedVehicle and towingVehicle then towedVehicle = towingVehicle:getVehicleTowing() end if not isPlayerAuthorizedForPair(player, towingVehicle, towedVehicle) then noise("rejected unauthorized towbar detach") return end if not isLinked(towingVehicle, towedVehicle) then noise("rejected mismatched or stale towbar detach") return end if not isExpectedTowBarPair(towingVehicle, towedVehicle) and not isLegacyTowBarPair(towingVehicle, towedVehicle) then noise("rejected detach for a non-towbar or stale linked pair") return end local shouldRefund = hasTowBarState(towingVehicle) or hasTowBarState(towedVehicle) breakTowBarConstraint(towingVehicle, towedVehicle) forgetTowBarPair(towingVehicle, towedVehicle) clearExpectedTowBarPair(towingVehicle, towedVehicle) queueSync("detach", player, args) if shouldRefund then giveTowBar(player, true) end end function Commands.consumeTowBar(player, args) local itemId = args and args.itemId removeTowBarItem(player, findTowBarItem(player, itemId)) end giveTowBar = function(player, equipPrimary) if not player then return end local inventory = player:getInventory() if not inventory then return end local towBarItem = inventory:AddItem(TowBarItemType) if not towBarItem then return end sendAddItemToContainer(inventory, towBarItem) if equipPrimary then player:setPrimaryHandItem(towBarItem) sendEquip(player) end end -- Compatibility aliases for older command names. Commands.attachConstraint = Commands.attachTowBar Commands.detachConstraint = Commands.detachTowBar TowingCommands.OnClientCommand = function(module, command, player, args) if module == "vehicle" and command == "detachTrailerSpontaneous" then args = args or {} local vehicle = args.vehicle and getVehicleById(args.vehicle) or nil local towingVehicle, towedVehicle = resolveExpectedTowBarPair(vehicle) if towingVehicle and towedVehicle then finalizeBrokenTowBarPair(towingVehicle, towedVehicle, "detachTrailerSpontaneous") end return end if module == "towbar" and Commands[command] then local argStr = "" args = args or {} for k, v in pairs(args) do argStr = argStr .. " " .. tostring(k) .. "=" .. tostring(v) end noise("received " .. module .. " " .. command .. " " .. tostring(player) .. argStr) Commands[command](player, args) end end Events.OnClientCommand.Add(TowingCommands.OnClientCommand) Events.OnTick.Add(processPendingSync)