42.20 and Towtrucks
@@ -6,5 +6,3 @@ TowBarMod.Config.rigidTowbarDistance = 1.0
|
|||||||
TowBarMod.Config.towAttachmentExteriorPadding = 0.25
|
TowBarMod.Config.towAttachmentExteriorPadding = 0.25
|
||||||
TowBarMod.Config.towedVehicleRollingMass = 75
|
TowBarMod.Config.towedVehicleRollingMass = 75
|
||||||
TowBarMod.Config.devMode = false
|
TowBarMod.Config.devMode = false
|
||||||
TowBarMod.Config.vanillaTowbarModelScaleMin = 1.5
|
|
||||||
TowBarMod.Config.vanillaTowbarModelScaleMax = 2.0
|
|
||||||
|
|||||||
@@ -4,6 +4,23 @@ if not TowBarMod then TowBarMod = {} end
|
|||||||
TowBarMod.Sync = TowBarMod.Sync or {}
|
TowBarMod.Sync = TowBarMod.Sync or {}
|
||||||
if TowBarMod.Sync._towSyncClientLoaded then return end
|
if TowBarMod.Sync._towSyncClientLoaded then return end
|
||||||
TowBarMod.Sync._towSyncClientLoaded = true
|
TowBarMod.Sync._towSyncClientLoaded = true
|
||||||
|
TowBarMod.Sync.appliedPairs = TowBarMod.Sync.appliedPairs or {}
|
||||||
|
|
||||||
|
local function pairKeyContainsVehicle(key, vehicleId)
|
||||||
|
local id = tostring(vehicleId)
|
||||||
|
return string.sub(key, 1, #id + 1) == id .. ":"
|
||||||
|
or string.sub(key, -(#id + 1)) == ":" .. id
|
||||||
|
end
|
||||||
|
|
||||||
|
local function clearAppliedPairForVehicle(vehicle)
|
||||||
|
if not vehicle then return end
|
||||||
|
local vehicleId = vehicle:getId()
|
||||||
|
for key in pairs(TowBarMod.Sync.appliedPairs) do
|
||||||
|
if pairKeyContainsVehicle(key, vehicleId) then
|
||||||
|
TowBarMod.Sync.appliedPairs[key] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
local function resolveVehicle(id)
|
local function resolveVehicle(id)
|
||||||
if not id then return nil end
|
if not id then return nil end
|
||||||
@@ -89,6 +106,8 @@ local function reconcilePairState(vehicleA, vehicleB, attachmentA, attachmentB)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local breakTowBarPair
|
||||||
|
|
||||||
local function applyAttachSync(args)
|
local function applyAttachSync(args)
|
||||||
if not args then return end
|
if not args then return end
|
||||||
|
|
||||||
@@ -102,8 +121,25 @@ local function applyAttachSync(args)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if not isLinked(vehicleA, vehicleB) then
|
local key = tostring(vehicleA:getId()) .. ":" .. tostring(vehicleB:getId())
|
||||||
vehicleA:addPointConstraint(nil, vehicleB, attachmentA, attachmentB)
|
if TowBarMod.Sync.appliedPairs[key] and not isLinked(vehicleA, vehicleB) then
|
||||||
|
-- Vehicle streaming can remove the native constraint while the Lua
|
||||||
|
-- module remains loaded. Allow the authoritative recovery sync to
|
||||||
|
-- rebuild local physics for the returning pair.
|
||||||
|
TowBarMod.Sync.appliedPairs[key] = nil
|
||||||
|
end
|
||||||
|
if not TowBarMod.Sync.appliedPairs[key] then
|
||||||
|
-- The server initially creates a normal Build 42 tow relation. It may
|
||||||
|
-- also restore one from the save. Replace that native rope exactly
|
||||||
|
-- once with this mod's rigid fake-trailer constraint.
|
||||||
|
breakTowBarPair(vehicleA, vehicleB)
|
||||||
|
if TowBarMod.Hook and TowBarMod.Hook.setVehicleScriptWithTowBarHidden then
|
||||||
|
TowBarMod.Hook.setVehicleScriptWithTowBarHidden(vehicleB, "notTowingA_Trailer")
|
||||||
|
end
|
||||||
|
-- The final true keeps this a local physics rebuild. The server owns
|
||||||
|
-- the persisted logical relation and must not receive a detach/attach race.
|
||||||
|
vehicleA:addPointConstraint(nil, vehicleB, attachmentA, attachmentB, true)
|
||||||
|
TowBarMod.Sync.appliedPairs[key] = true
|
||||||
end
|
end
|
||||||
|
|
||||||
reconcilePairState(vehicleA, vehicleB, attachmentA, attachmentB)
|
reconcilePairState(vehicleA, vehicleB, attachmentA, attachmentB)
|
||||||
@@ -130,18 +166,17 @@ local function hasConflictingTowLink(vehicle, expectedOther)
|
|||||||
and modData["towBarTowingVehicleId"] ~= expectedOtherId)
|
and modData["towBarTowingVehicleId"] ~= expectedOtherId)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function breakTowBarPair(vehicleA, vehicleB)
|
breakTowBarPair = function(vehicleA, vehicleB)
|
||||||
if not vehicleA or not vehicleB then return end
|
if not vehicleA or not vehicleB then return end
|
||||||
|
|
||||||
local vehicleAReferencesB = vehicleA:getVehicleTowing() == vehicleB
|
local vehicleAReferencesB = vehicleA:getVehicleTowing() == vehicleB
|
||||||
or vehicleA:getVehicleTowedBy() == vehicleB
|
or vehicleA:getVehicleTowedBy() == vehicleB
|
||||||
local vehicleBReferencesA = vehicleB:getVehicleTowing() == vehicleA
|
local vehicleBReferencesA = vehicleB:getVehicleTowing() == vehicleA
|
||||||
or vehicleB:getVehicleTowedBy() == vehicleA
|
or vehicleB:getVehicleTowedBy() == vehicleA
|
||||||
if vehicleBReferencesA then
|
|
||||||
vehicleB:breakConstraint(true, true)
|
|
||||||
end
|
|
||||||
if vehicleAReferencesB then
|
if vehicleAReferencesB then
|
||||||
vehicleA:breakConstraint(true, true)
|
vehicleA:breakConstraint(true, true)
|
||||||
|
elseif vehicleBReferencesA then
|
||||||
|
vehicleB:breakConstraint(true, true)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -154,6 +189,7 @@ local function applyDetachSync(args)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
breakTowBarPair(vehicleA, vehicleB)
|
breakTowBarPair(vehicleA, vehicleB)
|
||||||
|
TowBarMod.Sync.appliedPairs[tostring(vehicleA:getId()) .. ":" .. tostring(vehicleB:getId())] = nil
|
||||||
|
|
||||||
if TowBarMod.Hook and TowBarMod.Hook.cleanupDetachedTowBar then
|
if TowBarMod.Hook and TowBarMod.Hook.cleanupDetachedTowBar then
|
||||||
pcall(TowBarMod.Hook.cleanupDetachedTowBar, vehicleA, vehicleB)
|
pcall(TowBarMod.Hook.cleanupDetachedTowBar, vehicleA, vehicleB)
|
||||||
@@ -170,4 +206,10 @@ local function onServerCommand(module, command, args)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
TowBarMod.Sync.applyAttachSync = applyAttachSync
|
||||||
|
TowBarMod.Sync.applyDetachSync = applyDetachSync
|
||||||
|
|
||||||
Events.OnServerCommand.Add(onServerCommand)
|
Events.OnServerCommand.Add(onServerCommand)
|
||||||
|
if Events.OnSpawnVehicleEnd then
|
||||||
|
Events.OnSpawnVehicleEnd.Add(clearAppliedPairForVehicle)
|
||||||
|
end
|
||||||
|
|||||||
@@ -128,61 +128,54 @@ local function sendTowAttachCommand(playerObj, args)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local TowbarVariantSize = 24
|
local TowbarVariantSize = 24
|
||||||
local TowbarNormalStart = 0
|
|
||||||
local TowbarLargeStart = 24
|
|
||||||
local TowbarMaxIndex = TowbarVariantSize - 1
|
local TowbarMaxIndex = TowbarVariantSize - 1
|
||||||
local VanillaScaleMin = 1.5
|
local TowbarFirstZ = 1.0
|
||||||
local VanillaScaleMax = 2.0
|
local TowbarSlotStep = 0.1
|
||||||
|
-- Measured from Towbar.fbx at its original 0.01 script scale. The visual is
|
||||||
|
-- enlarged uniformly, so its center must move outward by the scaled half-length
|
||||||
|
-- to keep the inner end 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
|
if not script then return nil end
|
||||||
|
|
||||||
local ok, result = pcall(function()
|
local ok, shape = pcall(function()
|
||||||
return script:getModelScale()
|
return script:getPhysicsChassisShape()
|
||||||
end)
|
end)
|
||||||
if ok and type(result) == "number" then
|
if not ok or not shape then return nil end
|
||||||
return result
|
|
||||||
end
|
|
||||||
|
|
||||||
ok, result = pcall(function()
|
local shapeOk, shapeZ = pcall(function()
|
||||||
local model = script:getModel()
|
return shape:z()
|
||||||
if model then
|
end)
|
||||||
return model:getScale()
|
if not shapeOk or type(shapeZ) ~= "number" or shapeZ <= 0 then
|
||||||
end
|
|
||||||
return nil
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local centerOk, center = pcall(function()
|
||||||
|
return script:getCenterOfMassOffset()
|
||||||
end)
|
end)
|
||||||
if ok and type(result) == "number" then
|
if not centerOk or not center then return nil end
|
||||||
return result
|
|
||||||
end
|
|
||||||
|
|
||||||
return nil
|
local zOk, centerZ = pcall(function()
|
||||||
end
|
return center:z()
|
||||||
|
end)
|
||||||
|
if not zOk or type(centerZ) ~= "number" then return nil end
|
||||||
|
|
||||||
local function isVanillaScale(script)
|
return centerZ + shapeZ / 2
|
||||||
local modelScale = getVehicleModelScale(script)
|
|
||||||
if modelScale == nil then
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
|
|
||||||
local configuredMin = TowBarMod.Config and tonumber(TowBarMod.Config.vanillaTowbarModelScaleMin)
|
|
||||||
local configuredMax = 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))
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function getTowbarModelSlot(script)
|
local function getTowbarModelSlot(script)
|
||||||
if not isVanillaScale(script) then
|
local frontEdgeZ = getTowbarFrontEdgeZ(script)
|
||||||
-- KI5/small-scale vehicles use the normal towbar0 mesh at Z=1.
|
if frontEdgeZ == nil then return nil end
|
||||||
return 0
|
|
||||||
end
|
-- Model offsets position the mesh origin. Move its center outward so the
|
||||||
return getTowbarIndexVanilla(script)
|
-- 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
|
end
|
||||||
|
|
||||||
local function setTowBarModelVisible(vehicle, isVisible)
|
local function setTowBarModelVisible(vehicle, isVisible)
|
||||||
@@ -210,7 +203,7 @@ local function setTowBarModelVisible(vehicle, isVisible)
|
|||||||
|
|
||||||
local index = getTowbarModelSlot(script)
|
local index = getTowbarModelSlot(script)
|
||||||
local part = normalPart
|
local part = normalPart
|
||||||
if part then
|
if part and index ~= nil then
|
||||||
part:setModelVisible("towbar" .. index, true)
|
part:setModelVisible("towbar" .. index, true)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -409,14 +402,10 @@ local function recoverTowBarVehicleAfterLoad(playerObj, vehicle, retriesLeft)
|
|||||||
|
|
||||||
if towingVehicle then
|
if towingVehicle then
|
||||||
-- Apply rigid spacing as soon as the tow link exists to avoid a visible
|
-- Apply rigid spacing as soon as the tow link exists to avoid a visible
|
||||||
-- bumper-to-bumper snap while waiting for reattach recovery.
|
-- bumper-to-bumper snap. Server/SP reconciliation owns recovery and
|
||||||
|
-- never refunds or consumes an item while loading a save.
|
||||||
TowBarMod.Hook.setVehiclePostAttach(nil, vehicle)
|
TowBarMod.Hook.setVehiclePostAttach(nil, vehicle)
|
||||||
end
|
return
|
||||||
|
|
||||||
if localPlayer and towingVehicle then
|
|
||||||
if reattachTowBarPairAfterCleanDetach(localPlayer, towingVehicle, vehicle, false) then
|
|
||||||
return
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
if localPlayer and retries > 0 then
|
if localPlayer and retries > 0 then
|
||||||
@@ -425,9 +414,8 @@ local function recoverTowBarVehicleAfterLoad(playerObj, vehicle, retriesLeft)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Fallback: keep original post-attach restoration behavior.
|
-- Leave reciprocal saved state untouched. The authoritative audit will
|
||||||
setTowBarModelVisible(vehicle, true)
|
-- reconnect it when both vehicle chunks are loaded.
|
||||||
TowBarMod.Hook.setVehiclePostAttach(nil, vehicle)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function TowBarMod.Hook.setVehiclePostAttach(playerObj, towedVehicle, retriesLeft)
|
function TowBarMod.Hook.setVehiclePostAttach(playerObj, towedVehicle, retriesLeft)
|
||||||
@@ -514,8 +502,6 @@ end
|
|||||||
function TowBarMod.Hook.cleanupDetachedTowBar(towingVehicle, towedVehicle)
|
function TowBarMod.Hook.cleanupDetachedTowBar(towingVehicle, towedVehicle)
|
||||||
if towingVehicle == nil or towedVehicle == nil then return end
|
if towingVehicle == nil or towedVehicle == nil then return end
|
||||||
|
|
||||||
TowBarMod.Utils.updateAttachmentsOnDefaultValues(towingVehicle, towedVehicle)
|
|
||||||
|
|
||||||
local towingModData = towingVehicle:getModData()
|
local towingModData = towingVehicle:getModData()
|
||||||
local towedModData = towedVehicle:getModData()
|
local towedModData = towedVehicle:getModData()
|
||||||
if not towingModData or not towedModData then return end
|
if not towingModData or not towedModData then return end
|
||||||
@@ -523,17 +509,25 @@ function TowBarMod.Hook.cleanupDetachedTowBar(towingVehicle, towedVehicle)
|
|||||||
if towedModData.towBarOriginalScriptName then
|
if towedModData.towBarOriginalScriptName then
|
||||||
TowBarMod.Hook.setVehicleScriptWithTowBarHidden(towedVehicle, towedModData.towBarOriginalScriptName)
|
TowBarMod.Hook.setVehicleScriptWithTowBarHidden(towedVehicle, towedModData.towBarOriginalScriptName)
|
||||||
end
|
end
|
||||||
|
-- A very fast break can happen while the temporary fake-trailer script is
|
||||||
|
-- still active. Restore the real script first so the saved offsets are
|
||||||
|
-- written back to the actual vehicle attachments, not the temporary script.
|
||||||
|
TowBarMod.Utils.updateAttachmentsOnDefaultValues(towingVehicle, towedVehicle)
|
||||||
restoreFreeRollingTowState(towedVehicle, towedModData)
|
restoreFreeRollingTowState(towedVehicle, towedModData)
|
||||||
|
|
||||||
towingModData["isTowingByTowBar"] = false
|
towingModData["isTowingByTowBar"] = false
|
||||||
towingModData["towed"] = false
|
towingModData["towed"] = false
|
||||||
towingModData["towBarTowedVehicleId"] = nil
|
towingModData["towBarTowedVehicleId"] = nil
|
||||||
|
towingModData["towBarTowedVehicleSqlId"] = nil
|
||||||
towingModData["towBarTowingVehicleId"] = nil
|
towingModData["towBarTowingVehicleId"] = nil
|
||||||
|
towingModData["towBarTowingVehicleSqlId"] = nil
|
||||||
towingModData["towBarExpectedAttachment"] = nil
|
towingModData["towBarExpectedAttachment"] = nil
|
||||||
towedModData["isTowingByTowBar"] = false
|
towedModData["isTowingByTowBar"] = false
|
||||||
towedModData["towed"] = false
|
towedModData["towed"] = false
|
||||||
towedModData["towBarTowedVehicleId"] = nil
|
towedModData["towBarTowedVehicleId"] = nil
|
||||||
|
towedModData["towBarTowedVehicleSqlId"] = nil
|
||||||
towedModData["towBarTowingVehicleId"] = nil
|
towedModData["towBarTowingVehicleId"] = nil
|
||||||
|
towedModData["towBarTowingVehicleSqlId"] = nil
|
||||||
towedModData["towBarExpectedAttachment"] = nil
|
towedModData["towBarExpectedAttachment"] = nil
|
||||||
towedModData.towBarOriginalScriptName = nil
|
towedModData.towBarOriginalScriptName = nil
|
||||||
towedModData.towBarOriginalMass = nil
|
towedModData.towBarOriginalMass = nil
|
||||||
@@ -762,7 +756,6 @@ function TowBarMod.Hook.devShowAllTowbarModels(playerObj, vehicle)
|
|||||||
local script = vehicle:getScript()
|
local script = vehicle:getScript()
|
||||||
local chassisZ = script and script:getPhysicsChassisShape():z() or 0
|
local chassisZ = script and script:getPhysicsChassisShape():z() or 0
|
||||||
local halfZ = chassisZ / 2
|
local halfZ = chassisZ / 2
|
||||||
local modelScale = script and getVehicleModelScale(script) or nil
|
|
||||||
local index = 0
|
local index = 0
|
||||||
if script then
|
if script then
|
||||||
index = getTowbarModelSlot(script)
|
index = getTowbarModelSlot(script)
|
||||||
@@ -770,7 +763,7 @@ function TowBarMod.Hook.devShowAllTowbarModels(playerObj, vehicle)
|
|||||||
local selectedPart = "towbar"
|
local selectedPart = "towbar"
|
||||||
print("[TowBar DEV] Vehicle: " .. tostring(vehicle:getScriptName()))
|
print("[TowBar DEV] Vehicle: " .. tostring(vehicle:getScriptName()))
|
||||||
print("[TowBar DEV] chassisShape.z = " .. tostring(chassisZ) .. ", half = " .. tostring(halfZ))
|
print("[TowBar DEV] chassisShape.z = " .. tostring(chassisZ) .. ", half = " .. tostring(halfZ))
|
||||||
print("[TowBar DEV] modelScale = " .. tostring(modelScale) .. ", part = " .. selectedPart)
|
print("[TowBar DEV] frontEdgeZ = " .. tostring(script and getTowbarFrontEdgeZ(script) or nil) .. ", part = " .. selectedPart)
|
||||||
print("[TowBar DEV] Formula picks index = " .. tostring(index) .. " (towbar" .. tostring(index) .. " at Z offset " .. tostring(1.0 + index * 0.1) .. ")")
|
print("[TowBar DEV] Formula picks index = " .. tostring(index) .. " (towbar" .. tostring(index) .. " at Z offset " .. tostring(1.0 + index * 0.1) .. ")")
|
||||||
print("[TowBar DEV] Showing towbar0..towbar23 on both parts")
|
print("[TowBar DEV] Showing towbar0..towbar23 on both parts")
|
||||||
for j = 0, TowbarVariantSize - 1 do
|
for j = 0, TowbarVariantSize - 1 do
|
||||||
|
|||||||
@@ -215,7 +215,13 @@ end
|
|||||||
function ISVehicleMenu.showRadialMenu(playerObj)
|
function ISVehicleMenu.showRadialMenu(playerObj)
|
||||||
TowBarMod.UI.defaultShowRadialMenu(playerObj)
|
TowBarMod.UI.defaultShowRadialMenu(playerObj)
|
||||||
|
|
||||||
if playerObj:getVehicle() then return end
|
local currentVehicle = playerObj:getVehicle()
|
||||||
|
if currentVehicle then
|
||||||
|
if TowBarMod.WreckerUI then
|
||||||
|
TowBarMod.WreckerUI.addOptionsToMenu(playerObj, currentVehicle)
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
local vehicle = ISVehicleMenu.getVehicleToInteractWith(playerObj)
|
local vehicle = ISVehicleMenu.getVehicleToInteractWith(playerObj)
|
||||||
if vehicle == nil then return end
|
if vehicle == nil then return end
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
if isServer() then return end
|
||||||
|
if not TowBarMod then TowBarMod = {} end
|
||||||
|
TowBarMod.WreckerSync = TowBarMod.WreckerSync or {}
|
||||||
|
|
||||||
|
local Sync = TowBarMod.WreckerSync
|
||||||
|
Sync.appliedLevels = Sync.appliedLevels or {}
|
||||||
|
|
||||||
|
local function pairKeyContainsVehicle(key, vehicleId)
|
||||||
|
local id = tostring(vehicleId)
|
||||||
|
return string.sub(key, 1, #id + 1) == id .. ":"
|
||||||
|
or string.sub(key, -(#id + 1)) == ":" .. id
|
||||||
|
end
|
||||||
|
|
||||||
|
local function clearAppliedLevelForVehicle(vehicle)
|
||||||
|
if not vehicle then return end
|
||||||
|
local vehicleId = vehicle:getId()
|
||||||
|
for key in pairs(Sync.appliedLevels) do
|
||||||
|
if pairKeyContainsVehicle(key, vehicleId) then
|
||||||
|
Sync.appliedLevels[key] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function pairKey(wrecker, target)
|
||||||
|
return tostring(wrecker:getId()) .. ":" .. tostring(target:getId())
|
||||||
|
end
|
||||||
|
|
||||||
|
local function callVehicle(vehicle, methodName, value)
|
||||||
|
local method = vehicle and vehicle[methodName] or nil
|
||||||
|
if not method then return false, nil end
|
||||||
|
return pcall(function()
|
||||||
|
if value ~= nil then return method(vehicle, value) end
|
||||||
|
return method(vehicle)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function applyFreeRollingState(vehicle)
|
||||||
|
local md = vehicle and vehicle:getModData() or nil
|
||||||
|
if not md then return end
|
||||||
|
local capturedOriginal = false
|
||||||
|
if md.wreckerOriginalMass == nil then
|
||||||
|
md.wreckerOriginalMass = vehicle:getMass()
|
||||||
|
capturedOriginal = true
|
||||||
|
end
|
||||||
|
if md.wreckerOriginalBrakingForce == nil then
|
||||||
|
md.wreckerOriginalBrakingForce = vehicle:getBrakingForce()
|
||||||
|
capturedOriginal = true
|
||||||
|
end
|
||||||
|
if md.wreckerOriginalParkingBrakeOn == nil then
|
||||||
|
local ok, value = callVehicle(vehicle, "isParkingBrakeOn")
|
||||||
|
if ok then
|
||||||
|
md.wreckerOriginalParkingBrakeOn = value
|
||||||
|
capturedOriginal = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if md.wreckerOriginalParkingBrake == nil then
|
||||||
|
local ok, value = callVehicle(vehicle, "getParkingBrake")
|
||||||
|
if ok then
|
||||||
|
md.wreckerOriginalParkingBrake = value
|
||||||
|
capturedOriginal = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if md.wreckerOriginalHandbrake == nil then
|
||||||
|
local ok, value = callVehicle(vehicle, "isHandbrake")
|
||||||
|
if ok then
|
||||||
|
md.wreckerOriginalHandbrake = value
|
||||||
|
capturedOriginal = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
vehicle:setMass(200)
|
||||||
|
vehicle:setBrakingForce(0)
|
||||||
|
if md.wreckerOriginalParkingBrakeOn ~= nil then callVehicle(vehicle, "setParkingBrakeOn", false) end
|
||||||
|
if md.wreckerOriginalParkingBrake ~= nil then callVehicle(vehicle, "setParkingBrake", false) end
|
||||||
|
if md.wreckerOriginalHandbrake ~= nil then callVehicle(vehicle, "setHandbrake", false) end
|
||||||
|
if capturedOriginal then vehicle:transmitModData() end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function restoreFreeRollingState(vehicle)
|
||||||
|
local md = vehicle and vehicle:getModData() or nil
|
||||||
|
if not md then return end
|
||||||
|
if md.wreckerOriginalMass ~= nil then vehicle:setMass(md.wreckerOriginalMass) end
|
||||||
|
if md.wreckerOriginalBrakingForce ~= nil then vehicle:setBrakingForce(md.wreckerOriginalBrakingForce) end
|
||||||
|
if md.wreckerOriginalParkingBrakeOn ~= nil then
|
||||||
|
callVehicle(vehicle, "setParkingBrakeOn", md.wreckerOriginalParkingBrakeOn)
|
||||||
|
end
|
||||||
|
if md.wreckerOriginalParkingBrake ~= nil then
|
||||||
|
callVehicle(vehicle, "setParkingBrake", md.wreckerOriginalParkingBrake)
|
||||||
|
end
|
||||||
|
if md.wreckerOriginalHandbrake ~= nil then
|
||||||
|
callVehicle(vehicle, "setHandbrake", md.wreckerOriginalHandbrake)
|
||||||
|
end
|
||||||
|
md.wreckerOriginalMass = nil
|
||||||
|
md.wreckerOriginalBrakingForce = nil
|
||||||
|
md.wreckerOriginalParkingBrakeOn = nil
|
||||||
|
md.wreckerOriginalParkingBrake = nil
|
||||||
|
md.wreckerOriginalHandbrake = nil
|
||||||
|
vehicle:transmitModData()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function isPairLinked(wrecker, target)
|
||||||
|
return wrecker and target
|
||||||
|
and wrecker:getVehicleTowing() == target
|
||||||
|
and target:getVehicleTowedBy() == wrecker
|
||||||
|
end
|
||||||
|
|
||||||
|
local function hasConflictingLink(vehicle, other)
|
||||||
|
return vehicle and ((vehicle:getVehicleTowing() and vehicle:getVehicleTowing() ~= other)
|
||||||
|
or (vehicle:getVehicleTowedBy() and vehicle:getVehicleTowedBy() ~= other))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function breakWreckerPair(wrecker, target)
|
||||||
|
if not wrecker or not target then return end
|
||||||
|
if isPairLinked(wrecker, target) then
|
||||||
|
wrecker:breakConstraint(true, true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function setScriptSafely(vehicle, scriptName)
|
||||||
|
if TowBarMod.Hook and TowBarMod.Hook.setVehicleScriptWithTowBarHidden then
|
||||||
|
return TowBarMod.Hook.setVehicleScriptWithTowBarHidden(vehicle, scriptName)
|
||||||
|
end
|
||||||
|
vehicle:setScriptName(scriptName)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function applyAttachSync(args)
|
||||||
|
if not args then return end
|
||||||
|
local wrecker = getVehicleById(args.wrecker)
|
||||||
|
local target = getVehicleById(args.target)
|
||||||
|
if not wrecker or not target then return end
|
||||||
|
if hasConflictingLink(wrecker, target) or hasConflictingLink(target, wrecker) then return end
|
||||||
|
|
||||||
|
local hookAttachment = TowBarMod.Wrecker.getHeightAttachmentId(args.heightLevel)
|
||||||
|
local targetAttachment = args.targetAttachment
|
||||||
|
if not hookAttachment or (targetAttachment ~= "trailer" and targetAttachment ~= "trailerfront") then return end
|
||||||
|
if not wrecker:attachmentExist(hookAttachment) or not target:attachmentExist(targetAttachment) then return end
|
||||||
|
|
||||||
|
local key = pairKey(wrecker, target)
|
||||||
|
local canonicalLevel = tonumber(args.heightLevel) or 0
|
||||||
|
local appliedLevel = Sync.appliedLevels[key]
|
||||||
|
if isPairLinked(wrecker, target) and appliedLevel == canonicalLevel then
|
||||||
|
Sync.appliedLevels[key] = canonicalLevel
|
||||||
|
applyFreeRollingState(target)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
breakWreckerPair(wrecker, target)
|
||||||
|
local targetMd = target:getModData()
|
||||||
|
local originalScript = targetMd.wreckerOriginalScriptName or target:getScriptName()
|
||||||
|
targetMd.wreckerOriginalScriptName = originalScript
|
||||||
|
applyFreeRollingState(target)
|
||||||
|
|
||||||
|
setScriptSafely(target, "notTowingA_Trailer")
|
||||||
|
wrecker:addPointConstraint(nil, target, hookAttachment, targetAttachment, true)
|
||||||
|
setScriptSafely(target, originalScript)
|
||||||
|
|
||||||
|
local wreckerMd = wrecker:getModData()
|
||||||
|
wreckerMd.wreckerTowActive = true
|
||||||
|
wreckerMd.wreckerTowedVehicleId = target:getId()
|
||||||
|
wreckerMd.wreckerTowedVehicleSqlId = tonumber(args.targetSqlId)
|
||||||
|
wreckerMd.wreckerTargetAttachment = targetAttachment
|
||||||
|
wreckerMd.wreckerHeightLevel = canonicalLevel
|
||||||
|
targetMd.wreckerTowingVehicleId = wrecker:getId()
|
||||||
|
targetMd.wreckerTowingVehicleSqlId = tonumber(args.wreckerSqlId)
|
||||||
|
Sync.appliedLevels[key] = canonicalLevel
|
||||||
|
end
|
||||||
|
|
||||||
|
local function applyDetachSync(args)
|
||||||
|
if not args then return end
|
||||||
|
local wrecker = getVehicleById(args.wrecker)
|
||||||
|
local target = getVehicleById(args.target)
|
||||||
|
if not target then return end
|
||||||
|
local targetMd = target:getModData()
|
||||||
|
local expectedWreckerId = tonumber(targetMd.wreckerTowingVehicleId)
|
||||||
|
local expectedWreckerSqlId = tonumber(targetMd.wreckerTowingVehicleSqlId)
|
||||||
|
local commandWreckerSqlId = tonumber(args.wreckerSqlId)
|
||||||
|
if expectedWreckerSqlId and commandWreckerSqlId then
|
||||||
|
if expectedWreckerSqlId ~= commandWreckerSqlId then return end
|
||||||
|
elseif expectedWreckerId and expectedWreckerId ~= tonumber(args.wrecker) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if wrecker and (hasConflictingLink(wrecker, target) or hasConflictingLink(target, wrecker)) then return end
|
||||||
|
if wrecker then breakWreckerPair(wrecker, target) end
|
||||||
|
if targetMd.wreckerOriginalScriptName and target:getScriptName() ~= targetMd.wreckerOriginalScriptName then
|
||||||
|
setScriptSafely(target, targetMd.wreckerOriginalScriptName)
|
||||||
|
end
|
||||||
|
restoreFreeRollingState(target)
|
||||||
|
if wrecker then Sync.appliedLevels[pairKey(wrecker, target)] = nil end
|
||||||
|
|
||||||
|
if wrecker then
|
||||||
|
local wreckerMd = wrecker:getModData()
|
||||||
|
wreckerMd.wreckerTowActive = nil
|
||||||
|
wreckerMd.wreckerTowedVehicleId = nil
|
||||||
|
wreckerMd.wreckerTowedVehicleSqlId = nil
|
||||||
|
wreckerMd.wreckerTargetAttachment = nil
|
||||||
|
wreckerMd.wreckerHeightLevel = nil
|
||||||
|
end
|
||||||
|
targetMd.wreckerTowingVehicleId = nil
|
||||||
|
targetMd.wreckerTowingVehicleSqlId = nil
|
||||||
|
targetMd.wreckerOriginalScriptName = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
Sync.applyAttachSync = applyAttachSync
|
||||||
|
Sync.applyDetachSync = applyDetachSync
|
||||||
|
|
||||||
|
Events.OnServerCommand.Add(function(module, command, args)
|
||||||
|
if module ~= "towbar" then return end
|
||||||
|
if command == "wreckerAttachSync" then
|
||||||
|
applyAttachSync(args)
|
||||||
|
elseif command == "wreckerDetachSync" then
|
||||||
|
applyDetachSync(args)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
if Events.OnSpawnVehicleEnd then
|
||||||
|
Events.OnSpawnVehicleEnd.Add(clearAppliedLevelForVehicle)
|
||||||
|
end
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
require("TimedActions/ISBaseTimedAction")
|
||||||
|
|
||||||
|
WreckerTimedAction = ISBaseTimedAction:derive("WreckerTimedAction")
|
||||||
|
|
||||||
|
function WreckerTimedAction:isValid()
|
||||||
|
return self.isValidFunc ~= nil
|
||||||
|
and self.isValidFunc(self.character, self.arg1, self.arg2) == true
|
||||||
|
end
|
||||||
|
|
||||||
|
function WreckerTimedAction:start()
|
||||||
|
end
|
||||||
|
|
||||||
|
function WreckerTimedAction:perform()
|
||||||
|
if self:isValid() and self.performFunc then
|
||||||
|
self.performFunc(self.character, self.arg1, self.arg2)
|
||||||
|
end
|
||||||
|
ISBaseTimedAction.perform(self)
|
||||||
|
end
|
||||||
|
|
||||||
|
function WreckerTimedAction:stop()
|
||||||
|
ISBaseTimedAction.stop(self)
|
||||||
|
end
|
||||||
|
|
||||||
|
function WreckerTimedAction:new(character, time, isValidFunc, performFunc, arg1, arg2)
|
||||||
|
local action = ISBaseTimedAction.new(self, character)
|
||||||
|
action.maxTime = time
|
||||||
|
action.stopOnWalk = false
|
||||||
|
action.stopOnRun = false
|
||||||
|
action.useProgressBar = true
|
||||||
|
action.isValidFunc = isValidFunc
|
||||||
|
action.performFunc = performFunc
|
||||||
|
action.arg1 = arg1
|
||||||
|
action.arg2 = arg2
|
||||||
|
return action
|
||||||
|
end
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
if isServer() then return end
|
||||||
|
require("TowBar/WreckerTimedAction")
|
||||||
|
if not TowBarMod then TowBarMod = {} end
|
||||||
|
TowBarMod.WreckerUI = TowBarMod.WreckerUI or {}
|
||||||
|
|
||||||
|
local UI = TowBarMod.WreckerUI
|
||||||
|
local Wrecker = TowBarMod.Wrecker
|
||||||
|
local AttachDuration = 300
|
||||||
|
local DetachDuration = 200
|
||||||
|
|
||||||
|
local function sendAttachCommand(playerObj, wrecker, target)
|
||||||
|
if not playerObj or not wrecker or not target then return end
|
||||||
|
sendClientCommand(playerObj, "towbar", "attachWrecker", {
|
||||||
|
wrecker = wrecker:getId(),
|
||||||
|
target = target:getId()
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
local function sendDetachCommand(playerObj, wrecker, target)
|
||||||
|
if not playerObj or not wrecker or not target then return end
|
||||||
|
sendClientCommand(playerObj, "towbar", "detachWrecker", {
|
||||||
|
wrecker = wrecker:getId(),
|
||||||
|
target = target:getId()
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
local function isAttachStillValid(playerObj, wrecker, target)
|
||||||
|
if not playerObj or not wrecker or not target then return false end
|
||||||
|
if not wrecker:isDriver(playerObj) or not Wrecker.isSupportedWrecker(wrecker) then return false end
|
||||||
|
if wrecker:getVehicleTowing() or wrecker:getVehicleTowedBy()
|
||||||
|
or target:getVehicleTowing() or target:getVehicleTowedBy() then return false end
|
||||||
|
local nearest = Wrecker.resolveNearestTarget(wrecker, Wrecker.getWorldVehicles())
|
||||||
|
return nearest ~= nil and nearest.vehicle == target
|
||||||
|
end
|
||||||
|
|
||||||
|
local function isDetachStillValid(playerObj, wrecker, target)
|
||||||
|
if not playerObj or not wrecker or not target or not wrecker:isDriver(playerObj) then return false end
|
||||||
|
local md = wrecker:getModData()
|
||||||
|
return wrecker:getVehicleTowing() == target
|
||||||
|
or (md and tonumber(md.wreckerTowedVehicleId) == target:getId())
|
||||||
|
end
|
||||||
|
|
||||||
|
local function requestAttach(playerObj, wrecker, target)
|
||||||
|
if not isAttachStillValid(playerObj, wrecker, target) then return end
|
||||||
|
ISTimedActionQueue.add(WreckerTimedAction:new(
|
||||||
|
playerObj, AttachDuration, isAttachStillValid, sendAttachCommand, wrecker, target
|
||||||
|
))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function requestDetach(playerObj, wrecker, target)
|
||||||
|
if not isDetachStillValid(playerObj, wrecker, target) then return end
|
||||||
|
ISTimedActionQueue.add(WreckerTimedAction:new(
|
||||||
|
playerObj, DetachDuration, isDetachStillValid, sendDetachCommand, wrecker, target
|
||||||
|
))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function requestHeight(playerObj, wrecker, direction)
|
||||||
|
if not playerObj or not wrecker then return end
|
||||||
|
sendClientCommand(playerObj, "towbar", "adjustWreckerHeight", {
|
||||||
|
wrecker = wrecker:getId(),
|
||||||
|
direction = direction
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
function UI.addOptionsToMenu(playerObj, wrecker)
|
||||||
|
if not playerObj or not wrecker or not Wrecker.isSupportedWrecker(wrecker) then return false end
|
||||||
|
if not wrecker:isDriver(playerObj) then return false end
|
||||||
|
|
||||||
|
local menu = getPlayerRadialMenu(playerObj:getPlayerNum())
|
||||||
|
if not menu then return false end
|
||||||
|
|
||||||
|
local md = wrecker:getModData()
|
||||||
|
local targetId = md and tonumber(md.wreckerTowedVehicleId) or nil
|
||||||
|
local target = targetId and getVehicleById(targetId) or wrecker:getVehicleTowing()
|
||||||
|
if target and (target == wrecker:getVehicleTowing() or targetId == target:getId()) then
|
||||||
|
TowBarMod.UI.removeDefaultDetachOption(playerObj)
|
||||||
|
local level = tonumber(md.wreckerHeightLevel) or 0
|
||||||
|
if level < Wrecker.MaxHeightLevel then
|
||||||
|
menu:addSlice(
|
||||||
|
getText("UI_Text_Towing_heightUp"),
|
||||||
|
getTexture("media/textures/arrow_up.png"),
|
||||||
|
requestHeight, playerObj, wrecker, 1
|
||||||
|
)
|
||||||
|
end
|
||||||
|
if level > Wrecker.MinHeightLevel then
|
||||||
|
menu:addSlice(
|
||||||
|
getText("UI_Text_Towing_heightDown"),
|
||||||
|
getTexture("media/textures/arrow_down.png"),
|
||||||
|
requestHeight, playerObj, wrecker, -1
|
||||||
|
)
|
||||||
|
end
|
||||||
|
menu:addSlice(
|
||||||
|
getText("UI_Text_Towing_detachHook", ISVehicleMenu.getVehicleDisplayName(target)),
|
||||||
|
getTexture("media/textures/untow_car_icon.png"),
|
||||||
|
requestDetach, playerObj, wrecker, target
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
if wrecker:getVehicleTowing() or wrecker:getVehicleTowedBy() then return false end
|
||||||
|
local nearest = Wrecker.resolveNearestTarget(wrecker, Wrecker.getWorldVehicles())
|
||||||
|
if not nearest then return false end
|
||||||
|
menu:addSlice(
|
||||||
|
getText("UI_Text_Towing_attachHook", ISVehicleMenu.getVehicleDisplayName(nearest.vehicle)),
|
||||||
|
getTexture("media/textures/tow_car_icon.png"),
|
||||||
|
requestAttach, playerObj, wrecker, nearest.vehicle
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
end
|
||||||
@@ -3,61 +3,54 @@ BTtow.Create = {}
|
|||||||
BTtow.Init = {}
|
BTtow.Init = {}
|
||||||
|
|
||||||
local TowbarVariantSize = 24
|
local TowbarVariantSize = 24
|
||||||
local TowbarNormalStart = 0
|
|
||||||
local TowbarLargeStart = 24
|
|
||||||
local TowbarMaxIndex = TowbarVariantSize - 1
|
local TowbarMaxIndex = TowbarVariantSize - 1
|
||||||
local VanillaScaleMin = 1.5
|
local TowbarFirstZ = 1.0
|
||||||
local VanillaScaleMax = 2.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
|
if not script then return nil end
|
||||||
|
|
||||||
local ok, result = pcall(function()
|
local ok, shape = pcall(function()
|
||||||
return script:getModelScale()
|
return script:getPhysicsChassisShape()
|
||||||
end)
|
end)
|
||||||
if ok and type(result) == "number" then
|
if not ok or not shape then return nil end
|
||||||
return result
|
|
||||||
end
|
|
||||||
|
|
||||||
ok, result = pcall(function()
|
local shapeOk, shapeZ = pcall(function()
|
||||||
local model = script:getModel()
|
return shape:z()
|
||||||
if model then
|
end)
|
||||||
return model:getScale()
|
if not shapeOk or type(shapeZ) ~= "number" or shapeZ <= 0 then
|
||||||
end
|
|
||||||
return nil
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local centerOk, center = pcall(function()
|
||||||
|
return script:getCenterOfMassOffset()
|
||||||
end)
|
end)
|
||||||
if ok and type(result) == "number" then
|
if not centerOk or not center then return nil end
|
||||||
return result
|
|
||||||
end
|
|
||||||
|
|
||||||
return nil
|
local zOk, centerZ = pcall(function()
|
||||||
end
|
return center:z()
|
||||||
|
end)
|
||||||
|
if not zOk or type(centerZ) ~= "number" then return nil end
|
||||||
|
|
||||||
local function isVanillaScale(script)
|
return centerZ + shapeZ / 2
|
||||||
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))
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function getTowbarModelSlot(script)
|
local function getTowbarModelSlot(script)
|
||||||
if not isVanillaScale(script) then
|
local frontEdgeZ = getTowbarFrontEdgeZ(script)
|
||||||
-- KI5/small-scale vehicles use the normal towbar0 mesh at Z=1.
|
if frontEdgeZ == nil then return nil end
|
||||||
return 0
|
|
||||||
end
|
-- Model offsets position the mesh origin. Move its center outward so the
|
||||||
return getTowbarIndexVanilla(script)
|
-- 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
|
end
|
||||||
|
|
||||||
function BTtow.Create.towbar(vehicle, part)
|
function BTtow.Create.towbar(vehicle, part)
|
||||||
@@ -81,7 +74,7 @@ function BTtow.Init.towbar(vehicle, part)
|
|||||||
if script then
|
if script then
|
||||||
local index = getTowbarModelSlot(script)
|
local index = getTowbarModelSlot(script)
|
||||||
local shouldShowOnThisPart = part:getId() == "towbar"
|
local shouldShowOnThisPart = part:getId() == "towbar"
|
||||||
if shouldShowOnThisPart then
|
if shouldShowOnThisPart and index ~= nil then
|
||||||
part:setModelVisible("towbar" .. index, true)
|
part:setModelVisible("towbar" .. index, true)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
if isClient() then return end
|
if isClient() then return end
|
||||||
|
require("TowBar/Persistence")
|
||||||
|
|
||||||
local TowingCommands = {}
|
local TowingCommands = {}
|
||||||
local Commands = {}
|
local Commands = {}
|
||||||
@@ -10,6 +11,9 @@ local pendingSync = {}
|
|||||||
local snapshotTickCounter = 0
|
local snapshotTickCounter = 0
|
||||||
local brokenPairAuditTickCounter = 0
|
local brokenPairAuditTickCounter = 0
|
||||||
local confirmedTowPairs = {}
|
local confirmedTowPairs = {}
|
||||||
|
local towPairRuntime = {}
|
||||||
|
local RestoreRetryMs = 2000
|
||||||
|
local SustainedBreakMs = 5000
|
||||||
local giveTowBar
|
local giveTowBar
|
||||||
|
|
||||||
TowingCommands.wantNoise = getDebug() or false
|
TowingCommands.wantNoise = getDebug() or false
|
||||||
@@ -88,6 +92,22 @@ local function isLinked(vehicleA, vehicleB)
|
|||||||
return vehicleA:getVehicleTowing() == vehicleB and vehicleB:getVehicleTowedBy() == vehicleA
|
return vehicleA:getVehicleTowing() == vehicleB and vehicleB:getVehicleTowedBy() == vehicleA
|
||||||
end
|
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)
|
local function hasAnyTowLink(vehicle)
|
||||||
if not vehicle then return false end
|
if not vehicle then return false end
|
||||||
return vehicle:getVehicleTowing() ~= nil or vehicle:getVehicleTowedBy() ~= nil
|
return vehicle:getVehicleTowing() ~= nil or vehicle:getVehicleTowedBy() ~= nil
|
||||||
@@ -95,12 +115,17 @@ end
|
|||||||
|
|
||||||
local function getTowBarPairKey(vehicleA, vehicleB)
|
local function getTowBarPairKey(vehicleA, vehicleB)
|
||||||
if not vehicleA or not vehicleB then return nil end
|
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
|
end
|
||||||
|
|
||||||
local function markTowBarPairConfirmed(vehicleA, vehicleB)
|
local function markTowBarPairConfirmed(vehicleA, vehicleB)
|
||||||
local key = getTowBarPairKey(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
|
end
|
||||||
|
|
||||||
local function isTowBarPairConfirmed(vehicleA, vehicleB)
|
local function isTowBarPairConfirmed(vehicleA, vehicleB)
|
||||||
@@ -110,7 +135,10 @@ end
|
|||||||
|
|
||||||
local function forgetTowBarPair(vehicleA, vehicleB)
|
local function forgetTowBarPair(vehicleA, vehicleB)
|
||||||
local key = getTowBarPairKey(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
|
end
|
||||||
|
|
||||||
local function hasTowBarState(vehicle)
|
local function hasTowBarState(vehicle)
|
||||||
@@ -130,25 +158,35 @@ local function markExpectedTowBarPair(vehicleA, vehicleB, attachmentA, attachmen
|
|||||||
towingModData["isTowingByTowBar"] = true
|
towingModData["isTowingByTowBar"] = true
|
||||||
towingModData["towed"] = false
|
towingModData["towed"] = false
|
||||||
towingModData["towBarTowedVehicleId"] = vehicleB:getId()
|
towingModData["towBarTowedVehicleId"] = vehicleB:getId()
|
||||||
|
towingModData["towBarTowedVehicleSqlId"] = getPersistentVehicleId(vehicleB)
|
||||||
towingModData["towBarTowingVehicleId"] = nil
|
towingModData["towBarTowingVehicleId"] = nil
|
||||||
|
towingModData["towBarTowingVehicleSqlId"] = nil
|
||||||
towingModData["towBarExpectedAttachment"] = attachmentA
|
towingModData["towBarExpectedAttachment"] = attachmentA
|
||||||
towedModData["isTowingByTowBar"] = true
|
towedModData["isTowingByTowBar"] = true
|
||||||
towedModData["towed"] = true
|
towedModData["towed"] = true
|
||||||
towedModData["towBarTowedVehicleId"] = nil
|
towedModData["towBarTowedVehicleId"] = nil
|
||||||
|
towedModData["towBarTowedVehicleSqlId"] = nil
|
||||||
towedModData["towBarTowingVehicleId"] = vehicleA:getId()
|
towedModData["towBarTowingVehicleId"] = vehicleA:getId()
|
||||||
|
towedModData["towBarTowingVehicleSqlId"] = getPersistentVehicleId(vehicleA)
|
||||||
towedModData["towBarExpectedAttachment"] = attachmentB
|
towedModData["towBarExpectedAttachment"] = attachmentB
|
||||||
vehicleA:transmitModData()
|
vehicleA:transmitModData()
|
||||||
vehicleB:transmitModData()
|
vehicleB:transmitModData()
|
||||||
|
TowBarMod.Persistence.savePair("towbar", vehicleA, vehicleB, attachmentA, attachmentB)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function clearExpectedTowBarPair(vehicleA, vehicleB)
|
local function clearExpectedTowBarPair(vehicleA, vehicleB)
|
||||||
|
if vehicleA and vehicleB then
|
||||||
|
TowBarMod.Persistence.removePair("towbar", vehicleA, vehicleB)
|
||||||
|
end
|
||||||
if vehicleA then
|
if vehicleA then
|
||||||
local towingModData = vehicleA:getModData()
|
local towingModData = vehicleA:getModData()
|
||||||
if towingModData then
|
if towingModData then
|
||||||
towingModData["isTowingByTowBar"] = false
|
towingModData["isTowingByTowBar"] = false
|
||||||
towingModData["towed"] = false
|
towingModData["towed"] = false
|
||||||
towingModData["towBarTowedVehicleId"] = nil
|
towingModData["towBarTowedVehicleId"] = nil
|
||||||
|
towingModData["towBarTowedVehicleSqlId"] = nil
|
||||||
towingModData["towBarTowingVehicleId"] = nil
|
towingModData["towBarTowingVehicleId"] = nil
|
||||||
|
towingModData["towBarTowingVehicleSqlId"] = nil
|
||||||
towingModData["towBarExpectedAttachment"] = nil
|
towingModData["towBarExpectedAttachment"] = nil
|
||||||
vehicleA:transmitModData()
|
vehicleA:transmitModData()
|
||||||
end
|
end
|
||||||
@@ -160,7 +198,9 @@ local function clearExpectedTowBarPair(vehicleA, vehicleB)
|
|||||||
towedModData["isTowingByTowBar"] = false
|
towedModData["isTowingByTowBar"] = false
|
||||||
towedModData["towed"] = false
|
towedModData["towed"] = false
|
||||||
towedModData["towBarTowedVehicleId"] = nil
|
towedModData["towBarTowedVehicleId"] = nil
|
||||||
|
towedModData["towBarTowedVehicleSqlId"] = nil
|
||||||
towedModData["towBarTowingVehicleId"] = nil
|
towedModData["towBarTowingVehicleId"] = nil
|
||||||
|
towedModData["towBarTowingVehicleSqlId"] = nil
|
||||||
towedModData["towBarExpectedAttachment"] = nil
|
towedModData["towBarExpectedAttachment"] = nil
|
||||||
vehicleB:transmitModData()
|
vehicleB:transmitModData()
|
||||||
end
|
end
|
||||||
@@ -174,8 +214,11 @@ local function isExpectedTowBarPair(vehicleA, vehicleB)
|
|||||||
local towedModData = vehicleB:getModData()
|
local towedModData = vehicleB:getModData()
|
||||||
if not towingModData or not towedModData then return false end
|
if not towingModData or not towedModData then return false end
|
||||||
|
|
||||||
return towingModData["towBarTowedVehicleId"] == vehicleB:getId()
|
return matchesSavedVehicle(
|
||||||
and towedModData["towBarTowingVehicleId"] == vehicleA:getId()
|
towingModData["towBarTowedVehicleId"], towingModData["towBarTowedVehicleSqlId"], vehicleB
|
||||||
|
) and matchesSavedVehicle(
|
||||||
|
towedModData["towBarTowingVehicleId"], towedModData["towBarTowingVehicleSqlId"], vehicleA
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function isLegacyTowBarPair(vehicleA, vehicleB)
|
local function isLegacyTowBarPair(vehicleA, vehicleB)
|
||||||
@@ -295,14 +338,34 @@ local function forEachCollectionItem(collection, callback)
|
|||||||
end
|
end
|
||||||
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)
|
local function broadcastAttach(vehicleA, vehicleB, attachmentA, attachmentB)
|
||||||
if not vehicleA or not vehicleB then return end
|
if not vehicleA or not vehicleB then return end
|
||||||
sendServerCommand("towbar", "forceAttachSync", {
|
local args = {
|
||||||
vehicleA = vehicleA:getId(),
|
vehicleA = vehicleA:getId(),
|
||||||
vehicleB = vehicleB:getId(),
|
vehicleB = vehicleB:getId(),
|
||||||
attachmentA = attachmentA,
|
attachmentA = attachmentA,
|
||||||
attachmentB = attachmentB
|
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
|
end
|
||||||
|
|
||||||
local function broadcastDetach(vehicleAId, vehicleBId)
|
local function broadcastDetach(vehicleAId, vehicleBId)
|
||||||
@@ -320,6 +383,33 @@ local function broadcastSpontaneousDetach(vehicleA, vehicleB)
|
|||||||
})
|
})
|
||||||
end
|
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)
|
local function breakTowBarConstraint(towingVehicle, towedVehicle)
|
||||||
if not towingVehicle or not towedVehicle then return end
|
if not towingVehicle or not towedVehicle then return end
|
||||||
|
|
||||||
@@ -400,19 +490,80 @@ local function reconcileBrokenTowBarPairsServer()
|
|||||||
local vehicles = cell:getVehicles()
|
local vehicles = cell:getVehicles()
|
||||||
if not vehicles then return end
|
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)
|
forEachCollectionItem(vehicles, function(towingVehicle)
|
||||||
local towingModData = towingVehicle and towingVehicle:getModData() or nil
|
local towingModData = towingVehicle and towingVehicle:getModData() or nil
|
||||||
local towedVehicleId = towingModData and towingModData["towBarTowedVehicleId"] or nil
|
local towedVehicleId = towingModData and towingModData["towBarTowedVehicleId"] or nil
|
||||||
local towedVehicle = towedVehicleId and getVehicleById(towedVehicleId) or nil
|
local towedVehicleSqlId = towingModData and towingModData["towBarTowedVehicleSqlId"] or nil
|
||||||
if towingVehicle and towedVehicle and isExpectedTowBarPair(towingVehicle, towedVehicle) then
|
local towedVehicle = findLoadedVehicle(towedVehicleId, towedVehicleSqlId)
|
||||||
if isLinked(towingVehicle, towedVehicle) then
|
if towingVehicle and towedVehicle then reconcilePair(towingVehicle, towedVehicle) end
|
||||||
markTowBarPairConfirmed(towingVehicle, towedVehicle)
|
|
||||||
elseif isTowBarPairConfirmed(towingVehicle, towedVehicle)
|
|
||||||
and not hasAnyTowLink(towingVehicle)
|
|
||||||
and not hasAnyTowLink(towedVehicle) then
|
|
||||||
finalizeBrokenTowBarPair(towingVehicle, towedVehicle, "physical-link-audit")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
if not TowBarMod then TowBarMod = {} end
|
||||||
|
TowBarMod.Persistence = TowBarMod.Persistence or {}
|
||||||
|
|
||||||
|
local Persistence = TowBarMod.Persistence
|
||||||
|
local RegistryName = "TowBar.PersistentPairsV2"
|
||||||
|
|
||||||
|
local function getRegistry()
|
||||||
|
if not ModData or not ModData.getOrCreate then return nil end
|
||||||
|
local registry = ModData.getOrCreate(RegistryName)
|
||||||
|
registry.pairs = registry.pairs or {}
|
||||||
|
return registry
|
||||||
|
end
|
||||||
|
|
||||||
|
local function getSqlId(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 makeKey(kind, towingSqlId, towedSqlId)
|
||||||
|
if type(kind) ~= "string" or type(towingSqlId) ~= "number" or type(towedSqlId) ~= "number" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return kind .. ":" .. tostring(towingSqlId) .. ":" .. tostring(towedSqlId)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function transmit()
|
||||||
|
if isServer and isServer() and ModData.transmit then
|
||||||
|
ModData.transmit(RegistryName)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Persistence.savePair(kind, towingVehicle, towedVehicle, attachmentA, attachmentB, heightLevel)
|
||||||
|
local towingSqlId = getSqlId(towingVehicle)
|
||||||
|
local towedSqlId = getSqlId(towedVehicle)
|
||||||
|
local key = makeKey(kind, towingSqlId, towedSqlId)
|
||||||
|
local registry = getRegistry()
|
||||||
|
if not key or not registry then return false end
|
||||||
|
local previous = registry.pairs[key]
|
||||||
|
if previous and previous.attachmentA == attachmentA and previous.attachmentB == attachmentB
|
||||||
|
and previous.heightLevel == heightLevel then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
registry.pairs[key] = {
|
||||||
|
kind = kind,
|
||||||
|
towingSqlId = towingSqlId,
|
||||||
|
towedSqlId = towedSqlId,
|
||||||
|
attachmentA = attachmentA,
|
||||||
|
attachmentB = attachmentB,
|
||||||
|
heightLevel = heightLevel
|
||||||
|
}
|
||||||
|
transmit()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function Persistence.getPair(kind, towingVehicle, towedVehicle)
|
||||||
|
local key = makeKey(kind, getSqlId(towingVehicle), getSqlId(towedVehicle))
|
||||||
|
local registry = getRegistry()
|
||||||
|
return key and registry and registry.pairs[key] or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function Persistence.removePair(kind, towingVehicle, towedVehicle)
|
||||||
|
local key = makeKey(kind, getSqlId(towingVehicle), getSqlId(towedVehicle))
|
||||||
|
local registry = getRegistry()
|
||||||
|
if not key or not registry or registry.pairs[key] == nil then return false end
|
||||||
|
registry.pairs[key] = nil
|
||||||
|
transmit()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function Persistence.forEachPair(kind, callback)
|
||||||
|
local registry = getRegistry()
|
||||||
|
if not registry or not callback then return end
|
||||||
|
for _, record in pairs(registry.pairs) do
|
||||||
|
if type(record) == "table" and record.kind == kind then callback(record) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Persistence.notePeerAvailability(state, available)
|
||||||
|
state = state or {}
|
||||||
|
if not available then
|
||||||
|
state.peerMissing = true
|
||||||
|
return state
|
||||||
|
end
|
||||||
|
if state.peerMissing then
|
||||||
|
-- A missing vehicle means its cell was not loaded. When both members
|
||||||
|
-- return, treat the absent native constraint as load recovery rather
|
||||||
|
-- than a runtime break of a previously confirmed pair.
|
||||||
|
state.peerMissing = nil
|
||||||
|
state.confirmed = false
|
||||||
|
state.pendingUntil = nil
|
||||||
|
state.unlinkedSince = nil
|
||||||
|
end
|
||||||
|
return state
|
||||||
|
end
|
||||||
|
|
||||||
|
function Persistence.advanceRecoveryState(state, now, linked, occupied, retryMs, breakMs)
|
||||||
|
state = state or {}
|
||||||
|
if linked then
|
||||||
|
state.confirmed = true
|
||||||
|
state.pendingUntil = nil
|
||||||
|
state.unlinkedSince = nil
|
||||||
|
return state, "adopt"
|
||||||
|
end
|
||||||
|
if occupied then
|
||||||
|
state.unlinkedSince = nil
|
||||||
|
return state, "wait"
|
||||||
|
end
|
||||||
|
if state.confirmed then
|
||||||
|
state.unlinkedSince = state.unlinkedSince or now
|
||||||
|
if now - state.unlinkedSince >= breakMs then return state, "break" end
|
||||||
|
return state, "wait"
|
||||||
|
end
|
||||||
|
if not state.pendingUntil or now >= state.pendingUntil then
|
||||||
|
state.pendingUntil = now + retryMs
|
||||||
|
return state, "restore"
|
||||||
|
end
|
||||||
|
return state, "wait"
|
||||||
|
end
|
||||||
|
|
||||||
|
Persistence.getSqlId = getSqlId
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
if not TowBarMod then TowBarMod = {} end
|
||||||
|
TowBarMod.Wrecker = TowBarMod.Wrecker or {}
|
||||||
|
|
||||||
|
local Wrecker = TowBarMod.Wrecker
|
||||||
|
|
||||||
|
Wrecker.MaxAttachDistance = 0.6
|
||||||
|
Wrecker.HeightStep = 0.30
|
||||||
|
Wrecker.MinHeightLevel = 0
|
||||||
|
Wrecker.MaxHeightLevel = 2
|
||||||
|
Wrecker.ScanRadius = 8
|
||||||
|
|
||||||
|
local SupportedWreckers = {
|
||||||
|
-- KI5 '76 Chevrolet K Series (Workshop 3161951724)
|
||||||
|
["Base.76chevyC30CCwrecker"] = true,
|
||||||
|
["Base.76chevyC30SCwrecker"] = true,
|
||||||
|
["Base.76chevyK30CCwrecker"] = true,
|
||||||
|
["Base.76chevyK30SCwrecker"] = true,
|
||||||
|
|
||||||
|
-- KI5 '93 Chevrolet Suburban / Silverado (Workshop 3152529790).
|
||||||
|
-- The mechanic variant inherits the K3500 wrecker template.
|
||||||
|
["Base.93chevySilveradoK3500wrecker"] = true,
|
||||||
|
["Base.93chevySilveradoK3500mechanic"] = true,
|
||||||
|
|
||||||
|
-- KI5 '78 AM General M35 Series Trucks (Workshop 2799152995).
|
||||||
|
["Base.78amgeneralM62"] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
local HeightAttachmentIds = {
|
||||||
|
[0] = "towbarWreckerHookLow",
|
||||||
|
[1] = "towbarWreckerHookMid",
|
||||||
|
[2] = "towbarWreckerHookHigh"
|
||||||
|
}
|
||||||
|
|
||||||
|
local function getScriptFullName(script)
|
||||||
|
if not script then return nil end
|
||||||
|
local ok, fullName = pcall(function() return script:getFullName() end)
|
||||||
|
if ok and fullName then return tostring(fullName) end
|
||||||
|
local name = script:getName()
|
||||||
|
return name and ("Base." .. tostring(name)) or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function Wrecker.isSupportedWrecker(vehicle)
|
||||||
|
if not vehicle then return false end
|
||||||
|
local script = vehicle:getScript()
|
||||||
|
local fullName = getScriptFullName(script)
|
||||||
|
return SupportedWreckers[fullName] == true
|
||||||
|
and vehicle:attachmentExist("hook")
|
||||||
|
end
|
||||||
|
|
||||||
|
function Wrecker.getHeightAttachmentId(level)
|
||||||
|
return HeightAttachmentIds[Wrecker.normalizeHeightLevel(level)]
|
||||||
|
end
|
||||||
|
|
||||||
|
function Wrecker.normalizeHeightLevel(level)
|
||||||
|
local numeric = tonumber(level) or Wrecker.MinHeightLevel
|
||||||
|
numeric = math.floor(numeric + 0.5)
|
||||||
|
return math.max(Wrecker.MinHeightLevel, math.min(Wrecker.MaxHeightLevel, numeric))
|
||||||
|
end
|
||||||
|
|
||||||
|
function Wrecker.nextHeightLevel(currentLevel, direction)
|
||||||
|
local current = Wrecker.normalizeHeightLevel(currentLevel)
|
||||||
|
local delta = tonumber(direction)
|
||||||
|
if delta ~= -1 and delta ~= 1 then return nil end
|
||||||
|
return math.max(Wrecker.MinHeightLevel, math.min(Wrecker.MaxHeightLevel, current + delta))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function distanceSquared(pointA, pointB)
|
||||||
|
if not pointA or not pointB then return nil end
|
||||||
|
local dx = pointA:x() - pointB:x()
|
||||||
|
local dy = pointA:y() - pointB:y()
|
||||||
|
return dx * dx + dy * dy
|
||||||
|
end
|
||||||
|
|
||||||
|
local function forEachVehicle(collection, callback)
|
||||||
|
if not collection then return end
|
||||||
|
if type(collection) == "table" then
|
||||||
|
for _, vehicle in ipairs(collection) do callback(vehicle) end
|
||||||
|
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 sizeOk, size = pcall(function() return collection:size() end)
|
||||||
|
if not sizeOk then return end
|
||||||
|
for index = 0, size - 1 do callback(collection:get(index)) end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function isTargetEligible(wrecker, vehicle)
|
||||||
|
if not vehicle or vehicle == wrecker then return false end
|
||||||
|
if vehicle:getVehicleTowing() or vehicle:getVehicleTowedBy() then return false end
|
||||||
|
if wrecker:getVehicleTowing() or wrecker:getVehicleTowedBy() then return false end
|
||||||
|
local script = vehicle:getScript()
|
||||||
|
if not script then return false end
|
||||||
|
local name = string.lower(tostring(script:getName() or ""))
|
||||||
|
return not string.find(name, "trailer", 1, true)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Wrecker.resolveNearestTarget(wrecker, candidates)
|
||||||
|
if not Wrecker.isSupportedWrecker(wrecker) then return nil end
|
||||||
|
local hookPoint = wrecker:getAttachmentWorldPos("hook", Vector3f.new())
|
||||||
|
if not hookPoint then return nil end
|
||||||
|
|
||||||
|
local best
|
||||||
|
local maxDistanceSquared = Wrecker.MaxAttachDistance * Wrecker.MaxAttachDistance
|
||||||
|
forEachVehicle(candidates, function(vehicle)
|
||||||
|
if not isTargetEligible(wrecker, vehicle) then return end
|
||||||
|
for _, attachmentId in ipairs({ "trailerfront", "trailer" }) do
|
||||||
|
if vehicle:attachmentExist(attachmentId) then
|
||||||
|
local targetPoint = vehicle:getAttachmentWorldPos(attachmentId, Vector3f.new())
|
||||||
|
local score = distanceSquared(hookPoint, targetPoint)
|
||||||
|
if score and score <= maxDistanceSquared then
|
||||||
|
local vehicleId = vehicle:getId()
|
||||||
|
if not best or score < best.distanceSquared
|
||||||
|
or (score == best.distanceSquared and vehicleId < best.vehicle:getId()) then
|
||||||
|
best = {
|
||||||
|
vehicle = vehicle,
|
||||||
|
attachment = attachmentId,
|
||||||
|
distanceSquared = score
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
return best
|
||||||
|
end
|
||||||
|
|
||||||
|
function Wrecker.getWorldVehicles()
|
||||||
|
local cell = getCell()
|
||||||
|
return cell and cell:getVehicles() or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function registerHookAttachments()
|
||||||
|
local manager = getScriptManager()
|
||||||
|
if not manager then return end
|
||||||
|
for fullName in pairs(SupportedWreckers) do
|
||||||
|
local script = manager:getVehicle(fullName)
|
||||||
|
local baseAttachment = script and script:getAttachmentById("hook") or nil
|
||||||
|
local baseOffset = baseAttachment and baseAttachment:getOffset() or nil
|
||||||
|
if baseOffset then
|
||||||
|
for level = Wrecker.MinHeightLevel, Wrecker.MaxHeightLevel do
|
||||||
|
local attachmentId = HeightAttachmentIds[level]
|
||||||
|
if script:getAttachmentById(attachmentId) == nil then
|
||||||
|
local attachment = ModelAttachment.new(attachmentId)
|
||||||
|
attachment:getOffset():set(
|
||||||
|
baseOffset:x(),
|
||||||
|
baseOffset:y() + level * Wrecker.HeightStep,
|
||||||
|
baseOffset:z()
|
||||||
|
)
|
||||||
|
attachment:setUpdateConstraint(false)
|
||||||
|
script:addAttachment(attachment)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Wrecker.registerHookAttachments = registerHookAttachments
|
||||||
|
Events.OnGameBoot.Add(registerHookAttachments)
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"ItemName_TowBar.TowBar": "Tow Bar"
|
"TowBar.TowBar": "Tow Bar"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
"UI_Text_Towing_byRope": "by rope",
|
"UI_Text_Towing_byRope": "by rope",
|
||||||
"UI_Text_Towing_byTowBar": "by tow bar",
|
"UI_Text_Towing_byTowBar": "by tow bar",
|
||||||
"UI_Text_Towing_byHook": "by hook",
|
"UI_Text_Towing_byHook": "by hook",
|
||||||
|
"UI_Text_Towing_attachHook": "Tow %1 by hook",
|
||||||
|
"UI_Text_Towing_detachHook": "Detach %1 from hook",
|
||||||
|
"UI_Text_Towing_heightUp": "Raise tow hook",
|
||||||
|
"UI_Text_Towing_heightDown": "Lower tow hook",
|
||||||
"UI_Text_Towing_flipUpright": "Flip upright",
|
"UI_Text_Towing_flipUpright": "Flip upright",
|
||||||
"UI_Text_Towing_cannotDriveWhileTowed": "Cannot drive while being towed",
|
"UI_Text_Towing_cannotDriveWhileTowed": "Cannot drive while being towed",
|
||||||
"UI_Text_PushByHands": "Push vehicle",
|
"UI_Text_PushByHands": "Push vehicle",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ module Base
|
|||||||
{
|
{
|
||||||
mesh = vehicles/Towbar,
|
mesh = vehicles/Towbar,
|
||||||
texture = Vehicles/Towbar_Texture,
|
texture = Vehicles/Towbar_Texture,
|
||||||
scale = 0.01,
|
scale = 0.025,
|
||||||
}
|
}
|
||||||
|
|
||||||
model towbarModelLarge
|
model towbarModelLarge
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
name=Towbars
|
name=Towbars
|
||||||
id=hrsys_towbars
|
id=hrsys_towbars_testing
|
||||||
poster=../common/media/textures/preview.png
|
poster=../common/media/textures/preview.png
|
||||||
description=Tow bars for vehicle-to-vehicle towing.
|
description=Tow bars for vehicle-to-vehicle towing.
|
||||||
author=Riggs0
|
author=Riggs0
|
||||||
category=vehicle
|
category=vehicle
|
||||||
icon=../common/media/textures/tow_bar_icon.png
|
icon=../common/media/textures/tow_bar_icon.png
|
||||||
url=https://hudsonriggs.systems
|
url=https://hudsonriggs.systems
|
||||||
modversion=1.0.5
|
modversion=1.0.12
|
||||||
versionMin=42.20.0
|
versionMin=42.20.0
|
||||||
|
incompatible=\STowTruck_B42
|
||||||
|
|||||||
@@ -1,3 +1,31 @@
|
|||||||
# Towbar
|
# Towbar
|
||||||
|
|
||||||
Do you want to build a towbar
|
Vehicle-to-vehicle tow bars and server-authoritative wrecker controls for
|
||||||
|
Project Zomboid Build 42.20.
|
||||||
|
|
||||||
|
Portable towbar and wrecker connections persist through solo save/exit,
|
||||||
|
dedicated-server restarts, and vehicle-cell streaming. Saved pairs are matched
|
||||||
|
with stable vehicle database IDs and reconnected without consuming, refunding,
|
||||||
|
or dropping another tow bar during load recovery.
|
||||||
|
|
||||||
|
## Supported wrecker mods
|
||||||
|
|
||||||
|
Only the listed vehicle scripts receive the seated-driver hook controls. Each
|
||||||
|
vehicle must still expose its expected `hook`, `trailer`, and `trailerfront`
|
||||||
|
attachments at runtime.
|
||||||
|
|
||||||
|
| KI5 mod | Workshop ID | Mod ID | Supported vehicles |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| '76 Chevrolet K Series | [3161951724](https://steamcommunity.com/sharedfiles/filedetails/?id=3161951724) | `76chevyKseries` | `Base.76chevyC30CCwrecker`, `Base.76chevyC30SCwrecker`, `Base.76chevyK30CCwrecker`, `Base.76chevyK30SCwrecker` |
|
||||||
|
| '93 Chevrolet Suburban / Silverado | [3152529790](https://steamcommunity.com/sharedfiles/filedetails/?id=3152529790) | `93chevySuburban`, `93chevySuburbanExpanded` | `Base.93chevySilveradoK3500wrecker`, `Base.93chevySilveradoK3500mechanic` |
|
||||||
|
| '78 AM General M35 Series Trucks | [2799152995](https://steamcommunity.com/sharedfiles/filedetails/?id=2799152995) | `78amgeneralM35A2`, optional spawn module `78amgeneralM62` | `Base.78amgeneralM62` |
|
||||||
|
|
||||||
|
The ordinary AM General M35A2 and non-wrecker Chevrolet variants are not
|
||||||
|
included because their vehicle scripts do not define a tow hook.
|
||||||
|
|
||||||
|
## Incompatible mods
|
||||||
|
|
||||||
|
`Tow Truck[B42]` (Workshop `3446203945`, Mod ID `STowTruck_B42`) is marked
|
||||||
|
incompatible. It owns a competing radial menu, towing state, and vehicle
|
||||||
|
constraint implementation, so loading both systems could attach or detach the
|
||||||
|
same pair twice.
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
@@ -1,10 +1,11 @@
|
|||||||
name=Towbars
|
name=Towbars
|
||||||
id=hrsys_towbars
|
id=hrsys_towbars_testing
|
||||||
poster=common/media/textures/preview.png
|
poster=common/media/textures/preview.png
|
||||||
description=Tow bars for vehicle-to-vehicle towing.
|
description=Tow bars for vehicle-to-vehicle towing.
|
||||||
author=Riggs0
|
author=Riggs0
|
||||||
category=vehicle
|
category=vehicle
|
||||||
versionMin=42.13.0
|
versionMin=42.13.0
|
||||||
url=https://hudsonriggs.systems
|
url=https://hudsonriggs.systems
|
||||||
modversion=1.0.5
|
modversion=1.0.12
|
||||||
icon=common/media/textures/tow_bar_icon.png
|
icon=common/media/textures/tow_bar_icon.png
|
||||||
|
incompatible=\STowTruck_B42
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
local failures = 0
|
||||||
|
|
||||||
|
local function expect(condition, message)
|
||||||
|
if not condition then
|
||||||
|
failures = failures + 1
|
||||||
|
io.stderr:write("FAIL: " .. message .. "\n")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
package.path = "42.20/media/lua/shared/?.lua;" .. package.path
|
||||||
|
|
||||||
|
local globalData = {}
|
||||||
|
ModData = {
|
||||||
|
getOrCreate = function(name)
|
||||||
|
globalData[name] = globalData[name] or {}
|
||||||
|
return globalData[name]
|
||||||
|
end,
|
||||||
|
transmit = function() end
|
||||||
|
}
|
||||||
|
|
||||||
|
TowBarMod = {}
|
||||||
|
require("TowBar/Persistence")
|
||||||
|
|
||||||
|
local function collection(items)
|
||||||
|
return {
|
||||||
|
iterator = function()
|
||||||
|
local index = 0
|
||||||
|
return {
|
||||||
|
hasNext = function() return index < #items end,
|
||||||
|
next = function()
|
||||||
|
index = index + 1
|
||||||
|
return items[index]
|
||||||
|
end
|
||||||
|
}
|
||||||
|
end
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function newMetrics()
|
||||||
|
return {
|
||||||
|
addConstraint = 0,
|
||||||
|
breakConstraint = 0,
|
||||||
|
attachSync = 0,
|
||||||
|
detachSync = 0,
|
||||||
|
worldDrops = 0
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function newVehicle(id, sqlId, metrics)
|
||||||
|
local vehicle = {
|
||||||
|
id = id,
|
||||||
|
sqlId = sqlId,
|
||||||
|
modData = {},
|
||||||
|
towing = nil,
|
||||||
|
towedBy = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
function vehicle:getId() return self.id end
|
||||||
|
function vehicle:getSqlId() return self.sqlId end
|
||||||
|
function vehicle:getModData() return self.modData end
|
||||||
|
function vehicle:transmitModData() end
|
||||||
|
function vehicle:getVehicleTowing() return self.towing end
|
||||||
|
function vehicle:getVehicleTowedBy() return self.towedBy end
|
||||||
|
function vehicle:attachmentExist() return true end
|
||||||
|
function vehicle:addPointConstraint()
|
||||||
|
-- Deliberately do not establish the link. Build 42 may acknowledge a
|
||||||
|
-- new native constraint on a later tick.
|
||||||
|
metrics.addConstraint = metrics.addConstraint + 1
|
||||||
|
end
|
||||||
|
function vehicle:breakConstraint()
|
||||||
|
metrics.breakConstraint = metrics.breakConstraint + 1
|
||||||
|
self.towing = nil
|
||||||
|
self.towedBy = nil
|
||||||
|
end
|
||||||
|
function vehicle:getSquare()
|
||||||
|
return {
|
||||||
|
AddWorldInventoryItem = function()
|
||||||
|
metrics.worldDrops = metrics.worldDrops + 1
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return vehicle
|
||||||
|
end
|
||||||
|
|
||||||
|
local function linkPair(towing, towed)
|
||||||
|
towing.towing = towed
|
||||||
|
towing.towedBy = nil
|
||||||
|
towed.towing = nil
|
||||||
|
towed.towedBy = towing
|
||||||
|
end
|
||||||
|
|
||||||
|
local function unlinkPair(towing, towed)
|
||||||
|
towing.towing = nil
|
||||||
|
towing.towedBy = nil
|
||||||
|
towed.towing = nil
|
||||||
|
towed.towedBy = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function installEvents()
|
||||||
|
local callbacks = {}
|
||||||
|
Events = {
|
||||||
|
OnClientCommand = {
|
||||||
|
Add = function(callback) callbacks.clientCommand = callback end
|
||||||
|
},
|
||||||
|
OnTick = {
|
||||||
|
Add = function(callback) callbacks.tick = callback end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return callbacks
|
||||||
|
end
|
||||||
|
|
||||||
|
local function fireTicks(callback, count)
|
||||||
|
for _ = 1, count do callback() end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resetRegistry()
|
||||||
|
globalData["TowBar.PersistentPairsV2"] = { pairs = {} }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function runPortableHandlerSpec()
|
||||||
|
resetRegistry()
|
||||||
|
local metrics = newMetrics()
|
||||||
|
local callbacks = installEvents()
|
||||||
|
local now = 1000
|
||||||
|
local towing = newVehicle(101, 1001, metrics)
|
||||||
|
local towed = newVehicle(202, 2002, metrics)
|
||||||
|
local loaded = { towing, towed }
|
||||||
|
local byId = { [101] = towing, [202] = towed }
|
||||||
|
|
||||||
|
isClient = function() return false end
|
||||||
|
isServer = function() return true end
|
||||||
|
getDebug = function() return false end
|
||||||
|
getTimestampMs = function() return now end
|
||||||
|
getCell = function()
|
||||||
|
return { getVehicles = function() return collection(loaded) end }
|
||||||
|
end
|
||||||
|
getVehicleById = function(id) return byId[id] end
|
||||||
|
sendServerCommand = function(_, command)
|
||||||
|
if command == "forceAttachSync" then
|
||||||
|
metrics.attachSync = metrics.attachSync + 1
|
||||||
|
elseif command == "forceDetachSync" or command == "spontaneousDetachSync" then
|
||||||
|
metrics.detachSync = metrics.detachSync + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
expect(
|
||||||
|
TowBarMod.Persistence.savePair(
|
||||||
|
"towbar", towing, towed, "trailer", "trailerfront"
|
||||||
|
),
|
||||||
|
"portable setup must persist a stable pair"
|
||||||
|
)
|
||||||
|
dofile("42.20/media/lua/server/TowingCommands.lua")
|
||||||
|
expect(type(callbacks.tick) == "function", "portable server must register its audit tick")
|
||||||
|
|
||||||
|
fireTicks(callbacks.tick, 5)
|
||||||
|
expect(metrics.addConstraint == 1, "portable restart must request one native restore")
|
||||||
|
expect(metrics.attachSync == 1, "portable restart must broadcast one restore")
|
||||||
|
|
||||||
|
now = 1500
|
||||||
|
fireTicks(callbacks.tick, 5)
|
||||||
|
expect(metrics.addConstraint == 1, "portable pending restore must not duplicate its constraint")
|
||||||
|
expect(metrics.attachSync == 1, "portable pending restore must not duplicate its sync")
|
||||||
|
|
||||||
|
linkPair(towing, towed)
|
||||||
|
now = 2000
|
||||||
|
fireTicks(callbacks.tick, 5)
|
||||||
|
expect(metrics.addConstraint == 1, "portable delayed acknowledgement must be adopted without re-adding")
|
||||||
|
expect(towing.modData.towBarTowedVehicleId == towed:getId(), "portable adoption must refresh the towing live ID")
|
||||||
|
expect(towed.modData.towBarTowingVehicleId == towing:getId(), "portable adoption must refresh the towed live ID")
|
||||||
|
|
||||||
|
unlinkPair(towing, towed)
|
||||||
|
loaded = { towing }
|
||||||
|
byId[202] = nil
|
||||||
|
now = 10000
|
||||||
|
fireTicks(callbacks.tick, 5)
|
||||||
|
now = 16000
|
||||||
|
fireTicks(callbacks.tick, 5)
|
||||||
|
expect(metrics.worldDrops == 0, "an unloaded portable peer must not drop a towbar")
|
||||||
|
expect(metrics.detachSync == 0, "an unloaded portable peer must not broadcast destructive cleanup")
|
||||||
|
expect(towing.modData.isTowingByTowBar == true, "an unloaded portable peer must keep active metadata")
|
||||||
|
expect(towing.modData.towBarTowedVehicleSqlId == towed:getSqlId(), "an unloaded portable peer must keep stable identity")
|
||||||
|
|
||||||
|
loaded = { towing, towed }
|
||||||
|
byId[202] = towed
|
||||||
|
now = 17000
|
||||||
|
fireTicks(callbacks.tick, 5)
|
||||||
|
expect(metrics.addConstraint == 2, "a reloaded portable peer must re-enter recovery")
|
||||||
|
expect(metrics.attachSync == 2, "a reloaded portable peer must receive one fresh restore sync")
|
||||||
|
expect(metrics.worldDrops == 0, "portable peer reload must not be mistaken for a break")
|
||||||
|
expect(metrics.breakConstraint == 0, "portable peer reload must not break an unrelated constraint")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function runWreckerHandlerSpec()
|
||||||
|
resetRegistry()
|
||||||
|
local metrics = newMetrics()
|
||||||
|
local callbacks = installEvents()
|
||||||
|
local now = 1000
|
||||||
|
local wrecker = newVehicle(303, 3003, metrics)
|
||||||
|
local target = newVehicle(404, 4004, metrics)
|
||||||
|
local loaded = { wrecker, target }
|
||||||
|
local byId = { [303] = wrecker, [404] = target }
|
||||||
|
|
||||||
|
TowBarMod.Wrecker = {
|
||||||
|
getWorldVehicles = function() return collection(loaded) end,
|
||||||
|
isSupportedWrecker = function(vehicle) return vehicle == wrecker end,
|
||||||
|
normalizeHeightLevel = function(level)
|
||||||
|
return math.max(0, math.min(2, tonumber(level) or 0))
|
||||||
|
end,
|
||||||
|
getHeightAttachmentId = function(level)
|
||||||
|
return ({ [0] = "towbarWreckerHookLow", [1] = "towbarWreckerHookMid", [2] = "towbarWreckerHookHigh" })[level]
|
||||||
|
end
|
||||||
|
}
|
||||||
|
|
||||||
|
isClient = function() return false end
|
||||||
|
isServer = function() return true end
|
||||||
|
getTimestampMs = function() return now end
|
||||||
|
getVehicleById = function(id) return byId[id] end
|
||||||
|
sendServerCommand = function(_, command)
|
||||||
|
if command == "wreckerAttachSync" then
|
||||||
|
metrics.attachSync = metrics.attachSync + 1
|
||||||
|
elseif command == "wreckerDetachSync" then
|
||||||
|
metrics.detachSync = metrics.detachSync + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
expect(
|
||||||
|
TowBarMod.Persistence.savePair(
|
||||||
|
"wrecker", wrecker, target,
|
||||||
|
"towbarWreckerHookHigh", "trailerfront", 2
|
||||||
|
),
|
||||||
|
"wrecker setup must persist a stable pair"
|
||||||
|
)
|
||||||
|
dofile("42.20/media/lua/server/WreckerCommands.lua")
|
||||||
|
expect(type(callbacks.tick) == "function", "wrecker server must register its audit tick")
|
||||||
|
|
||||||
|
fireTicks(callbacks.tick, 30)
|
||||||
|
expect(metrics.addConstraint == 1, "wrecker restart must request one native restore")
|
||||||
|
expect(metrics.attachSync == 1, "wrecker restart must broadcast one restore")
|
||||||
|
|
||||||
|
now = 1500
|
||||||
|
fireTicks(callbacks.tick, 30)
|
||||||
|
expect(metrics.addConstraint == 1, "wrecker pending restore must not duplicate its constraint")
|
||||||
|
expect(metrics.attachSync == 1, "wrecker pending restore must not duplicate its sync")
|
||||||
|
|
||||||
|
linkPair(wrecker, target)
|
||||||
|
now = 2000
|
||||||
|
fireTicks(callbacks.tick, 30)
|
||||||
|
expect(metrics.addConstraint == 1, "wrecker delayed acknowledgement must be adopted without re-adding")
|
||||||
|
expect(wrecker.modData.wreckerHeightLevel == 2, "wrecker adoption must preserve the saved hook height")
|
||||||
|
expect(wrecker.modData.wreckerTargetAttachment == "trailerfront", "wrecker adoption must preserve the target endpoint")
|
||||||
|
|
||||||
|
unlinkPair(wrecker, target)
|
||||||
|
loaded = { wrecker }
|
||||||
|
byId[404] = nil
|
||||||
|
now = 10000
|
||||||
|
fireTicks(callbacks.tick, 30)
|
||||||
|
now = 16000
|
||||||
|
fireTicks(callbacks.tick, 30)
|
||||||
|
expect(metrics.detachSync == 0, "an unloaded wrecker peer must not broadcast destructive cleanup")
|
||||||
|
expect(wrecker.modData.wreckerTowActive == true, "an unloaded wrecker peer must keep active metadata")
|
||||||
|
expect(wrecker.modData.wreckerTowedVehicleSqlId == target:getSqlId(), "an unloaded wrecker peer must keep stable identity")
|
||||||
|
|
||||||
|
loaded = { wrecker, target }
|
||||||
|
byId[404] = target
|
||||||
|
now = 17000
|
||||||
|
fireTicks(callbacks.tick, 30)
|
||||||
|
expect(metrics.addConstraint == 2, "a reloaded wrecker peer must re-enter recovery")
|
||||||
|
expect(metrics.attachSync == 2, "a reloaded wrecker peer must receive one fresh restore sync")
|
||||||
|
expect(metrics.detachSync == 0, "wrecker peer reload must not be mistaken for a break")
|
||||||
|
expect(metrics.worldDrops == 0, "wrecker recovery must never create a portable towbar item")
|
||||||
|
expect(metrics.breakConstraint == 0, "wrecker peer reload must not break an unrelated constraint")
|
||||||
|
end
|
||||||
|
|
||||||
|
runPortableHandlerSpec()
|
||||||
|
runWreckerHandlerSpec()
|
||||||
|
|
||||||
|
if failures > 0 then os.exit(1) end
|
||||||
|
print("PASS: production persistence handlers debounce recovery and survive peer unload")
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
local failures = 0
|
||||||
|
local function expect(condition, message)
|
||||||
|
if not condition then
|
||||||
|
failures = failures + 1
|
||||||
|
io.stderr:write("FAIL: " .. message .. "\n")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local globalData = {}
|
||||||
|
local transmitted = 0
|
||||||
|
ModData = {
|
||||||
|
getOrCreate = function(name)
|
||||||
|
globalData[name] = globalData[name] or {}
|
||||||
|
return globalData[name]
|
||||||
|
end,
|
||||||
|
transmit = function() transmitted = transmitted + 1 end
|
||||||
|
}
|
||||||
|
function isServer() return true end
|
||||||
|
TowBarMod = {}
|
||||||
|
|
||||||
|
dofile("42.20/media/lua/shared/TowBar/Persistence.lua")
|
||||||
|
|
||||||
|
local function vehicle(sqlId)
|
||||||
|
return { getSqlId = function() return sqlId end }
|
||||||
|
end
|
||||||
|
|
||||||
|
local towing, towed = vehicle(101), vehicle(202)
|
||||||
|
expect(TowBarMod.Persistence.savePair("towbar", towing, towed, "trailer", "trailerfront"), "valid SQL pair must save")
|
||||||
|
local pair = TowBarMod.Persistence.getPair("towbar", towing, towed)
|
||||||
|
expect(pair and pair.towingSqlId == 101 and pair.towedSqlId == 202, "saved pair must use stable SQL IDs")
|
||||||
|
expect(pair and pair.attachmentA == "trailer" and pair.attachmentB == "trailerfront", "saved pair must retain attachments")
|
||||||
|
|
||||||
|
expect(TowBarMod.Persistence.savePair("wrecker", towing, towed, "towbarWreckerHookHigh", "trailer", 2), "wrecker pair must save")
|
||||||
|
local wreckerPair = TowBarMod.Persistence.getPair("wrecker", towing, towed)
|
||||||
|
expect(wreckerPair and wreckerPair.heightLevel == 2, "wrecker pair must retain hook height")
|
||||||
|
|
||||||
|
local seen = 0
|
||||||
|
TowBarMod.Persistence.forEachPair("towbar", function(record)
|
||||||
|
if record.towingSqlId == 101 and record.towedSqlId == 202 then seen = seen + 1 end
|
||||||
|
end)
|
||||||
|
expect(seen == 1, "registry iteration must return the saved pair exactly once")
|
||||||
|
expect(TowBarMod.Persistence.removePair("towbar", towing, towed), "saved pair must be removable")
|
||||||
|
expect(TowBarMod.Persistence.getPair("towbar", towing, towed) == nil, "removed pair must stay absent")
|
||||||
|
expect(transmitted >= 3, "server registry changes must transmit")
|
||||||
|
expect(not TowBarMod.Persistence.savePair("towbar", vehicle(-1), towed, "trailer", "trailerfront"), "unsaved vehicles must not create unstable records")
|
||||||
|
|
||||||
|
local state, action = TowBarMod.Persistence.advanceRecoveryState(nil, 1000, false, false, 2000, 5000)
|
||||||
|
expect(action == "restore", "first missing-link observation must start one restore")
|
||||||
|
state, action = TowBarMod.Persistence.advanceRecoveryState(state, 1500, false, false, 2000, 5000)
|
||||||
|
expect(action == "wait", "an in-flight restore must not be duplicated")
|
||||||
|
state, action = TowBarMod.Persistence.advanceRecoveryState(state, 3000, true, false, 2000, 5000)
|
||||||
|
expect(action == "adopt" and state.confirmed, "a restored link must be adopted")
|
||||||
|
state, action = TowBarMod.Persistence.advanceRecoveryState(state, 3100, false, false, 2000, 5000)
|
||||||
|
expect(action == "wait", "one missing sample must not destroy an adopted link")
|
||||||
|
state, action = TowBarMod.Persistence.advanceRecoveryState(state, 8099, false, false, 2000, 5000)
|
||||||
|
expect(action == "wait", "cleanup must wait for the complete break grace period")
|
||||||
|
state, action = TowBarMod.Persistence.advanceRecoveryState(state, 8100, false, false, 2000, 5000)
|
||||||
|
expect(action == "break", "a continuously missing confirmed link must eventually break")
|
||||||
|
|
||||||
|
state = TowBarMod.Persistence.notePeerAvailability({ confirmed = true }, false)
|
||||||
|
expect(state.peerMissing, "an unloaded peer must be remembered")
|
||||||
|
state = TowBarMod.Persistence.notePeerAvailability(state, true)
|
||||||
|
expect(state.confirmed == false, "a reloaded pair must re-enter recovery instead of break cleanup")
|
||||||
|
expect(state.peerMissing == nil, "the peer-missing marker must clear after both vehicles load")
|
||||||
|
state, action = TowBarMod.Persistence.advanceRecoveryState(state, 9000, false, false, 2000, 5000)
|
||||||
|
expect(action == "restore", "a reloaded pair must rebuild its constraint")
|
||||||
|
|
||||||
|
if failures > 0 then os.exit(1) end
|
||||||
|
print("PASS: persistent pair registry behavior")
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
local failures = 0
|
||||||
|
|
||||||
|
local function expect(condition, message)
|
||||||
|
if not condition then
|
||||||
|
failures = failures + 1
|
||||||
|
io.stderr:write("FAIL: " .. message .. "\n")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local serverCommand
|
||||||
|
local vehicleSpawned
|
||||||
|
Events = {
|
||||||
|
OnServerCommand = {
|
||||||
|
Add = function(callback) serverCommand = callback end
|
||||||
|
},
|
||||||
|
OnSpawnVehicleEnd = {
|
||||||
|
Add = function(callback) vehicleSpawned = callback end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isServer = function() return false end
|
||||||
|
|
||||||
|
local trace = {}
|
||||||
|
local function record(value) trace[#trace + 1] = value end
|
||||||
|
|
||||||
|
local function script()
|
||||||
|
return {
|
||||||
|
getAttachmentById = function(_, id)
|
||||||
|
if id == "trailer" or id == "trailerfront" then return {} end
|
||||||
|
return nil
|
||||||
|
end,
|
||||||
|
getWheelCount = function() return 0 end,
|
||||||
|
getPhysicsChassisShape = function()
|
||||||
|
return { z = function() return 4 end }
|
||||||
|
end,
|
||||||
|
addAttachment = function() end
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local rigidAdds = 0
|
||||||
|
local breaks = 0
|
||||||
|
local towing
|
||||||
|
local towed
|
||||||
|
|
||||||
|
local function vehicle(id)
|
||||||
|
local value = {
|
||||||
|
id = id,
|
||||||
|
modData = {},
|
||||||
|
script = script(),
|
||||||
|
towing = nil,
|
||||||
|
towedBy = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
function value:getId() return self.id end
|
||||||
|
function value:getScript() return self.script end
|
||||||
|
function value:getScriptName() return "Base.TestVehicle" end
|
||||||
|
function value:getModData() return self.modData end
|
||||||
|
function value:transmitModData() end
|
||||||
|
function value:getMass() return 1200 end
|
||||||
|
function value:getBrakingForce() return 25 end
|
||||||
|
function value:getVehicleTowing() return self.towing end
|
||||||
|
function value:getVehicleTowedBy() return self.towedBy end
|
||||||
|
function value:addPointConstraint(_, other, attachmentA, attachmentB, localOnly)
|
||||||
|
rigidAdds = rigidAdds + 1
|
||||||
|
record("rigid-add")
|
||||||
|
expect(self == towing and other == towed, "rigid rebuild must use the original pair")
|
||||||
|
expect(attachmentA == "trailer" and attachmentB == "trailerfront", "rigid rebuild must preserve endpoints")
|
||||||
|
expect(localOnly == true, "rigid rebuild must be local-only to avoid an attach/detach command race")
|
||||||
|
self.towing = other
|
||||||
|
other.towedBy = self
|
||||||
|
end
|
||||||
|
function value:breakConstraint()
|
||||||
|
breaks = breaks + 1
|
||||||
|
record("break-native")
|
||||||
|
if self == towing or self == towed then
|
||||||
|
towing.towing = nil
|
||||||
|
towing.towedBy = nil
|
||||||
|
towed.towing = nil
|
||||||
|
towed.towedBy = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
|
||||||
|
towing = vehicle(101)
|
||||||
|
towed = vehicle(202)
|
||||||
|
local vehicles = { [101] = towing, [202] = towed }
|
||||||
|
getVehicleById = function(id) return vehicles[id] end
|
||||||
|
|
||||||
|
TowBarMod = {
|
||||||
|
Utils = {
|
||||||
|
updateAttachmentsForRigidTow = function()
|
||||||
|
record("rigid-offsets")
|
||||||
|
end
|
||||||
|
},
|
||||||
|
Hook = {
|
||||||
|
cleanupDetachedTowBar = function()
|
||||||
|
record("cleanup")
|
||||||
|
towing.modData = {}
|
||||||
|
towed.modData = {}
|
||||||
|
end,
|
||||||
|
setVehicleScriptWithTowBarHidden = function(_, scriptName)
|
||||||
|
record("script:" .. tostring(scriptName))
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
setVehiclePostAttach = function()
|
||||||
|
record("post-attach")
|
||||||
|
end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dofile("42.20/media/lua/client/TowBar/TowSyncClient.lua")
|
||||||
|
expect(type(serverCommand) == "function", "client sync must register its server-command handler")
|
||||||
|
|
||||||
|
-- Begin with the old physical pair so spontaneous cleanup exercises the same
|
||||||
|
-- path as a real Build 42 break notification.
|
||||||
|
towing.towing = towed
|
||||||
|
towed.towedBy = towing
|
||||||
|
towing.modData.towBarTowedVehicleId = towed:getId()
|
||||||
|
towed.modData.towBarTowingVehicleId = towing:getId()
|
||||||
|
serverCommand("towbar", "spontaneousDetachSync", {
|
||||||
|
vehicleA = towing:getId(),
|
||||||
|
vehicleB = towed:getId()
|
||||||
|
})
|
||||||
|
expect(breaks > 0, "spontaneous cleanup must remove the old native relation")
|
||||||
|
expect(towing:getVehicleTowing() == nil and towed:getVehicleTowedBy() == nil, "spontaneous cleanup must leave the pair unlinked")
|
||||||
|
|
||||||
|
-- A direct reattach can arrive from the server as a native B42 relationship
|
||||||
|
-- before forceAttachSync reaches the client. That native relationship is the
|
||||||
|
-- rope-like constraint; forceAttachSync must replace it with the rigid path.
|
||||||
|
towing.towing = towed
|
||||||
|
towed.towedBy = towing
|
||||||
|
local breaksBeforeReattach = breaks
|
||||||
|
serverCommand("towbar", "forceAttachSync", {
|
||||||
|
vehicleA = towing:getId(),
|
||||||
|
vehicleB = towed:getId(),
|
||||||
|
attachmentA = "trailer",
|
||||||
|
attachmentB = "trailerfront"
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(breaks == breaksBeforeReattach + 1, "direct reattach must replace the native B42 rope relation")
|
||||||
|
expect(rigidAdds == 1, "direct reattach must create exactly one rigid local constraint")
|
||||||
|
|
||||||
|
local fakeScriptIndex
|
||||||
|
local rigidAddIndex
|
||||||
|
for index, value in ipairs(trace) do
|
||||||
|
if value == "script:notTowingA_Trailer" and not fakeScriptIndex then fakeScriptIndex = index end
|
||||||
|
if value == "rigid-add" and not rigidAddIndex then rigidAddIndex = index end
|
||||||
|
end
|
||||||
|
expect(fakeScriptIndex ~= nil, "rigid reattach must select the fake-trailer script")
|
||||||
|
expect(rigidAddIndex ~= nil and fakeScriptIndex < rigidAddIndex, "fake-trailer selection must precede rigid constraint creation")
|
||||||
|
expect(towing:getVehicleTowing() == towed and towed:getVehicleTowedBy() == towing, "rigid rebuild must finish with a reciprocal tow relation")
|
||||||
|
|
||||||
|
local rigidAddsAfterFirstSync = rigidAdds
|
||||||
|
local breaksAfterFirstSync = breaks
|
||||||
|
serverCommand("towbar", "forceAttachSync", {
|
||||||
|
vehicleA = towing:getId(),
|
||||||
|
vehicleB = towed:getId(),
|
||||||
|
attachmentA = "trailer",
|
||||||
|
attachmentB = "trailerfront"
|
||||||
|
})
|
||||||
|
expect(rigidAdds == rigidAddsAfterFirstSync, "a repeated attach snapshot must not layer a second rigid constraint")
|
||||||
|
expect(breaks == breaksAfterFirstSync, "a repeated attach snapshot must adopt the already-rigid pair")
|
||||||
|
|
||||||
|
-- Streaming can recreate a native relationship with the same runtime IDs
|
||||||
|
-- while the Lua module and its applied cache survive. The spawn notification
|
||||||
|
-- must invalidate that stale cache so the next snapshot replaces the rope.
|
||||||
|
expect(type(vehicleSpawned) == "function", "client sync must observe vehicle streaming")
|
||||||
|
vehicleSpawned(towed)
|
||||||
|
local rigidAddsBeforeStreamRestore = rigidAdds
|
||||||
|
local breaksBeforeStreamRestore = breaks
|
||||||
|
serverCommand("towbar", "forceAttachSync", {
|
||||||
|
vehicleA = towing:getId(),
|
||||||
|
vehicleB = towed:getId(),
|
||||||
|
attachmentA = "trailer",
|
||||||
|
attachmentB = "trailerfront"
|
||||||
|
})
|
||||||
|
expect(rigidAdds == rigidAddsBeforeStreamRestore + 1, "stream-restored native rope must be rebuilt rigid")
|
||||||
|
expect(breaks == breaksBeforeStreamRestore + 1, "stream-restored native rope must be removed exactly once")
|
||||||
|
|
||||||
|
if failures > 0 then os.exit(1) end
|
||||||
|
print("PASS: spontaneous break followed by direct attach rebuilds a rigid towbar")
|
||||||
@@ -29,14 +29,23 @@ Assert-True (Test-Path -LiteralPath $releaseRoot) "Missing 42.20 release folder.
|
|||||||
|
|
||||||
$releaseInfo = Get-Content -LiteralPath (Join-Path $releaseRoot "mod.info")
|
$releaseInfo = Get-Content -LiteralPath (Join-Path $releaseRoot "mod.info")
|
||||||
$rootInfo = Get-Content -LiteralPath (Join-Path $repositoryRoot "mod.info")
|
$rootInfo = Get-Content -LiteralPath (Join-Path $repositoryRoot "mod.info")
|
||||||
Assert-True ($releaseInfo -contains "id=hrsys_towbars") "42.20 must use the stable mod id."
|
Assert-True ($releaseInfo -contains "id=hrsys_towbars_testing") "42.20 must retain the current testing mod id."
|
||||||
Assert-True ($releaseInfo -contains "versionMin=42.20.0") "42.20 must require Build 42.20."
|
Assert-True ($releaseInfo -contains "versionMin=42.20.0") "42.20 must require Build 42.20."
|
||||||
Assert-True ($releaseInfo -contains "modversion=1.0.5") "42.20 must declare mod version 1.0.5."
|
Assert-True ($releaseInfo -contains "modversion=1.0.12") "42.20 must declare mod version 1.0.12."
|
||||||
Assert-True ($rootInfo -contains "modversion=1.0.5") "Root and release mod versions must match."
|
Assert-True ($rootInfo -contains "modversion=1.0.12") "Root and release mod versions must match."
|
||||||
|
|
||||||
$baselineFiles = Get-RelativeFileNames $baselineRoot
|
$baselineFiles = Get-RelativeFileNames $baselineRoot
|
||||||
$releaseFiles = Get-RelativeFileNames $releaseRoot
|
$releaseFiles = Get-RelativeFileNames $releaseRoot
|
||||||
Assert-True (-not (Compare-Object $baselineFiles $releaseFiles)) "42.20 file layout must match the latest baseline."
|
$allowedAdditions = @(
|
||||||
|
"media/lua/client/TowBar/WreckerSyncClient.lua",
|
||||||
|
"media/lua/client/TowBar/WreckerTimedAction.lua",
|
||||||
|
"media/lua/client/TowBar/WreckerUI.lua",
|
||||||
|
"media/lua/server/WreckerCommands.lua",
|
||||||
|
"media/lua/shared/TowBar/Persistence.lua",
|
||||||
|
"media/lua/shared/TowBar/WreckerUtils.lua"
|
||||||
|
)
|
||||||
|
$expectedReleaseFiles = @($baselineFiles) + $allowedAdditions | Sort-Object
|
||||||
|
Assert-True (-not (Compare-Object $expectedReleaseFiles $releaseFiles)) "42.20 file layout must match baseline plus declared wrecker compatibility files."
|
||||||
|
|
||||||
foreach ($jsonFile in Get-ChildItem -LiteralPath $releaseRoot -Recurse -Filter "*.json") {
|
foreach ($jsonFile in Get-ChildItem -LiteralPath $releaseRoot -Recurse -Filter "*.json") {
|
||||||
try {
|
try {
|
||||||
@@ -52,18 +61,42 @@ $btTowPath = Join-Path $releaseRoot "media/lua/server/BTTow.lua"
|
|||||||
$clientSyncPath = Join-Path $releaseRoot "media/lua/client/TowBar/TowSyncClient.lua"
|
$clientSyncPath = Join-Path $releaseRoot "media/lua/client/TowBar/TowSyncClient.lua"
|
||||||
$hookingPath = Join-Path $releaseRoot "media/lua/client/TowBar/TowingHooking.lua"
|
$hookingPath = Join-Path $releaseRoot "media/lua/client/TowBar/TowingHooking.lua"
|
||||||
$towbarTemplatePath = Join-Path $releaseRoot "media/scripts/vehicles/template_towbar.txt"
|
$towbarTemplatePath = Join-Path $releaseRoot "media/scripts/vehicles/template_towbar.txt"
|
||||||
|
$itemNamePath = Join-Path $releaseRoot "media/lua/shared/Translate/EN/ItemName.json"
|
||||||
|
$persistencePath = Join-Path $releaseRoot "media/lua/shared/TowBar/Persistence.lua"
|
||||||
|
$legacyItemNamePath = Join-Path $repositoryRoot "common/media/lua/shared/Translate/EN/ItemName_EN.txt"
|
||||||
$serverCommands = Get-Content -LiteralPath $serverCommandsPath -Raw
|
$serverCommands = Get-Content -LiteralPath $serverCommandsPath -Raw
|
||||||
$btTow = Get-Content -LiteralPath $btTowPath -Raw
|
$btTow = Get-Content -LiteralPath $btTowPath -Raw
|
||||||
$clientSync = Get-Content -LiteralPath $clientSyncPath -Raw
|
$clientSync = Get-Content -LiteralPath $clientSyncPath -Raw
|
||||||
$hooking = Get-Content -LiteralPath $hookingPath -Raw
|
$hooking = Get-Content -LiteralPath $hookingPath -Raw
|
||||||
$towbarTemplate = Get-Content -LiteralPath $towbarTemplatePath -Raw
|
$towbarTemplate = Get-Content -LiteralPath $towbarTemplatePath -Raw
|
||||||
|
$itemNames = Get-Content -LiteralPath $itemNamePath -Raw | ConvertFrom-Json
|
||||||
|
$persistence = Get-Content -LiteralPath $persistencePath -Raw
|
||||||
|
$legacyItemNames = Get-Content -LiteralPath $legacyItemNamePath -Raw
|
||||||
|
|
||||||
|
$towbarVisualScale = 2.5
|
||||||
|
$towbarBaseScriptScale = 0.01
|
||||||
|
$towbarScaledScriptScale = $towbarBaseScriptScale * $towbarVisualScale
|
||||||
|
$towbarMeasuredLengthAtBaseScale = 0.9714089036
|
||||||
|
$towbarScaledHalfLength = ($towbarMeasuredLengthAtBaseScale * $towbarVisualScale) / 2
|
||||||
|
|
||||||
|
Assert-True ($towbarTemplate -match ('(?s)model towbarModel\s*\{{.*?scale = {0},' -f [regex]::Escape($towbarScaledScriptScale.ToString('0.000', [Globalization.CultureInfo]::InvariantCulture)))) "The normal towbar visual must render at 2.5x its measured 0.01 script scale."
|
||||||
|
Assert-True ($hooking -match 'local TowbarVisualScale = 2\.5') "Client placement must declare the 2.5x visual scale."
|
||||||
|
Assert-True ($btTow -match 'local TowbarVisualScale = 2\.5') "Part initialization must declare the 2.5x visual scale."
|
||||||
|
Assert-True ($hooking -match '(?s)local TowbarScaledModelLength = TowbarModelLength \* TowbarVisualScale.*?local TowbarModelHalfLength = TowbarScaledModelLength / 2') "Client placement must compensate offsets using the scaled mesh length."
|
||||||
|
Assert-True ($btTow -match '(?s)local TowbarScaledModelLength = TowbarModelLength \* TowbarVisualScale.*?local TowbarModelHalfLength = TowbarScaledModelLength / 2') "Part initialization must compensate offsets using the scaled mesh length."
|
||||||
|
Assert-True ([Math]::Abs((0.9714089036 / 2) - ($towbarScaledHalfLength / $towbarVisualScale)) -lt 0.000000001) "Scaling and offset compensation must retain the original mesh-relative half-length contact point."
|
||||||
|
|
||||||
|
$towBarDisplayName = $itemNames.PSObject.Properties["TowBar.TowBar"]
|
||||||
|
Assert-True ($null -ne $towBarDisplayName -and $towBarDisplayName.Value -ceq "Tow Bar") "B42.20 must display TowBar.TowBar as Tow Bar."
|
||||||
|
Assert-True ($null -eq $itemNames.PSObject.Properties["ItemName_TowBar.TowBar"]) "B42 JSON must not use the legacy ItemName_ prefix."
|
||||||
|
Assert-True ($legacyItemNames -match 'ItemName_TowBar\.TowBar\s*=\s*"Tow Bar"') "Legacy translations must retain their prefixed item-name key."
|
||||||
|
|
||||||
Assert-True ($serverCommands -match 'detachTrailerSpontaneous') "Server must handle Build 42.20 spontaneous towing detach."
|
Assert-True ($serverCommands -match 'detachTrailerSpontaneous') "Server must handle Build 42.20 spontaneous towing detach."
|
||||||
Assert-True ($serverCommands -match 'towBarTowedVehicleId') "Server must persist the expected towbar pair."
|
Assert-True ($serverCommands -match 'towBarTowedVehicleId') "Server must persist the expected towbar pair."
|
||||||
Assert-True ($serverCommands -match '(?s)local function markExpectedTowBarPair.*?towingModData\["towBarTowingVehicleId"\] = nil.*?towedModData\["towBarTowedVehicleId"\] = nil') "Pair marking must clear stale opposite-role IDs."
|
Assert-True ($serverCommands -match '(?s)local function markExpectedTowBarPair.*?towingModData\["towBarTowingVehicleId"\] = nil.*?towedModData\["towBarTowedVehicleId"\] = nil') "Pair marking must clear stale opposite-role IDs."
|
||||||
Assert-True ($serverCommands -match '(?s)local function clearExpectedTowBarPair.*?towingModData\["towBarTowedVehicleId"\] = nil.*?towingModData\["towBarTowingVehicleId"\] = nil.*?towedModData\["towBarTowedVehicleId"\] = nil.*?towedModData\["towBarTowingVehicleId"\] = nil') "Pair cleanup must clear both role IDs from both vehicles."
|
Assert-True ($serverCommands -match '(?s)local function clearExpectedTowBarPair.*?towingModData\["towBarTowedVehicleId"\] = nil.*?towingModData\["towBarTowingVehicleId"\] = nil.*?towedModData\["towBarTowedVehicleId"\] = nil.*?towedModData\["towBarTowingVehicleId"\] = nil') "Pair cleanup must clear both role IDs from both vehicles."
|
||||||
Assert-True ($serverCommands -match 'local function isExpectedTowBarPair') "Server must validate reciprocal expected towbar IDs."
|
Assert-True ($serverCommands -match 'local function isExpectedTowBarPair') "Server must validate reciprocal expected towbar IDs."
|
||||||
Assert-True ($serverCommands -match '(?s)isExpectedTowBarPair.*?towBarTowedVehicleId.*?vehicleB:getId\(\).*?towBarTowingVehicleId.*?vehicleA:getId\(\)') "Expected towbar pair validation must be reciprocal."
|
Assert-True ($serverCommands -match '(?s)isExpectedTowBarPair.*?matchesSavedVehicle\(.*?towBarTowedVehicleId.*?towBarTowedVehicleSqlId.*?vehicleB.*?matchesSavedVehicle\(.*?towBarTowingVehicleId.*?towBarTowingVehicleSqlId.*?vehicleA') "Expected towbar pair validation must use reciprocal stable vehicle identities."
|
||||||
Assert-True ($serverCommands -match 'local function isLegacyTowBarPair') "Server must isolate legacy pair recovery from stale modern pair IDs."
|
Assert-True ($serverCommands -match 'local function isLegacyTowBarPair') "Server must isolate legacy pair recovery from stale modern pair IDs."
|
||||||
Assert-True ($serverCommands -match '(?s)isLegacyTowBarPair.*?hasTowBarState\(vehicleA\) and hasTowBarState\(vehicleB\).*?towed.*?== true.*?towingModData\["towBarTowedVehicleId"\].*?== nil.*?towingModData\["towBarTowingVehicleId"\].*?== nil.*?towedModData\["towBarTowedVehicleId"\].*?== nil.*?towedModData\["towBarTowingVehicleId"\].*?== nil') "Legacy recovery must require both role markers and no modern pair IDs on either vehicle."
|
Assert-True ($serverCommands -match '(?s)isLegacyTowBarPair.*?hasTowBarState\(vehicleA\) and hasTowBarState\(vehicleB\).*?towed.*?== true.*?towingModData\["towBarTowedVehicleId"\].*?== nil.*?towingModData\["towBarTowingVehicleId"\].*?== nil.*?towedModData\["towBarTowedVehicleId"\].*?== nil.*?towedModData\["towBarTowingVehicleId"\].*?== nil') "Legacy recovery must require both role markers and no modern pair IDs on either vehicle."
|
||||||
Assert-True ($serverCommands -match '(?s)local function resolveExpectedTowBarPair.*?towingVehicle and towedVehicle.*?isExpectedTowBarPair\(towingVehicle, towedVehicle\)') "Current-link spontaneous resolution must still require reciprocal towbar identity."
|
Assert-True ($serverCommands -match '(?s)local function resolveExpectedTowBarPair.*?towingVehicle and towedVehicle.*?isExpectedTowBarPair\(towingVehicle, towedVehicle\)') "Current-link spontaneous resolution must still require reciprocal towbar identity."
|
||||||
@@ -83,13 +116,30 @@ Assert-True ($serverCommands -match 'if item\.reservedTowBar then') "Failed mult
|
|||||||
Assert-True ($serverCommands -match 'Constraint creation may complete on a later server tick') "Multiplayer attach must preserve delayed constraint confirmation."
|
Assert-True ($serverCommands -match 'Constraint creation may complete on a later server tick') "Multiplayer attach must preserve delayed constraint confirmation."
|
||||||
Assert-True ($serverCommands -match 'local function finalizeBrokenTowBarPair') "Broken towbar cleanup must use one idempotent finalizer."
|
Assert-True ($serverCommands -match 'local function finalizeBrokenTowBarPair') "Broken towbar cleanup must use one idempotent finalizer."
|
||||||
Assert-True ($serverCommands -match 'local function reconcileBrokenTowBarPairsServer') "Server and single-player must audit confirmed towbar constraints for silent breaks."
|
Assert-True ($serverCommands -match 'local function reconcileBrokenTowBarPairsServer') "Server and single-player must audit confirmed towbar constraints for silent breaks."
|
||||||
Assert-True ($serverCommands -match '(?s)local function reconcileBrokenTowBarPairsServer.*?towBarTowedVehicleId.*?getVehicleById.*?isExpectedTowBarPair.*?isLinked.*?markTowBarPairConfirmed.*?isTowBarPairConfirmed.*?finalizeBrokenTowBarPair') "The physical-link audit must confirm reciprocal pairs and finalize a missing constraint."
|
Assert-True ($serverCommands -match '(?s)local function reconcileBrokenTowBarPairsServer.*?Persistence\.advanceRecoveryState.*?RestoreRetryMs, SustainedBreakMs.*?action == "break".*?finalizeBrokenTowBarPair.*?action == "restore".*?restorePersistedTowBarPair') "The physical-link audit must use the shared bounded recovery state machine."
|
||||||
|
Assert-True ($persistence -match '(?s)function Persistence\.advanceRecoveryState.*?state\.unlinkedSince = state\.unlinkedSince or now.*?now - state\.unlinkedSince >= breakMs.*?return state, "break"' -and $persistence -match '(?s)if not state\.pendingUntil or now >= state\.pendingUntil then.*?state\.pendingUntil = now \+ retryMs.*?return state, "restore"') "Persistent-pair recovery must debounce restores and require a sustained break before cleanup."
|
||||||
|
Assert-True ($serverCommands -match '(?s)local function restorePersistedTowBarPair.*?towBarExpectedAttachment.*?addPointConstraint.*?broadcastAttach') "A saved towbar pair must rebuild its physical constraint without consuming another item."
|
||||||
|
Assert-True ($persistence -match '(?s)if not state\.pendingUntil or now >= state\.pendingUntil then.*?return state, "restore"') "An unconfirmed saved pair must schedule a bounded restoration attempt."
|
||||||
|
Assert-True ($clientSync -match 'TowBarMod\.Sync\.applyAttachSync = applyAttachSync') "Single-player must expose the existing attach synchronizer for saved-pair recovery."
|
||||||
|
Assert-True ($clientSync -match '(?s)local key = tostring\(vehicleA:getId\(\)\).*?if TowBarMod\.Sync\.appliedPairs\[key\] and not isLinked\(vehicleA, vehicleB\) then.*?TowBarMod\.Sync\.appliedPairs\[key\] = nil.*?if not TowBarMod\.Sync\.appliedPairs\[key\] then.*?breakTowBarPair\(vehicleA, vehicleB\).*?setVehicleScriptWithTowBarHidden\(vehicleB, "notTowingA_Trailer"\).*?addPointConstraint\(nil, vehicleB, attachmentA, attachmentB, true\)') "Every fresh or recovered towbar pair must replace the native rope with one local rigid constraint."
|
||||||
|
Assert-True ($clientSync -notmatch '(?s)if linked then\s*--.*?appliedPairs\[key\] = true') "A newly observed native tow link must not be accepted as rigid without rebuilding it."
|
||||||
|
Assert-True ($clientSync -match '(?s)local function clearAppliedPairForVehicle.*?TowBarMod\.Sync\.appliedPairs\[key\] = nil.*?Events\.OnSpawnVehicleEnd\.Add\(clearAppliedPairForVehicle\)') "Vehicle streaming must invalidate cached rigid towbar constraints."
|
||||||
|
Assert-True ($serverCommands -match '(?s)local function broadcastAttach.*?if isServer\(\).*?sendServerCommand.*?elseif not isClient\(\).*?TowBarMod\.Sync\.applyAttachSync') "Saved towbar recovery must synchronize both dedicated-server and single-player sessions."
|
||||||
|
Assert-True ($serverCommands -match 'towBarTowedVehicleSqlId' -and $serverCommands -match 'towBarTowingVehicleSqlId' -and $serverCommands -match 'getSqlId') "Towbar persistence must use stable save IDs in addition to live network IDs."
|
||||||
|
Assert-True ($serverCommands -match 'Persistence\.savePair\("towbar"' -and $serverCommands -match 'Persistence\.forEachPair\("towbar"') "Towbar persistence must use the server-owned saved-pair registry."
|
||||||
|
Assert-True ($hooking -match '(?s)local function recoverTowBarVehicleAfterLoad.*?authoritative audit will.*?reconnect') "Client load recovery must defer missing constraints to the authoritative saved-pair audit."
|
||||||
|
$loadRecoveryStart = $hooking.IndexOf('local function recoverTowBarVehicleAfterLoad')
|
||||||
|
$loadRecoveryEnd = $hooking.IndexOf('function TowBarMod.Hook.setVehiclePostAttach', $loadRecoveryStart)
|
||||||
|
if ($loadRecoveryStart -ge 0 -and $loadRecoveryEnd -gt $loadRecoveryStart) {
|
||||||
|
$loadRecovery = $hooking.Substring($loadRecoveryStart, $loadRecoveryEnd - $loadRecoveryStart)
|
||||||
|
Assert-True ($loadRecovery -notmatch 'detachTowBar|attachTowBar|reattachTowBarPairAfterCleanDetach') "Loading a saved towbar pair must not refund or consume the item."
|
||||||
|
}
|
||||||
Assert-True ($serverCommands -match '(?s)local function processPendingSync.*?reconcileBrokenTowBarPairsServer\(\)') "The physical-link audit must run from the server/SP tick handler."
|
Assert-True ($serverCommands -match '(?s)local function processPendingSync.*?reconcileBrokenTowBarPairsServer\(\)') "The physical-link audit must run from the server/SP tick handler."
|
||||||
Assert-True ($serverCommands -match '(?s)local function finalizeBrokenTowBarPair.*?dropTowBarOnGround.*?cancelPendingAttach.*?breakTowBarConstraint.*?clearExpectedTowBarPair.*?broadcastSpontaneousDetach') "Broken-pair finalization must drop, disconnect, clear state, and synchronize cleanup."
|
Assert-True ($serverCommands -match '(?s)local function finalizeBrokenTowBarPair.*?dropTowBarOnGround.*?cancelPendingAttach.*?breakTowBarConstraint.*?clearExpectedTowBarPair.*?broadcastSpontaneousDetach') "Broken-pair finalization must drop, disconnect, clear state, and synchronize cleanup."
|
||||||
Assert-True ($serverCommands -match '(?s)local function finalizeBrokenTowBarPair.*?if isServer\(\) then.*?elseif not isClient\(\).*?TowBarMod\.Hook\.cleanupDetachedTowBar') "Single-player breaks must directly restore the vehicle without relying on a server command."
|
Assert-True ($serverCommands -match '(?s)local function finalizeBrokenTowBarPair.*?if isServer\(\) then.*?elseif not isClient\(\).*?TowBarMod\.Hook\.cleanupDetachedTowBar') "Single-player breaks must directly restore the vehicle without relying on a server command."
|
||||||
Assert-True ($clientSync -match 'spontaneousDetachSync') "Client sync must receive spontaneous towbar cleanup."
|
Assert-True ($clientSync -match 'spontaneousDetachSync') "Client sync must receive spontaneous towbar cleanup."
|
||||||
Assert-True ($clientSync -match 'local function hasConflictingTowLink') "Client detach sync must detect links to unrelated vehicles."
|
Assert-True ($clientSync -match 'local function hasConflictingTowLink') "Client detach sync must detect links to unrelated vehicles."
|
||||||
Assert-True ($clientSync -match 'local function breakTowBarPair') "Client detach sync must break only the requested pair."
|
Assert-True ($clientSync -match 'local breakTowBarPair' -and $clientSync -match 'breakTowBarPair = function') "Client detach sync must break only the requested pair."
|
||||||
Assert-True ($clientSync -notmatch 'local function safeBreak') "Client detach sync must not use an unrestricted per-vehicle constraint break."
|
Assert-True ($clientSync -notmatch 'local function safeBreak') "Client detach sync must not use an unrestricted per-vehicle constraint break."
|
||||||
Assert-True ($clientSync -match '(?s)local function applyDetachSync.*?hasConflictingTowLink\(vehicleA, vehicleB\).*?return.*?breakTowBarPair\(vehicleA, vehicleB\).*?cleanupDetachedTowBar') "Client detach sync must reject conflicting links before cleanup."
|
Assert-True ($clientSync -match '(?s)local function applyDetachSync.*?hasConflictingTowLink\(vehicleA, vehicleB\).*?return.*?breakTowBarPair\(vehicleA, vehicleB\).*?cleanupDetachedTowBar') "Client detach sync must reject conflicting links before cleanup."
|
||||||
Assert-True ($hooking -match 'function TowBarMod\.Hook\.cleanupDetachedTowBar') "Client must expose idempotent towbar cleanup."
|
Assert-True ($hooking -match 'function TowBarMod\.Hook\.cleanupDetachedTowBar') "Client must expose idempotent towbar cleanup."
|
||||||
@@ -97,13 +147,63 @@ Assert-True ($hooking -match '(?s)if modData\.towBarOriginalParkingBrakeOn ~= ni
|
|||||||
Assert-True ($hooking -notmatch 'tryVehicleCall\(vehicle, "setBrake"') "Free-rolling state must not change an unrestorable brake control."
|
Assert-True ($hooking -notmatch 'tryVehicleCall\(vehicle, "setBrake"') "Free-rolling state must not change an unrestorable brake control."
|
||||||
Assert-True ($hooking -notmatch 'tryVehicleCall\(vehicle, "setBraking"') "Free-rolling state must not change an unrestorable braking control."
|
Assert-True ($hooking -notmatch 'tryVehicleCall\(vehicle, "setBraking"') "Free-rolling state must not change an unrestorable braking control."
|
||||||
|
|
||||||
Assert-True ($hooking -match '(?s)local function getTowbarModelSlot\(script\).*?if not isVanillaScale\(script\) then.*?return 0\s+end') "KI5 client rendering must select towbar slot 0."
|
Assert-True ($hooking -match 'local TowbarFirstZ = 1\.0') "Client hitbox placement must use the first model's Z position."
|
||||||
Assert-True ($btTow -match '(?s)local function getTowbarModelSlot\(script\).*?if not isVanillaScale\(script\) then.*?return 0\s+end') "KI5 part initialization must select towbar slot 0."
|
Assert-True ($hooking -match 'local TowbarSlotStep = 0\.1') "Client hitbox placement must use the model bank's Z spacing."
|
||||||
|
Assert-True ($hooking -match 'local TowbarVisualScale = 2\.5') "Client placement must account for the enlarged visual."
|
||||||
|
Assert-True ($hooking -match 'local TowbarScaledModelLength = TowbarModelLength \* TowbarVisualScale') "Client placement must derive the enlarged mesh length."
|
||||||
|
Assert-True ($hooking -match 'local TowbarModelHalfLength = TowbarScaledModelLength / 2') "Client placement must offset the mesh center by half its enlarged length."
|
||||||
|
Assert-True ($btTow -match 'local TowbarVisualScale = 2\.5') "Part initialization must account for the enlarged visual."
|
||||||
|
Assert-True ($btTow -match 'local TowbarScaledModelLength = TowbarModelLength \* TowbarVisualScale') "Part initialization must derive the enlarged mesh length."
|
||||||
|
Assert-True ($btTow -match 'local TowbarModelHalfLength = TowbarScaledModelLength / 2') "Part initialization must offset the mesh center by half its enlarged length."
|
||||||
|
Assert-True ($hooking -match '(?s)local function getTowbarFrontEdgeZ\(script\).*?getPhysicsChassisShape\(\).*?getCenterOfMassOffset\(\).*?centerZ.*?shapeZ / 2') "Client visual position must derive from the hitbox front edge."
|
||||||
|
Assert-True ($btTow -match '(?s)local function getTowbarFrontEdgeZ\(script\).*?getPhysicsChassisShape\(\).*?getCenterOfMassOffset\(\).*?centerZ.*?shapeZ / 2') "Part initialization must derive visual position from the hitbox front edge."
|
||||||
|
Assert-True ($hooking -match '(?s)local centerOk, center = pcall.*?if not centerOk or not center then return nil end.*?if not zOk or type\(centerZ\) ~= "number" then return nil end') "Client must not show a dynamically placed towbar when center-of-mass geometry is invalid."
|
||||||
|
Assert-True ($btTow -match '(?s)local centerOk, center = pcall.*?if not centerOk or not center then return nil end.*?if not zOk or type\(centerZ\) ~= "number" then return nil end') "Part initialization must not show a towbar when center-of-mass geometry is invalid."
|
||||||
|
Assert-True ($hooking -match '(?s)local function getTowbarModelSlot\(script\).*?local frontEdgeZ = getTowbarFrontEdgeZ\(script\).*?local modelCenterZ = frontEdgeZ \+ TowbarModelHalfLength.*?math\.floor\(\(\(modelCenterZ - TowbarFirstZ\) / TowbarSlotStep\) \+ 0\.5\).*?math\.max\(0, math\.min\(TowbarMaxIndex, index\)\)') "Client must place the towbar's inner end at the hitbox edge."
|
||||||
|
Assert-True ($btTow -match '(?s)local function getTowbarModelSlot\(script\).*?local frontEdgeZ = getTowbarFrontEdgeZ\(script\).*?local modelCenterZ = frontEdgeZ \+ TowbarModelHalfLength.*?math\.floor\(\(\(modelCenterZ - TowbarFirstZ\) / TowbarSlotStep\) \+ 0\.5\).*?math\.max\(0, math\.min\(TowbarMaxIndex, index\)\)') "Part initialization must place the towbar's inner end at the hitbox edge."
|
||||||
|
Assert-True ($hooking -notmatch 'isVanillaScale|modelScale') "Dynamic hitbox placement must not apply a second model-scale correction."
|
||||||
|
Assert-True ($btTow -notmatch 'isVanillaScale|modelScale') "Part initialization must not apply a second model-scale correction."
|
||||||
Assert-True ($hooking -match '(?s)local function setTowBarModelVisible.*?local part = normalPart.*?part:setModelVisible\("towbar" \.\. index, true\)') "Automatic rendering must use the normal towbar part."
|
Assert-True ($hooking -match '(?s)local function setTowBarModelVisible.*?local part = normalPart.*?part:setModelVisible\("towbar" \.\. index, true\)') "Automatic rendering must use the normal towbar part."
|
||||||
Assert-True ($btTow -match '(?s)function BTtow\.Init\.towbar.*?local shouldShowOnThisPart = part:getId\(\) == "towbar"') "Automatic part initialization must only show the normal towbar part."
|
Assert-True ($btTow -match '(?s)function BTtow\.Init\.towbar.*?local shouldShowOnThisPart = part:getId\(\) == "towbar"') "Automatic part initialization must only show the normal towbar part."
|
||||||
Assert-True ($hooking -notmatch 'local part = isVanilla and normalPart or largePart') "KI5 automatic rendering must not select the large towbar part."
|
Assert-True ($hooking -notmatch 'local part = isVanilla and normalPart or largePart') "KI5 automatic rendering must not select the large towbar part."
|
||||||
$normalTowbarPart = $towbarTemplate.Substring($towbarTemplate.IndexOf('part towbar'), $towbarTemplate.IndexOf('part towbarLarge') - $towbarTemplate.IndexOf('part towbar'))
|
$normalTowbarPart = $towbarTemplate.Substring($towbarTemplate.IndexOf('part towbar'), $towbarTemplate.IndexOf('part towbarLarge') - $towbarTemplate.IndexOf('part towbar'))
|
||||||
|
Assert-True ($towbarTemplate -match '(?s)model towbarModel\s*\{.*?scale = 0\.025,') "The rendered towbar mesh must be exactly 2.5 times its original 0.01 scale."
|
||||||
Assert-True ($normalTowbarPart -match '(?s)model towbar0\s*\{\s*file = towbarModel,\s*offset = 0 -0\.3 1\.0,') "Normal towbar0 must retain the radial-menu Z=1.0 geometry."
|
Assert-True ($normalTowbarPart -match '(?s)model towbar0\s*\{\s*file = towbarModel,\s*offset = 0 -0\.3 1\.0,') "Normal towbar0 must retain the radial-menu Z=1.0 geometry."
|
||||||
|
$normalTowbarModels = [regex]::Matches($normalTowbarPart, '(?s)model towbar(\d+)\s*\{\s*file = towbarModel,\s*offset = ([\d.-]+) ([\d.-]+) ([\d.-]+),')
|
||||||
|
Assert-True ($normalTowbarModels.Count -eq 24) "Normal towbar part must provide all 24 dynamic Z slots."
|
||||||
|
for ($slot = 0; $slot -lt $normalTowbarModels.Count; $slot++) {
|
||||||
|
$match = $normalTowbarModels[$slot]
|
||||||
|
Assert-True ([int]$match.Groups[1].Value -eq $slot) "Normal towbar slots must be sequential."
|
||||||
|
Assert-True ([double]$match.Groups[2].Value -eq 0) "Dynamic placement must not alter lateral X."
|
||||||
|
Assert-True ([double]$match.Groups[3].Value -eq -0.3) "Dynamic placement must not alter vertical Y."
|
||||||
|
$expectedZ = 1.0 + ($slot * 0.1)
|
||||||
|
Assert-True ([Math]::Abs(([double]$match.Groups[4].Value) - $expectedZ) -lt 0.000001) "Towbar slot $slot must map to Z=$expectedZ."
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ExpectedTowbarSlot {
|
||||||
|
param([double]$ShapeZ, [double]$CenterZ)
|
||||||
|
$towbarModelHalfLength = (0.9714089036 * 2.5) / 2
|
||||||
|
$targetCenterZ = $CenterZ + $ShapeZ / 2 + $towbarModelHalfLength
|
||||||
|
$raw = [Math]::Floor((($targetCenterZ - 1.0) / 0.1) + 0.5)
|
||||||
|
return [Math]::Max(0, [Math]::Min(23, [int]$raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
$geometryCases = @(
|
||||||
|
@{ Name = "B42 Ranger"; ShapeZ = 2.6044; CenterZ = 0.0; Slot = 15 },
|
||||||
|
@{ Name = "KI5 Corolla"; ShapeZ = 4.3111; CenterZ = -0.0444; Slot = 23 },
|
||||||
|
@{ Name = "KI5 Range"; ShapeZ = 4.4444; CenterZ = -0.2556; Slot = 22 },
|
||||||
|
@{ Name = "short vehicle"; ShapeZ = 1.2; CenterZ = 0.0; Slot = 8 },
|
||||||
|
@{ Name = "long clamp"; ShapeZ = 7.2; CenterZ = 0.0; Slot = 23 }
|
||||||
|
)
|
||||||
|
foreach ($case in $geometryCases) {
|
||||||
|
Assert-True ((Get-ExpectedTowbarSlot $case.ShapeZ $case.CenterZ) -eq $case.Slot) "$($case.Name) hitbox must map to towbar slot $($case.Slot)."
|
||||||
|
$frontEdgeZ = $case.CenterZ + ($case.ShapeZ / 2)
|
||||||
|
$chosenCenterZ = 1.0 + ($case.Slot * 0.1)
|
||||||
|
$chosenInnerEndZ = $chosenCenterZ - $towbarScaledHalfLength
|
||||||
|
if ($case.Slot -gt 0 -and $case.Slot -lt 23) {
|
||||||
|
Assert-True ([Math]::Abs($chosenInnerEndZ - $frontEdgeZ) -le 0.0500001) "$($case.Name) enlarged visual must retain contact with the hitbox edge within half a model slot."
|
||||||
|
}
|
||||||
|
}
|
||||||
Assert-True ($hooking -match 'function TowBarMod\.Hook\.setVehicleScriptWithTowBarHidden') "Vehicle script swaps must use the anti-flicker helper."
|
Assert-True ($hooking -match 'function TowBarMod\.Hook\.setVehicleScriptWithTowBarHidden') "Vehicle script swaps must use the anti-flicker helper."
|
||||||
Assert-True ($hooking -match '(?s)setVehicleScriptWithTowBarHidden.*?setTowBarModelVisible\(vehicle, false\).*?towBarModelSwapInProgress.*?vehicle:setScriptName\(scriptName\).*?setTowBarModelVisible\(vehicle, false\)') "The anti-flicker helper must hide towbar models for the full script swap."
|
Assert-True ($hooking -match '(?s)setVehicleScriptWithTowBarHidden.*?setTowBarModelVisible\(vehicle, false\).*?towBarModelSwapInProgress.*?vehicle:setScriptName\(scriptName\).*?setTowBarModelVisible\(vehicle, false\)') "The anti-flicker helper must hide towbar models for the full script swap."
|
||||||
Assert-True ($btTow -match '(?s)function BTtow\.Init\.towbar.*?setModelVisible\("towbar" \.\. j, false\).*?towBarModelSwapInProgress.*?return') "Part initialization must not show a towbar during a script swap."
|
Assert-True ($btTow -match '(?s)function BTtow\.Init\.towbar.*?setModelVisible\("towbar" \.\. j, false\).*?towBarModelSwapInProgress.*?return') "Part initialization must not show a towbar during a script swap."
|
||||||
@@ -130,6 +230,7 @@ if ($cleanupStart -ge 0 -and $cleanupEnd -gt $cleanupStart) {
|
|||||||
$cleanupBranch = $hooking.Substring($cleanupStart, $cleanupEnd - $cleanupStart)
|
$cleanupBranch = $hooking.Substring($cleanupStart, $cleanupEnd - $cleanupStart)
|
||||||
Assert-True ($cleanupBranch -match 'updateAttachmentsOnDefaultValues') "Break cleanup must restore attachment offsets."
|
Assert-True ($cleanupBranch -match 'updateAttachmentsOnDefaultValues') "Break cleanup must restore attachment offsets."
|
||||||
Assert-True ($cleanupBranch -match 'setVehicleScriptWithTowBarHidden.*?towBarOriginalScriptName') "Break cleanup must restore the original vehicle script."
|
Assert-True ($cleanupBranch -match 'setVehicleScriptWithTowBarHidden.*?towBarOriginalScriptName') "Break cleanup must restore the original vehicle script."
|
||||||
|
Assert-True ($cleanupBranch -match '(?s)setVehicleScriptWithTowBarHidden.*?towBarOriginalScriptName.*?updateAttachmentsOnDefaultValues') "Break cleanup must restore the real vehicle script before restoring its attachment offsets."
|
||||||
Assert-True ($cleanupBranch -match 'restoreFreeRollingTowState') "Break cleanup must restore mass, brakes, and parking-brake state."
|
Assert-True ($cleanupBranch -match 'restoreFreeRollingTowState') "Break cleanup must restore mass, brakes, and parking-brake state."
|
||||||
Assert-True ($cleanupBranch -match 'setTowBarModelVisible\(towedVehicle, false\)') "Break cleanup must hide the towbar model."
|
Assert-True ($cleanupBranch -match 'setTowBarModelVisible\(towedVehicle, false\)') "Break cleanup must hide the towbar model."
|
||||||
Assert-True ($cleanupBranch -match '(?s)towingModData\["towBarTowedVehicleId"\] = nil.*?towingModData\["towBarTowingVehicleId"\] = nil.*?towedModData\["towBarTowedVehicleId"\] = nil.*?towedModData\["towBarTowingVehicleId"\] = nil') "Client cleanup must remove stale IDs for either tow role."
|
Assert-True ($cleanupBranch -match '(?s)towingModData\["towBarTowedVehicleId"\] = nil.*?towingModData\["towBarTowingVehicleId"\] = nil.*?towedModData\["towBarTowedVehicleId"\] = nil.*?towedModData\["towBarTowingVehicleId"\] = nil') "Client cleanup must remove stale IDs for either tow role."
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$repositoryRoot = Split-Path -Parent $PSScriptRoot
|
||||||
|
$releaseRoot = Join-Path $repositoryRoot "42.20"
|
||||||
|
$failures = [Collections.Generic.List[string]]::new()
|
||||||
|
|
||||||
|
function Assert-True {
|
||||||
|
param([bool]$Condition, [string]$Message)
|
||||||
|
if (-not $Condition) { $script:failures.Add($Message) }
|
||||||
|
}
|
||||||
|
|
||||||
|
$expectedFiles = @(
|
||||||
|
"media/lua/shared/TowBar/WreckerUtils.lua",
|
||||||
|
"media/lua/client/TowBar/WreckerTimedAction.lua",
|
||||||
|
"media/lua/client/TowBar/WreckerUI.lua",
|
||||||
|
"media/lua/client/TowBar/WreckerSyncClient.lua",
|
||||||
|
"media/lua/server/WreckerCommands.lua"
|
||||||
|
)
|
||||||
|
foreach ($relativePath in $expectedFiles) {
|
||||||
|
Assert-True (Test-Path -LiteralPath (Join-Path $releaseRoot $relativePath)) "Missing wrecker runtime file: $relativePath"
|
||||||
|
}
|
||||||
|
|
||||||
|
$sharedPath = Join-Path $releaseRoot "media/lua/shared/TowBar/WreckerUtils.lua"
|
||||||
|
$timedActionPath = Join-Path $releaseRoot "media/lua/client/TowBar/WreckerTimedAction.lua"
|
||||||
|
$uiPath = Join-Path $releaseRoot "media/lua/client/TowBar/WreckerUI.lua"
|
||||||
|
$syncPath = Join-Path $releaseRoot "media/lua/client/TowBar/WreckerSyncClient.lua"
|
||||||
|
$serverPath = Join-Path $releaseRoot "media/lua/server/WreckerCommands.lua"
|
||||||
|
$persistencePath = Join-Path $releaseRoot "media/lua/shared/TowBar/Persistence.lua"
|
||||||
|
$menuPath = Join-Path $releaseRoot "media/lua/client/TowBar/TowingUI.lua"
|
||||||
|
|
||||||
|
$shared = if (Test-Path $sharedPath) { Get-Content $sharedPath -Raw } else { "" }
|
||||||
|
$timedAction = if (Test-Path $timedActionPath) { Get-Content $timedActionPath -Raw } else { "" }
|
||||||
|
$ui = if (Test-Path $uiPath) { Get-Content $uiPath -Raw } else { "" }
|
||||||
|
$sync = if (Test-Path $syncPath) { Get-Content $syncPath -Raw } else { "" }
|
||||||
|
$server = if (Test-Path $serverPath) { Get-Content $serverPath -Raw } else { "" }
|
||||||
|
$persistence = if (Test-Path $persistencePath) { Get-Content $persistencePath -Raw } else { "" }
|
||||||
|
$menu = Get-Content $menuPath -Raw
|
||||||
|
|
||||||
|
$supportedWreckers = @(
|
||||||
|
"Base.76chevyC30CCwrecker",
|
||||||
|
"Base.76chevyC30SCwrecker",
|
||||||
|
"Base.76chevyK30CCwrecker",
|
||||||
|
"Base.76chevyK30SCwrecker",
|
||||||
|
"Base.93chevySilveradoK3500wrecker",
|
||||||
|
"Base.93chevySilveradoK3500mechanic",
|
||||||
|
"Base.78amgeneralM62"
|
||||||
|
)
|
||||||
|
foreach ($fullName in $supportedWreckers) {
|
||||||
|
Assert-True ($shared.Contains($fullName)) "Supported wrecker missing from exact allowlist: $fullName"
|
||||||
|
}
|
||||||
|
$allowlistStart = $shared.IndexOf("local SupportedWreckers = {")
|
||||||
|
$allowlistEnd = $shared.IndexOf("local HeightAttachmentIds", $allowlistStart)
|
||||||
|
Assert-True ($allowlistStart -ge 0 -and $allowlistEnd -gt $allowlistStart) "Could not isolate the supported wrecker allowlist."
|
||||||
|
if ($allowlistStart -ge 0 -and $allowlistEnd -gt $allowlistStart) {
|
||||||
|
$allowlistBody = $shared.Substring($allowlistStart, $allowlistEnd - $allowlistStart)
|
||||||
|
$actualWreckers = [regex]::Matches($allowlistBody, '\["(Base\.[^"]+)"\]\s*=\s*true') |
|
||||||
|
ForEach-Object { $_.Groups[1].Value } | Sort-Object
|
||||||
|
Assert-True (-not (Compare-Object ($supportedWreckers | Sort-Object) $actualWreckers)) "Supported wrecker allowlist must contain exactly the seven verified KI5 vehicle scripts."
|
||||||
|
}
|
||||||
|
Assert-True ($shared -match '(?s)function Wrecker\.isSupportedWrecker.*?SupportedWreckers\[fullName\].*?attachmentExist\("hook"\)') "Wrecker detection must require an exact script name and a hook attachment."
|
||||||
|
Assert-True ($shared -notmatch 'Chevalier_Rhino_TowTruck') "The competing Rhino tow-truck implementation must not be supported by this wrecker system."
|
||||||
|
Assert-True ($shared -match 'Wrecker\.MaxAttachDistance = 0\.6') "Wrecker target selection must use the discovered 0.6-unit hook range."
|
||||||
|
Assert-True ($shared -match 'Wrecker\.HeightStep = 0\.30') "Hook height must move in 0.30-unit steps."
|
||||||
|
Assert-True ($shared -match 'Wrecker\.MinHeightLevel = 0' -and $shared -match 'Wrecker\.MaxHeightLevel = 2') "Hook height must begin at bottom and allow exactly two upward steps."
|
||||||
|
Assert-True ($shared -match 'towbarWreckerHookLow' -and $shared -match 'towbarWreckerHookMid' -and $shared -match 'towbarWreckerHookHigh') "Wreckers must use immutable low/mid/high hook attachment IDs."
|
||||||
|
Assert-True ($shared -match '(?s)registerHookAttachments.*?ModelAttachment\.new.*?baseOffset:y\(\) \+ level \* Wrecker\.HeightStep') "Hook banks must copy each wrecker's own hook geometry and vary only height."
|
||||||
|
Assert-True ($shared -match '(?s)resolveNearestTarget.*?MaxAttachDistance.*?trailerfront.*?trailer.*?distanceSquared') "Target selection must server-recompute the nearest front/rear tow point."
|
||||||
|
|
||||||
|
Assert-True ($menu -match '(?s)playerObj:getVehicle\(\).*?TowBarMod\.WreckerUI\.addOptionsToMenu.*?return') "The seated driver radial path must run before the existing early return."
|
||||||
|
Assert-True ($ui -match 'media/textures/tow_car_icon\.png') "Attach radial action must use the supplied tow-car icon."
|
||||||
|
Assert-True ($ui -match 'media/textures/untow_car_icon\.png') "Detach radial action must use the supplied untow-car icon."
|
||||||
|
Assert-True ($ui -match 'media/textures/arrow_up\.png' -and $ui -match 'media/textures/arrow_down\.png') "Height controls must use the supplied arrow icons."
|
||||||
|
Assert-True ($ui -match 'sendClientCommand\(playerObj, "towbar", "attachWrecker"' -and $ui -match 'sendClientCommand\(playerObj, "towbar", "adjustWreckerHeight"') "Wrecker radial actions must request server-authoritative commands."
|
||||||
|
Assert-True ($ui -match 'local AttachDuration = 300' -and $ui -match 'local DetachDuration = 200') "Wrecker attach and detach must take about three and two seconds respectively."
|
||||||
|
Assert-True ($ui -match '(?s)requestAttach.*?WreckerTimedAction:new.*?AttachDuration') "Attach must complete through the three-second timed action."
|
||||||
|
Assert-True ($ui -match '(?s)requestDetach.*?WreckerTimedAction:new.*?DetachDuration') "Detach must complete through the two-second timed action."
|
||||||
|
Assert-True ($timedAction -match 'ISBaseTimedAction:derive\("WreckerTimedAction"\)' -and $timedAction -match 'self\.isValidFunc') "Wrecker timed actions must revalidate while waiting."
|
||||||
|
|
||||||
|
Assert-True ($server -match 'function Commands\.attachWrecker') "Missing server-authoritative wrecker attach command."
|
||||||
|
Assert-True ($server -match 'function Commands\.detachWrecker') "Missing server-authoritative wrecker detach command."
|
||||||
|
Assert-True ($server -match 'function Commands\.adjustWreckerHeight') "Missing server-authoritative hook-height command."
|
||||||
|
Assert-True ($server -match '(?s)attachWrecker.*?isDriver\(player\).*?isSupportedWrecker.*?resolveNearestTarget.*?syncPairState\(wrecker, target, hookAttachment, targetAttachment, heightLevel, player\)') "Attach must validate driver, wrecker, geometry, and canonical target before synchronizing a constraint."
|
||||||
|
Assert-True ($server -match '(?s)local function syncPairState.*?if isServer\(\) then.*?addPointConstraint\(player, target, hookAttachment, targetAttachment\).*?broadcastWreckerSync') "Dedicated servers must own the logical towing relation while SP uses the client rigid sync path."
|
||||||
|
Assert-True ($server -match '(?s)auditWreckerPairs.*?shouldSnapshot.*?wreckerAttachSync') "The server must periodically reconcile active wrecker pairs for joining clients."
|
||||||
|
Assert-True ($server -match 'local confirmedWreckerPairs = \{\}') "Wrecker persistence must distinguish restored pairs from live constraint breaks."
|
||||||
|
Assert-True ($server -match 'wreckerTowedVehicleSqlId' -and $server -match 'wreckerTowingVehicleSqlId' -and $server -match 'getSqlId') "Wrecker persistence must use stable save IDs in addition to live network IDs."
|
||||||
|
Assert-True ($server -match 'Persistence\.savePair\(\s*"wrecker"' -and $server -match 'Persistence\.forEachPair\("wrecker"') "Wrecker persistence must use the server-owned saved-pair registry."
|
||||||
|
Assert-True ($server -match '(?s)auditWreckerPairs.*?isLinked\(wrecker, target\).*?wreckerTowedVehicleId.*?setPairState') "Adopting an engine-restored pair must refresh stale live IDs immediately."
|
||||||
|
Assert-True ($server -match '(?s)auditWreckerPairs.*?Persistence\.advanceRecoveryState.*?RestoreRetryMs, SustainedBreakMs.*?action == "break".*?detachPair.*?action == "restore".*?syncPairState') "Missing wrecker constraints must use the shared bounded recovery state machine."
|
||||||
|
Assert-True ($persistence -match '(?s)function Persistence\.advanceRecoveryState.*?state\.unlinkedSince = state\.unlinkedSince or now.*?now - state\.unlinkedSince >= breakMs.*?return state, "break"' -and $persistence -match '(?s)if not state\.pendingUntil or now >= state\.pendingUntil then.*?state\.pendingUntil = now \+ retryMs.*?return state, "restore"') "Wrecker recovery must debounce restores and require a sustained break before cleanup."
|
||||||
|
Assert-True ($server -notmatch '(?s)elseif not target then.*?wreckerTowActive = nil') "Temporarily unloaded targets must not erase saved wrecker state."
|
||||||
|
Assert-True ($server -match '(?s)auditWreckerPairs.*?wreckerTowingVehicleId.*?if towingVehicle and not isExpectedPair.*?wreckerDetachSync') "Loaded reciprocal mismatches must receive orphan cleanup."
|
||||||
|
Assert-True ($server -notmatch '(?s)wreckerTowingVehicleId.*?if not towingVehicle.*?wreckerTowingVehicleId = nil') "Temporarily unloaded wreckers must not erase saved target state."
|
||||||
|
Assert-True ($server -match '(?s)adjustWreckerHeight.*?direction ~= -1 and direction ~= 1.*?nextHeightLevel.*?syncPairState') "Height changes must validate direction, clamp on the server, and broadcast canonical state."
|
||||||
|
Assert-True ($server -notmatch 'TowBar\.TowBar|consumeTowBar|giveTowBar|dropTowBarOnGround|inventory:AddItem') "Wrecker commands must not mutate the separate towbar item lifecycle."
|
||||||
|
Assert-True ($server -match '(?s)onClientCommand.*?getTimestampMs.*?CommandCooldownMs') "Wrecker commands must be rate-limited at the server boundary."
|
||||||
|
|
||||||
|
Assert-True ($sync -match 'notTowingA_Trailer') "Client sync must use the existing fake-trailer path to select vanilla rigid constraints."
|
||||||
|
Assert-True ($sync -match '(?s)applyAttachSync.*?breakWreckerPair.*?setScriptSafely\(target, "notTowingA_Trailer"\).*?addPointConstraint.*?setScriptSafely\(target, originalScript\)') "Client sync must rebuild the constraint while the target uses the fake trailer script, then restore it."
|
||||||
|
Assert-True ($sync -match 'addPointConstraint\(nil, target, hookAttachment, targetAttachment, true\)') "Wrecker local physics rebuilds must suppress server detach/attach traffic."
|
||||||
|
Assert-True ($sync -match '(?s)local appliedLevel = Sync\.appliedLevels\[key\].*?if isPairLinked\(wrecker, target\).*?and appliedLevel == canonicalLevel then.*?return') "Only a locally applied wrecker constraint at the requested height may be reused."
|
||||||
|
Assert-True ($sync -match '(?s)local appliedLevel = Sync\.appliedLevels\[key\].*?appliedLevel == canonicalLevel.*?return\s+end.*?breakWreckerPair\(wrecker, target\).*?addPointConstraint') "A native-restored wrecker rope without a locally applied height must be rebuilt through the rigid local constraint path."
|
||||||
|
Assert-True ($sync -match '(?s)local function clearAppliedLevelForVehicle.*?Sync\.appliedLevels\[key\] = nil.*?Events\.OnSpawnVehicleEnd\.Add\(clearAppliedLevelForVehicle\)') "Vehicle streaming must invalidate cached local wrecker constraints."
|
||||||
|
Assert-True ($sync -match '(?s)if isPairLinked\(wrecker, target\).*?appliedLevel.*?canonicalLevel.*?breakWreckerPair\(wrecker, target\).*?addPointConstraint') "A requested wrecker height change must rebuild the local constraint at the new hook attachment."
|
||||||
|
Assert-True ($sync -match '(?s)local function applyFreeRollingState.*?wreckerOriginalMass.*?wreckerOriginalBrakingForce.*?setMass\(200\).*?setBrakingForce\(0\)') "Attached vehicles must be made free-rolling like trailers."
|
||||||
|
Assert-True ($sync -match '(?s)local function restoreFreeRollingState.*?setMass.*?setBrakingForce.*?wreckerOriginalMass = nil') "Detach must restore the target vehicle's pre-tow mass and braking state."
|
||||||
|
Assert-True ($sync -match '(?s)local function applyDetachSync.*?if not target then return end.*?if wrecker then breakWreckerPair.*?restoreFreeRollingState\(target\)') "Detach sync must restore a reloaded target even when the former wrecker is unavailable."
|
||||||
|
Assert-True ($sync -match '(?s)applyDetachSync.*?wreckerTowingVehicleSqlId.*?args\.wreckerSqlId.*?expectedWreckerSqlId ~= commandWreckerSqlId') "Detach sync must validate stable IDs when live IDs changed across restart."
|
||||||
|
|
||||||
|
foreach ($name in @("tow_car_icon.png", "untow_car_icon.png", "arrow_up.png", "arrow_down.png")) {
|
||||||
|
$iconPath = Join-Path $repositoryRoot "common/media/textures/$name"
|
||||||
|
Assert-True (Test-Path -LiteralPath $iconPath) "Missing runtime icon: $name"
|
||||||
|
if (Test-Path -LiteralPath $iconPath) {
|
||||||
|
$bytes = [IO.File]::ReadAllBytes($iconPath)
|
||||||
|
Assert-True ($bytes.Length -gt 24) "$name is not a valid PNG payload."
|
||||||
|
if ($bytes.Length -gt 24) {
|
||||||
|
Assert-True ($bytes[0] -eq 0x89 -and $bytes[1] -eq 0x50 -and $bytes[2] -eq 0x4E -and $bytes[3] -eq 0x47) "$name lacks a PNG signature."
|
||||||
|
$width = [Net.IPAddress]::NetworkToHostOrder([BitConverter]::ToInt32($bytes, 16))
|
||||||
|
$height = [Net.IPAddress]::NetworkToHostOrder([BitConverter]::ToInt32($bytes, 20))
|
||||||
|
Assert-True ($width -eq 64 -and $height -eq 64) "$name must be 64x64."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$luaFiles = @($sharedPath, $timedActionPath, $uiPath, $syncPath, $serverPath) | Where-Object { Test-Path $_ }
|
||||||
|
if ($luaFiles.Count -gt 0) {
|
||||||
|
& npx --yes luaparse --quiet @luaFiles
|
||||||
|
Assert-True ($LASTEXITCODE -eq 0) "One or more wrecker Lua files failed to parse."
|
||||||
|
}
|
||||||
|
|
||||||
|
$releaseInfo = Get-Content -LiteralPath (Join-Path $releaseRoot "mod.info")
|
||||||
|
$rootInfo = Get-Content -LiteralPath (Join-Path $repositoryRoot "mod.info")
|
||||||
|
Assert-True ($releaseInfo -contains "incompatible=\STowTruck_B42") "42.20 must mark the competing Tow Truck [B42] implementation incompatible."
|
||||||
|
Assert-True ($rootInfo -contains "incompatible=\STowTruck_B42") "Root metadata must mark the competing Tow Truck [B42] implementation incompatible."
|
||||||
|
Assert-True (@($releaseInfo | Where-Object { $_ -like "incompatible=*" }).Count -eq 1) "42.20 must contain exactly one incompatibility declaration."
|
||||||
|
Assert-True (@($rootInfo | Where-Object { $_ -like "incompatible=*" }).Count -eq 1) "Root metadata must contain exactly one incompatibility declaration."
|
||||||
|
|
||||||
|
$readme = Get-Content -LiteralPath (Join-Path $repositoryRoot "README.md") -Raw
|
||||||
|
Assert-True ($readme -match '## Supported wrecker mods') "README must publish the supported-wrecker mod list."
|
||||||
|
foreach ($workshopId in @("3161951724", "3152529790", "2799152995")) {
|
||||||
|
Assert-True ($readme.Contains($workshopId)) "README supported list must include Workshop ID $workshopId."
|
||||||
|
}
|
||||||
|
foreach ($modId in @("76chevyKseries", "93chevySuburban", "93chevySuburbanExpanded", "78amgeneralM35A2")) {
|
||||||
|
Assert-True ($readme.Contains($modId)) "README supported list must include Mod ID $modId."
|
||||||
|
}
|
||||||
|
foreach ($fullName in $supportedWreckers) {
|
||||||
|
Assert-True ($readme.Contains($fullName)) "README supported list must include vehicle $fullName."
|
||||||
|
}
|
||||||
|
Assert-True ($readme.Contains("3446203945") -and $readme.Contains("STowTruck_B42") -and $readme -match 'incompatible') "README must identify and explain the competing Tow Truck mod incompatibility."
|
||||||
|
|
||||||
|
if ($failures.Count -gt 0) {
|
||||||
|
$failures | ForEach-Object { Write-Error $_ }
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output "PASS: Build 42.20 wrecker compatibility contracts."
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
local failures = 0
|
||||||
|
|
||||||
|
local function expect(condition, message)
|
||||||
|
if not condition then
|
||||||
|
failures = failures + 1
|
||||||
|
io.stderr:write("FAIL: " .. message .. "\n")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Events = { OnGameBoot = { Add = function() end } }
|
||||||
|
Vector3f = { new = function() return {} end }
|
||||||
|
|
||||||
|
local function point(x, y, z)
|
||||||
|
return {
|
||||||
|
x = function() return x end,
|
||||||
|
y = function() return y end,
|
||||||
|
z = function() return z or 0 end
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function vehicle(id, fullName, attachments)
|
||||||
|
local linkedTowing
|
||||||
|
local linkedTowedBy
|
||||||
|
local script = {
|
||||||
|
getFullName = function() return fullName end,
|
||||||
|
getName = function() return string.gsub(fullName, "^Base%.", "") end
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
getId = function() return id end,
|
||||||
|
getScript = function() return script end,
|
||||||
|
attachmentExist = function(_, name) return attachments[name] ~= nil end,
|
||||||
|
getAttachmentWorldPos = function(_, name) return attachments[name] end,
|
||||||
|
getVehicleTowing = function() return linkedTowing end,
|
||||||
|
getVehicleTowedBy = function() return linkedTowedBy end,
|
||||||
|
setLinks = function(_, towing, towedBy) linkedTowing, linkedTowedBy = towing, towedBy end
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
dofile("42.20/media/lua/shared/TowBar/WreckerUtils.lua")
|
||||||
|
|
||||||
|
local supported = {
|
||||||
|
"Base.76chevyC30CCwrecker",
|
||||||
|
"Base.76chevyC30SCwrecker",
|
||||||
|
"Base.76chevyK30CCwrecker",
|
||||||
|
"Base.76chevyK30SCwrecker",
|
||||||
|
"Base.93chevySilveradoK3500wrecker",
|
||||||
|
"Base.93chevySilveradoK3500mechanic",
|
||||||
|
"Base.78amgeneralM62"
|
||||||
|
}
|
||||||
|
for index, fullName in ipairs(supported) do
|
||||||
|
expect(TowBarMod.Wrecker.isSupportedWrecker(vehicle(index, fullName, { hook = point(0, 0) })), fullName .. " should be supported")
|
||||||
|
expect(not TowBarMod.Wrecker.isSupportedWrecker(vehicle(index + 100, fullName, {})), fullName .. " without a hook must be rejected")
|
||||||
|
end
|
||||||
|
expect(not TowBarMod.Wrecker.isSupportedWrecker(vehicle(10, supported[1], {})), "exact variant without hook must be rejected")
|
||||||
|
expect(not TowBarMod.Wrecker.isSupportedWrecker(vehicle(11, "Base.76chevyK30CC", { hook = point(0, 0) })), "regular pickup must be rejected")
|
||||||
|
expect(not TowBarMod.Wrecker.isSupportedWrecker(vehicle(12, supported[1] .. "Burnt", { hook = point(0, 0) })), "suffixed lookalike must be rejected")
|
||||||
|
expect(not TowBarMod.Wrecker.isSupportedWrecker(vehicle(13, "Base.78amgeneralM35A2", { hook = point(0, 0) })), "ordinary M35A2 must be rejected")
|
||||||
|
expect(not TowBarMod.Wrecker.isSupportedWrecker(vehicle(14, "Base.Chevalier_Rhino_TowTruck", { hook = point(0, 0) })), "competing Rhino tow truck must be rejected")
|
||||||
|
|
||||||
|
expect(TowBarMod.Wrecker.nextHeightLevel(0, 1) == 1, "height should raise once from bottom")
|
||||||
|
expect(TowBarMod.Wrecker.nextHeightLevel(1, 1) == 2, "height should raise twice from bottom")
|
||||||
|
expect(TowBarMod.Wrecker.nextHeightLevel(2, 1) == 2, "height must clamp at the second upward step")
|
||||||
|
expect(TowBarMod.Wrecker.nextHeightLevel(0, -1) == 0, "height must not move below the initial hook position")
|
||||||
|
expect(TowBarMod.Wrecker.nextHeightLevel(2, -1) == 1, "height should lower one step")
|
||||||
|
expect(TowBarMod.Wrecker.nextHeightLevel(0, 0) == nil, "invalid height direction must be rejected")
|
||||||
|
expect(TowBarMod.Wrecker.getHeightAttachmentId(0) == "towbarWreckerHookLow", "level 0 must use the bottom hook")
|
||||||
|
expect(TowBarMod.Wrecker.getHeightAttachmentId(1) == "towbarWreckerHookMid", "level 1 must use the first upward step")
|
||||||
|
expect(TowBarMod.Wrecker.getHeightAttachmentId(2) == "towbarWreckerHookHigh", "level 2 must use the second upward step")
|
||||||
|
expect(TowBarMod.Wrecker.normalizeHeightLevel(-1) == 0, "old below-bottom state must migrate to bottom")
|
||||||
|
|
||||||
|
local wrecker = vehicle(20, supported[1], { hook = point(0, 0, 9) })
|
||||||
|
local nearRear = vehicle(21, "Base.CarA", {
|
||||||
|
trailer = point(0.599, 0, -50), trailerfront = point(4, 0, 50)
|
||||||
|
})
|
||||||
|
local nearFront = vehicle(22, "Base.CarB", {
|
||||||
|
trailer = point(5, 0), trailerfront = point(0.4, 0.1)
|
||||||
|
})
|
||||||
|
local result = TowBarMod.Wrecker.resolveNearestTarget(wrecker, { nearRear, nearFront })
|
||||||
|
expect(result and result.vehicle == nearFront and result.attachment == "trailerfront", "nearest endpoint across all vehicles must win")
|
||||||
|
|
||||||
|
result = TowBarMod.Wrecker.resolveNearestTarget(wrecker, { nearRear })
|
||||||
|
expect(result and result.attachment == "trailer", "0.599 endpoint must be accepted and world Z ignored")
|
||||||
|
local tooFar = vehicle(23, "Base.CarC", { trailer = point(0.601, 0) })
|
||||||
|
expect(TowBarMod.Wrecker.resolveNearestTarget(wrecker, { tooFar }) == nil, "0.601 endpoint must be rejected")
|
||||||
|
local trailer = vehicle(24, "Base.UtilityTrailer", { trailerfront = point(0.1, 0) })
|
||||||
|
expect(TowBarMod.Wrecker.resolveNearestTarget(wrecker, { trailer }) == nil, "trailers must be excluded from wrecker vehicle towing")
|
||||||
|
|
||||||
|
if failures > 0 then os.exit(1) end
|
||||||
|
print("PASS: wrecker helper behavior")
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
local failures = 0
|
||||||
|
|
||||||
|
local function expect(condition, message)
|
||||||
|
if not condition then
|
||||||
|
failures = failures + 1
|
||||||
|
io.stderr:write("FAIL: " .. message .. "\n")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
package.preload["TimedActions/ISBaseTimedAction"] = function() return true end
|
||||||
|
ISBaseTimedAction = {}
|
||||||
|
function ISBaseTimedAction:derive()
|
||||||
|
local derived = {}
|
||||||
|
setmetatable(derived, { __index = self })
|
||||||
|
return derived
|
||||||
|
end
|
||||||
|
function ISBaseTimedAction.new(class, character)
|
||||||
|
local action = { character = character }
|
||||||
|
setmetatable(action, { __index = class })
|
||||||
|
return action
|
||||||
|
end
|
||||||
|
function ISBaseTimedAction.perform(action)
|
||||||
|
action.completed = true
|
||||||
|
end
|
||||||
|
function ISBaseTimedAction.stop(action)
|
||||||
|
action.stopped = true
|
||||||
|
end
|
||||||
|
|
||||||
|
dofile("42.20/media/lua/client/TowBar/WreckerTimedAction.lua")
|
||||||
|
|
||||||
|
local sent = 0
|
||||||
|
local valid = true
|
||||||
|
local function validate() return valid end
|
||||||
|
local function perform() sent = sent + 1 end
|
||||||
|
|
||||||
|
local attach = WreckerTimedAction:new({}, 300, validate, perform, {}, {})
|
||||||
|
expect(attach.maxTime == 300, "attach duration must be 300 action units")
|
||||||
|
expect(sent == 0, "constructing an action must not send a command")
|
||||||
|
attach:perform()
|
||||||
|
expect(sent == 1 and attach.completed, "valid attach must send once after its timer completes")
|
||||||
|
|
||||||
|
local detach = WreckerTimedAction:new({}, 200, validate, perform, {}, {})
|
||||||
|
expect(detach.maxTime == 200, "detach duration must be 200 action units")
|
||||||
|
valid = false
|
||||||
|
detach:perform()
|
||||||
|
expect(sent == 1, "an invalidated timed action must not send its command")
|
||||||
|
|
||||||
|
valid = true
|
||||||
|
local cancelled = WreckerTimedAction:new({}, 300, validate, perform, {}, {})
|
||||||
|
cancelled:stop()
|
||||||
|
expect(cancelled.stopped and sent == 1, "cancelling an action must not send its command")
|
||||||
|
|
||||||
|
if failures > 0 then os.exit(1) end
|
||||||
|
print("PASS: wrecker timed action behavior")
|
||||||