if isClient() then return end require("TowBar/Persistence") if not TowBarMod or not TowBarMod.Wrecker then return end local Wrecker = TowBarMod.Wrecker local Commands = {} local AuditInterval = 30 local SnapshotInterval = 120 local CommandCooldownMs = 150 local auditTicks = 0 local snapshotTicks = 0 local commandTimes = {} local confirmedWreckerPairs = {} local wreckerPairRuntime = {} local RestoreRetryMs = 2000 local SustainedBreakMs = 5000 local function isInteger(value) return type(value) == "number" and value == math.floor(value) end local function resolveVehicleId(value) if not isInteger(value) or value < 0 then return nil end return getVehicleById(value) end local function isLinked(wrecker, target) return wrecker and target and wrecker:getVehicleTowing() == target and target:getVehicleTowedBy() == wrecker 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 findLoadedVehicle(runtimeId, sqlId) local stableId = tonumber(sqlId) if stableId and stableId >= 0 then local vehicles = Wrecker.getWorldVehicles() local iterator = vehicles and vehicles:iterator() or nil while iterator and iterator:hasNext() do local vehicle = iterator:next() if getPersistentVehicleId(vehicle) == stableId then return vehicle end end return nil end return runtimeId and getVehicleById(tonumber(runtimeId)) or 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 pairKey(wrecker, target) local wreckerId = getPersistentVehicleId(wrecker) or wrecker:getId() local targetId = getPersistentVehicleId(target) or target:getId() return tostring(wreckerId) .. ":" .. tostring(targetId) end local function markWreckerPairConfirmed(wrecker, target) local key = pairKey(wrecker, target) confirmedWreckerPairs[key] = true wreckerPairRuntime[key] = { confirmed = true } end local function isWreckerPairConfirmed(wrecker, target) return confirmedWreckerPairs[pairKey(wrecker, target)] == true end local function forgetWreckerPair(wrecker, target) local key = pairKey(wrecker, target) confirmedWreckerPairs[key] = nil wreckerPairRuntime[key] = nil end local function isExpectedPair(wrecker, target) if not wrecker or not target then return false end local wreckerMd = wrecker:getModData() local targetMd = target:getModData() return matchesSavedVehicle( wreckerMd.wreckerTowedVehicleId, wreckerMd.wreckerTowedVehicleSqlId, target ) and matchesSavedVehicle( targetMd.wreckerTowingVehicleId, targetMd.wreckerTowingVehicleSqlId, wrecker ) end local function setPairState(wrecker, target, targetAttachment, heightLevel) local wreckerMd = wrecker:getModData() local targetMd = target:getModData() wreckerMd.wreckerTowActive = true wreckerMd.wreckerTowedVehicleId = target:getId() wreckerMd.wreckerTowedVehicleSqlId = getPersistentVehicleId(target) wreckerMd.wreckerTowingVehicleId = nil wreckerMd.wreckerTowingVehicleSqlId = nil wreckerMd.wreckerTargetAttachment = targetAttachment wreckerMd.wreckerHeightLevel = Wrecker.normalizeHeightLevel(heightLevel) targetMd.wreckerTowingVehicleId = wrecker:getId() targetMd.wreckerTowingVehicleSqlId = getPersistentVehicleId(wrecker) targetMd.wreckerTowedVehicleId = nil targetMd.wreckerTowedVehicleSqlId = nil wrecker:transmitModData() target:transmitModData() TowBarMod.Persistence.savePair( "wrecker", wrecker, target, Wrecker.getHeightAttachmentId(wreckerMd.wreckerHeightLevel), targetAttachment, wreckerMd.wreckerHeightLevel ) end local function clearPairState(wrecker, target) if not wrecker or not target then return end local wreckerMd = wrecker:getModData() local targetMd = target:getModData() TowBarMod.Persistence.removePair("wrecker", wrecker, target) wreckerMd.wreckerTowActive = nil wreckerMd.wreckerTowedVehicleId = nil wreckerMd.wreckerTowedVehicleSqlId = nil wreckerMd.wreckerTowingVehicleId = nil wreckerMd.wreckerTowingVehicleSqlId = nil wreckerMd.wreckerTargetAttachment = nil wreckerMd.wreckerHeightLevel = nil targetMd.wreckerTowingVehicleId = nil targetMd.wreckerTowingVehicleSqlId = nil targetMd.wreckerTowedVehicleId = nil targetMd.wreckerTowedVehicleSqlId = nil wrecker:transmitModData() target:transmitModData() end local function broadcastWreckerSync(command, args) if isServer() then sendServerCommand("towbar", command, args) elseif TowBarMod.WreckerSync then if command == "wreckerAttachSync" then TowBarMod.WreckerSync.applyAttachSync(args) else TowBarMod.WreckerSync.applyDetachSync(args) end end end local function syncPairState(wrecker, target, hookAttachment, targetAttachment, heightLevel, player) if isServer() then -- Dedicated servers own the logical relation; clients replace their -- local rope with the rigid height-indexed constraint on sync. wrecker:addPointConstraint(player, target, hookAttachment, targetAttachment) end broadcastWreckerSync("wreckerAttachSync", { wrecker = wrecker:getId(), target = target:getId(), wreckerSqlId = getPersistentVehicleId(wrecker), targetSqlId = getPersistentVehicleId(target), targetAttachment = targetAttachment, heightLevel = heightLevel }) if isLinked(wrecker, target) then markWreckerPairConfirmed(wrecker, target) end end local function breakPair(wrecker, target) if isLinked(wrecker, target) then wrecker:breakConstraint(true, false) end end local function detachPair(wrecker, target) if not isExpectedPair(wrecker, target) then return false end breakPair(wrecker, target) forgetWreckerPair(wrecker, target) clearPairState(wrecker, target) broadcastWreckerSync("wreckerDetachSync", { wrecker = wrecker:getId(), target = target:getId(), wreckerSqlId = getPersistentVehicleId(wrecker) }) return true end function Commands.attachWrecker(player, args) if type(args) ~= "table" then return end local wrecker = resolveVehicleId(args.wrecker) local target = resolveVehicleId(args.target) if not wrecker or not target or wrecker == target then return end if not wrecker:isDriver(player) or not Wrecker.isSupportedWrecker(wrecker) then return end if wrecker:getVehicleTowing() or wrecker:getVehicleTowedBy() or target:getVehicleTowing() or target:getVehicleTowedBy() then return end local nearest = Wrecker.resolveNearestTarget(wrecker, Wrecker.getWorldVehicles()) if not nearest or nearest.vehicle ~= target then return end local targetAttachment = nearest.attachment local heightLevel = 0 local hookAttachment = Wrecker.getHeightAttachmentId(heightLevel) if not hookAttachment or not wrecker:attachmentExist(hookAttachment) then return end setPairState(wrecker, target, targetAttachment, heightLevel) syncPairState(wrecker, target, hookAttachment, targetAttachment, heightLevel, player) end function Commands.detachWrecker(player, args) if type(args) ~= "table" then return end local wrecker = resolveVehicleId(args.wrecker) local target = resolveVehicleId(args.target) if not wrecker or not target or not wrecker:isDriver(player) then return end detachPair(wrecker, target) end function Commands.adjustWreckerHeight(player, args) if type(args) ~= "table" then return end local wrecker = resolveVehicleId(args.wrecker) local direction = args.direction if not wrecker or not wrecker:isDriver(player) or not Wrecker.isSupportedWrecker(wrecker) then return end if direction ~= -1 and direction ~= 1 then return end local md = wrecker:getModData() local target = findLoadedVehicle(md.wreckerTowedVehicleId, md.wreckerTowedVehicleSqlId) if not target or not isExpectedPair(wrecker, target) or not isLinked(wrecker, target) then return end local currentLevel = tonumber(md.wreckerHeightLevel) or 0 local nextLevel = Wrecker.nextHeightLevel(currentLevel, direction) if not nextLevel or nextLevel == currentLevel then return end local hookAttachment = Wrecker.getHeightAttachmentId(nextLevel) local targetAttachment = md.wreckerTargetAttachment if not hookAttachment or not wrecker:attachmentExist(hookAttachment) or (targetAttachment ~= "trailer" and targetAttachment ~= "trailerfront") then return end breakPair(wrecker, target) setPairState(wrecker, target, targetAttachment, nextLevel) syncPairState(wrecker, target, hookAttachment, targetAttachment, nextLevel, player) end local function onClientCommand(module, command, player, args) if module == "towbar" and Commands[command] then if not player then return end local playerTimes = commandTimes[player] if not playerTimes then playerTimes = {} commandTimes[player] = playerTimes end local now = getTimestampMs() local previous = playerTimes[command] if previous and now - previous < CommandCooldownMs then return end playerTimes[command] = now Commands[command](player, args) return end if module == "vehicle" and command == "detachTrailerSpontaneous" then local vehicle = args and resolveVehicleId(args.vehicle) or nil if not vehicle then return end local md = vehicle:getModData() local wrecker = (md.wreckerTowedVehicleId or md.wreckerTowedVehicleSqlId) and vehicle or nil local target = wrecker and findLoadedVehicle( md.wreckerTowedVehicleId, md.wreckerTowedVehicleSqlId ) or nil if not wrecker and (md.wreckerTowingVehicleId or md.wreckerTowingVehicleSqlId) then wrecker = findLoadedVehicle(md.wreckerTowingVehicleId, md.wreckerTowingVehicleSqlId) target = vehicle end if wrecker and target then detachPair(wrecker, target) end end end local function auditWreckerPairs() auditTicks = auditTicks + 1 if auditTicks < AuditInterval then return end auditTicks = 0 snapshotTicks = snapshotTicks + AuditInterval local shouldSnapshot = snapshotTicks >= SnapshotInterval if shouldSnapshot then snapshotTicks = 0 end local vehicles = Wrecker.getWorldVehicles() if not vehicles then return end local loadedBySqlId = {} local scan = vehicles:iterator() while scan:hasNext() do local vehicle = scan:next() local sqlId = getPersistentVehicleId(vehicle) if sqlId then loadedBySqlId[sqlId] = vehicle end end local processed = {} local function reconcilePair(wrecker, target, targetAttachment, heightLevel) if not wrecker or not target or not Wrecker.isSupportedWrecker(wrecker) then return end local key = pairKey(wrecker, target) if processed[key] then return end processed[key] = true local canonicalLevel = Wrecker.normalizeHeightLevel(heightLevel) local canonicalTargetAttachment = targetAttachment if canonicalTargetAttachment ~= "trailer" and canonicalTargetAttachment ~= "trailerfront" then return end local hookAttachment = Wrecker.getHeightAttachmentId(canonicalLevel) if not hookAttachment or not wrecker:attachmentExist(hookAttachment) or not target:attachmentExist(canonicalTargetAttachment) then return end if not isExpectedPair(wrecker, target) then setPairState(wrecker, target, canonicalTargetAttachment, canonicalLevel) end if not isExpectedPair(wrecker, target) then return end local now = getTimestampMs() local state = wreckerPairRuntime[key] or {} local linked = isLinked(wrecker, target) local occupied = wrecker:getVehicleTowing() or wrecker:getVehicleTowedBy() or target:getVehicleTowing() or target:getVehicleTowedBy() local action state, action = TowBarMod.Persistence.advanceRecoveryState( state, now, linked, occupied ~= nil, RestoreRetryMs, SustainedBreakMs ) wreckerPairRuntime[key] = state if action == "adopt" then local wreckerMd = wrecker:getModData() local targetMd = target:getModData() if tonumber(wreckerMd.wreckerTowedVehicleId) ~= target:getId() or tonumber(targetMd.wreckerTowingVehicleId) ~= wrecker:getId() or tonumber(wreckerMd.wreckerTowedVehicleSqlId) ~= getPersistentVehicleId(target) or tonumber(targetMd.wreckerTowingVehicleSqlId) ~= getPersistentVehicleId(wrecker) then setPairState(wrecker, target, canonicalTargetAttachment, canonicalLevel) end TowBarMod.Persistence.savePair( "wrecker", wrecker, target, hookAttachment, canonicalTargetAttachment, canonicalLevel ) confirmedWreckerPairs[key] = true if shouldSnapshot then broadcastWreckerSync("wreckerAttachSync", { wrecker = wrecker:getId(), target = target:getId(), wreckerSqlId = getPersistentVehicleId(wrecker), targetSqlId = getPersistentVehicleId(target), targetAttachment = canonicalTargetAttachment, heightLevel = canonicalLevel }) end elseif action == "break" then detachPair(wrecker, target) elseif action == "restore" then setPairState(wrecker, target, canonicalTargetAttachment, canonicalLevel) syncPairState(wrecker, target, hookAttachment, canonicalTargetAttachment, canonicalLevel, nil) end end TowBarMod.Persistence.forEachPair("wrecker", function(record) local wrecker = loadedBySqlId[tonumber(record.towingSqlId)] local target = loadedBySqlId[tonumber(record.towedSqlId)] local key = tostring(record.towingSqlId) .. ":" .. tostring(record.towedSqlId) wreckerPairRuntime[key] = TowBarMod.Persistence.notePeerAvailability( wreckerPairRuntime[key], wrecker ~= nil and target ~= nil ) reconcilePair( wrecker, target, record.attachmentB, record.heightLevel ) end) local iterator = vehicles:iterator() while iterator:hasNext() do local vehicle = iterator:next() local vehicleMd = vehicle:getModData() local targetId = tonumber(vehicleMd.wreckerTowedVehicleId) local targetSqlId = tonumber(vehicleMd.wreckerTowedVehicleSqlId) if targetId or targetSqlId then local wrecker = vehicle local target = findLoadedVehicle(targetId, targetSqlId) if target then reconcilePair( wrecker, target, vehicleMd.wreckerTargetAttachment, vehicleMd.wreckerHeightLevel ) end end local towingVehicleId = tonumber(vehicleMd.wreckerTowingVehicleId) local towingVehicleSqlId = tonumber(vehicleMd.wreckerTowingVehicleSqlId) if towingVehicleId or towingVehicleSqlId then local towingVehicle = findLoadedVehicle(towingVehicleId, towingVehicleSqlId) if towingVehicle and not isExpectedPair(towingVehicle, vehicle) then vehicleMd.wreckerTowingVehicleId = nil vehicleMd.wreckerTowingVehicleSqlId = nil vehicle:transmitModData() broadcastWreckerSync("wreckerDetachSync", { wrecker = towingVehicleId, wreckerSqlId = towingVehicleSqlId, target = vehicle:getId() }) end end end end Events.OnClientCommand.Add(onClientCommand) Events.OnTick.Add(auditWreckerPairs)