821 lines
30 KiB
Lua
821 lines
30 KiB
Lua
if isClient() then return end
|
|
require("TowBar/Persistence")
|
|
|
|
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 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 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 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)
|
|
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 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
|
|
|
|
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
|
|
|
|
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 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)
|