398 lines
16 KiB
Lua
398 lines
16 KiB
Lua
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)
|