42.20 and Towtrucks

This commit is contained in:
2026-08-13 18:59:54 -04:00
parent a6ba4877d0
commit e403323017
33 changed files with 2333 additions and 142 deletions
+36 -43
View File
@@ -3,61 +3,54 @@ BTtow.Create = {}
BTtow.Init = {}
local TowbarVariantSize = 24
local TowbarNormalStart = 0
local TowbarLargeStart = 24
local TowbarMaxIndex = TowbarVariantSize - 1
local VanillaScaleMin = 1.5
local VanillaScaleMax = 2.0
local TowbarFirstZ = 1.0
local TowbarSlotStep = 0.1
-- Measured from Towbar.fbx at its original 0.01 script scale. Keep this in
-- lockstep with the client selector so the enlarged mesh's inner end remains
-- on the same vehicle-hitbox contact point.
local TowbarVisualScale = 2.5
local TowbarModelLength = 0.9714089036
local TowbarScaledModelLength = TowbarModelLength * TowbarVisualScale
local TowbarModelHalfLength = TowbarScaledModelLength / 2
local function getVehicleModelScale(script)
local function getTowbarFrontEdgeZ(script)
if not script then return nil end
local ok, result = pcall(function()
return script:getModelScale()
local ok, shape = pcall(function()
return script:getPhysicsChassisShape()
end)
if ok and type(result) == "number" then
return result
end
if not ok or not shape then return nil end
ok, result = pcall(function()
local model = script:getModel()
if model then
return model:getScale()
end
local shapeOk, shapeZ = pcall(function()
return shape:z()
end)
if not shapeOk or type(shapeZ) ~= "number" or shapeZ <= 0 then
return nil
end
local centerOk, center = pcall(function()
return script:getCenterOfMassOffset()
end)
if ok and type(result) == "number" then
return result
end
if not centerOk or not center then return nil end
return nil
end
local zOk, centerZ = pcall(function()
return center:z()
end)
if not zOk or type(centerZ) ~= "number" then return nil end
local function isVanillaScale(script)
local modelScale = getVehicleModelScale(script)
if modelScale == nil then
return true
end
local configuredMin = TowBarMod and TowBarMod.Config and tonumber(TowBarMod.Config.vanillaTowbarModelScaleMin)
local configuredMax = TowBarMod and TowBarMod.Config and tonumber(TowBarMod.Config.vanillaTowbarModelScaleMax)
local minScale = configuredMin or VanillaScaleMin
local maxScale = configuredMax or VanillaScaleMax
return modelScale >= minScale and modelScale <= maxScale
end
local function getTowbarIndexVanilla(script)
local z = script:getPhysicsChassisShape():z() / 2 - 0.1
local index = math.floor((z * 2 / 3 - 1) * 10)
return math.max(0, math.min(TowbarMaxIndex, index))
return centerZ + shapeZ / 2
end
local function getTowbarModelSlot(script)
if not isVanillaScale(script) then
-- KI5/small-scale vehicles use the normal towbar0 mesh at Z=1.
return 0
end
return getTowbarIndexVanilla(script)
local frontEdgeZ = getTowbarFrontEdgeZ(script)
if frontEdgeZ == nil then return nil end
-- Model offsets position the mesh origin. Move its center outward so the
-- towbar's inner end, rather than its center, meets the vehicle hitbox.
local modelCenterZ = frontEdgeZ + TowbarModelHalfLength
local index = math.floor(((modelCenterZ - TowbarFirstZ) / TowbarSlotStep) + 0.5)
return math.max(0, math.min(TowbarMaxIndex, index))
end
function BTtow.Create.towbar(vehicle, part)
@@ -81,7 +74,7 @@ function BTtow.Init.towbar(vehicle, part)
if script then
local index = getTowbarModelSlot(script)
local shouldShowOnThisPart = part:getId() == "towbar"
if shouldShowOnThisPart then
if shouldShowOnThisPart and index ~= nil then
part:setModelVisible("towbar" .. index, true)
end
end
+168 -17
View File
@@ -1,4 +1,5 @@
if isClient() then return end
require("TowBar/Persistence")
local TowingCommands = {}
local Commands = {}
@@ -10,6 +11,9 @@ local pendingSync = {}
local snapshotTickCounter = 0
local brokenPairAuditTickCounter = 0
local confirmedTowPairs = {}
local towPairRuntime = {}
local RestoreRetryMs = 2000
local SustainedBreakMs = 5000
local giveTowBar
TowingCommands.wantNoise = getDebug() or false
@@ -88,6 +92,22 @@ local function isLinked(vehicleA, vehicleB)
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
@@ -95,12 +115,17 @@ end
local function getTowBarPairKey(vehicleA, vehicleB)
if not vehicleA or not vehicleB then return nil end
return tostring(vehicleA:getId()) .. ":" .. tostring(vehicleB:getId())
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 end
if key then
confirmedTowPairs[key] = true
towPairRuntime[key] = { confirmed = true }
end
end
local function isTowBarPairConfirmed(vehicleA, vehicleB)
@@ -110,7 +135,10 @@ end
local function forgetTowBarPair(vehicleA, vehicleB)
local key = getTowBarPairKey(vehicleA, vehicleB)
if key then confirmedTowPairs[key] = nil end
if key then
confirmedTowPairs[key] = nil
towPairRuntime[key] = nil
end
end
local function hasTowBarState(vehicle)
@@ -130,25 +158,35 @@ local function markExpectedTowBarPair(vehicleA, vehicleB, attachmentA, attachmen
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
@@ -160,7 +198,9 @@ local function clearExpectedTowBarPair(vehicleA, vehicleB)
towedModData["isTowingByTowBar"] = false
towedModData["towed"] = false
towedModData["towBarTowedVehicleId"] = nil
towedModData["towBarTowedVehicleSqlId"] = nil
towedModData["towBarTowingVehicleId"] = nil
towedModData["towBarTowingVehicleSqlId"] = nil
towedModData["towBarExpectedAttachment"] = nil
vehicleB:transmitModData()
end
@@ -174,8 +214,11 @@ local function isExpectedTowBarPair(vehicleA, vehicleB)
local towedModData = vehicleB:getModData()
if not towingModData or not towedModData then return false end
return towingModData["towBarTowedVehicleId"] == vehicleB:getId()
and towedModData["towBarTowingVehicleId"] == vehicleA:getId()
return matchesSavedVehicle(
towingModData["towBarTowedVehicleId"], towingModData["towBarTowedVehicleSqlId"], vehicleB
) and matchesSavedVehicle(
towedModData["towBarTowingVehicleId"], towedModData["towBarTowingVehicleSqlId"], vehicleA
)
end
local function isLegacyTowBarPair(vehicleA, vehicleB)
@@ -295,14 +338,34 @@ local function forEachCollectionItem(collection, callback)
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
sendServerCommand("towbar", "forceAttachSync", {
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)
@@ -320,6 +383,33 @@ local function broadcastSpontaneousDetach(vehicleA, vehicleB)
})
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
@@ -400,19 +490,80 @@ local function reconcileBrokenTowBarPairsServer()
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 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
local towedVehicleSqlId = towingModData and towingModData["towBarTowedVehicleSqlId"] or nil
local towedVehicle = findLoadedVehicle(towedVehicleId, towedVehicleSqlId)
if towingVehicle and towedVehicle then reconcilePair(towingVehicle, towedVehicle) end
end)
end
+397
View File
@@ -0,0 +1,397 @@
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)