From e40332301725616d522fbaca686e7fd13ffd98d7 Mon Sep 17 00:00:00 2001 From: HRiggs Date: Thu, 13 Aug 2026 18:59:54 -0400 Subject: [PATCH] 42.20 and Towtrucks --- 42.20/media/lua/client/TowBar/Config.lua | 2 - .../media/lua/client/TowBar/TowSyncClient.lua | 54 ++- .../media/lua/client/TowBar/TowingHooking.lua | 107 +++-- 42.20/media/lua/client/TowBar/TowingUI.lua | 8 +- .../lua/client/TowBar/WreckerSyncClient.lua | 216 ++++++++++ .../lua/client/TowBar/WreckerTimedAction.lua | 35 ++ 42.20/media/lua/client/TowBar/WreckerUI.lua | 109 +++++ 42.20/media/lua/server/BTTow.lua | 79 ++-- 42.20/media/lua/server/TowingCommands.lua | 185 +++++++- 42.20/media/lua/server/WreckerCommands.lua | 397 ++++++++++++++++++ 42.20/media/lua/shared/TowBar/Persistence.lua | 122 ++++++ .../media/lua/shared/TowBar/WreckerUtils.lua | 163 +++++++ .../lua/shared/Translate/EN/ItemName.json | 2 +- 42.20/media/lua/shared/Translate/EN/UI.json | 4 + .../scripts/vehicles/template_towbar.txt | 2 +- 42.20/mod.info | 5 +- README.md | 30 +- art/arrow_down.png | Bin 0 -> 1786 bytes art/arrow_up.png | Bin 0 -> 2096 bytes art/tow_car_icon.png | Bin 0 -> 1077 bytes art/untow_car_icon.png | Bin 0 -> 1395 bytes common/media/textures/arrow_down.png | Bin 0 -> 1786 bytes common/media/textures/arrow_up.png | Bin 0 -> 2096 bytes common/media/textures/tow_car_icon.png | Bin 0 -> 1077 bytes common/media/textures/untow_car_icon.png | Bin 0 -> 1395 bytes mod.info | 5 +- tests/persistence-handlers-42.20-spec.lua | 280 ++++++++++++ tests/persistence-registry-42.20-spec.lua | 69 +++ tests/towbar-rigid-reattach-42.20-spec.lua | 183 ++++++++ tests/validate-42.20.ps1 | 119 +++++- tests/validate-wrecker-42.20.ps1 | 156 +++++++ tests/wrecker-42.20-spec.lua | 89 ++++ tests/wrecker-timed-action-42.20-spec.lua | 54 +++ 33 files changed, 2333 insertions(+), 142 deletions(-) create mode 100644 42.20/media/lua/client/TowBar/WreckerSyncClient.lua create mode 100644 42.20/media/lua/client/TowBar/WreckerTimedAction.lua create mode 100644 42.20/media/lua/client/TowBar/WreckerUI.lua create mode 100644 42.20/media/lua/server/WreckerCommands.lua create mode 100644 42.20/media/lua/shared/TowBar/Persistence.lua create mode 100644 42.20/media/lua/shared/TowBar/WreckerUtils.lua create mode 100644 art/arrow_down.png create mode 100644 art/arrow_up.png create mode 100644 art/tow_car_icon.png create mode 100644 art/untow_car_icon.png create mode 100644 common/media/textures/arrow_down.png create mode 100644 common/media/textures/arrow_up.png create mode 100644 common/media/textures/tow_car_icon.png create mode 100644 common/media/textures/untow_car_icon.png create mode 100644 tests/persistence-handlers-42.20-spec.lua create mode 100644 tests/persistence-registry-42.20-spec.lua create mode 100644 tests/towbar-rigid-reattach-42.20-spec.lua create mode 100644 tests/validate-wrecker-42.20.ps1 create mode 100644 tests/wrecker-42.20-spec.lua create mode 100644 tests/wrecker-timed-action-42.20-spec.lua diff --git a/42.20/media/lua/client/TowBar/Config.lua b/42.20/media/lua/client/TowBar/Config.lua index abcff59..2efde22 100644 --- a/42.20/media/lua/client/TowBar/Config.lua +++ b/42.20/media/lua/client/TowBar/Config.lua @@ -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 diff --git a/42.20/media/lua/client/TowBar/TowSyncClient.lua b/42.20/media/lua/client/TowBar/TowSyncClient.lua index 2c58639..f1d6cf6 100644 --- a/42.20/media/lua/client/TowBar/TowSyncClient.lua +++ b/42.20/media/lua/client/TowBar/TowSyncClient.lua @@ -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 diff --git a/42.20/media/lua/client/TowBar/TowingHooking.lua b/42.20/media/lua/client/TowBar/TowingHooking.lua index 6947150..52a37ab 100644 --- a/42.20/media/lua/client/TowBar/TowingHooking.lua +++ b/42.20/media/lua/client/TowBar/TowingHooking.lua @@ -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 + local shapeOk, shapeZ = pcall(function() + return shape:z() + end) + if not shapeOk or type(shapeZ) ~= "number" or shapeZ <= 0 then return nil + end + + local centerOk, center = pcall(function() + return script:getCenterOfMassOffset() end) - if ok and type(result) == "number" then - return result - end + if not centerOk or not center then return nil end - return nil -end + local zOk, centerZ = pcall(function() + return center:z() + end) + if not zOk or type(centerZ) ~= "number" then return nil end -local function isVanillaScale(script) - local modelScale = getVehicleModelScale(script) - if modelScale == nil then - return true - end - - local configuredMin = TowBarMod.Config and tonumber(TowBarMod.Config.vanillaTowbarModelScaleMin) - local configuredMax = TowBarMod.Config and tonumber(TowBarMod.Config.vanillaTowbarModelScaleMax) - local minScale = configuredMin or VanillaScaleMin - local maxScale = configuredMax or VanillaScaleMax - return modelScale >= minScale and modelScale <= maxScale -end - -local function getTowbarIndexVanilla(script) - local z = script:getPhysicsChassisShape():z() / 2 - 0.1 - local index = math.floor((z * 2 / 3 - 1) * 10) - return math.max(0, math.min(TowbarMaxIndex, index)) + 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,14 +402,10 @@ 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 + return end if localPlayer and retries > 0 then @@ -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 diff --git a/42.20/media/lua/client/TowBar/TowingUI.lua b/42.20/media/lua/client/TowBar/TowingUI.lua index e80a87a..cd85d05 100644 --- a/42.20/media/lua/client/TowBar/TowingUI.lua +++ b/42.20/media/lua/client/TowBar/TowingUI.lua @@ -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 diff --git a/42.20/media/lua/client/TowBar/WreckerSyncClient.lua b/42.20/media/lua/client/TowBar/WreckerSyncClient.lua new file mode 100644 index 0000000..42595aa --- /dev/null +++ b/42.20/media/lua/client/TowBar/WreckerSyncClient.lua @@ -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 diff --git a/42.20/media/lua/client/TowBar/WreckerTimedAction.lua b/42.20/media/lua/client/TowBar/WreckerTimedAction.lua new file mode 100644 index 0000000..1cf307a --- /dev/null +++ b/42.20/media/lua/client/TowBar/WreckerTimedAction.lua @@ -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 diff --git a/42.20/media/lua/client/TowBar/WreckerUI.lua b/42.20/media/lua/client/TowBar/WreckerUI.lua new file mode 100644 index 0000000..8fb9e70 --- /dev/null +++ b/42.20/media/lua/client/TowBar/WreckerUI.lua @@ -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 diff --git a/42.20/media/lua/server/BTTow.lua b/42.20/media/lua/server/BTTow.lua index e910409..4d64dae 100644 --- a/42.20/media/lua/server/BTTow.lua +++ b/42.20/media/lua/server/BTTow.lua @@ -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 + local shapeOk, shapeZ = pcall(function() + return shape:z() + end) + if not shapeOk or type(shapeZ) ~= "number" or shapeZ <= 0 then return nil + end + + local centerOk, center = pcall(function() + return script:getCenterOfMassOffset() end) - if ok and type(result) == "number" then - return result - end + if not centerOk or not center then return nil end - return nil -end + local zOk, centerZ = pcall(function() + return center:z() + end) + if not zOk or type(centerZ) ~= "number" then return nil end -local function isVanillaScale(script) - local modelScale = getVehicleModelScale(script) - if modelScale == nil then - return true - end - - local configuredMin = TowBarMod and TowBarMod.Config and tonumber(TowBarMod.Config.vanillaTowbarModelScaleMin) - local configuredMax = TowBarMod and TowBarMod.Config and tonumber(TowBarMod.Config.vanillaTowbarModelScaleMax) - local minScale = configuredMin or VanillaScaleMin - local maxScale = configuredMax or VanillaScaleMax - return modelScale >= minScale and modelScale <= maxScale -end - -local function getTowbarIndexVanilla(script) - local z = script:getPhysicsChassisShape():z() / 2 - 0.1 - local index = math.floor((z * 2 / 3 - 1) * 10) - return math.max(0, math.min(TowbarMaxIndex, index)) + 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 diff --git a/42.20/media/lua/server/TowingCommands.lua b/42.20/media/lua/server/TowingCommands.lua index 21b2cf2..2c19916 100644 --- a/42.20/media/lua/server/TowingCommands.lua +++ b/42.20/media/lua/server/TowingCommands.lua @@ -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 diff --git a/42.20/media/lua/server/WreckerCommands.lua b/42.20/media/lua/server/WreckerCommands.lua new file mode 100644 index 0000000..eee1fbc --- /dev/null +++ b/42.20/media/lua/server/WreckerCommands.lua @@ -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) diff --git a/42.20/media/lua/shared/TowBar/Persistence.lua b/42.20/media/lua/shared/TowBar/Persistence.lua new file mode 100644 index 0000000..937c8b3 --- /dev/null +++ b/42.20/media/lua/shared/TowBar/Persistence.lua @@ -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 diff --git a/42.20/media/lua/shared/TowBar/WreckerUtils.lua b/42.20/media/lua/shared/TowBar/WreckerUtils.lua new file mode 100644 index 0000000..a58fcf5 --- /dev/null +++ b/42.20/media/lua/shared/TowBar/WreckerUtils.lua @@ -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) diff --git a/42.20/media/lua/shared/Translate/EN/ItemName.json b/42.20/media/lua/shared/Translate/EN/ItemName.json index c5abe93..c16bd72 100644 --- a/42.20/media/lua/shared/Translate/EN/ItemName.json +++ b/42.20/media/lua/shared/Translate/EN/ItemName.json @@ -1,3 +1,3 @@ { - "ItemName_TowBar.TowBar": "Tow Bar" + "TowBar.TowBar": "Tow Bar" } diff --git a/42.20/media/lua/shared/Translate/EN/UI.json b/42.20/media/lua/shared/Translate/EN/UI.json index 2e33852..a41fdb5 100644 --- a/42.20/media/lua/shared/Translate/EN/UI.json +++ b/42.20/media/lua/shared/Translate/EN/UI.json @@ -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", diff --git a/42.20/media/scripts/vehicles/template_towbar.txt b/42.20/media/scripts/vehicles/template_towbar.txt index 68f1b24..aeaaae7 100644 --- a/42.20/media/scripts/vehicles/template_towbar.txt +++ b/42.20/media/scripts/vehicles/template_towbar.txt @@ -4,7 +4,7 @@ module Base { mesh = vehicles/Towbar, texture = Vehicles/Towbar_Texture, - scale = 0.01, + scale = 0.025, } model towbarModelLarge diff --git a/42.20/mod.info b/42.20/mod.info index bda128c..c32fb69 100644 --- a/42.20/mod.info +++ b/42.20/mod.info @@ -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 diff --git a/README.md b/README.md index ba1c82d..ef89d32 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,31 @@ # Towbar -Do you want to build a towbar \ No newline at end of file +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. diff --git a/art/arrow_down.png b/art/arrow_down.png new file mode 100644 index 0000000000000000000000000000000000000000..5468cd7ebe4d566470dded3595986ce897bb5c2a GIT binary patch literal 1786 zcmV1Yl@rF<@6h#6pnrb0Jpjar< zsD)aT7E_}I`ydp>m{MD$4}H*=mR=~dFMZGlL88^v8iKwE6|q7~5p6Xl+s3`k?CzOc z|9@AZGdpWzvNMCzoQG`AnVEC`|2yCLFXtS9=`xMLGy?x;1h_DjXSOXDOQnQmSrW;? zbDS;+f-drcu80B)LN+moLA=H|d+zMPqut#fw(2^7X@PCoz=;xYk^p0t1w+HbyF1pc z``qi>dbWiIz^&^yUS0Fsuds5?958LWtdq@(AR#BOJjlRUDz&_xiq`5YGyoLfAQp>* zC5m7@1b~FJIS!aCK}#%_sU>th+xdES_9T6&nmSuV<=ushl4=;BD2nEO=0`m=0Pftm z(&tM2eUP@CwEO92dxbK(!gJnv?oBXsS9}rfxkL zP_Rs}6Akqrv__Ai0HCV*S+Z@R{!Rp_h9&@F$)-RIAYl>;0ETWzGSc04>R+BClgB(j z3MLN-4FEKP&D9RDtUu^P0T(kWPyG2C3Ip=_eAx!L2|#t7_67sW0|?Sk0N_naQZVW9 z$^>clBM*S3>rx|bhj{_OVImZ7Xt?JO;j4QMg#p=Ic3vEFe%rMfLgE?N7Svt~)0~G-yK`$dfn%j2yurvq|LIEJg7%1gXxCR0q zQ=wXR&aw~yj{}p)7(vr8 zBps6_r`wwFU{n%#z-w}j(I`DO@W3S2m`vd^kqyg~blg9* z5g1vLbWEY^eqib=0C_wCdN4jdFPF>BS2b-G#lDw^hkMr~8!M@wQ-2BoU~W!>)BE>d zo6+0^>2w;jyb1y?nSvm~PZuw~w_)SPPaUXDvEmy5pPe{y=}1>sM~h*CT+l!fWk@Ot z%tR2LYi%v>{kdhwL=awRZUiGW3f9PND0Z-_~};{moSCz5yV`;+Z)k zBkDk!^tu{3X~;ui9g&Ofm!eLoL#nL$>m4y)$7+c zFR*26_pa-U7Q^j)wsQ9mC8hvhe~j{OrDRn8=3nFR%kpQTd&iD#?&tNW`vyRBGMU)N*>YRS=N=S+xM)<8M!Ye`jMX=1Xkg8vvx*OY7I4Sor3StLN|D1LX;# zyK;bppnWkq3R^$uKeS@mvJ17Ln%Waz0U*(K_x5i1W9?eFh4+6&oXv?836s<_Blls& z-rk{?H*GpPk#8pZj_&{@6h+5a{mU&GEk>)7c4o_w0FbvWiXg zJKq5yK^D%PcXM-p{~H5zC*U#reFRP#UFOe=7s0!Iea}o(iK)C72mmDD^X={5F6im` zZZMSsvhH4mLvYSB12!K#^hzrlQLlIE-R3Pxy+?W2U-$OyyY6*#z_rn_Dx*l$zk=Y* ze0}@i>Q$>w`y@hDw}Ap62?E>K*SBbB!2~hLh|1+~DAW_gAm6fA8wD zKWT5b&kPJ4nOsj#@mJq``PIp{mM*rxK7RbtQ>@0+L?5_t;XskG;I4&DP2B0PrxBP& cAYuso0}IPdrDO+A>;M1&07*qoM6N<$f*kWJmH+?% literal 0 HcmV?d00001 diff --git a/art/arrow_up.png b/art/arrow_up.png new file mode 100644 index 0000000000000000000000000000000000000000..f1e1167c9e903f6809af67e87b48ebe3f243bbd2 GIT binary patch literal 2096 zcmV-02+#M4P)k7RCodHT3KjaMHv2P<}CLnH%o8RXzb$B7U}|ZK}8fqMD)Qv zhzcq`3BKxM6-5w16!A?Tv?{eKjkOxByP~Z%(l!c`wW)SVliF63rdgVM?^$M!|9@iA zyPTxy<=m-+J7mbYbN*TWZ~k@W08~pAfhq!31gZ#JI0S+qsC)k9=Qg~%Wy@886L8%t z(LP~5gUEdHo%i2*a_=8Icl00Y{Cv}UZ{GRax4l+AO{JfWN>4D?_{hAuY4aP8e!j2u zg{GSo?Ct*Y3MZF+k4xim??mvO*}|N;`o`JL;v8(DW3|5hi8(NO;J&V*eIKeTjzdFZ zJqR11Z}%XqUwTK+tykZ*@xh1g|DG}CEp*Is>9kA0YcFlKjtn1o;NagqA2=<8uq>Sh z=_v>@4A$MY3i|f!xyJ2upRP~UPob3TMi7E>nOjIdu@IuTXga>`N*o^Ty?1E*;D-ao zudrfubIAPc_!6LTr42(zhgKaqJlH%rlI?73Yx`&ReFYx00f4({{n-B9-G@*0yxx=9 zUAJcaCEz=%geAcYu$lle6WO&V21ZuichAG^JAe9NqJ%J}1}$CynP*>p_Vvx3$M!tc zJ@(TjYp!bnFN@n9QKVE*lSqQGA`4Fa=!OY@wkP^e zcigh-+9r_M`PJHY6*Q*n!D$?Zzr4QNJ9^tUB8X@v<1ZCgfwm96O#j^d?F*;Hu}4yC zB$!|WGILH&6beYH(T2(FnBCuZV0j%Z9opL3+I!B9=Sfge2?$hBd-Qbw6WP?rgI?1q zNG57QsZza!*891ZuT%hrFd?ywfxLKh#lYWxZbRv*or}Jl_KK;%)~#D_+_C5V7oED{D;t(4 z0Llz$P>R4#n7|SWy1Khl4MEGqwr#C@%V9REPeml)&z_@8f9-336E4cEPc#E?z<3L1 zVpJ*ioG1RI_e4kZAszlEwMnD|EWbWg_t7_7Ug_=HzcPxMa@uH`*h_oIu8o7)p4AQ? z2LTqiQyzFZ2`*L-)nG0=fKea#7!^8GJ~$IT_CtDfmtIzZbQqX6hhcV*A09ZgF|I{d zBoyBD%lB6)%Q_yMWRMv%1HpkwB@Hxdz=Ws~wra7OJrE*qdfaIrO}{s6JDKD1Bmy;Q z+5!xl0W$>lLrIV()C*G{89G!S7Xb4JWBF5{p(5Lo#!9Iz+H&-L3K~ePzu5lG4L@}5 zc>7pm2T!LPXI-K&8T@r*1a4~B&~^V!k3D_mEtl`Yezd+~w-KPE{nE=sUriUR)8I)vt*G47j&SuFNZYmcQ( z8`3kY6@n$lCr^WE5%yr3$m}Ukq8&1Q&}`0wg3+#G73|1DqEEFzxcm5Q39TCMkB%Qi@km(Mi2iQg~7(y}&~W zAhUP2P?QLm_@EB>wrMX;2^byASSaJExwzw)`f@IO%QP3K7f{N_jmK!GaPw!yVA?J) zbc3N&m~F4*gH{!AJ(uAjhe5`!6a$I@B-Xj)qcSjP1pv9Dtwr{yO6##Eiav4ZlLaB_CMqn<8 zl>jPzUmkns?xhugk+Cxtihc?W0SbJv5g@xjtz0N6wXI9rM6?%p9L5-0Kw(kZTev%v z4220I;y|}Sx{6i+OhYIxKneq=Ek-~W$3^pK1%M3Q|E*{RK;f?AA0HpqnxAc$pj2Q+KlpcgdVsOx8 zugD2y>?c0000O)6l|#H z`WFqHIC0{ChH$w^DHXD}>msLT2!RmqxBi{@o?2*^n*)IQW;YuVYe-3e;zIX*@3e^4 z>_tQh0u=BP#8qexT4NBg840lj=uUsvA8}Z344S=EdUoc1GchLN~@8nKj=e1j)X;@{$7ZR0=W~QE#ON)wzPGr z4VKgU6O#1+mnO(3QE?0Ww^cSU+XqpCfU1J!AiEklNhy)_SvUdlEUoJ zsjx4~veae($K2qM1cdYhLwkr@`G7U(q{kGhgQ}(A409Z&cYXmYAo8A9p0sspMCf1zZF7FB_#H8zF vL+s$>$OHgJ*U5IxCQowW#EBCp{z3c!4sgYu*!7FE00000NkvXXu0mjfubScd literal 0 HcmV?d00001 diff --git a/art/untow_car_icon.png b/art/untow_car_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..40c623a636d7f4b3e4f44a8f23f5092db805cc55 GIT binary patch literal 1395 zcmV-(1&sQMP)uoHQCUw{*N|_(9&A!%bWe{uF zW(6UjkeA{qf`~v&QYeLH3;B-koA)&z@5$a>9WYqZ%;=pn_s;zo0UKwA*d|z0M+-oI|bx7?bD3 z2zlf6vF&!-ON6=vFe1;%L{XIbd~a_rbAFW~+(Z~u1UTt9jtl2-5t_|rrq$F1TMH=M z{n>10MH~QvAONtk2)>?vCb(F^S}lN+Hz7{Y$C3!#62{{(j}Dba1{(o#cfW{yuh+|5 zY&aYyUh{dr5yb|To&lT$5znh5$UJo+vJT8T(J&k*^D;f!eIh@dPSNZ2Af@DfoBE^A zxrG$D4!Hr$i!38JS%7Y$ocv%gFg>tVY^0Pz2!vsXIF1t{P^hdEUM|3W-;#VLC?XJO z?K*`pb%8n|q?G7(yEpRX-U7sNjOlb*#k1t1&V$ie06poc8aBCv4{n1CDCoYnja48o!98CbM z9ykkR30Eh)ga!H2*B|hE_?r_6A6Pdps!3iCWEDu%Om)3D@oI`T#I{(`wdy74O;d6O-)hlVfTdjMZ#kv6N`&4|D*W-o z54b)62`m>-pt}U_BK>~f+6r7Pz==FTi7=XADHjkzq(mUI&r5{(g^Ew#z(oL~^p>Nn zcHrQbK3lh4}yT7N2Q zyGGWsIcuau0Dw-XlRE68D8j|X1ps84ew_l;jZ6{r8iCv-&+q@u0suK8=qNh$ZsfPZ z6+-3uMew~ixy=Fqtm{-XW-U*$!3G;_u)%*2{{UqG1Zr6%h0y>2002ovPDHLkV1lMR Bj7|Um literal 0 HcmV?d00001 diff --git a/common/media/textures/arrow_down.png b/common/media/textures/arrow_down.png new file mode 100644 index 0000000000000000000000000000000000000000..5468cd7ebe4d566470dded3595986ce897bb5c2a GIT binary patch literal 1786 zcmV1Yl@rF<@6h#6pnrb0Jpjar< zsD)aT7E_}I`ydp>m{MD$4}H*=mR=~dFMZGlL88^v8iKwE6|q7~5p6Xl+s3`k?CzOc z|9@AZGdpWzvNMCzoQG`AnVEC`|2yCLFXtS9=`xMLGy?x;1h_DjXSOXDOQnQmSrW;? zbDS;+f-drcu80B)LN+moLA=H|d+zMPqut#fw(2^7X@PCoz=;xYk^p0t1w+HbyF1pc z``qi>dbWiIz^&^yUS0Fsuds5?958LWtdq@(AR#BOJjlRUDz&_xiq`5YGyoLfAQp>* zC5m7@1b~FJIS!aCK}#%_sU>th+xdES_9T6&nmSuV<=ushl4=;BD2nEO=0`m=0Pftm z(&tM2eUP@CwEO92dxbK(!gJnv?oBXsS9}rfxkL zP_Rs}6Akqrv__Ai0HCV*S+Z@R{!Rp_h9&@F$)-RIAYl>;0ETWzGSc04>R+BClgB(j z3MLN-4FEKP&D9RDtUu^P0T(kWPyG2C3Ip=_eAx!L2|#t7_67sW0|?Sk0N_naQZVW9 z$^>clBM*S3>rx|bhj{_OVImZ7Xt?JO;j4QMg#p=Ic3vEFe%rMfLgE?N7Svt~)0~G-yK`$dfn%j2yurvq|LIEJg7%1gXxCR0q zQ=wXR&aw~yj{}p)7(vr8 zBps6_r`wwFU{n%#z-w}j(I`DO@W3S2m`vd^kqyg~blg9* z5g1vLbWEY^eqib=0C_wCdN4jdFPF>BS2b-G#lDw^hkMr~8!M@wQ-2BoU~W!>)BE>d zo6+0^>2w;jyb1y?nSvm~PZuw~w_)SPPaUXDvEmy5pPe{y=}1>sM~h*CT+l!fWk@Ot z%tR2LYi%v>{kdhwL=awRZUiGW3f9PND0Z-_~};{moSCz5yV`;+Z)k zBkDk!^tu{3X~;ui9g&Ofm!eLoL#nL$>m4y)$7+c zFR*26_pa-U7Q^j)wsQ9mC8hvhe~j{OrDRn8=3nFR%kpQTd&iD#?&tNW`vyRBGMU)N*>YRS=N=S+xM)<8M!Ye`jMX=1Xkg8vvx*OY7I4Sor3StLN|D1LX;# zyK;bppnWkq3R^$uKeS@mvJ17Ln%Waz0U*(K_x5i1W9?eFh4+6&oXv?836s<_Blls& z-rk{?H*GpPk#8pZj_&{@6h+5a{mU&GEk>)7c4o_w0FbvWiXg zJKq5yK^D%PcXM-p{~H5zC*U#reFRP#UFOe=7s0!Iea}o(iK)C72mmDD^X={5F6im` zZZMSsvhH4mLvYSB12!K#^hzrlQLlIE-R3Pxy+?W2U-$OyyY6*#z_rn_Dx*l$zk=Y* ze0}@i>Q$>w`y@hDw}Ap62?E>K*SBbB!2~hLh|1+~DAW_gAm6fA8wD zKWT5b&kPJ4nOsj#@mJq``PIp{mM*rxK7RbtQ>@0+L?5_t;XskG;I4&DP2B0PrxBP& cAYuso0}IPdrDO+A>;M1&07*qoM6N<$f*kWJmH+?% literal 0 HcmV?d00001 diff --git a/common/media/textures/arrow_up.png b/common/media/textures/arrow_up.png new file mode 100644 index 0000000000000000000000000000000000000000..f1e1167c9e903f6809af67e87b48ebe3f243bbd2 GIT binary patch literal 2096 zcmV-02+#M4P)k7RCodHT3KjaMHv2P<}CLnH%o8RXzb$B7U}|ZK}8fqMD)Qv zhzcq`3BKxM6-5w16!A?Tv?{eKjkOxByP~Z%(l!c`wW)SVliF63rdgVM?^$M!|9@iA zyPTxy<=m-+J7mbYbN*TWZ~k@W08~pAfhq!31gZ#JI0S+qsC)k9=Qg~%Wy@886L8%t z(LP~5gUEdHo%i2*a_=8Icl00Y{Cv}UZ{GRax4l+AO{JfWN>4D?_{hAuY4aP8e!j2u zg{GSo?Ct*Y3MZF+k4xim??mvO*}|N;`o`JL;v8(DW3|5hi8(NO;J&V*eIKeTjzdFZ zJqR11Z}%XqUwTK+tykZ*@xh1g|DG}CEp*Is>9kA0YcFlKjtn1o;NagqA2=<8uq>Sh z=_v>@4A$MY3i|f!xyJ2upRP~UPob3TMi7E>nOjIdu@IuTXga>`N*o^Ty?1E*;D-ao zudrfubIAPc_!6LTr42(zhgKaqJlH%rlI?73Yx`&ReFYx00f4({{n-B9-G@*0yxx=9 zUAJcaCEz=%geAcYu$lle6WO&V21ZuichAG^JAe9NqJ%J}1}$CynP*>p_Vvx3$M!tc zJ@(TjYp!bnFN@n9QKVE*lSqQGA`4Fa=!OY@wkP^e zcigh-+9r_M`PJHY6*Q*n!D$?Zzr4QNJ9^tUB8X@v<1ZCgfwm96O#j^d?F*;Hu}4yC zB$!|WGILH&6beYH(T2(FnBCuZV0j%Z9opL3+I!B9=Sfge2?$hBd-Qbw6WP?rgI?1q zNG57QsZza!*891ZuT%hrFd?ywfxLKh#lYWxZbRv*or}Jl_KK;%)~#D_+_C5V7oED{D;t(4 z0Llz$P>R4#n7|SWy1Khl4MEGqwr#C@%V9REPeml)&z_@8f9-336E4cEPc#E?z<3L1 zVpJ*ioG1RI_e4kZAszlEwMnD|EWbWg_t7_7Ug_=HzcPxMa@uH`*h_oIu8o7)p4AQ? z2LTqiQyzFZ2`*L-)nG0=fKea#7!^8GJ~$IT_CtDfmtIzZbQqX6hhcV*A09ZgF|I{d zBoyBD%lB6)%Q_yMWRMv%1HpkwB@Hxdz=Ws~wra7OJrE*qdfaIrO}{s6JDKD1Bmy;Q z+5!xl0W$>lLrIV()C*G{89G!S7Xb4JWBF5{p(5Lo#!9Iz+H&-L3K~ePzu5lG4L@}5 zc>7pm2T!LPXI-K&8T@r*1a4~B&~^V!k3D_mEtl`Yezd+~w-KPE{nE=sUriUR)8I)vt*G47j&SuFNZYmcQ( z8`3kY6@n$lCr^WE5%yr3$m}Ukq8&1Q&}`0wg3+#G73|1DqEEFzxcm5Q39TCMkB%Qi@km(Mi2iQg~7(y}&~W zAhUP2P?QLm_@EB>wrMX;2^byASSaJExwzw)`f@IO%QP3K7f{N_jmK!GaPw!yVA?J) zbc3N&m~F4*gH{!AJ(uAjhe5`!6a$I@B-Xj)qcSjP1pv9Dtwr{yO6##Eiav4ZlLaB_CMqn<8 zl>jPzUmkns?xhugk+Cxtihc?W0SbJv5g@xjtz0N6wXI9rM6?%p9L5-0Kw(kZTev%v z4220I;y|}Sx{6i+OhYIxKneq=Ek-~W$3^pK1%M3Q|E*{RK;f?AA0HpqnxAc$pj2Q+KlpcgdVsOx8 zugD2y>?c0000O)6l|#H z`WFqHIC0{ChH$w^DHXD}>msLT2!RmqxBi{@o?2*^n*)IQW;YuVYe-3e;zIX*@3e^4 z>_tQh0u=BP#8qexT4NBg840lj=uUsvA8}Z344S=EdUoc1GchLN~@8nKj=e1j)X;@{$7ZR0=W~QE#ON)wzPGr z4VKgU6O#1+mnO(3QE?0Ww^cSU+XqpCfU1J!AiEklNhy)_SvUdlEUoJ zsjx4~veae($K2qM1cdYhLwkr@`G7U(q{kGhgQ}(A409Z&cYXmYAo8A9p0sspMCf1zZF7FB_#H8zF vL+s$>$OHgJ*U5IxCQowW#EBCp{z3c!4sgYu*!7FE00000NkvXXu0mjfubScd literal 0 HcmV?d00001 diff --git a/common/media/textures/untow_car_icon.png b/common/media/textures/untow_car_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..40c623a636d7f4b3e4f44a8f23f5092db805cc55 GIT binary patch literal 1395 zcmV-(1&sQMP)uoHQCUw{*N|_(9&A!%bWe{uF zW(6UjkeA{qf`~v&QYeLH3;B-koA)&z@5$a>9WYqZ%;=pn_s;zo0UKwA*d|z0M+-oI|bx7?bD3 z2zlf6vF&!-ON6=vFe1;%L{XIbd~a_rbAFW~+(Z~u1UTt9jtl2-5t_|rrq$F1TMH=M z{n>10MH~QvAONtk2)>?vCb(F^S}lN+Hz7{Y$C3!#62{{(j}Dba1{(o#cfW{yuh+|5 zY&aYyUh{dr5yb|To&lT$5znh5$UJo+vJT8T(J&k*^D;f!eIh@dPSNZ2Af@DfoBE^A zxrG$D4!Hr$i!38JS%7Y$ocv%gFg>tVY^0Pz2!vsXIF1t{P^hdEUM|3W-;#VLC?XJO z?K*`pb%8n|q?G7(yEpRX-U7sNjOlb*#k1t1&V$ie06poc8aBCv4{n1CDCoYnja48o!98CbM z9ykkR30Eh)ga!H2*B|hE_?r_6A6Pdps!3iCWEDu%Om)3D@oI`T#I{(`wdy74O;d6O-)hlVfTdjMZ#kv6N`&4|D*W-o z54b)62`m>-pt}U_BK>~f+6r7Pz==FTi7=XADHjkzq(mUI&r5{(g^Ew#z(oL~^p>Nn zcHrQbK3lh4}yT7N2Q zyGGWsIcuau0Dw-XlRE68D8j|X1ps84ew_l;jZ6{r8iCv-&+q@u0suK8=qNh$ZsfPZ z6+-3uMew~ixy=Fqtm{-XW-U*$!3G;_u)%*2{{UqG1Zr6%h0y>2002ovPDHLkV1lMR Bj7|Um literal 0 HcmV?d00001 diff --git a/mod.info b/mod.info index d0d7f82..8b6cebb 100644 --- a/mod.info +++ b/mod.info @@ -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 diff --git a/tests/persistence-handlers-42.20-spec.lua b/tests/persistence-handlers-42.20-spec.lua new file mode 100644 index 0000000..d1233db --- /dev/null +++ b/tests/persistence-handlers-42.20-spec.lua @@ -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") diff --git a/tests/persistence-registry-42.20-spec.lua b/tests/persistence-registry-42.20-spec.lua new file mode 100644 index 0000000..669f019 --- /dev/null +++ b/tests/persistence-registry-42.20-spec.lua @@ -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") diff --git a/tests/towbar-rigid-reattach-42.20-spec.lua b/tests/towbar-rigid-reattach-42.20-spec.lua new file mode 100644 index 0000000..95c3554 --- /dev/null +++ b/tests/towbar-rigid-reattach-42.20-spec.lua @@ -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") diff --git a/tests/validate-42.20.ps1 b/tests/validate-42.20.ps1 index 4b38f6d..fb2c965 100644 --- a/tests/validate-42.20.ps1 +++ b/tests/validate-42.20.ps1 @@ -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." diff --git a/tests/validate-wrecker-42.20.ps1 b/tests/validate-wrecker-42.20.ps1 new file mode 100644 index 0000000..924043d --- /dev/null +++ b/tests/validate-wrecker-42.20.ps1 @@ -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." diff --git a/tests/wrecker-42.20-spec.lua b/tests/wrecker-42.20-spec.lua new file mode 100644 index 0000000..0be0721 --- /dev/null +++ b/tests/wrecker-42.20-spec.lua @@ -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") diff --git a/tests/wrecker-timed-action-42.20-spec.lua b/tests/wrecker-timed-action-42.20-spec.lua new file mode 100644 index 0000000..be6f2c5 --- /dev/null +++ b/tests/wrecker-timed-action-42.20-spec.lua @@ -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")