42.20 and Towtrucks

This commit is contained in:
2026-08-13 18:59:54 -04:00
parent a6ba4877d0
commit e403323017
33 changed files with 2333 additions and 142 deletions
-2
View File
@@ -6,5 +6,3 @@ TowBarMod.Config.rigidTowbarDistance = 1.0
TowBarMod.Config.towAttachmentExteriorPadding = 0.25
TowBarMod.Config.towedVehicleRollingMass = 75
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 {}
if TowBarMod.Sync._towSyncClientLoaded then return end
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)
if not id then return nil end
@@ -89,6 +106,8 @@ local function reconcilePairState(vehicleA, vehicleB, attachmentA, attachmentB)
end
end
local breakTowBarPair
local function applyAttachSync(args)
if not args then return end
@@ -102,8 +121,25 @@ local function applyAttachSync(args)
return
end
if not isLinked(vehicleA, vehicleB) then
vehicleA:addPointConstraint(nil, vehicleB, attachmentA, attachmentB)
local key = tostring(vehicleA:getId()) .. ":" .. tostring(vehicleB:getId())
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
reconcilePairState(vehicleA, vehicleB, attachmentA, attachmentB)
@@ -130,18 +166,17 @@ local function hasConflictingTowLink(vehicle, expectedOther)
and modData["towBarTowingVehicleId"] ~= expectedOtherId)
end
local function breakTowBarPair(vehicleA, vehicleB)
breakTowBarPair = function(vehicleA, vehicleB)
if not vehicleA or not vehicleB then return end
local vehicleAReferencesB = vehicleA:getVehicleTowing() == vehicleB
or vehicleA:getVehicleTowedBy() == vehicleB
local vehicleBReferencesA = vehicleB:getVehicleTowing() == vehicleA
or vehicleB:getVehicleTowedBy() == vehicleA
if vehicleBReferencesA then
vehicleB:breakConstraint(true, true)
end
if vehicleAReferencesB then
vehicleA:breakConstraint(true, true)
elseif vehicleBReferencesA then
vehicleB:breakConstraint(true, true)
end
end
@@ -154,6 +189,7 @@ local function applyDetachSync(args)
return
end
breakTowBarPair(vehicleA, vehicleB)
TowBarMod.Sync.appliedPairs[tostring(vehicleA:getId()) .. ":" .. tostring(vehicleB:getId())] = nil
if TowBarMod.Hook and TowBarMod.Hook.cleanupDetachedTowBar then
pcall(TowBarMod.Hook.cleanupDetachedTowBar, vehicleA, vehicleB)
@@ -170,4 +206,10 @@ local function onServerCommand(module, command, args)
end
end
TowBarMod.Sync.applyAttachSync = applyAttachSync
TowBarMod.Sync.applyDetachSync = applyDetachSync
Events.OnServerCommand.Add(onServerCommand)
if Events.OnSpawnVehicleEnd then
Events.OnSpawnVehicleEnd.Add(clearAppliedPairForVehicle)
end
+47 -54
View File
@@ -128,61 +128,54 @@ local function sendTowAttachCommand(playerObj, args)
end
local TowbarVariantSize = 24
local TowbarNormalStart = 0
local TowbarLargeStart = 24
local TowbarMaxIndex = TowbarVariantSize - 1
local VanillaScaleMin = 1.5
local VanillaScaleMax = 2.0
local TowbarFirstZ = 1.0
local TowbarSlotStep = 0.1
-- Measured from Towbar.fbx at its original 0.01 script scale. 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
local ok, result = pcall(function()
return script:getModelScale()
local ok, shape = pcall(function()
return script:getPhysicsChassisShape()
end)
if ok and type(result) == "number" then
return result
end
if not ok or not shape then return nil end
ok, result = pcall(function()
local model = script:getModel()
if model then
return model:getScale()
end
return nil
local shapeOk, shapeZ = pcall(function()
return shape:z()
end)
if ok and type(result) == "number" then
return result
end
if not shapeOk or type(shapeZ) ~= "number" or shapeZ <= 0 then
return nil
end
local function isVanillaScale(script)
local modelScale = getVehicleModelScale(script)
if modelScale == nil then
return true
end
local centerOk, center = pcall(function()
return script:getCenterOfMassOffset()
end)
if not centerOk or not center then return nil 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 zOk, centerZ = pcall(function()
return center:z()
end)
if not zOk or type(centerZ) ~= "number" then return nil end
local function getTowbarIndexVanilla(script)
local z = script:getPhysicsChassisShape():z() / 2 - 0.1
local index = math.floor((z * 2 / 3 - 1) * 10)
return math.max(0, math.min(TowbarMaxIndex, index))
return centerZ + shapeZ / 2
end
local function getTowbarModelSlot(script)
if not isVanillaScale(script) then
-- KI5/small-scale vehicles use the normal towbar0 mesh at Z=1.
return 0
end
return getTowbarIndexVanilla(script)
local frontEdgeZ = getTowbarFrontEdgeZ(script)
if frontEdgeZ == nil then return nil end
-- Model offsets position the mesh origin. Move its center outward so the
-- towbar's inner end, rather than its center, meets the vehicle hitbox.
local modelCenterZ = frontEdgeZ + TowbarModelHalfLength
local index = math.floor(((modelCenterZ - TowbarFirstZ) / TowbarSlotStep) + 0.5)
return math.max(0, math.min(TowbarMaxIndex, index))
end
local function setTowBarModelVisible(vehicle, isVisible)
@@ -210,7 +203,7 @@ local function setTowBarModelVisible(vehicle, isVisible)
local index = getTowbarModelSlot(script)
local part = normalPart
if part then
if part and index ~= nil then
part:setModelVisible("towbar" .. index, true)
end
@@ -409,15 +402,11 @@ local function recoverTowBarVehicleAfterLoad(playerObj, vehicle, retriesLeft)
if towingVehicle then
-- 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)
end
if localPlayer and towingVehicle then
if reattachTowBarPairAfterCleanDetach(localPlayer, towingVehicle, vehicle, false) then
return
end
end
if localPlayer and retries > 0 then
-- During world load, tow links can become available a few ticks later.
@@ -425,9 +414,8 @@ local function recoverTowBarVehicleAfterLoad(playerObj, vehicle, retriesLeft)
return
end
-- Fallback: keep original post-attach restoration behavior.
setTowBarModelVisible(vehicle, true)
TowBarMod.Hook.setVehiclePostAttach(nil, vehicle)
-- Leave reciprocal saved state untouched. The authoritative audit will
-- reconnect it when both vehicle chunks are loaded.
end
function TowBarMod.Hook.setVehiclePostAttach(playerObj, towedVehicle, retriesLeft)
@@ -514,8 +502,6 @@ end
function TowBarMod.Hook.cleanupDetachedTowBar(towingVehicle, towedVehicle)
if towingVehicle == nil or towedVehicle == nil then return end
TowBarMod.Utils.updateAttachmentsOnDefaultValues(towingVehicle, towedVehicle)
local towingModData = towingVehicle:getModData()
local towedModData = towedVehicle:getModData()
if not towingModData or not towedModData then return end
@@ -523,17 +509,25 @@ function TowBarMod.Hook.cleanupDetachedTowBar(towingVehicle, towedVehicle)
if towedModData.towBarOriginalScriptName then
TowBarMod.Hook.setVehicleScriptWithTowBarHidden(towedVehicle, towedModData.towBarOriginalScriptName)
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)
towingModData["isTowingByTowBar"] = false
towingModData["towed"] = false
towingModData["towBarTowedVehicleId"] = nil
towingModData["towBarTowedVehicleSqlId"] = nil
towingModData["towBarTowingVehicleId"] = nil
towingModData["towBarTowingVehicleSqlId"] = nil
towingModData["towBarExpectedAttachment"] = nil
towedModData["isTowingByTowBar"] = false
towedModData["towed"] = false
towedModData["towBarTowedVehicleId"] = nil
towedModData["towBarTowedVehicleSqlId"] = nil
towedModData["towBarTowingVehicleId"] = nil
towedModData["towBarTowingVehicleSqlId"] = nil
towedModData["towBarExpectedAttachment"] = nil
towedModData.towBarOriginalScriptName = nil
towedModData.towBarOriginalMass = nil
@@ -762,7 +756,6 @@ function TowBarMod.Hook.devShowAllTowbarModels(playerObj, vehicle)
local script = vehicle:getScript()
local chassisZ = script and script:getPhysicsChassisShape():z() or 0
local halfZ = chassisZ / 2
local modelScale = script and getVehicleModelScale(script) or nil
local index = 0
if script then
index = getTowbarModelSlot(script)
@@ -770,7 +763,7 @@ function TowBarMod.Hook.devShowAllTowbarModels(playerObj, vehicle)
local selectedPart = "towbar"
print("[TowBar DEV] Vehicle: " .. tostring(vehicle:getScriptName()))
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] Showing towbar0..towbar23 on both parts")
for j = 0, TowbarVariantSize - 1 do
+7 -1
View File
@@ -215,7 +215,13 @@ end
function ISVehicleMenu.showRadialMenu(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)
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
+109
View File
@@ -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
+34 -41
View File
@@ -3,61 +3,54 @@ BTtow.Create = {}
BTtow.Init = {}
local TowbarVariantSize = 24
local TowbarNormalStart = 0
local TowbarLargeStart = 24
local TowbarMaxIndex = TowbarVariantSize - 1
local VanillaScaleMin = 1.5
local VanillaScaleMax = 2.0
local TowbarFirstZ = 1.0
local TowbarSlotStep = 0.1
-- Measured from Towbar.fbx at its original 0.01 script scale. Keep this in
-- lockstep with the client selector so the enlarged mesh's inner end remains
-- on the same vehicle-hitbox contact point.
local TowbarVisualScale = 2.5
local TowbarModelLength = 0.9714089036
local TowbarScaledModelLength = TowbarModelLength * TowbarVisualScale
local TowbarModelHalfLength = TowbarScaledModelLength / 2
local function getVehicleModelScale(script)
local function getTowbarFrontEdgeZ(script)
if not script then return nil end
local ok, result = pcall(function()
return script:getModelScale()
local ok, shape = pcall(function()
return script:getPhysicsChassisShape()
end)
if ok and type(result) == "number" then
return result
end
if not ok or not shape then return nil end
ok, result = pcall(function()
local model = script:getModel()
if model then
return model:getScale()
end
return nil
local shapeOk, shapeZ = pcall(function()
return shape:z()
end)
if ok and type(result) == "number" then
return result
end
if not shapeOk or type(shapeZ) ~= "number" or shapeZ <= 0 then
return nil
end
local function isVanillaScale(script)
local modelScale = getVehicleModelScale(script)
if modelScale == nil then
return true
end
local centerOk, center = pcall(function()
return script:getCenterOfMassOffset()
end)
if not centerOk or not center then return nil 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 zOk, centerZ = pcall(function()
return center:z()
end)
if not zOk or type(centerZ) ~= "number" then return nil end
local function getTowbarIndexVanilla(script)
local z = script:getPhysicsChassisShape():z() / 2 - 0.1
local index = math.floor((z * 2 / 3 - 1) * 10)
return math.max(0, math.min(TowbarMaxIndex, index))
return centerZ + shapeZ / 2
end
local function getTowbarModelSlot(script)
if not isVanillaScale(script) then
-- KI5/small-scale vehicles use the normal towbar0 mesh at Z=1.
return 0
end
return getTowbarIndexVanilla(script)
local frontEdgeZ = getTowbarFrontEdgeZ(script)
if frontEdgeZ == nil then return nil end
-- Model offsets position the mesh origin. Move its center outward so the
-- towbar's inner end, rather than its center, meets the vehicle hitbox.
local modelCenterZ = frontEdgeZ + TowbarModelHalfLength
local index = math.floor(((modelCenterZ - TowbarFirstZ) / TowbarSlotStep) + 0.5)
return math.max(0, math.min(TowbarMaxIndex, index))
end
function BTtow.Create.towbar(vehicle, part)
@@ -81,7 +74,7 @@ function BTtow.Init.towbar(vehicle, part)
if script then
local index = getTowbarModelSlot(script)
local shouldShowOnThisPart = part:getId() == "towbar"
if shouldShowOnThisPart then
if shouldShowOnThisPart and index ~= nil then
part:setModelVisible("towbar" .. index, true)
end
end
+168 -17
View File
@@ -1,4 +1,5 @@
if isClient() then return end
require("TowBar/Persistence")
local TowingCommands = {}
local Commands = {}
@@ -10,6 +11,9 @@ local pendingSync = {}
local snapshotTickCounter = 0
local brokenPairAuditTickCounter = 0
local confirmedTowPairs = {}
local towPairRuntime = {}
local RestoreRetryMs = 2000
local SustainedBreakMs = 5000
local giveTowBar
TowingCommands.wantNoise = getDebug() or false
@@ -88,6 +92,22 @@ local function isLinked(vehicleA, vehicleB)
return vehicleA:getVehicleTowing() == vehicleB and vehicleB:getVehicleTowedBy() == vehicleA
end
local function getPersistentVehicleId(vehicle)
if not vehicle or not vehicle.getSqlId then return nil end
local ok, sqlId = pcall(function() return vehicle:getSqlId() end)
if ok and type(sqlId) == "number" and sqlId >= 0 then return sqlId end
return nil
end
local function matchesSavedVehicle(runtimeId, sqlId, vehicle)
if not vehicle then return false end
local stableId = tonumber(sqlId)
if stableId and stableId >= 0 then
return getPersistentVehicleId(vehicle) == stableId
end
return tonumber(runtimeId) == vehicle:getId()
end
local function hasAnyTowLink(vehicle)
if not vehicle then return false end
return vehicle:getVehicleTowing() ~= nil or vehicle:getVehicleTowedBy() ~= nil
@@ -95,12 +115,17 @@ end
local function getTowBarPairKey(vehicleA, vehicleB)
if not vehicleA or not vehicleB then return nil end
return tostring(vehicleA:getId()) .. ":" .. tostring(vehicleB:getId())
local vehicleAId = getPersistentVehicleId(vehicleA) or vehicleA:getId()
local vehicleBId = getPersistentVehicleId(vehicleB) or vehicleB:getId()
return tostring(vehicleAId) .. ":" .. tostring(vehicleBId)
end
local function markTowBarPairConfirmed(vehicleA, vehicleB)
local key = getTowBarPairKey(vehicleA, vehicleB)
if key then confirmedTowPairs[key] = true end
if key then
confirmedTowPairs[key] = true
towPairRuntime[key] = { confirmed = true }
end
end
local function isTowBarPairConfirmed(vehicleA, vehicleB)
@@ -110,7 +135,10 @@ end
local function forgetTowBarPair(vehicleA, vehicleB)
local key = getTowBarPairKey(vehicleA, vehicleB)
if key then confirmedTowPairs[key] = nil end
if key then
confirmedTowPairs[key] = nil
towPairRuntime[key] = nil
end
end
local function hasTowBarState(vehicle)
@@ -130,25 +158,35 @@ local function markExpectedTowBarPair(vehicleA, vehicleB, attachmentA, attachmen
towingModData["isTowingByTowBar"] = true
towingModData["towed"] = false
towingModData["towBarTowedVehicleId"] = vehicleB:getId()
towingModData["towBarTowedVehicleSqlId"] = getPersistentVehicleId(vehicleB)
towingModData["towBarTowingVehicleId"] = nil
towingModData["towBarTowingVehicleSqlId"] = nil
towingModData["towBarExpectedAttachment"] = attachmentA
towedModData["isTowingByTowBar"] = true
towedModData["towed"] = true
towedModData["towBarTowedVehicleId"] = nil
towedModData["towBarTowedVehicleSqlId"] = nil
towedModData["towBarTowingVehicleId"] = vehicleA:getId()
towedModData["towBarTowingVehicleSqlId"] = getPersistentVehicleId(vehicleA)
towedModData["towBarExpectedAttachment"] = attachmentB
vehicleA:transmitModData()
vehicleB:transmitModData()
TowBarMod.Persistence.savePair("towbar", vehicleA, vehicleB, attachmentA, attachmentB)
end
local function clearExpectedTowBarPair(vehicleA, vehicleB)
if vehicleA and vehicleB then
TowBarMod.Persistence.removePair("towbar", vehicleA, vehicleB)
end
if vehicleA then
local towingModData = vehicleA:getModData()
if towingModData then
towingModData["isTowingByTowBar"] = false
towingModData["towed"] = false
towingModData["towBarTowedVehicleId"] = nil
towingModData["towBarTowedVehicleSqlId"] = nil
towingModData["towBarTowingVehicleId"] = nil
towingModData["towBarTowingVehicleSqlId"] = nil
towingModData["towBarExpectedAttachment"] = nil
vehicleA:transmitModData()
end
@@ -160,7 +198,9 @@ local function clearExpectedTowBarPair(vehicleA, vehicleB)
towedModData["isTowingByTowBar"] = false
towedModData["towed"] = false
towedModData["towBarTowedVehicleId"] = nil
towedModData["towBarTowedVehicleSqlId"] = nil
towedModData["towBarTowingVehicleId"] = nil
towedModData["towBarTowingVehicleSqlId"] = nil
towedModData["towBarExpectedAttachment"] = nil
vehicleB:transmitModData()
end
@@ -174,8 +214,11 @@ local function isExpectedTowBarPair(vehicleA, vehicleB)
local towedModData = vehicleB:getModData()
if not towingModData or not towedModData then return false end
return towingModData["towBarTowedVehicleId"] == vehicleB:getId()
and towedModData["towBarTowingVehicleId"] == vehicleA:getId()
return matchesSavedVehicle(
towingModData["towBarTowedVehicleId"], towingModData["towBarTowedVehicleSqlId"], vehicleB
) and matchesSavedVehicle(
towedModData["towBarTowingVehicleId"], towedModData["towBarTowingVehicleSqlId"], vehicleA
)
end
local function isLegacyTowBarPair(vehicleA, vehicleB)
@@ -295,14 +338,34 @@ local function forEachCollectionItem(collection, callback)
end
end
local function findLoadedVehicle(runtimeId, sqlId)
local stableId = tonumber(sqlId)
if stableId and stableId >= 0 then
local cell = getCell()
local vehicles = cell and cell:getVehicles() or nil
local match
forEachCollectionItem(vehicles, function(vehicle)
if not match and getPersistentVehicleId(vehicle) == stableId then match = vehicle end
end)
return match
end
return runtimeId and getVehicleById(tonumber(runtimeId)) or nil
end
local function broadcastAttach(vehicleA, vehicleB, attachmentA, attachmentB)
if not vehicleA or not vehicleB then return end
sendServerCommand("towbar", "forceAttachSync", {
local args = {
vehicleA = vehicleA:getId(),
vehicleB = vehicleB:getId(),
attachmentA = attachmentA,
attachmentB = attachmentB
})
}
if isServer() then
sendServerCommand("towbar", "forceAttachSync", args)
elseif not isClient() and TowBarMod and TowBarMod.Sync
and TowBarMod.Sync.applyAttachSync then
TowBarMod.Sync.applyAttachSync(args)
end
end
local function broadcastDetach(vehicleAId, vehicleBId)
@@ -320,6 +383,33 @@ local function broadcastSpontaneousDetach(vehicleA, vehicleB)
})
end
local function restorePersistedTowBarPair(towingVehicle, towedVehicle)
if not isExpectedTowBarPair(towingVehicle, towedVehicle)
or hasAnyTowLink(towingVehicle) or hasAnyTowLink(towedVehicle) then
return false
end
local towingModData = towingVehicle:getModData()
local towedModData = towedVehicle:getModData()
local attachmentA = towingModData["towBarExpectedAttachment"] or "trailer"
local attachmentB = towedModData["towBarExpectedAttachment"] or "trailerfront"
if not towingVehicle:attachmentExist(attachmentA)
or not towedVehicle:attachmentExist(attachmentB) then
return false
end
markExpectedTowBarPair(towingVehicle, towedVehicle, attachmentA, attachmentB)
if isServer() then
towingVehicle:addPointConstraint(nil, towedVehicle, attachmentA, attachmentB)
end
broadcastAttach(towingVehicle, towedVehicle, attachmentA, attachmentB)
if isLinked(towingVehicle, towedVehicle) then
markTowBarPairConfirmed(towingVehicle, towedVehicle)
return true
end
return false
end
local function breakTowBarConstraint(towingVehicle, towedVehicle)
if not towingVehicle or not towedVehicle then return end
@@ -400,19 +490,80 @@ local function reconcileBrokenTowBarPairsServer()
local vehicles = cell:getVehicles()
if not vehicles then return end
local loadedBySqlId = {}
forEachCollectionItem(vehicles, function(vehicle)
local sqlId = getPersistentVehicleId(vehicle)
if sqlId then loadedBySqlId[sqlId] = vehicle end
end)
local processed = {}
local function reconcilePair(towingVehicle, towedVehicle, attachmentA, attachmentB)
if not towingVehicle or not towedVehicle then return end
local key = getTowBarPairKey(towingVehicle, towedVehicle)
if not key or processed[key] then return end
processed[key] = true
if not isExpectedTowBarPair(towingVehicle, towedVehicle) then
markExpectedTowBarPair(
towingVehicle, towedVehicle,
attachmentA or "trailer", attachmentB or "trailerfront"
)
end
if not isExpectedTowBarPair(towingVehicle, towedVehicle) then return end
local now = getTimestampMs()
local state = towPairRuntime[key] or {}
local linked = isLinked(towingVehicle, towedVehicle)
local occupied = hasAnyTowLink(towingVehicle) or hasAnyTowLink(towedVehicle)
local action
state, action = TowBarMod.Persistence.advanceRecoveryState(
state, now, linked, occupied, RestoreRetryMs, SustainedBreakMs
)
towPairRuntime[key] = state
if action == "adopt" then
local towingMd = towingVehicle:getModData()
local towedMd = towedVehicle:getModData()
local savedAttachmentA = towingMd["towBarExpectedAttachment"] or attachmentA or "trailer"
local savedAttachmentB = towedMd["towBarExpectedAttachment"] or attachmentB or "trailerfront"
if tonumber(towingMd["towBarTowedVehicleId"]) ~= towedVehicle:getId()
or tonumber(towedMd["towBarTowingVehicleId"]) ~= towingVehicle:getId() then
markExpectedTowBarPair(
towingVehicle, towedVehicle,
savedAttachmentA, savedAttachmentB
)
end
TowBarMod.Persistence.savePair(
"towbar", towingVehicle, towedVehicle, savedAttachmentA, savedAttachmentB
)
confirmedTowPairs[key] = true
elseif action == "break" then
finalizeBrokenTowBarPair(towingVehicle, towedVehicle, "sustained-physical-link-audit")
elseif action == "restore" then
restorePersistedTowBarPair(towingVehicle, towedVehicle)
end
end
TowBarMod.Persistence.forEachPair("towbar", function(record)
local towingVehicle = loadedBySqlId[tonumber(record.towingSqlId)]
local towedVehicle = loadedBySqlId[tonumber(record.towedSqlId)]
local key = tostring(record.towingSqlId) .. ":" .. tostring(record.towedSqlId)
towPairRuntime[key] = TowBarMod.Persistence.notePeerAvailability(
towPairRuntime[key], towingVehicle ~= nil and towedVehicle ~= nil
)
reconcilePair(
towingVehicle,
towedVehicle,
record.attachmentA,
record.attachmentB
)
end)
forEachCollectionItem(vehicles, function(towingVehicle)
local towingModData = towingVehicle and towingVehicle:getModData() or nil
local towedVehicleId = towingModData and towingModData["towBarTowedVehicleId"] or nil
local towedVehicle = towedVehicleId and getVehicleById(towedVehicleId) or nil
if towingVehicle and towedVehicle and isExpectedTowBarPair(towingVehicle, towedVehicle) then
if isLinked(towingVehicle, towedVehicle) then
markTowBarPairConfirmed(towingVehicle, towedVehicle)
elseif isTowBarPairConfirmed(towingVehicle, towedVehicle)
and not hasAnyTowLink(towingVehicle)
and not hasAnyTowLink(towedVehicle) then
finalizeBrokenTowBarPair(towingVehicle, towedVehicle, "physical-link-audit")
end
end
local towedVehicleSqlId = towingModData and towingModData["towBarTowedVehicleSqlId"] or nil
local towedVehicle = findLoadedVehicle(towedVehicleId, towedVehicleSqlId)
if towingVehicle and towedVehicle then reconcilePair(towingVehicle, towedVehicle) end
end)
end
+397
View File
@@ -0,0 +1,397 @@
if isClient() then return end
require("TowBar/Persistence")
if not TowBarMod or not TowBarMod.Wrecker then return end
local Wrecker = TowBarMod.Wrecker
local Commands = {}
local AuditInterval = 30
local SnapshotInterval = 120
local CommandCooldownMs = 150
local auditTicks = 0
local snapshotTicks = 0
local commandTimes = {}
local confirmedWreckerPairs = {}
local wreckerPairRuntime = {}
local RestoreRetryMs = 2000
local SustainedBreakMs = 5000
local function isInteger(value)
return type(value) == "number" and value == math.floor(value)
end
local function resolveVehicleId(value)
if not isInteger(value) or value < 0 then return nil end
return getVehicleById(value)
end
local function isLinked(wrecker, target)
return wrecker and target
and wrecker:getVehicleTowing() == target
and target:getVehicleTowedBy() == wrecker
end
local function getPersistentVehicleId(vehicle)
if not vehicle or not vehicle.getSqlId then return nil end
local ok, sqlId = pcall(function() return vehicle:getSqlId() end)
if ok and type(sqlId) == "number" and sqlId >= 0 then return sqlId end
return nil
end
local function findLoadedVehicle(runtimeId, sqlId)
local stableId = tonumber(sqlId)
if stableId and stableId >= 0 then
local vehicles = Wrecker.getWorldVehicles()
local iterator = vehicles and vehicles:iterator() or nil
while iterator and iterator:hasNext() do
local vehicle = iterator:next()
if getPersistentVehicleId(vehicle) == stableId then return vehicle end
end
return nil
end
return runtimeId and getVehicleById(tonumber(runtimeId)) or nil
end
local function matchesSavedVehicle(runtimeId, sqlId, vehicle)
if not vehicle then return false end
local stableId = tonumber(sqlId)
if stableId and stableId >= 0 then
return getPersistentVehicleId(vehicle) == stableId
end
return tonumber(runtimeId) == vehicle:getId()
end
local function pairKey(wrecker, target)
local wreckerId = getPersistentVehicleId(wrecker) or wrecker:getId()
local targetId = getPersistentVehicleId(target) or target:getId()
return tostring(wreckerId) .. ":" .. tostring(targetId)
end
local function markWreckerPairConfirmed(wrecker, target)
local key = pairKey(wrecker, target)
confirmedWreckerPairs[key] = true
wreckerPairRuntime[key] = { confirmed = true }
end
local function isWreckerPairConfirmed(wrecker, target)
return confirmedWreckerPairs[pairKey(wrecker, target)] == true
end
local function forgetWreckerPair(wrecker, target)
local key = pairKey(wrecker, target)
confirmedWreckerPairs[key] = nil
wreckerPairRuntime[key] = nil
end
local function isExpectedPair(wrecker, target)
if not wrecker or not target then return false end
local wreckerMd = wrecker:getModData()
local targetMd = target:getModData()
return matchesSavedVehicle(
wreckerMd.wreckerTowedVehicleId, wreckerMd.wreckerTowedVehicleSqlId, target
) and matchesSavedVehicle(
targetMd.wreckerTowingVehicleId, targetMd.wreckerTowingVehicleSqlId, wrecker
)
end
local function setPairState(wrecker, target, targetAttachment, heightLevel)
local wreckerMd = wrecker:getModData()
local targetMd = target:getModData()
wreckerMd.wreckerTowActive = true
wreckerMd.wreckerTowedVehicleId = target:getId()
wreckerMd.wreckerTowedVehicleSqlId = getPersistentVehicleId(target)
wreckerMd.wreckerTowingVehicleId = nil
wreckerMd.wreckerTowingVehicleSqlId = nil
wreckerMd.wreckerTargetAttachment = targetAttachment
wreckerMd.wreckerHeightLevel = Wrecker.normalizeHeightLevel(heightLevel)
targetMd.wreckerTowingVehicleId = wrecker:getId()
targetMd.wreckerTowingVehicleSqlId = getPersistentVehicleId(wrecker)
targetMd.wreckerTowedVehicleId = nil
targetMd.wreckerTowedVehicleSqlId = nil
wrecker:transmitModData()
target:transmitModData()
TowBarMod.Persistence.savePair(
"wrecker", wrecker, target,
Wrecker.getHeightAttachmentId(wreckerMd.wreckerHeightLevel),
targetAttachment,
wreckerMd.wreckerHeightLevel
)
end
local function clearPairState(wrecker, target)
if not wrecker or not target then return end
local wreckerMd = wrecker:getModData()
local targetMd = target:getModData()
TowBarMod.Persistence.removePair("wrecker", wrecker, target)
wreckerMd.wreckerTowActive = nil
wreckerMd.wreckerTowedVehicleId = nil
wreckerMd.wreckerTowedVehicleSqlId = nil
wreckerMd.wreckerTowingVehicleId = nil
wreckerMd.wreckerTowingVehicleSqlId = nil
wreckerMd.wreckerTargetAttachment = nil
wreckerMd.wreckerHeightLevel = nil
targetMd.wreckerTowingVehicleId = nil
targetMd.wreckerTowingVehicleSqlId = nil
targetMd.wreckerTowedVehicleId = nil
targetMd.wreckerTowedVehicleSqlId = nil
wrecker:transmitModData()
target:transmitModData()
end
local function broadcastWreckerSync(command, args)
if isServer() then
sendServerCommand("towbar", command, args)
elseif TowBarMod.WreckerSync then
if command == "wreckerAttachSync" then
TowBarMod.WreckerSync.applyAttachSync(args)
else
TowBarMod.WreckerSync.applyDetachSync(args)
end
end
end
local function syncPairState(wrecker, target, hookAttachment, targetAttachment, heightLevel, player)
if isServer() then
-- Dedicated servers own the logical relation; clients replace their
-- local rope with the rigid height-indexed constraint on sync.
wrecker:addPointConstraint(player, target, hookAttachment, targetAttachment)
end
broadcastWreckerSync("wreckerAttachSync", {
wrecker = wrecker:getId(), target = target:getId(),
wreckerSqlId = getPersistentVehicleId(wrecker),
targetSqlId = getPersistentVehicleId(target),
targetAttachment = targetAttachment, heightLevel = heightLevel
})
if isLinked(wrecker, target) then markWreckerPairConfirmed(wrecker, target) end
end
local function breakPair(wrecker, target)
if isLinked(wrecker, target) then
wrecker:breakConstraint(true, false)
end
end
local function detachPair(wrecker, target)
if not isExpectedPair(wrecker, target) then return false end
breakPair(wrecker, target)
forgetWreckerPair(wrecker, target)
clearPairState(wrecker, target)
broadcastWreckerSync("wreckerDetachSync", {
wrecker = wrecker:getId(), target = target:getId(),
wreckerSqlId = getPersistentVehicleId(wrecker)
})
return true
end
function Commands.attachWrecker(player, args)
if type(args) ~= "table" then return end
local wrecker = resolveVehicleId(args.wrecker)
local target = resolveVehicleId(args.target)
if not wrecker or not target or wrecker == target then return end
if not wrecker:isDriver(player) or not Wrecker.isSupportedWrecker(wrecker) then return end
if wrecker:getVehicleTowing() or wrecker:getVehicleTowedBy()
or target:getVehicleTowing() or target:getVehicleTowedBy() then return end
local nearest = Wrecker.resolveNearestTarget(wrecker, Wrecker.getWorldVehicles())
if not nearest or nearest.vehicle ~= target then return end
local targetAttachment = nearest.attachment
local heightLevel = 0
local hookAttachment = Wrecker.getHeightAttachmentId(heightLevel)
if not hookAttachment or not wrecker:attachmentExist(hookAttachment) then return end
setPairState(wrecker, target, targetAttachment, heightLevel)
syncPairState(wrecker, target, hookAttachment, targetAttachment, heightLevel, player)
end
function Commands.detachWrecker(player, args)
if type(args) ~= "table" then return end
local wrecker = resolveVehicleId(args.wrecker)
local target = resolveVehicleId(args.target)
if not wrecker or not target or not wrecker:isDriver(player) then return end
detachPair(wrecker, target)
end
function Commands.adjustWreckerHeight(player, args)
if type(args) ~= "table" then return end
local wrecker = resolveVehicleId(args.wrecker)
local direction = args.direction
if not wrecker or not wrecker:isDriver(player) or not Wrecker.isSupportedWrecker(wrecker) then return end
if direction ~= -1 and direction ~= 1 then return end
local md = wrecker:getModData()
local target = findLoadedVehicle(md.wreckerTowedVehicleId, md.wreckerTowedVehicleSqlId)
if not target or not isExpectedPair(wrecker, target) or not isLinked(wrecker, target) then return end
local currentLevel = tonumber(md.wreckerHeightLevel) or 0
local nextLevel = Wrecker.nextHeightLevel(currentLevel, direction)
if not nextLevel or nextLevel == currentLevel then return end
local hookAttachment = Wrecker.getHeightAttachmentId(nextLevel)
local targetAttachment = md.wreckerTargetAttachment
if not hookAttachment or not wrecker:attachmentExist(hookAttachment)
or (targetAttachment ~= "trailer" and targetAttachment ~= "trailerfront") then return end
breakPair(wrecker, target)
setPairState(wrecker, target, targetAttachment, nextLevel)
syncPairState(wrecker, target, hookAttachment, targetAttachment, nextLevel, player)
end
local function onClientCommand(module, command, player, args)
if module == "towbar" and Commands[command] then
if not player then return end
local playerTimes = commandTimes[player]
if not playerTimes then
playerTimes = {}
commandTimes[player] = playerTimes
end
local now = getTimestampMs()
local previous = playerTimes[command]
if previous and now - previous < CommandCooldownMs then return end
playerTimes[command] = now
Commands[command](player, args)
return
end
if module == "vehicle" and command == "detachTrailerSpontaneous" then
local vehicle = args and resolveVehicleId(args.vehicle) or nil
if not vehicle then return end
local md = vehicle:getModData()
local wrecker = (md.wreckerTowedVehicleId or md.wreckerTowedVehicleSqlId) and vehicle or nil
local target = wrecker and findLoadedVehicle(
md.wreckerTowedVehicleId, md.wreckerTowedVehicleSqlId
) or nil
if not wrecker and (md.wreckerTowingVehicleId or md.wreckerTowingVehicleSqlId) then
wrecker = findLoadedVehicle(md.wreckerTowingVehicleId, md.wreckerTowingVehicleSqlId)
target = vehicle
end
if wrecker and target then detachPair(wrecker, target) end
end
end
local function auditWreckerPairs()
auditTicks = auditTicks + 1
if auditTicks < AuditInterval then return end
auditTicks = 0
snapshotTicks = snapshotTicks + AuditInterval
local shouldSnapshot = snapshotTicks >= SnapshotInterval
if shouldSnapshot then snapshotTicks = 0 end
local vehicles = Wrecker.getWorldVehicles()
if not vehicles then return end
local loadedBySqlId = {}
local scan = vehicles:iterator()
while scan:hasNext() do
local vehicle = scan:next()
local sqlId = getPersistentVehicleId(vehicle)
if sqlId then loadedBySqlId[sqlId] = vehicle end
end
local processed = {}
local function reconcilePair(wrecker, target, targetAttachment, heightLevel)
if not wrecker or not target or not Wrecker.isSupportedWrecker(wrecker) then return end
local key = pairKey(wrecker, target)
if processed[key] then return end
processed[key] = true
local canonicalLevel = Wrecker.normalizeHeightLevel(heightLevel)
local canonicalTargetAttachment = targetAttachment
if canonicalTargetAttachment ~= "trailer" and canonicalTargetAttachment ~= "trailerfront" then return end
local hookAttachment = Wrecker.getHeightAttachmentId(canonicalLevel)
if not hookAttachment or not wrecker:attachmentExist(hookAttachment)
or not target:attachmentExist(canonicalTargetAttachment) then return end
if not isExpectedPair(wrecker, target) then
setPairState(wrecker, target, canonicalTargetAttachment, canonicalLevel)
end
if not isExpectedPair(wrecker, target) then return end
local now = getTimestampMs()
local state = wreckerPairRuntime[key] or {}
local linked = isLinked(wrecker, target)
local occupied = wrecker:getVehicleTowing() or wrecker:getVehicleTowedBy()
or target:getVehicleTowing() or target:getVehicleTowedBy()
local action
state, action = TowBarMod.Persistence.advanceRecoveryState(
state, now, linked, occupied ~= nil, RestoreRetryMs, SustainedBreakMs
)
wreckerPairRuntime[key] = state
if action == "adopt" then
local wreckerMd = wrecker:getModData()
local targetMd = target:getModData()
if tonumber(wreckerMd.wreckerTowedVehicleId) ~= target:getId()
or tonumber(targetMd.wreckerTowingVehicleId) ~= wrecker:getId()
or tonumber(wreckerMd.wreckerTowedVehicleSqlId) ~= getPersistentVehicleId(target)
or tonumber(targetMd.wreckerTowingVehicleSqlId) ~= getPersistentVehicleId(wrecker) then
setPairState(wrecker, target, canonicalTargetAttachment, canonicalLevel)
end
TowBarMod.Persistence.savePair(
"wrecker", wrecker, target, hookAttachment, canonicalTargetAttachment, canonicalLevel
)
confirmedWreckerPairs[key] = true
if shouldSnapshot then
broadcastWreckerSync("wreckerAttachSync", {
wrecker = wrecker:getId(), target = target:getId(),
wreckerSqlId = getPersistentVehicleId(wrecker),
targetSqlId = getPersistentVehicleId(target),
targetAttachment = canonicalTargetAttachment,
heightLevel = canonicalLevel
})
end
elseif action == "break" then
detachPair(wrecker, target)
elseif action == "restore" then
setPairState(wrecker, target, canonicalTargetAttachment, canonicalLevel)
syncPairState(wrecker, target, hookAttachment, canonicalTargetAttachment, canonicalLevel, nil)
end
end
TowBarMod.Persistence.forEachPair("wrecker", function(record)
local wrecker = loadedBySqlId[tonumber(record.towingSqlId)]
local target = loadedBySqlId[tonumber(record.towedSqlId)]
local key = tostring(record.towingSqlId) .. ":" .. tostring(record.towedSqlId)
wreckerPairRuntime[key] = TowBarMod.Persistence.notePeerAvailability(
wreckerPairRuntime[key], wrecker ~= nil and target ~= nil
)
reconcilePair(
wrecker,
target,
record.attachmentB,
record.heightLevel
)
end)
local iterator = vehicles:iterator()
while iterator:hasNext() do
local vehicle = iterator:next()
local vehicleMd = vehicle:getModData()
local targetId = tonumber(vehicleMd.wreckerTowedVehicleId)
local targetSqlId = tonumber(vehicleMd.wreckerTowedVehicleSqlId)
if targetId or targetSqlId then
local wrecker = vehicle
local target = findLoadedVehicle(targetId, targetSqlId)
if target then
reconcilePair(
wrecker, target,
vehicleMd.wreckerTargetAttachment,
vehicleMd.wreckerHeightLevel
)
end
end
local towingVehicleId = tonumber(vehicleMd.wreckerTowingVehicleId)
local towingVehicleSqlId = tonumber(vehicleMd.wreckerTowingVehicleSqlId)
if towingVehicleId or towingVehicleSqlId then
local towingVehicle = findLoadedVehicle(towingVehicleId, towingVehicleSqlId)
if towingVehicle and not isExpectedPair(towingVehicle, vehicle) then
vehicleMd.wreckerTowingVehicleId = nil
vehicleMd.wreckerTowingVehicleSqlId = nil
vehicle:transmitModData()
broadcastWreckerSync("wreckerDetachSync", {
wrecker = towingVehicleId,
wreckerSqlId = towingVehicleSqlId,
target = vehicle:getId()
})
end
end
end
end
Events.OnClientCommand.Add(onClientCommand)
Events.OnTick.Add(auditWreckerPairs)
@@ -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_byTowBar": "by tow bar",
"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_cannotDriveWhileTowed": "Cannot drive while being towed",
"UI_Text_PushByHands": "Push vehicle",
@@ -4,7 +4,7 @@ module Base
{
mesh = vehicles/Towbar,
texture = Vehicles/Towbar_Texture,
scale = 0.01,
scale = 0.025,
}
model towbarModelLarge
+3 -2
View File
@@ -1,10 +1,11 @@
name=Towbars
id=hrsys_towbars
id=hrsys_towbars_testing
poster=../common/media/textures/preview.png
description=Tow bars for vehicle-to-vehicle towing.
author=Riggs0
category=vehicle
icon=../common/media/textures/tow_bar_icon.png
url=https://hudsonriggs.systems
modversion=1.0.5
modversion=1.0.12
versionMin=42.20.0
incompatible=\STowTruck_B42
+29 -1
View File
@@ -1,3 +1,31 @@
# 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

+3 -2
View File
@@ -1,10 +1,11 @@
name=Towbars
id=hrsys_towbars
id=hrsys_towbars_testing
poster=common/media/textures/preview.png
description=Tow bars for vehicle-to-vehicle towing.
author=Riggs0
category=vehicle
versionMin=42.13.0
url=https://hudsonriggs.systems
modversion=1.0.5
modversion=1.0.12
icon=common/media/textures/tow_bar_icon.png
incompatible=\STowTruck_B42
+280
View File
@@ -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")
+69
View File
@@ -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")
+183
View File
@@ -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")
+110 -9
View File
@@ -29,14 +29,23 @@ Assert-True (Test-Path -LiteralPath $releaseRoot) "Missing 42.20 release folder.
$releaseInfo = Get-Content -LiteralPath (Join-Path $releaseRoot "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 "modversion=1.0.5") "42.20 must declare mod version 1.0.5."
Assert-True ($rootInfo -contains "modversion=1.0.5") "Root and release mod versions must match."
Assert-True ($releaseInfo -contains "modversion=1.0.12") "42.20 must declare mod version 1.0.12."
Assert-True ($rootInfo -contains "modversion=1.0.12") "Root and release mod versions must match."
$baselineFiles = Get-RelativeFileNames $baselineRoot
$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") {
try {
@@ -52,18 +61,42 @@ $btTowPath = Join-Path $releaseRoot "media/lua/server/BTTow.lua"
$clientSyncPath = Join-Path $releaseRoot "media/lua/client/TowBar/TowSyncClient.lua"
$hookingPath = Join-Path $releaseRoot "media/lua/client/TowBar/TowingHooking.lua"
$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
$btTow = Get-Content -LiteralPath $btTowPath -Raw
$clientSync = Get-Content -LiteralPath $clientSyncPath -Raw
$hooking = Get-Content -LiteralPath $hookingPath -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 '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 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 '(?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 '(?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."
@@ -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 '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 '(?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 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 ($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 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 -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."
@@ -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, "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 ($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 TowbarFirstZ = 1\.0') "Client hitbox placement must use the first model's Z position."
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 ($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."
$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."
$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 '(?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."
@@ -130,6 +230,7 @@ if ($cleanupStart -ge 0 -and $cleanupEnd -gt $cleanupStart) {
$cleanupBranch = $hooking.Substring($cleanupStart, $cleanupEnd - $cleanupStart)
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 '(?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 '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."
+156
View File
@@ -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."
+89
View File
@@ -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")
+54
View File
@@ -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")