Files
Towbar/42.20/media/lua/server/WreckerCommands.lua
T
2026-08-13 18:59:54 -04:00

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)