if isClient() then return end require("TowBar/Persistence") local TowingCommands = {} local Commands = {} local TowBarItemType = "TowBar.TowBar" local SnapshotIntervalTicks = 120 local BrokenPairAuditIntervalTicks = 5 local snapshotTickCounter = 0 local brokenPairAuditTickCounter = 0 local confirmedTowPairs = {} local towPairRuntime = {} local RestoreRetryMs = 2000 local SustainedBreakMs = 5000 local giveTowBar TowingCommands.wantNoise = getDebug() or false local noise = function(msg) if TowingCommands.wantNoise then print("TowBarCommands: " .. msg) end 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 getPersistentVehicleId(vehicle) if not vehicle or not vehicle.getSqlId then return nil end local ok, sqlId = pcall(function() return vehicle:getSqlId() end) if ok and type(sqlId) == "number" and sqlId >= 0 then return sqlId end return nil end local function matchesSavedVehicle(runtimeId, sqlId, vehicle) if not vehicle then return false end local stableId = tonumber(sqlId) if stableId and stableId >= 0 then return getPersistentVehicleId(vehicle) == stableId end return tonumber(runtimeId) == vehicle:getId() 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 local vehicleAId = getPersistentVehicleId(vehicleA) or vehicleA:getId() local vehicleBId = getPersistentVehicleId(vehicleB) or vehicleB:getId() return tostring(vehicleAId) .. ":" .. tostring(vehicleBId) end local function markTowBarPairConfirmed(vehicleA, vehicleB) local key = getTowBarPairKey(vehicleA, vehicleB) if key then confirmedTowPairs[key] = true towPairRuntime[key] = { confirmed = 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 towPairRuntime[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["towBarTowedVehicleSqlId"] = getPersistentVehicleId(vehicleB) towingModData["towBarTowingVehicleId"] = nil towingModData["towBarTowingVehicleSqlId"] = nil towingModData["towBarExpectedAttachment"] = attachmentA towedModData["isTowingByTowBar"] = true towedModData["towed"] = true towedModData["towBarTowedVehicleId"] = nil towedModData["towBarTowedVehicleSqlId"] = nil towedModData["towBarTowingVehicleId"] = vehicleA:getId() towedModData["towBarTowingVehicleSqlId"] = getPersistentVehicleId(vehicleA) towedModData["towBarExpectedAttachment"] = attachmentB vehicleA:transmitModData() vehicleB:transmitModData() TowBarMod.Persistence.savePair("towbar", vehicleA, vehicleB, attachmentA, attachmentB) end local function clearExpectedTowBarPair(vehicleA, vehicleB) if vehicleA and vehicleB then TowBarMod.Persistence.removePair("towbar", vehicleA, vehicleB) end if vehicleA then local towingModData = vehicleA:getModData() if towingModData then towingModData["isTowingByTowBar"] = false towingModData["towed"] = false towingModData["towBarTowedVehicleId"] = nil towingModData["towBarTowedVehicleSqlId"] = nil towingModData["towBarTowingVehicleId"] = nil towingModData["towBarTowingVehicleSqlId"] = 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["towBarTowedVehicleSqlId"] = nil towedModData["towBarTowingVehicleId"] = nil towedModData["towBarTowingVehicleSqlId"] = 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 matchesSavedVehicle( towingModData["towBarTowedVehicleId"], towingModData["towBarTowedVehicleSqlId"], vehicleB ) and matchesSavedVehicle( towedModData["towBarTowingVehicleId"], towedModData["towBarTowingVehicleSqlId"], vehicleA ) 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 findLoadedVehicle(runtimeId, sqlId) local stableId = tonumber(sqlId) if stableId and stableId >= 0 then local cell = getCell() local vehicles = cell and cell:getVehicles() or nil local match forEachCollectionItem(vehicles, function(vehicle) if not match and getPersistentVehicleId(vehicle) == stableId then match = vehicle end end) return match end return runtimeId and getVehicleById(tonumber(runtimeId)) or nil end local function broadcastAttach(vehicleA, vehicleB, attachmentA, attachmentB) if not vehicleA or not vehicleB then return end local args = { vehicleA = vehicleA:getId(), vehicleB = vehicleB:getId(), attachmentA = attachmentA, attachmentB = attachmentB } if isServer() then sendServerCommand("towbar", "forceAttachSync", args) elseif not isClient() and TowBarMod and TowBarMod.Sync and TowBarMod.Sync.applyAttachSync then TowBarMod.Sync.applyAttachSync(args) end end local function broadcastDetach(vehicleAId, vehicleBId) local args = { vehicleA = vehicleAId, vehicleB = vehicleBId } if isServer() then sendServerCommand("towbar", "forceDetachSync", args) elseif not isClient() and TowBarMod and TowBarMod.Sync and TowBarMod.Sync.applyDetachSync then TowBarMod.Sync.applyDetachSync(args) end 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 restorePersistedTowBarPair(towingVehicle, towedVehicle) if not isExpectedTowBarPair(towingVehicle, towedVehicle) or hasAnyTowLink(towingVehicle) or hasAnyTowLink(towedVehicle) then return false end local towingModData = towingVehicle:getModData() local towedModData = towedVehicle:getModData() local attachmentA = towingModData["towBarExpectedAttachment"] or "trailer" local attachmentB = towedModData["towBarExpectedAttachment"] or "trailerfront" if not towingVehicle:attachmentExist(attachmentA) or not towedVehicle:attachmentExist(attachmentB) then return false end markExpectedTowBarPair(towingVehicle, towedVehicle, attachmentA, attachmentB) if isServer() then towingVehicle:addPointConstraint(nil, towedVehicle, attachmentA, attachmentB) end broadcastAttach(towingVehicle, towedVehicle, attachmentA, attachmentB) if isLinked(towingVehicle, towedVehicle) then markTowBarPairConfirmed(towingVehicle, towedVehicle) return true end return false 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 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 local loadedBySqlId = {} forEachCollectionItem(vehicles, function(vehicle) local sqlId = getPersistentVehicleId(vehicle) if sqlId then loadedBySqlId[sqlId] = vehicle end end) local processed = {} local function reconcilePair(towingVehicle, towedVehicle, attachmentA, attachmentB) if not towingVehicle or not towedVehicle then return end local key = getTowBarPairKey(towingVehicle, towedVehicle) if not key or processed[key] then return end processed[key] = true if not isExpectedTowBarPair(towingVehicle, towedVehicle) then markExpectedTowBarPair( towingVehicle, towedVehicle, attachmentA or "trailer", attachmentB or "trailerfront" ) end if not isExpectedTowBarPair(towingVehicle, towedVehicle) then return end local now = getTimestampMs() local state = towPairRuntime[key] or {} local linked = isLinked(towingVehicle, towedVehicle) local occupied = hasAnyTowLink(towingVehicle) or hasAnyTowLink(towedVehicle) local action state, action = TowBarMod.Persistence.advanceRecoveryState( state, now, linked, occupied, RestoreRetryMs, SustainedBreakMs ) towPairRuntime[key] = state if action == "adopt" then local towingMd = towingVehicle:getModData() local towedMd = towedVehicle:getModData() local savedAttachmentA = towingMd["towBarExpectedAttachment"] or attachmentA or "trailer" local savedAttachmentB = towedMd["towBarExpectedAttachment"] or attachmentB or "trailerfront" if tonumber(towingMd["towBarTowedVehicleId"]) ~= towedVehicle:getId() or tonumber(towedMd["towBarTowingVehicleId"]) ~= towingVehicle:getId() then markExpectedTowBarPair( towingVehicle, towedVehicle, savedAttachmentA, savedAttachmentB ) end TowBarMod.Persistence.savePair( "towbar", towingVehicle, towedVehicle, savedAttachmentA, savedAttachmentB ) confirmedTowPairs[key] = true elseif action == "break" then finalizeBrokenTowBarPair(towingVehicle, towedVehicle, "sustained-physical-link-audit") elseif action == "restore" then restorePersistedTowBarPair(towingVehicle, towedVehicle) end end TowBarMod.Persistence.forEachPair("towbar", function(record) local towingVehicle = loadedBySqlId[tonumber(record.towingSqlId)] local towedVehicle = loadedBySqlId[tonumber(record.towedSqlId)] local key = tostring(record.towingSqlId) .. ":" .. tostring(record.towedSqlId) towPairRuntime[key] = TowBarMod.Persistence.notePeerAvailability( towPairRuntime[key], towingVehicle ~= nil and towedVehicle ~= nil ) reconcilePair( towingVehicle, towedVehicle, record.attachmentA, record.attachmentB ) end) forEachCollectionItem(vehicles, function(towingVehicle) local towingModData = towingVehicle and towingVehicle:getModData() or nil local towedVehicleId = towingModData and towingModData["towBarTowedVehicleId"] or nil local towedVehicleSqlId = towingModData and towingModData["towBarTowedVehicleSqlId"] or nil local towedVehicle = findLoadedVehicle(towedVehicleId, towedVehicleSqlId) if towingVehicle and towedVehicle then reconcilePair(towingVehicle, towedVehicle) end end) 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 processTowBarServerTick() reconcileBrokenTowBarPairsServer() snapshotTickCounter = snapshotTickCounter + 1 if snapshotTickCounter >= SnapshotIntervalTicks then snapshotTickCounter = 0 snapshotActiveTowbarLinksServer() end 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) 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 -- Match the working wrecker path: persist the accepted logical pair, ask -- the server for its native relation, then immediately tell clients to -- replace that relation with the local rigid fake-trailer constraint. markExpectedTowBarPair(vehicleA, vehicleB, args.attachmentA, args.attachmentB) if isServer() then vehicleA:addPointConstraint(player, vehicleB, args.attachmentA, args.attachmentB) end broadcastAttach(vehicleA, 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 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) broadcastDetach(towingVehicle:getId(), towedVehicle:getId()) 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(processTowBarServerTick)