diff --git a/42.20/media/lua/client/TowBar/RigidTow.lua b/42.20/media/lua/client/TowBar/RigidTow.lua new file mode 100644 index 0000000..08c1b97 --- /dev/null +++ b/42.20/media/lua/client/TowBar/RigidTow.lua @@ -0,0 +1,104 @@ +if isServer() then return end + +if not TowBarMod then TowBarMod = {} end +TowBarMod.RigidTow = TowBarMod.RigidTow or {} + +local RigidTow = TowBarMod.RigidTow + +local function isExactPairLinked(towingVehicle, towedVehicle) + return towingVehicle and towedVehicle + and towingVehicle:getVehicleTowing() == towedVehicle + and towedVehicle:getVehicleTowedBy() == towingVehicle +end + +local function hasConflictingLink(vehicle, expectedOther) + if not vehicle or not expectedOther then return true end + local towing = vehicle:getVehicleTowing() + local towedBy = vehicle:getVehicleTowedBy() + return (towing ~= nil and towing ~= expectedOther) + or (towedBy ~= nil and towedBy ~= expectedOther) +end + +local function attachmentExists(vehicle, attachmentId) + if not vehicle or type(attachmentId) ~= "string" then return false end + local script = vehicle:getScript() + return script ~= nil and script:getAttachmentById(attachmentId) ~= nil +end + +local function breakExactPair(towingVehicle, towedVehicle) + if not towingVehicle or not towedVehicle then return end + if towingVehicle:getVehicleTowing() == towedVehicle + or towingVehicle:getVehicleTowedBy() == towedVehicle then + towingVehicle:breakConstraint(true, true) + elseif towedVehicle:getVehicleTowing() == towingVehicle + or towedVehicle:getVehicleTowedBy() == towingVehicle then + towedVehicle:breakConstraint(true, true) + end +end + +function RigidTow.attach(towingVehicle, towedVehicle, attachmentA, attachmentB) + if not towingVehicle or not towedVehicle or towingVehicle == towedVehicle then return false end + if hasConflictingLink(towingVehicle, towedVehicle) + or hasConflictingLink(towedVehicle, towingVehicle) then + return false + end + if not attachmentExists(towingVehicle, attachmentA) + or not attachmentExists(towedVehicle, attachmentB) then + return false + end + + if TowBarMod.Utils and TowBarMod.Utils.updateAttachmentsForRigidTow then + TowBarMod.Utils.updateAttachmentsForRigidTow( + towingVehicle, towedVehicle, attachmentA, attachmentB + ) + end + + -- Match the proven wrecker sequence exactly. The fake trailer script is + -- used only while Bullet creates the rigid constraint; leaving it active + -- until the broader post-attach hook is unreliable in multiplayer because + -- that hook may return early while vehicle state is still replicating. + local towedModData = towedVehicle:getModData() + local currentScript = towedVehicle:getScriptName() + local originalScript = towedModData and towedModData.towBarOriginalScriptName or nil + if originalScript == nil and currentScript ~= "notTowingA_Trailer" then + originalScript = currentScript + if towedModData then + towedModData.towBarOriginalScriptName = originalScript + end + end + if originalScript == nil or originalScript == "notTowingA_Trailer" then + return false + end + + breakExactPair(towingVehicle, towedVehicle) + if TowBarMod.Hook and TowBarMod.Hook.applyFreeRollingTowState then + TowBarMod.Hook.applyFreeRollingTowState(towedVehicle) + elseif TowBarMod.Hook and TowBarMod.Hook.setVehiclePostAttach then + -- Compatibility fallback for older hook modules and the regression + -- harness. Current builds expose the narrower free-roll helper. + TowBarMod.Hook.setVehiclePostAttach(nil, towedVehicle, towingVehicle) + end + if TowBarMod.Hook and TowBarMod.Hook.setVehicleScriptWithTowBarHidden then + TowBarMod.Hook.setVehicleScriptWithTowBarHidden(towedVehicle, "notTowingA_Trailer") + else + towedVehicle:setScriptName("notTowingA_Trailer") + end + + towingVehicle:addPointConstraint(nil, towedVehicle, attachmentA, attachmentB, true) + if TowBarMod.Hook and TowBarMod.Hook.setVehicleScriptWithTowBarHidden then + TowBarMod.Hook.setVehicleScriptWithTowBarHidden(towedVehicle, originalScript) + else + towedVehicle:setScriptName(originalScript) + end + if TowBarMod.Hook and TowBarMod.Hook.setVehiclePostAttach then + -- Multiplayer updates the reciprocal towing getters after this Lua + -- call. The authoritative sync already validated the pair, so finalize + -- with the known towing vehicle instead of treating that delay as a + -- failed constraint submission. + TowBarMod.Hook.setVehiclePostAttach(nil, towedVehicle, towingVehicle) + end + return true +end + +RigidTow.isExactPairLinked = isExactPairLinked +RigidTow.breakExactPair = breakExactPair diff --git a/42.20/media/lua/client/TowBar/TowSyncClient.lua b/42.20/media/lua/client/TowBar/TowSyncClient.lua index f1d6cf6..1662fe1 100644 --- a/42.20/media/lua/client/TowBar/TowSyncClient.lua +++ b/42.20/media/lua/client/TowBar/TowSyncClient.lua @@ -5,6 +5,34 @@ TowBarMod.Sync = TowBarMod.Sync or {} if TowBarMod.Sync._towSyncClientLoaded then return end TowBarMod.Sync._towSyncClientLoaded = true TowBarMod.Sync.appliedPairs = TowBarMod.Sync.appliedPairs or {} +TowBarMod.Sync.desiredPairs = TowBarMod.Sync.desiredPairs or {} + +if not TowBarMod.RigidTow or not TowBarMod.RigidTow.attach then + require("TowBar/RigidTow") +end + +local Sync = TowBarMod.Sync + +local function argsPairKey(args) + if type(args) ~= "table" then return nil end + local vehicleA = tonumber(args.vehicleA) + local vehicleB = tonumber(args.vehicleB) + if not vehicleA or not vehicleB then return nil end + return tostring(vehicleA) .. ":" .. tostring(vehicleB) +end + +local function copyAttachArgs(args) + return { + vehicleA = tonumber(args.vehicleA), + vehicleB = tonumber(args.vehicleB), + attachmentA = args.attachmentA, + attachmentB = args.attachmentB + } +end + +local function pairKey(vehicleA, vehicleB) + return tostring(vehicleA:getId()) .. ":" .. tostring(vehicleB:getId()) +end local function pairKeyContainsVehicle(key, vehicleId) local id = tostring(vehicleId) @@ -15,201 +43,218 @@ end local function clearAppliedPairForVehicle(vehicle) if not vehicle then return end local vehicleId = vehicle:getId() - for key in pairs(TowBarMod.Sync.appliedPairs) do + for key in pairs(Sync.appliedPairs) do if pairKeyContainsVehicle(key, vehicleId) then - TowBarMod.Sync.appliedPairs[key] = nil + Sync.appliedPairs[key] = nil end end end -local function resolveVehicle(id) - if not id then return nil end - return getVehicleById(id) +local function resolvePair(args) + if type(args) ~= "table" then return nil, nil end + local vehicleA = args.vehicleA and getVehicleById(tonumber(args.vehicleA)) or nil + local vehicleB = args.vehicleB and getVehicleById(tonumber(args.vehicleB)) or nil + return vehicleA, vehicleB end -local function ensureAttachment(vehicle, attachmentId) - if not vehicle or not attachmentId then return false end +local function isPairLinked(vehicleA, vehicleB) + return vehicleA and vehicleB + and vehicleA:getVehicleTowing() == vehicleB + and vehicleB:getVehicleTowedBy() == vehicleA +end - local script = vehicle:getScript() - if not script then return false end - if script:getAttachmentById(attachmentId) ~= nil then return true end +local function hasConflictingLink(vehicle, expectedOther) + if not vehicle or not expectedOther then return false end + local towing = vehicle:getVehicleTowing() + local towedBy = vehicle:getVehicleTowedBy() + return (towing ~= nil and towing ~= expectedOther) + or (towedBy ~= nil and towedBy ~= expectedOther) +end - local wheelCount = script:getWheelCount() - local yOffset = -0.5 - if wheelCount > 0 then - local wheel = script:getWheel(0) - if wheel and wheel:getOffset() then - yOffset = wheel:getOffset():y() + 0.1 - end +local function preparePairState(vehicleA, vehicleB, attachmentA, attachmentB) + local towingMd = vehicleA:getModData() + local towedMd = vehicleB:getModData() + if not towingMd or not towedMd then return false end + + local currentScript = vehicleB:getScriptName() + if towedMd.towBarOriginalScriptName == nil and currentScript ~= "notTowingA_Trailer" then + towedMd.towBarOriginalScriptName = currentScript + end + if towedMd.towBarOriginalMass == nil then + towedMd.towBarOriginalMass = vehicleB:getMass() + end + if towedMd.towBarOriginalBrakingForce == nil then + towedMd.towBarOriginalBrakingForce = vehicleB:getBrakingForce() end - local chassis = script:getPhysicsChassisShape() - if not chassis then return false end - - local attach = ModelAttachment.new(attachmentId) - if attachmentId == "trailer" then - attach:getOffset():set(0, yOffset, -chassis:z() / 2 - 0.1) - attach:setZOffset(-1) - else - attach:getOffset():set(0, yOffset, chassis:z() / 2 + 0.1) - attach:setZOffset(1) - end - script:addAttachment(attach) + towingMd.isTowingByTowBar = true + towingMd.towed = false + towingMd.towBarTowedVehicleId = vehicleB:getId() + towingMd.towBarTowingVehicleId = nil + towingMd.towBarExpectedAttachment = attachmentA + towedMd.isTowingByTowBar = true + towedMd.towed = true + towedMd.towBarTowedVehicleId = nil + towedMd.towBarTowingVehicleId = vehicleA:getId() + towedMd.towBarExpectedAttachment = attachmentB + vehicleA:transmitModData() + vehicleB:transmitModData() return true end -local function isLinked(vehicleA, vehicleB) +local function isLocalDriver(vehicleA, playerObj) + if not vehicleA or not playerObj then return false end + return vehicleA:isDriver(playerObj) +end + +local function applyAttachSync(args, playerObj, forceReattach) + local desiredKey = argsPairKey(args) + if not desiredKey then return false end + Sync.desiredPairs[desiredKey] = copyAttachArgs(args) + + local vehicleA, vehicleB = resolvePair(args) if not vehicleA or not vehicleB then return false end - return vehicleA:getVehicleTowing() == vehicleB and vehicleB:getVehicleTowedBy() == vehicleA -end - -local function reconcilePairState(vehicleA, vehicleB, attachmentA, attachmentB) - if TowBarMod.Utils and TowBarMod.Utils.updateAttachmentsForRigidTow then - TowBarMod.Utils.updateAttachmentsForRigidTow(vehicleA, vehicleB, attachmentA, attachmentB) - end - - local towingMd = vehicleA:getModData() - local towedMd = vehicleB:getModData() - local currentScript = vehicleB:getScriptName() - - if towingMd then - towingMd["isTowingByTowBar"] = true - towingMd["towed"] = false - towingMd["towBarTowedVehicleId"] = vehicleB:getId() - towingMd["towBarTowingVehicleId"] = nil - towingMd["towBarExpectedAttachment"] = attachmentA - vehicleA:transmitModData() - end - if towedMd then - if towedMd.towBarOriginalScriptName == nil and currentScript ~= "notTowingA_Trailer" then - towedMd.towBarOriginalScriptName = currentScript - end - if towedMd.towBarOriginalMass == nil then - towedMd.towBarOriginalMass = vehicleB:getMass() - end - if towedMd.towBarOriginalBrakingForce == nil then - towedMd.towBarOriginalBrakingForce = vehicleB:getBrakingForce() - end - towedMd["isTowingByTowBar"] = true - towedMd["towed"] = true - towedMd["towBarTowedVehicleId"] = nil - towedMd["towBarTowingVehicleId"] = vehicleA:getId() - towedMd["towBarExpectedAttachment"] = attachmentB - vehicleB:transmitModData() - end - - if TowBarMod.Hook and TowBarMod.Hook.setVehicleScriptWithTowBarHidden then - TowBarMod.Hook.setVehicleScriptWithTowBarHidden(vehicleB, "notTowingA_Trailer") - end - if TowBarMod.Hook and TowBarMod.Hook.setVehiclePostAttach then - pcall(TowBarMod.Hook.setVehiclePostAttach, nil, vehicleB) - end -end - -local breakTowBarPair - -local function applyAttachSync(args) - if not args then return end - - local vehicleA = resolveVehicle(args.vehicleA) - local vehicleB = resolveVehicle(args.vehicleB) - if not vehicleA or not vehicleB then return end local attachmentA = args.attachmentA or "trailer" local attachmentB = args.attachmentB or "trailerfront" - if not ensureAttachment(vehicleA, attachmentA) or not ensureAttachment(vehicleB, attachmentB) then - return + if not preparePairState(vehicleA, vehicleB, attachmentA, attachmentB) then return false end + + local localPlayer = playerObj + if not localPlayer and type(getPlayer) == "function" then localPlayer = getPlayer() end + if not isLocalDriver(vehicleA, localPlayer) then + -- Portable installation happens while the player is outside. Keep the + -- server/native relation until this client becomes the towing vehicle's + -- physics owner; otherwise the local-only rigid add is discarded. + return true + end + if hasConflictingLink(vehicleA, vehicleB) or hasConflictingLink(vehicleB, vehicleA) then + return false end - 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) -end - -local function hasConflictingTowLink(vehicle, expectedOther) - if not vehicle or not expectedOther then return false end - - local towing = vehicle:getVehicleTowing() - local towedBy = vehicle:getVehicleTowedBy() - if (towing ~= nil and towing ~= expectedOther) - or (towedBy ~= nil and towedBy ~= expectedOther) then + local key = pairKey(vehicleA, vehicleB) + if not forceReattach and isPairLinked(vehicleA, vehicleB) and Sync.appliedPairs[key] then return true end - -- Also reject a delayed detach while a new tow is being established but - -- has not created its physical constraint yet. - local modData = vehicle:getModData() - if not modData then return false end - local expectedOtherId = expectedOther:getId() - return (modData["towBarTowedVehicleId"] ~= nil - and modData["towBarTowedVehicleId"] ~= expectedOtherId) - or (modData["towBarTowingVehicleId"] ~= nil - and modData["towBarTowingVehicleId"] ~= expectedOtherId) -end - -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 vehicleAReferencesB then - vehicleA:breakConstraint(true, true) - elseif vehicleBReferencesA then - vehicleB:breakConstraint(true, true) + -- This is deliberately the same one-shot method as WreckerSyncClient: + -- replace any exact native relation with one client-local rigid relation. + if TowBarMod.RigidTow.attach(vehicleA, vehicleB, attachmentA, attachmentB) ~= true then + return false end + Sync.appliedPairs[key] = true + return true end local function applyDetachSync(args) - if not args then return end - local vehicleA = resolveVehicle(args.vehicleA) - local vehicleB = resolveVehicle(args.vehicleB) - if not vehicleA or not vehicleB then return end - if hasConflictingTowLink(vehicleA, vehicleB) or hasConflictingTowLink(vehicleB, vehicleA) then - return + local desiredKey = argsPairKey(args) + local wasApplied = desiredKey and Sync.appliedPairs[desiredKey] == true + if desiredKey then + Sync.desiredPairs[desiredKey] = nil + Sync.appliedPairs[desiredKey] = nil end - breakTowBarPair(vehicleA, vehicleB) - TowBarMod.Sync.appliedPairs[tostring(vehicleA:getId()) .. ":" .. tostring(vehicleB:getId())] = nil + local vehicleA, vehicleB = resolvePair(args) + if not vehicleA or not vehicleB then return end + if hasConflictingLink(vehicleA, vehicleB) or hasConflictingLink(vehicleB, vehicleA) then return end + local key = pairKey(vehicleA, vehicleB) + Sync.appliedPairs[key] = nil + if wasApplied and TowBarMod.RigidTow and TowBarMod.RigidTow.breakExactPair then + TowBarMod.RigidTow.breakExactPair(vehicleA, vehicleB) + elseif wasApplied and isPairLinked(vehicleA, vehicleB) then + vehicleA:breakConstraint(true, true) + end if TowBarMod.Hook and TowBarMod.Hook.cleanupDetachedTowBar then - pcall(TowBarMod.Hook.cleanupDetachedTowBar, vehicleA, vehicleB) + TowBarMod.Hook.cleanupDetachedTowBar(vehicleA, vehicleB) end end -local function onServerCommand(module, command, args) - if module ~= "towbar" then return end +Sync.applyAttachSync = applyAttachSync +Sync.applyDetachSync = applyDetachSync +local function retryDesiredPairsForDriver(character) + if not character then return end + + local desired = {} + for _, args in pairs(Sync.desiredPairs) do + local vehicleA = args.vehicleA and getVehicleById(tonumber(args.vehicleA)) or nil + if vehicleA and vehicleA:isDriver(character) then + desired[#desired + 1] = args + end + end + for i = 1, #desired do + applyAttachSync(desired[i], character) + end +end + +local function getPersistedPairArgsForDriver(character) + if not character or type(character.getVehicle) ~= "function" then return nil end + local vehicleA = character:getVehicle() + if not vehicleA or not vehicleA:isDriver(character) then return nil end + + local towingMd = vehicleA:getModData() + if not towingMd or towingMd.isTowingByTowBar ~= true or towingMd.towed == true then return nil end + local vehicleBId = tonumber(towingMd.towBarTowedVehicleId) + local vehicleB = vehicleBId and getVehicleById(vehicleBId) or nil + if not vehicleB then return nil end + + local towedMd = vehicleB:getModData() + if not towedMd or towedMd.isTowingByTowBar ~= true or towedMd.towed ~= true + or tonumber(towedMd.towBarTowingVehicleId) ~= vehicleA:getId() then + return nil + end + + return { + vehicleA = vehicleA:getId(), + vehicleB = vehicleB:getId(), + attachmentA = towingMd.towBarExpectedAttachment or "trailer", + attachmentB = towedMd.towBarExpectedAttachment or "trailerfront" + } +end + +local function forceReattachForDriver(character) + if not character then return end + + local persistedArgs = getPersistedPairArgsForDriver(character) + if persistedArgs then + applyAttachSync(persistedArgs, character, true) + return + end + + -- Initial installation may have the authoritative command before all + -- reciprocal modData reaches this client. Retained server args are still + -- safe because only a driver-owned exact pair can pass applyAttachSync. + local desired = {} + for _, args in pairs(Sync.desiredPairs) do + local vehicleA = args.vehicleA and getVehicleById(tonumber(args.vehicleA)) or nil + if vehicleA and vehicleA:isDriver(character) then desired[#desired + 1] = args end + end + for i = 1, #desired do + applyAttachSync(desired[i], character, true) + end +end + +local function onSpawnVehicle(vehicle) + clearAppliedPairForVehicle(vehicle) + if type(getPlayer) == "function" then + retryDesiredPairsForDriver(getPlayer()) + end +end + +Events.OnServerCommand.Add(function(module, command, args) + if module ~= "towbar" then return end if command == "forceAttachSync" then applyAttachSync(args) elseif command == "forceDetachSync" or command == "spontaneousDetachSync" then applyDetachSync(args) end -end +end) -TowBarMod.Sync.applyAttachSync = applyAttachSync -TowBarMod.Sync.applyDetachSync = applyDetachSync - -Events.OnServerCommand.Add(onServerCommand) if Events.OnSpawnVehicleEnd then - Events.OnSpawnVehicleEnd.Add(clearAppliedPairForVehicle) + Events.OnSpawnVehicleEnd.Add(onSpawnVehicle) +end +if Events.OnEnterVehicle then + Events.OnEnterVehicle.Add(forceReattachForDriver) +end +if Events.OnSwitchVehicleSeat then + Events.OnSwitchVehicleSeat.Add(forceReattachForDriver) end diff --git a/42.20/media/lua/client/TowBar/TowingHooking.lua b/42.20/media/lua/client/TowBar/TowingHooking.lua index 52a37ab..9228028 100644 --- a/42.20/media/lua/client/TowBar/TowingHooking.lua +++ b/42.20/media/lua/client/TowBar/TowingHooking.lua @@ -1,11 +1,9 @@ if not TowBarMod then TowBarMod = {} end if not TowBarMod.Hook then TowBarMod.Hook = {} end +require("TowBar/VehicleCompatibility") + local DefaultTowBarTowMass = 200 -local AutoReattachCooldownHours = 1 / 7200 -- 0.5 seconds -TowBarMod.Hook.lastAutoReattachAtByVehicle = TowBarMod.Hook.lastAutoReattachAtByVehicle or {} -local AutoReattachPlayerCooldownHours = 1 / 14400 -- 0.25 seconds -TowBarMod.Hook.lastAutoReattachAtByPlayer = TowBarMod.Hook.lastAutoReattachAtByPlayer or {} local FreeRollTickInterval = 15 local freeRollTickCounter = 0 @@ -44,7 +42,7 @@ local function applyFreeRollingTowState(vehicle) modData.towBarOriginalBrakingForce = vehicle:getBrakingForce() end storeOriginalVehicleCall(vehicle, modData, "towBarOriginalParkingBrakeOn", "isParkingBrakeOn") - storeOriginalVehicleCall(vehicle, modData, "towBarOriginalParkingBrake", "isParkingBrake") + storeOriginalVehicleCall(vehicle, modData, "towBarOriginalParkingBrake", "getParkingBrake") storeOriginalVehicleCall(vehicle, modData, "towBarOriginalHandbrake", "isHandbrake") local configuredTowMass = TowBarMod.Config and tonumber(TowBarMod.Config.towedVehicleRollingMass) @@ -61,10 +59,13 @@ local function applyFreeRollingTowState(vehicle) tryVehicleCall(vehicle, "setHandbrake", false) end - vehicle:constraintChanged() - vehicle:updateTotalMass() + -- Match the working wrecker path. Recalculating total mass here would + -- immediately replace the temporary towing mass in multiplayer; creating + -- the rigid constraint below notifies Bullet of the changed vehicle state. end +TowBarMod.Hook.applyFreeRollingTowState = applyFreeRollingTowState + local function restoreFreeRollingTowState(vehicle, modData) if not vehicle or not modData then return end @@ -88,25 +89,6 @@ local function restoreFreeRollingTowState(vehicle, modData) vehicle:updateTotalMass() end -local function isTowBarTowPair(towingVehicle, towedVehicle) - if not towingVehicle or not towedVehicle then return false end - - local towingModData = towingVehicle:getModData() - local towedModData = towedVehicle:getModData() - if not towingModData or not towedModData then return false end - - if towingModData["isTowingByTowBar"] and towedModData["isTowingByTowBar"] and towedModData["towed"] then - return true - end - - -- Rejoin fallback: original towbar state on the towed vehicle is enough to reapply rigid spacing. - if towedModData.towBarOriginalScriptName ~= nil then - return true - end - - return false -end - local function getTowBarItem(playerObj) if not playerObj then return nil end local inventory = playerObj:getInventory() @@ -116,15 +98,8 @@ end local function sendTowAttachCommand(playerObj, args) if not playerObj or not args then return end - - -- MP-safe/server-authoritative attach path (Landtrain style). - if isClient() and isMultiplayer() then - sendClientCommand(playerObj, "towbar", "attachTowBar", args) - return - end - - -- Keep vanilla attach path for SP/local behavior. - sendClientCommand(playerObj, "vehicle", "attachTrailer", args) + -- SP and MP now share the same authoritative attach lifecycle. + sendClientCommand(playerObj, "towbar", "attachTowBar", args) end local TowbarVariantSize = 24 @@ -138,6 +113,8 @@ local TowbarVisualScale = 2.5 local TowbarModelLength = 0.9714089036 local TowbarScaledModelLength = TowbarModelLength * TowbarVisualScale local TowbarModelHalfLength = TowbarScaledModelLength / 2 +local VanillaScaleMin = 1.5 +local VanillaScaleMax = 2.0 local function getTowbarFrontEdgeZ(script) if not script then return nil end @@ -178,16 +155,87 @@ local function getTowbarModelSlot(script) return math.max(0, math.min(TowbarMaxIndex, index)) end +local function getVehicleModelScale(script) + if not script then return nil end + + local ok, result = pcall(function() + return script:getModelScale() + end) + if ok and type(result) == "number" then return result end + + ok, result = pcall(function() + local model = script:getModel() + return model and model:getScale() or nil + end) + if ok and type(result) == "number" then return result end + + 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) + return modelScale >= (configuredMin or VanillaScaleMin) + and modelScale <= (configuredMax or VanillaScaleMax) +end + +local function getTowbarIndexVanilla(script) + if not script then return nil end + local ok, shape = pcall(function() return script:getPhysicsChassisShape() end) + if not ok or not shape then return nil end + local zOk, shapeZ = pcall(function() return shape:z() end) + if not zOk or type(shapeZ) ~= "number" then return nil end + + local z = shapeZ / 2 - 0.1 + local index = math.floor((z * 2 / 3 - 1) * 10) + return math.max(0, math.min(TowbarMaxIndex, index)) +end + +local function getTowbarIndexSmallScale(script) + if not script then return nil end + + local maxAbsTowZ = nil + local trailer = script:getAttachmentById("trailer") + if trailer then maxAbsTowZ = math.abs(trailer:getOffset():z()) end + local trailerFront = script:getAttachmentById("trailerfront") + if trailerFront then + local frontAbsZ = math.abs(trailerFront:getOffset():z()) + if not maxAbsTowZ or frontAbsZ > maxAbsTowZ then maxAbsTowZ = frontAbsZ end + end + + if maxAbsTowZ == nil then return nil end + local index = math.floor((maxAbsTowZ + 0.1 - 1.0) * 10) + return math.max(0, math.min(TowbarMaxIndex, index)) +end + +local function getLegacyTowbarModelSlot(script) + local useNormalPart = isVanillaScale(script) + local index = getTowbarIndexVanilla(script) + if not useNormalPart then + index = getTowbarIndexSmallScale(script) or index + if index == nil then + local offset = TowBarMod.Config and tonumber(TowBarMod.Config.smallScaleTowbarIndexOffset) or 2 + index = math.max(0, math.min(TowbarMaxIndex, offset)) + end + end + return index, useNormalPart +end + local function setTowBarModelVisible(vehicle, isVisible) if not vehicle then return end local normalPart = vehicle:getPartById("towbar") local largePart = vehicle:getPartById("towbarLarge") - if normalPart == nil and largePart == nil then return end + local ki5Part = vehicle:getPartById("towbarKI5") + if normalPart == nil and largePart == nil and ki5Part == nil then return end for j = 0, TowbarVariantSize - 1 do if normalPart then normalPart:setModelVisible("towbar" .. j, false) end if largePart then largePart:setModelVisible("towbar" .. j, false) end + if ki5Part then ki5Part:setModelVisible("towbar" .. j, false) end end if not isVisible then @@ -201,8 +249,16 @@ local function setTowBarModelVisible(vehicle, isVisible) return end - local index = getTowbarModelSlot(script) - local part = normalPart + local isKi5 = TowBarMod.Compatibility.isKi5Vehicle(vehicle) + local index, useNormalPart + if isKi5 then + index = getTowbarModelSlot(script) + else + index, useNormalPart = getLegacyTowbarModelSlot(script) + end + + local part = isKi5 and ki5Part or (useNormalPart and normalPart or largePart) + if part == nil and not isKi5 then part = normalPart or largePart end if part and index ~= nil then part:setModelVisible("towbar" .. index, true) end @@ -259,23 +315,6 @@ local function resolveTowAttachmentsForPair(towingVehicle, towedVehicle, towedMo return attachmentA, attachmentB end -local function hasTowBarTowState(modData) - if not modData then - return false - end - - if modData["isTowingByTowBar"] and modData["towed"] then - return true - end - - -- Rejoin fallback: legacy saves may only have the original-script marker. - if modData.towBarOriginalScriptName ~= nil then - return true - end - - return false -end - local function isActiveTowBarTowedVehicle(vehicle, modData) if not vehicle or not modData then return false @@ -293,142 +332,22 @@ local function isActiveTowBarTowedVehicle(vehicle, modData) return false end -local function reattachTowBarPair(playerObj, towingVehicle, towedVehicle, requireDriver) - if not playerObj or not towingVehicle or not towedVehicle then - return false - end - if requireDriver and not towingVehicle:isDriver(playerObj) then - return false - end - - local towingModData = towingVehicle:getModData() - local towedModData = towedVehicle:getModData() - if not towingModData or not towedModData then - return false - end - if requireDriver then - if not isTowBarTowPair(towingVehicle, towedVehicle) then - return false - end - else - if not isActiveTowBarTowedVehicle(towedVehicle, towedModData) then - return false - end - end - - local attachmentA, attachmentB = resolveTowAttachmentsForPair(towingVehicle, towedVehicle, towedModData) - if not attachmentA or not attachmentB then - return false - end - - local towingScript = towingVehicle:getScript() - local towedScript = towedVehicle:getScript() - if not towingScript or not towedScript then - return false - end - if not towingScript:getAttachmentById(attachmentA) or not towedScript:getAttachmentById(attachmentB) then - return false - end - - TowBarMod.Utils.updateAttachmentsForRigidTow(towingVehicle, towedVehicle, attachmentA, attachmentB) - - towedModData.towBarOriginalScriptName = towedModData.towBarOriginalScriptName or towedVehicle:getScriptName() - applyFreeRollingTowState(towedVehicle) - - towingModData["isTowingByTowBar"] = true - towingModData["towed"] = false - towingModData["towBarTowedVehicleId"] = towedVehicle:getId() - towingModData["towBarTowingVehicleId"] = nil - towingModData["towBarExpectedAttachment"] = attachmentA - towedModData["isTowingByTowBar"] = true - towedModData["towed"] = true - towedModData["towBarTowedVehicleId"] = nil - towedModData["towBarTowingVehicleId"] = towingVehicle:getId() - towedModData["towBarExpectedAttachment"] = attachmentB - towingVehicle:transmitModData() - towedVehicle:transmitModData() - - TowBarMod.Hook.setVehicleScriptWithTowBarHidden(towedVehicle, "notTowingA_Trailer") - - local args = { - vehicleA = towingVehicle:getId(), - vehicleB = towedVehicle:getId(), - attachmentA = attachmentA, - attachmentB = attachmentB - } - sendTowAttachCommand(playerObj, args) - ISTimedActionQueue.add(TowBarScheduleAction:new(playerObj, 10, TowBarMod.Hook.setVehiclePostAttach, towedVehicle)) - return true -end - -local function reattachTowBarPairAfterCleanDetach(playerObj, towingVehicle, towedVehicle, requireDriver) - if not playerObj or not towingVehicle or not towedVehicle then - return false - end - if requireDriver and not towingVehicle:isDriver(playerObj) then - return false - end - - local detachArgs = { - towingVehicle = towingVehicle:getId(), - vehicle = towedVehicle:getId() - } - sendClientCommand(playerObj, "towbar", "detachTowBar", detachArgs) - - -- World load/spawn can restore constraints in a bad state. Reattach one - -- short tick later so the detach is fully applied first. - ISTimedActionQueue.add(TowBarScheduleAction:new( - playerObj, - 1, - reattachTowBarPair, - towingVehicle, - towedVehicle, - requireDriver - )) - return true -end - -local function recoverTowBarVehicleAfterLoad(playerObj, vehicle, retriesLeft) - if not vehicle then return end - - local modData = vehicle:getModData() - if not hasTowBarTowState(modData) then - return - end - - local retries = tonumber(retriesLeft) or 0 - local localPlayer = playerObj or getPlayer() - local towingVehicle = vehicle:getVehicleTowedBy() - - if towingVehicle then - -- Apply rigid spacing as soon as the tow link exists to avoid a visible - -- 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) - return - end - - if localPlayer and retries > 0 then - -- During world load, tow links can become available a few ticks later. - ISTimedActionQueue.add(TowBarScheduleAction:new(localPlayer, 10, recoverTowBarVehicleAfterLoad, vehicle, retries - 1)) - return - end - - -- 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) +function TowBarMod.Hook.setVehiclePostAttach(playerObj, towedVehicle, knownTowingVehicle) if not towedVehicle then return end local towedModData = towedVehicle:getModData() if not isActiveTowBarTowedVehicle(towedVehicle, towedModData) then return end - if towedModData and towedModData.towBarOriginalScriptName then + local towingVehicle = knownTowingVehicle or towedVehicle:getVehicleTowedBy() + if not towingVehicle then return end + + -- The rigid primitive passes the authoritative towing vehicle because MP + -- reciprocal getters can lag behind accepted local constraint submission. + if towedModData and towedModData.towBarOriginalScriptName + and towedVehicle:getScriptName() ~= towedModData.towBarOriginalScriptName then TowBarMod.Hook.setVehicleScriptWithTowBarHidden(towedVehicle, towedModData.towBarOriginalScriptName) end - local towingVehicle = towedVehicle:getVehicleTowedBy() if towingVehicle then local attachmentA, attachmentB = resolveTowAttachmentsForPair(towingVehicle, towedVehicle, towedModData) if attachmentA and attachmentB then @@ -443,7 +362,6 @@ function TowBarMod.Hook.setVehiclePostAttach(playerObj, towedVehicle, retriesLef towingVehicle:transmitModData() towedVehicle:transmitModData() end - TowBarMod.Utils.updateAttachmentsForRigidTow(towingVehicle, towedVehicle, attachmentA, attachmentB) end end @@ -459,35 +377,9 @@ function TowBarMod.Hook.performAttachTowBar(playerObj, towingVehicle, towedVehic if #(TowBarMod.Utils.getHookTypeVariants(towingVehicle, towedVehicle, true)) == 0 then return end local towBarItem = getTowBarItem(playerObj) - if towBarItem ~= nil and not (isClient() and isMultiplayer()) then - sendClientCommand(playerObj, "towbar", "consumeTowBar", { itemId = towBarItem:getID() }) - end + if towBarItem == nil then return end playerObj:setPrimaryHandItem(nil) - TowBarMod.Utils.updateAttachmentsForRigidTow(towingVehicle, towedVehicle, attachmentA, attachmentB) - - local towingModData = towingVehicle:getModData() - local towedModData = towedVehicle:getModData() - - towedModData.towBarOriginalScriptName = towedVehicle:getScriptName() - applyFreeRollingTowState(towedVehicle) - - towingModData["isTowingByTowBar"] = true - towingModData["towed"] = false - towingModData["towBarTowedVehicleId"] = towedVehicle:getId() - towingModData["towBarTowingVehicleId"] = nil - towingModData["towBarExpectedAttachment"] = attachmentA - towedModData["isTowingByTowBar"] = true - towedModData["towed"] = true - towedModData["towBarTowedVehicleId"] = nil - towedModData["towBarTowingVehicleId"] = towingVehicle:getId() - towedModData["towBarExpectedAttachment"] = attachmentB - towingVehicle:transmitModData() - towedVehicle:transmitModData() - - -- Match the known-good rigid tow path: fake trailer + vanilla attach command. - TowBarMod.Hook.setVehicleScriptWithTowBarHidden(towedVehicle, "notTowingA_Trailer") - local args = { vehicleA = towingVehicle:getId(), vehicleB = towedVehicle:getId(), @@ -496,7 +388,6 @@ function TowBarMod.Hook.performAttachTowBar(playerObj, towingVehicle, towedVehic itemId = towBarItem and towBarItem:getID() or nil } sendTowAttachCommand(playerObj, args) - ISTimedActionQueue.add(TowBarScheduleAction:new(playerObj, 10, TowBarMod.Hook.setVehiclePostAttach, towedVehicle)) end function TowBarMod.Hook.cleanupDetachedTowBar(towingVehicle, towedVehicle) @@ -538,7 +429,6 @@ function TowBarMod.Hook.cleanupDetachedTowBar(towingVehicle, towedVehicle) towingVehicle:transmitModData() towedVehicle:transmitModData() - TowBarMod.Hook.lastAutoReattachAtByVehicle[towingVehicle:getId()] = nil setTowBarModelVisible(towedVehicle, false) end @@ -547,46 +437,6 @@ function TowBarMod.Hook.performDetachTowBar(playerObj, towingVehicle, towedVehic local args = { towingVehicle = towingVehicle:getId(), vehicle = towedVehicle:getId() } sendClientCommand(playerObj, "towbar", "detachTowBar", args) - - TowBarMod.Hook.cleanupDetachedTowBar(towingVehicle, towedVehicle) -end - -function TowBarMod.Hook.reattachTowBarFromDriverSeat(playerObj, towingVehicle) - if not playerObj or not towingVehicle then return end - - local towedVehicle = towingVehicle:getVehicleTowing() - if not towedVehicle then return end - - reattachTowBarPair(playerObj, towingVehicle, towedVehicle, true) -end - -local function tryAutoReattachFromCharacter(character) - if not character or not instanceof(character, "IsoPlayer") or not character:isLocalPlayer() then return end - - local playerObj = character - local nowHours = getGameTime() and getGameTime():getWorldAgeHours() or 0 - local playerNum = playerObj:getPlayerNum() - local lastPlayerHours = TowBarMod.Hook.lastAutoReattachAtByPlayer[playerNum] - if lastPlayerHours and (nowHours - lastPlayerHours) < AutoReattachPlayerCooldownHours then - return - end - - local towingVehicle = playerObj:getVehicle() - if not towingVehicle then return end - if not towingVehicle:isDriver(playerObj) then return end - local towedVehicle = towingVehicle:getVehicleTowing() - if not towedVehicle then return end - if not isTowBarTowPair(towingVehicle, towedVehicle) then return end - - local vehicleId = towingVehicle:getId() - local lastHours = TowBarMod.Hook.lastAutoReattachAtByVehicle[vehicleId] - if lastHours and (nowHours - lastHours) < AutoReattachCooldownHours then - return - end - - TowBarMod.Hook.lastAutoReattachAtByPlayer[playerNum] = nowHours - TowBarMod.Hook.lastAutoReattachAtByVehicle[vehicleId] = nowHours - TowBarMod.Hook.reattachTowBarFromDriverSeat(playerObj, towingVehicle) end local function forEachCollectionItem(collection, callback) @@ -634,14 +484,6 @@ local function keepTowBarVehiclesFreeRolling() end) end -function TowBarMod.Hook.OnEnterVehicle(character) - tryAutoReattachFromCharacter(character) -end - -function TowBarMod.Hook.OnSwitchVehicleSeat(character) - tryAutoReattachFromCharacter(character) -end - function TowBarMod.Hook.attachByTowBarAction(playerObj, towingVehicle, towedVehicle) if playerObj == nil or towingVehicle == nil or towedVehicle == nil then return end @@ -725,20 +567,11 @@ function TowBarMod.Hook.deattachTowBarAction(playerObj, vehicle) end function TowBarMod.Hook.OnSpawnVehicle(vehicle) - recoverTowBarVehicleAfterLoad(nil, vehicle, 6) + -- Server persistence snapshots own attach recovery after vehicle streaming. end function TowBarMod.Hook.OnGameStart() - local cell = getCell() - if not cell then return end - - local vehicles = cell:getVehicles() - if not vehicles then return end - - local playerObj = getPlayer() - forEachCollectionItem(vehicles, function(vehicle) - recoverTowBarVehicleAfterLoad(playerObj, vehicle, 6) - end) + -- Server persistence broadcasts the same attach snapshot used by a fresh pair. end --------------------------------------------------------------------------- @@ -749,8 +582,9 @@ function TowBarMod.Hook.devShowAllTowbarModels(playerObj, vehicle) if not vehicle then return end local normalPart = vehicle:getPartById("towbar") local largePart = vehicle:getPartById("towbarLarge") - if normalPart == nil and largePart == nil then - print("[TowBar DEV] No 'towbar' or 'towbarLarge' part found on vehicle " .. tostring(vehicle:getScriptName())) + local ki5Part = vehicle:getPartById("towbarKI5") + if normalPart == nil and largePart == nil and ki5Part == nil then + print("[TowBar DEV] No towbar model part found on vehicle " .. tostring(vehicle:getScriptName())) return end local script = vehicle:getScript() @@ -765,10 +599,11 @@ function TowBarMod.Hook.devShowAllTowbarModels(playerObj, vehicle) print("[TowBar DEV] chassisShape.z = " .. tostring(chassisZ) .. ", half = " .. tostring(halfZ)) 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") + print("[TowBar DEV] Showing towbar0..towbar23 on all parts") for j = 0, TowbarVariantSize - 1 do if normalPart then normalPart:setModelVisible("towbar" .. j, true) end if largePart then largePart:setModelVisible("towbar" .. j, true) end + if ki5Part then ki5Part:setModelVisible("towbar" .. j, true) end end vehicle:doDamageOverlay() end @@ -777,14 +612,16 @@ function TowBarMod.Hook.devHideAllTowbarModels(playerObj, vehicle) if not vehicle then return end local normalPart = vehicle:getPartById("towbar") local largePart = vehicle:getPartById("towbarLarge") - if normalPart == nil and largePart == nil then - print("[TowBar DEV] No 'towbar' or 'towbarLarge' part found on vehicle " .. tostring(vehicle:getScriptName())) + local ki5Part = vehicle:getPartById("towbarKI5") + if normalPart == nil and largePart == nil and ki5Part == nil then + print("[TowBar DEV] No towbar model part found on vehicle " .. tostring(vehicle:getScriptName())) return end print("[TowBar DEV] Hiding ALL towbar models on " .. tostring(vehicle:getScriptName())) for j = 0, TowbarVariantSize - 1 do if normalPart then normalPart:setModelVisible("towbar" .. j, false) end if largePart then largePart:setModelVisible("towbar" .. j, false) end + if ki5Part then ki5Part:setModelVisible("towbar" .. j, false) end end vehicle:doDamageOverlay() end @@ -793,24 +630,32 @@ function TowBarMod.Hook.devShowSingleTowbar(playerObj, vehicle, index) if not vehicle then return end local normalPart = vehicle:getPartById("towbar") local largePart = vehicle:getPartById("towbarLarge") - if normalPart == nil and largePart == nil then - print("[TowBar DEV] No 'towbar' or 'towbarLarge' part found on vehicle " .. tostring(vehicle:getScriptName())) + local ki5Part = vehicle:getPartById("towbarKI5") + if normalPart == nil and largePart == nil and ki5Part == nil then + print("[TowBar DEV] No towbar model part found on vehicle " .. tostring(vehicle:getScriptName())) return end local localIndex = math.max(0, math.min(TowbarMaxIndex, index % TowbarVariantSize)) - local useLargePart = index >= TowbarVariantSize + local selectedPartId = "towbar" + if index >= TowbarVariantSize * 2 then + selectedPartId = "towbarKI5" + elseif index >= TowbarVariantSize then + selectedPartId = "towbarLarge" + end for j = 0, TowbarVariantSize - 1 do if normalPart then normalPart:setModelVisible("towbar" .. j, false) end if largePart then largePart:setModelVisible("towbar" .. j, false) end + if ki5Part then ki5Part:setModelVisible("towbar" .. j, false) end end - local part = useLargePart and largePart or normalPart + local part = selectedPartId == "towbarKI5" and ki5Part + or (selectedPartId == "towbarLarge" and largePart or normalPart) if part == nil then - part = normalPart or largePart + part = normalPart or largePart or ki5Part end - print("[TowBar DEV] Showing only towbar" .. tostring(localIndex) .. " on part " .. tostring(useLargePart and "towbarLarge" or "towbar") .. " (Z offset " .. tostring(1.0 + localIndex * 0.1) .. ") on " .. tostring(vehicle:getScriptName())) + print("[TowBar DEV] Showing only towbar" .. tostring(localIndex) .. " on part " .. selectedPartId .. " (Z offset " .. tostring(1.0 + localIndex * 0.1) .. ") on " .. tostring(vehicle:getScriptName())) if part then part:setModelVisible("towbar" .. localIndex, true) end @@ -821,6 +666,4 @@ Events.OnSpawnVehicleEnd.Add(TowBarMod.Hook.OnSpawnVehicle) if Events.OnGameStart then Events.OnGameStart.Add(TowBarMod.Hook.OnGameStart) end -Events.OnEnterVehicle.Add(TowBarMod.Hook.OnEnterVehicle) -Events.OnSwitchVehicleSeat.Add(TowBarMod.Hook.OnSwitchVehicleSeat) Events.OnTick.Add(keepTowBarVehiclesFreeRolling) diff --git a/42.20/media/lua/client/TowBar/WreckerSyncClient.lua b/42.20/media/lua/client/TowBar/WreckerSyncClient.lua index 42595aa..46d90f8 100644 --- a/42.20/media/lua/client/TowBar/WreckerSyncClient.lua +++ b/42.20/media/lua/client/TowBar/WreckerSyncClient.lua @@ -4,6 +4,26 @@ TowBarMod.WreckerSync = TowBarMod.WreckerSync or {} local Sync = TowBarMod.WreckerSync Sync.appliedLevels = Sync.appliedLevels or {} +Sync.desiredPairs = Sync.desiredPairs or {} + +local function argsPairKey(args) + if type(args) ~= "table" then return nil end + local wreckerId = tonumber(args.wrecker) + local targetId = tonumber(args.target) + if not wreckerId or not targetId then return nil end + return tostring(wreckerId) .. ":" .. tostring(targetId) +end + +local function copyAttachArgs(args) + return { + wrecker = tonumber(args.wrecker), + target = tonumber(args.target), + wreckerSqlId = tonumber(args.wreckerSqlId), + targetSqlId = tonumber(args.targetSqlId), + targetAttachment = args.targetAttachment, + heightLevel = tonumber(args.heightLevel) or 0 + } +end local function pairKeyContainsVehicle(key, vehicleId) local id = tostring(vehicleId) @@ -123,8 +143,10 @@ local function setScriptSafely(vehicle, scriptName) return true end -local function applyAttachSync(args) +local function applyAttachSync(args, forceReattach) if not args then return end + local desiredKey = argsPairKey(args) + if desiredKey then Sync.desiredPairs[desiredKey] = copyAttachArgs(args) end local wrecker = getVehicleById(args.wrecker) local target = getVehicleById(args.target) if not wrecker or not target then return end @@ -138,7 +160,7 @@ local function applyAttachSync(args) 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 + if not forceReattach and isPairLinked(wrecker, target) and appliedLevel == canonicalLevel then Sync.appliedLevels[key] = canonicalLevel applyFreeRollingState(target) return @@ -167,6 +189,8 @@ end local function applyDetachSync(args) if not args then return end + local desiredKey = argsPairKey(args) + if desiredKey then Sync.desiredPairs[desiredKey] = nil end local wrecker = getVehicleById(args.wrecker) local target = getVehicleById(args.target) if not target then return end @@ -203,6 +227,30 @@ end Sync.applyAttachSync = applyAttachSync Sync.applyDetachSync = applyDetachSync +local function forceReattachForDriver(character) + if not character or type(character.getVehicle) ~= "function" then return end + local wrecker = character:getVehicle() + if not wrecker or not wrecker:isDriver(character) then return end + + local wreckerMd = wrecker:getModData() + if not wreckerMd or wreckerMd.wreckerTowActive ~= true then return end + local targetId = tonumber(wreckerMd.wreckerTowedVehicleId) + local target = targetId and getVehicleById(targetId) or nil + if not target then return end + + local targetMd = target:getModData() + if not targetMd or tonumber(targetMd.wreckerTowingVehicleId) ~= wrecker:getId() then return end + + applyAttachSync({ + wrecker = wrecker:getId(), + target = target:getId(), + wreckerSqlId = wrecker:getSqlId(), + targetSqlId = wreckerMd.wreckerTowedVehicleSqlId, + targetAttachment = wreckerMd.wreckerTargetAttachment, + heightLevel = tonumber(wreckerMd.wreckerHeightLevel) or 0 + }, true) +end + Events.OnServerCommand.Add(function(module, command, args) if module ~= "towbar" then return end if command == "wreckerAttachSync" then @@ -214,3 +262,9 @@ end) if Events.OnSpawnVehicleEnd then Events.OnSpawnVehicleEnd.Add(clearAppliedLevelForVehicle) end +if Events.OnEnterVehicle then + Events.OnEnterVehicle.Add(forceReattachForDriver) +end +if Events.OnSwitchVehicleSeat then + Events.OnSwitchVehicleSeat.Add(forceReattachForDriver) +end diff --git a/42.20/media/lua/server/BTTow.lua b/42.20/media/lua/server/BTTow.lua index 4d64dae..d13eb25 100644 --- a/42.20/media/lua/server/BTTow.lua +++ b/42.20/media/lua/server/BTTow.lua @@ -2,6 +2,8 @@ BTtow = {} BTtow.Create = {} BTtow.Init = {} +require("TowBar/VehicleCompatibility") + local TowbarVariantSize = 24 local TowbarMaxIndex = TowbarVariantSize - 1 local TowbarFirstZ = 1.0 @@ -13,6 +15,8 @@ local TowbarVisualScale = 2.5 local TowbarModelLength = 0.9714089036 local TowbarScaledModelLength = TowbarModelLength * TowbarVisualScale local TowbarModelHalfLength = TowbarScaledModelLength / 2 +local VanillaScaleMin = 1.5 +local VanillaScaleMax = 2.0 local function getTowbarFrontEdgeZ(script) if not script then return nil end @@ -53,6 +57,59 @@ local function getTowbarModelSlot(script) return math.max(0, math.min(TowbarMaxIndex, index)) end +local function getVehicleModelScale(script) + if not script then return nil end + local ok, result = pcall(function() return script:getModelScale() end) + if ok and type(result) == "number" then return result end + ok, result = pcall(function() + local model = script:getModel() + return model and model:getScale() or nil + end) + return ok and type(result) == "number" and result or 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) + return modelScale >= (configuredMin or VanillaScaleMin) + and modelScale <= (configuredMax or VanillaScaleMax) +end + +local function getTowbarIndexVanilla(script) + if not script then return nil end + local ok, shape = pcall(function() return script:getPhysicsChassisShape() end) + if not ok or not shape then return nil end + local zOk, shapeZ = pcall(function() return shape:z() end) + if not zOk or type(shapeZ) ~= "number" then return nil end + local z = shapeZ / 2 - 0.1 + local index = math.floor((z * 2 / 3 - 1) * 10) + return math.max(0, math.min(TowbarMaxIndex, index)) +end + +local function getTowbarIndexSmallScale(script) + if not script then return nil end + local maxAbsTowZ = nil + local trailer = script:getAttachmentById("trailer") + if trailer then maxAbsTowZ = math.abs(trailer:getOffset():z()) end + local trailerFront = script:getAttachmentById("trailerfront") + if trailerFront then + local frontAbsZ = math.abs(trailerFront:getOffset():z()) + if not maxAbsTowZ or frontAbsZ > maxAbsTowZ then maxAbsTowZ = frontAbsZ end + end + if maxAbsTowZ == nil then return nil end + local index = math.floor((maxAbsTowZ + 0.1 - 1.0) * 10) + return math.max(0, math.min(TowbarMaxIndex, index)) +end + +local function getLegacyTowbarModelSlot(script) + local useNormalPart = isVanillaScale(script) + local index = getTowbarIndexVanilla(script) + if not useNormalPart then index = getTowbarIndexSmallScale(script) or index end + return index, useNormalPart +end + function BTtow.Create.towbar(vehicle, part) if part == nil then return end for j=0, TowbarVariantSize - 1 do @@ -72,8 +129,17 @@ function BTtow.Init.towbar(vehicle, part) if modData and modData["isTowingByTowBar"] and modData["towed"] then local script = vehicle:getScript() if script then - local index = getTowbarModelSlot(script) - local shouldShowOnThisPart = part:getId() == "towbar" + local isKi5 = TowBarMod.Compatibility.isKi5Vehicle(vehicle) + local index, useNormalPart + if isKi5 then + index = getTowbarModelSlot(script) + else + index, useNormalPart = getLegacyTowbarModelSlot(script) + end + local partId = part:getId() + local shouldShowOnThisPart = (isKi5 and partId == "towbarKI5") + or (not isKi5 and useNormalPart and partId == "towbar") + or (not isKi5 and not useNormalPart and partId == "towbarLarge") if shouldShowOnThisPart and index ~= nil then part:setModelVisible("towbar" .. index, true) end diff --git a/42.20/media/lua/server/TowingCommands.lua b/42.20/media/lua/server/TowingCommands.lua index 2c19916..9ae708a 100644 --- a/42.20/media/lua/server/TowingCommands.lua +++ b/42.20/media/lua/server/TowingCommands.lua @@ -4,10 +4,8 @@ require("TowBar/Persistence") local TowingCommands = {} local Commands = {} local TowBarItemType = "TowBar.TowBar" -local SyncDelayTicks = 2 local SnapshotIntervalTicks = 120 local BrokenPairAuditIntervalTicks = 5 -local pendingSync = {} local snapshotTickCounter = 0 local brokenPairAuditTickCounter = 0 local confirmedTowPairs = {} @@ -24,57 +22,6 @@ local noise = function(msg) end end -local function queueSync(kind, player, args, reservedTowBar) - if not args then return end - table.insert(pendingSync, { - kind = kind, - ticks = SyncDelayTicks, - attempts = 0, - reservedTowBar = reservedTowBar == true, - player = player, - args = args - }) -end - -local function cancelPendingAttach(vehicleA, vehicleB) - if not vehicleA or not vehicleB then return end - - local vehicleAId = vehicleA:getId() - local vehicleBId = vehicleB:getId() - local remaining = {} - for i = 1, #pendingSync do - local item = pendingSync[i] - local itemArgs = item.args or {} - local isThisAttach = item.kind == "attach" - and itemArgs.vehicleA == vehicleAId - and itemArgs.vehicleB == vehicleBId - if isThisAttach then - -- The reserved item is about to become the single ground drop. - item.reservedTowBar = false - else - table.insert(remaining, item) - end - end - pendingSync = remaining -end - -local function hasPendingAttach(vehicleA, vehicleB) - if not vehicleA or not vehicleB then return false end - - local vehicleAId = vehicleA:getId() - local vehicleBId = vehicleB:getId() - for i = 1, #pendingSync do - local item = pendingSync[i] - local itemArgs = item.args or {} - if item.kind == "attach" - and itemArgs.vehicleA == vehicleAId - and itemArgs.vehicleB == vehicleBId then - return true - end - end - return false -end - local function resolveAttachmentA(args, vehicleA) if args and args.attachmentA then return args.attachmentA end if vehicleA and vehicleA:getTowAttachmentSelf() then return vehicleA:getTowAttachmentSelf() end @@ -369,10 +316,16 @@ local function broadcastAttach(vehicleA, vehicleB, attachmentA, attachmentB) end local function broadcastDetach(vehicleAId, vehicleBId) - sendServerCommand("towbar", "forceDetachSync", { + local args = { vehicleA = vehicleAId, vehicleB = vehicleBId - }) + } + if isServer() then + sendServerCommand("towbar", "forceDetachSync", args) + elseif not isClient() and TowBarMod and TowBarMod.Sync + and TowBarMod.Sync.applyDetachSync then + TowBarMod.Sync.applyDetachSync(args) + end end local function broadcastSpontaneousDetach(vehicleA, vehicleB) @@ -458,7 +411,6 @@ local function finalizeBrokenTowBarPair(towingVehicle, towedVehicle, reason) return false end - cancelPendingAttach(towingVehicle, towedVehicle) forgetTowBarPair(towingVehicle, towedVehicle) breakTowBarConstraint(towingVehicle, towedVehicle) clearExpectedTowBarPair(towingVehicle, towedVehicle) @@ -567,54 +519,6 @@ local function reconcileBrokenTowBarPairsServer() end) end -local function processAttachSync(item) - local args = item.args or {} - local vehicleA = args.vehicleA and getVehicleById(args.vehicleA) or nil - local vehicleB = args.vehicleB and getVehicleById(args.vehicleB) or nil - if not vehicleA or not vehicleB then - noise("attach sync skipped missing vehicles A=" .. tostring(args.vehicleA) .. " B=" .. tostring(args.vehicleB)) - return "failed" - end - - local attachmentA = resolveAttachmentA(args, vehicleA) - local attachmentB = resolveAttachmentB(args, vehicleB) - if not isLinked(vehicleA, vehicleB) then - if isTowBarPairConfirmed(vehicleA, vehicleB) then return "broken" end - if item.attempts >= 3 then return "failed" end - vehicleA:addPointConstraint(item.player, vehicleB, attachmentA, attachmentB) - return "retry" - end - - markExpectedTowBarPair(vehicleA, vehicleB, attachmentA, attachmentB) - markTowBarPairConfirmed(vehicleA, vehicleB) - broadcastAttach(vehicleA, vehicleB, attachmentA, attachmentB) - return "complete" -end - -local function failAttachSync(item) - local args = item.args or {} - local vehicleA = args.vehicleA and getVehicleById(args.vehicleA) or nil - local vehicleB = args.vehicleB and getVehicleById(args.vehicleB) or nil - - if isLinked(vehicleA, vehicleB) then - vehicleA:breakConstraint(true, false) - end - clearExpectedTowBarPair(vehicleA, vehicleB) - broadcastDetach(args.vehicleA, args.vehicleB) - - if item.reservedTowBar then - giveTowBar(item.player, true) - item.reservedTowBar = false - end -end - -local function processDetachSync(item) - local args = item.args or {} - local vehicleAId = args.towingVehicle or args.vehicleA or args.vehicle - local vehicleBId = args.vehicleB or args.vehicle - broadcastDetach(vehicleAId, vehicleBId) -end - local function snapshotActiveTowbarLinksServer() local cell = getCell() if not cell then return end @@ -643,7 +547,7 @@ local function snapshotActiveTowbarLinksServer() end) end -local function processPendingSync() +local function processTowBarServerTick() reconcileBrokenTowBarPairsServer() snapshotTickCounter = snapshotTickCounter + 1 @@ -651,37 +555,6 @@ local function processPendingSync() snapshotTickCounter = 0 snapshotActiveTowbarLinksServer() end - - if #pendingSync == 0 then return end - - local remaining = {} - for i = 1, #pendingSync do - local item = pendingSync[i] - item.ticks = item.ticks - 1 - if item.ticks <= 0 then - if item.kind == "attach" then - local status = processAttachSync(item) - if status == "retry" and item.attempts < 3 then - item.attempts = item.attempts + 1 - item.ticks = SyncDelayTicks - table.insert(remaining, item) - elseif status == "broken" then - finalizeBrokenTowBarPair( - item.args.vehicleA and getVehicleById(item.args.vehicleA) or nil, - item.args.vehicleB and getVehicleById(item.args.vehicleB) or nil, - "attach-confirmation-break" - ) - elseif status ~= "complete" then - failAttachSync(item) - end - elseif item.kind == "detach" then - processDetachSync(item) - end - else - table.insert(remaining, item) - end - end - pendingSync = remaining end function Commands.attachTowBar(player, args) @@ -707,9 +580,7 @@ function Commands.attachTowBar(player, args) return end if isExpectedTowBarPair(vehicleA, vehicleB) - or isExpectedTowBarPair(vehicleB, vehicleA) - or hasPendingAttach(vehicleA, vehicleB) - or hasPendingAttach(vehicleB, vehicleA) then + or isExpectedTowBarPair(vehicleB, vehicleA) then noise("rejected duplicate pending towbar attach") return end @@ -724,12 +595,14 @@ function Commands.attachTowBar(player, args) return end - -- Constraint creation may complete on a later server tick in multiplayer. - -- Record and queue the reserved item first so an immediate Build 42.20 - -- spontaneous break can resolve the pair and consume the reservation once. + -- Match the working wrecker path: persist the accepted logical pair, ask + -- the server for its native relation, then immediately tell clients to + -- replace that relation with the local rigid fake-trailer constraint. markExpectedTowBarPair(vehicleA, vehicleB, args.attachmentA, args.attachmentB) - queueSync("attach", player, args, true) - vehicleA:addPointConstraint(player, vehicleB, args.attachmentA, args.attachmentB) + if isServer() then + vehicleA:addPointConstraint(player, vehicleB, args.attachmentA, args.attachmentB) + end + broadcastAttach(vehicleA, vehicleB, args.attachmentA, args.attachmentB) if isLinked(vehicleA, vehicleB) then markTowBarPairConfirmed(vehicleA, vehicleB) end @@ -749,10 +622,6 @@ function Commands.detachTowBar(player, args) noise("rejected unauthorized towbar detach") return end - if not isLinked(towingVehicle, towedVehicle) then - noise("rejected mismatched or stale towbar detach") - return - end if not isExpectedTowBarPair(towingVehicle, towedVehicle) and not isLegacyTowBarPair(towingVehicle, towedVehicle) then noise("rejected detach for a non-towbar or stale linked pair") @@ -764,7 +633,7 @@ function Commands.detachTowBar(player, args) breakTowBarConstraint(towingVehicle, towedVehicle) forgetTowBarPair(towingVehicle, towedVehicle) clearExpectedTowBarPair(towingVehicle, towedVehicle) - queueSync("detach", player, args) + broadcastDetach(towingVehicle:getId(), towedVehicle:getId()) if shouldRefund then giveTowBar(player, true) end @@ -817,4 +686,4 @@ TowingCommands.OnClientCommand = function(module, command, player, args) end Events.OnClientCommand.Add(TowingCommands.OnClientCommand) -Events.OnTick.Add(processPendingSync) +Events.OnTick.Add(processTowBarServerTick) diff --git a/42.20/media/lua/shared/TowBar/VehicleCompatibility.lua b/42.20/media/lua/shared/TowBar/VehicleCompatibility.lua new file mode 100644 index 0000000..eae7bdd --- /dev/null +++ b/42.20/media/lua/shared/TowBar/VehicleCompatibility.lua @@ -0,0 +1,148 @@ +if not TowBarMod then TowBarMod = {} end + +TowBarMod.Compatibility = TowBarMod.Compatibility or {} +local Compatibility = TowBarMod.Compatibility + +-- Exact Mod ID -> exact VehicleScript full-name records for KI5's vehicle +-- catalogue. This deliberately avoids model-scale and attachment heuristics: +-- unrelated vehicle mods can use the same scale and attachment names. +local Ki5VehiclesByModId = { + ["04vwTouran"] = "Base.04vwTouran", + ["49powerWagon"] = "Base.49powerWagon|Base.49powerWagonMP|Base.49powerWagonPA|Base.49powerWagonPD", + ["59meteor"] = "Base.59ambulance|Base.59meteor|Base.ECTO1|Base.ECTO1Burnt", + ["63beetle"] = "Base.63beetle|Base.63beetleBuggy|Base.63beetleHP", + ["63Type2Van"] = "Base.63Type2Van|Base.63Type2VanApocalypse|Base.63Type2VanHippie|Base.63Type2VanMilitary", + ["65banshee"] = "Base.65banshee400|Base.65bansheeSprint|Base.65bansheeXP", + ["66pontiacLeMans"] = "Base.66pontiacGTO|Base.66pontiacGTOconv|Base.66pontiacLeMans|Base.66pontiacLeMansConv", + ["67commando"] = "Base.67commando|Base.67commandoBurnt|Base.67commandoPolice|Base.67commandoT50", + ["67gt500"] = "Base.67gt500|Base.67gt500e", + ["68firebird"] = "Base.68firebird350|Base.68firebird400|Base.68firebirdRamAir|Base.68firebirdRamAirCustom", + ["69camaro"] = "Base.69camaroRS|Base.69camaroSS", + ["69charger"] = "Base.69charger440|Base.69charger500|Base.69chargerDaytona|Base.69chargerDemon|Base.69chargerRT", + ["69fordMustang"] = "Base.69fordMustangBoss302|Base.69fordMustangBoss429|Base.69fordMustangEV6|Base.69fordMustangMach1|Base.69fordMustangTBC|Base.69fordMustangUBC", + ["69mini"] = "Base.69mini|Base.69miniIJ|Base.69miniMrB|Base.69miniPS|Base.69miniUnionJack", + ["70barracuda"] = "Base.70barracuda|Base.70barracudaAAR|Base.70cuda", + ["70chevelle"] = "Base.70chevelleCoupe|Base.70chevelleCoupeSS|Base.70chevelleCoupeSSL6|Base.70chevelleSedan|Base.70chevelleWagon|Base.70chevelleWagonSS|Base.70elCamino|Base.70elCaminoSS", + ["70dodge"] = "Base.70dodgeBG|Base.70dodgeOP|Base.70dodgePD|Base.70dodgeRT|Base.70dodgeTA", + ["70fordEscort"] = "Base.70fordEscortCoupe|Base.70fordEscortRS|Base.70fordEscortSedan|Base.70fordEscortWagon", + ["70roadRunner"] = "Base.70roadRunner", + ["73fordFalcon"] = "Base.73fordFalconPS|Base.73fordFalconPSlhd|Base.73fordFalconXBGT|Base.73fordFalconXBGTlhd", + ["73nissanGTR"] = "Base.73nissanGTR|Base.73nissanGTRlhd", + ["75grandPrix"] = "Base.75grandPrixHurst|Base.75grandPrixLJ|Base.75grandPrixSJ", + ["76chevyKseries"] = "Base.76chevyBlazer|Base.76chevyC30CCwrecker|Base.76chevyC30SCwrecker|Base.76chevyK10|Base.76chevyK10fd|Base.76chevyK10spirit|Base.76chevyK20|Base.76chevyK20BigRed|Base.76chevyK20fd|Base.76chevyK20utility|Base.76chevyK30CC|Base.76chevyK30CCdually|Base.76chevyK30CCduallyS|Base.76chevyK30CCfd|Base.76chevyK30CCutility|Base.76chevyK30CCwrecker|Base.76chevyK30SCdually|Base.76chevyK30SCwrecker|Base.76chevySuburban|Base.76chevySuburban2", + ["76chryslerNewYorker"] = "Base.76chryslerNewYorker|Base.76chryslerNewYorkerTPB", + ["77firebird"] = "Base.77firebird|Base.77firebirdES|Base.77firebirdFR|Base.77firebirdTA", + ["78amgeneralM35A2"] = "Base.78amgeneralM35A2|Base.78amgeneralM35A2Burnt|Base.78amgeneralM49A2C|Base.78amgeneralM50A3|Base.78amgeneralM62", + ["78amgeneralM49A2C"] = "Base.78amgeneralM49A2C", + ["78amgeneralM50A3"] = "Base.78amgeneralM50A3", + ["78amgeneralM62"] = "Base.78amgeneralM62", + ["78lamboCountach"] = "Base.78lamboCountachLP400|Base.78lamboCountachLP400S|Base.78lamboCountachLP400Scb", + ["79camaro"] = "Base.79camaro|Base.79camaroGhost|Base.79camaroRS|Base.79camaroZ28", + ["80manKat1"] = "Base.80manKat1", + ["81deloreanDMC12"] = "Base.81deloreanDMC12|Base.81deloreanDMC12BTTF", + ["81deloreanDMC12BTTF"] = "Base.81deloreanDMC12BTTF", + ["82firebird"] = "Base.82firebird|Base.82firebirdKARR|Base.82firebirdKITT|Base.82firebirdSE|Base.82firebirdTA", + ["82firebirdKITT"] = "Base.82firebirdKARR|Base.82firebirdKITT", + ["82jeepJ10"] = "Base.82jeepJ10|Base.82jeepJ10pd|Base.82jeepJ10ranger|Base.82jeepJ10t", + ["82jeepJ10t"] = "Base.82jeepJ10t", + ["82oshkoshM911"] = "Base.82oshkoshM911|Base.82oshkoshM911B|Base.82oshkoshM911Burnt", + ["82porsche911"] = "Base.82porsche911rwb|Base.82porsche911sc|Base.82porsche911targa|Base.82porsche911turbo", + ["83amgeneralM923"] = "Base.83amgeneralM923|Base.83amgeneralM923Burnt", + ["84buickElectra"] = "Base.84buickElectraCoupe|Base.84buickElectraSedan", + ["84cadillacDeVille"] = "Base.84cadillacDeVilleCoupe|Base.84cadillacDeVilleSedan", + ["84corvette"] = "Base.84corvetteC4|Base.93corvetteC4", + ["84jeepXJ"] = "Base.84jeepXJ2|Base.84jeepXJ4|Base.84jeepXJksp|Base.84jeepXJpd|Base.84jeepXJranger", + ["84merc"] = "Base.84mercLWB2|Base.84mercLWB4|Base.84mercLWB4M|Base.84mercSWB", + ["84oldsmobile98"] = "Base.84oldsmobile98Coupe|Base.84oldsmobile98Sedan", + ["85buickLeSabre"] = "Base.85buickLeSabreCoupe|Base.85buickLeSabreSedan|Base.85buickLeSabreWagon|Base.85buickLeSabreWagon2", + ["85chevyCaprice"] = "Base.85chevyCapriceCoupe|Base.85chevyCapriceSedan|Base.85chevyCapriceWagon|Base.85chevyCapriceWagon2|Base.85chevyImpalaSedanAirport|Base.85chevyImpalaSedanBCS|Base.85chevyImpalaSedanCLPD|Base.85chevyImpalaSedanFD|Base.85chevyImpalaSedanKSP|Base.85chevyImpalaSedanLCPD|Base.85chevyImpalaSedanMCS|Base.85chevyImpalaSedanMPD|Base.85chevyImpalaSedanPD|Base.85chevyImpalaSedanPDu|Base.85chevyImpalaSedanPrison|Base.85chevyImpalaSedanRanger|Base.85chevyImpalaSedanTaxi|Base.85chevyImpalaSedanWPPD", + ["85chevyStepVan"] = "Base.85chevyStepVan|Base.85chevyStepVanSWAT", + ["85chevyStepVanexpanded"] = "Base.85chevyStepVanBlacksmith|Base.85chevyStepVanButchers|Base.85chevyStepVanCitrusWave|Base.85chevyStepVanDelirosPlonkies|Base.85chevyStepVanFlorist|Base.85chevyStepVanGenuine|Base.85chevyStepVanHerald|Base.85chevyStepVanJorgensen|Base.85chevyStepVanLibrary|Base.85chevyStepVanLvAirportCatering|Base.85chevyStepVanLvMotorshop|Base.85chevyStepVanMarineBites|Base.85chevyStepVanMasonry|Base.85chevyStepVanMrHuangsLaundry|Base.85chevyStepVanPostal|Base.85chevyStepVanPropane|Base.85chevyStepVanRandys|Base.85chevyStepVanScarletOak|Base.85chevyStepVanSeHospitality|Base.85chevyStepVanSePaintingServices|Base.85chevyStepVanSmartCut|Base.85chevyStepVanSunBallz|Base.85chevyStepVanTheCompleteRepair|Base.85chevyStepVanTimelessGlass|Base.85chevyStepVanUsLogistics|Base.85chevyStepVanZippeeMarket", + ["85oldsmobileDelta88"] = "Base.85oldsmobileDelta88Coupe|Base.85oldsmobileDelta88Sedan|Base.85oldsmobileDelta88Wagon|Base.85oldsmobileDelta88Wagon2", + ["85pontiacParisienne"] = "Base.85pontiacParisienneSedan|Base.85pontiacParisienneWagon|Base.85pontiacParisienneWagon2", + ["86chevyCUCV"] = "Base.86chevyK5blazer|Base.86chevyK5ksp|Base.86chevyK5pd|Base.86chevyM1008|Base.86chevyM1009|Base.86chevyM1009mp|Base.86chevyM1010|Base.86chevyM1028|Base.86chevyM1031", + ["86fordE150"] = "Base.86fordE150|Base.86fordE150dnd|Base.86fordE150ksp|Base.86fordE150long|Base.86fordE150longW|Base.86fordE150mccoy|Base.86fordE150med|Base.86fordE150mm|Base.86fordE150pd|Base.86fordE150slide|Base.86fordE150slideSpiffo|Base.86fordE150so", + ["86fordE150dnd"] = "Base.86fordE150dnd", + ["86fordE150expanded"] = "Base.86fordE150beckmansBuilding|Base.86fordE150blacksmith|Base.86fordE150brewster|Base.86fordE150brushAndClay|Base.86fordE150bugWipers|Base.86fordE150ccconstruction|Base.86fordE150creatureCruiser|Base.86fordE150deerValley|Base.86fordE150fossoil|Base.86fordE150greenes|Base.86fordE150heritageTailors|Base.86fordE150jones|Base.86fordE150kerrHomes|Base.86fordE150knobCreek|Base.86fordE150knoxDistilery|Base.86fordE150knoxTelecom|Base.86fordE150korshunovs|Base.86fordE150kyTransit|Base.86fordE150LBMWradio|Base.86fordE150leatherwork|Base.86fordE150lectromax|Base.86fordE150locksmith|Base.86fordE150LVairportShuttle|Base.86fordE150lvLandscaping|Base.86fordE150massGenfac|Base.86fordE150McCoyWoodworking|Base.86fordE150meltingPointMetal|Base.86fordE150mesmerWagon|Base.86fordE150metalheads|Base.86fordE150michelesWoodshop|Base.86fordE150mobileMechanics|Base.86fordE150mooresMechanics|Base.86fordE150oldMillWaterCompany|Base.86fordE150oVoFarms|Base.86fordE150pennSham|Base.86fordE150perfick|Base.86fordE150plattAutoRepair|Base.86fordE150pluggedInElectrics|Base.86fordE150postal|Base.86fordE150quantumVessel|Base.86fordE150riversideFab|Base.86fordE150rosewoodWorking|Base.86fordE150schwab|Base.86fordE150stoneworksMasonry|Base.86fordE150tasteTheBrew|Base.86fordE150theGardenGods|Base.86fordE150theLadyDelighter|Base.86fordE150treyBaines|Base.86fordE150uncloggers|Base.86fordE150valkyriesSpear|Base.86fordE150voltMojo|Base.86fordE150wpCarpentry|Base.86fordE150zenith", + ["86fordE150mm"] = "Base.86fordE150mm", + ["86fordE150pd"] = "Base.86fordE150pd", + ["86oshkoshP19A"] = "Base.86oshkoshFRTR55|Base.86oshkoshKYFD|Base.86oshkoshP19ABurnt|Base.86oshkoshUSMC", + ["87buickRegal"] = "Base.87buickRegalGNX|Base.87buickRegalTurboT|Base.87buickRegalTurboTfbi", + ["87chevySuburban"] = "Base.87chevySuburban|Base.87chevySuburbanCUCV|Base.87chevySuburbanOP", + ["87fordB700"] = "Base.87fordB700military|Base.87fordB700prison|Base.87fordB700school|Base.87fordF700bank|Base.87fordF700box|Base.87fordF700swat", + ["87toyotaCorolla"] = "Base.87toyotaCorollaAE92levin|Base.87toyotaCorollaAE92levinLhd|Base.87toyotaCorollaAE92trueno|Base.87toyotaCorollaAE92truenoLhd", + ["87toyotaMR2"] = "Base.87toyotaMR2|Base.87toyotaMR2c", + ["88chevyS10"] = "Base.88chevyS10", + ["88toyotaHilux"] = "Base.88toyotaHiluxSC|Base.88toyotaHiluxXC|Base.88toyotaHiluxXCS", + ["89defender"] = "Base.89defender110|Base.89defender110utility|Base.89defender130|Base.89defender90|Base.89defender90utility|Base.89defenderWolf", + ["89dodgeCaravan"] = "Base.89dodgeCaravan|Base.89dodgeCaravanLE|Base.89dodgeCaravanNomad", + ["89fordBronco"] = "Base.89fordBronco|Base.89fordBroncoPD|Base.89fordBroncoRanger", + ["89trooper"] = "Base.89trooper|Base.89trooperOP|Base.89trooperRS", + ["89volvo200"] = "Base.89volvo242turbo|Base.89volvo244sedan|Base.89volvo245wagon", + ["90bmwE30"] = "Base.90bmwE30cabrio|Base.90bmwE30m3|Base.90bmwE30sedan2|Base.90bmwE30sedan4|Base.90bmwE30touring", + ["90fordF350ambulance"] = "Base.90fordF350ambulance|Base.90fordF350SWAT", + ["90pierceArrow"] = "Base.90pierceArrow|Base.90pierceArrowQuint", + ["91fordLTD"] = "Base.91fordLTD|Base.91fordLTDksp|Base.91fordLTDksp2|Base.91fordLTDpd|Base.91fordLTDranger|Base.91fordLTDtaxi|Base.91fordLTDunmarked|Base.91fordLTDwagon", + ["91fordRanger"] = "Base.91fordRangerPD|Base.91fordRangerRanger|Base.91fordRangerSC|Base.91fordRangerSClong|Base.91fordRangerXC|Base.91fordRangerXClong", + ["91geoMetro"] = "Base.91geoMetro", + ["91lexusLS400"] = "Base.91lexusLS400", + ["91nissan240sx"] = "Base.91nissan240sx|Base.91nissan240sx2", + ["91range"] = "Base.91range|Base.91range2", + ["92amgeneralM998"] = "Base.92amgeneralM998|Base.92amgeneralM998Burnt", + ["92fordCVPI"] = "Base.92fordCV|Base.92fordCVPI|Base.92fordCVPI2|Base.92fordCVPI2ksp|Base.92fordCVPI2kspst|Base.92fordCVPI2so|Base.92fordCVPI2sup|Base.92fordCVPIfd|Base.92fordCVPIpdu|Base.92fordCVPItaxi|Base.92fordCVPIunmarked", + ["92jeepYJ"] = "Base.92jeepYJjp|Base.92jeepYJranger|Base.92jeepYJs|Base.92jeepYJse", + ["92jeepYJJP18"] = "Base.92jeepYJjp", + ["92nissanGTR"] = "Base.92nissanGTR|Base.92nissanGTRlhd", + ["93chevySuburban"] = "Base.93chevySilveradoCC|Base.93chevySilveradoCCdually|Base.93chevySilveradoCClong|Base.93chevySilveradoCClongfd|Base.93chevySilveradoK3500flatbed|Base.93chevySilveradoK3500wrecker|Base.93chevySilveradoSC|Base.93chevySilveradoSCdually|Base.93chevySilveradoSClong|Base.93chevySilveradoSClongFossoil|Base.93chevySilveradoXC|Base.93chevySilveradoXCdually|Base.93chevySilveradoXClong|Base.93chevySilveradoXClongMcCoy|Base.93chevySilveradoXClongRanger|Base.93chevySuburban|Base.93chevySuburbanDually|Base.93chevySuburbanfbi|Base.93chevySuburbanfd|Base.93chevySuburbanksp|Base.93chevySuburbanpd|Base.93chevySuburbanpdu", + ["93chevySuburbanExpanded"] = "Base.93chevySilveradoAirport|Base.93chevySilveradoK3500lvLandscaping|Base.93chevySilveradoK3500mechanic|Base.93chevySilveradoMcCoyWoodworking|Base.93chevySilveradoPennSham|Base.93chevySilveradoPoliceBCS|Base.93chevySilveradoPoliceMCS|Base.93chevySilveradoRiversideFab|Base.93chevySilveradoStoneworksMasonry|Base.93chevySilveradoUncloggers|Base.93chevySilveradoVoltMojo|Base.93chevySilveradoWpCarpentry|Base.93chevySuburbanAirportSec|Base.93chevySuburbanPoliceBCS|Base.93chevySuburbanPoliceCLPD|Base.93chevySuburbanPoliceLCPD|Base.93chevySuburbanPoliceMCS|Base.93chevySuburbanPoliceMPD|Base.93chevySuburbanPoliceWPPD|Base.93chevySuburbanPrison", + ["93fordElgin"] = "Base.93fordElgin|Base.93fordElginSpec", + ["93fordF350"] = "Base.93fordF150|Base.93fordF150S|Base.93fordF250|Base.93fordF350|Base.93fordF350dually|Base.93fordF350fd|Base.93fordF350pd|Base.93fordF350so|Base.93fordF350utility|Base.93fordF350utilityDpw|Base.93fordF350utilityFd", + ["93fordTaurus"] = "Base.93fordTaurus|Base.93fordTaurusSHO|Base.93fordTaurusWagon", + ["93mustangSSP"] = "Base.93mustangGT|Base.93mustangSSP|Base.93mustangSSPksp|Base.93mustangSSPksp2|Base.93mustangSSPkspCol|Base.93mustangSSPpd|Base.93mustangSSPpd2|Base.93mustangSSPunmarked|Base.93mustangSVTcobraR", + ["93townCar"] = "Base.93townCar|Base.93townCarLimo", + ["95impreza"] = "Base.95impreza|Base.95imprezalhd", + ["96lancerEVO"] = "Base.96lancerEVO|Base.96lancerEVOlhd", + ["96saturnSseries"] = "Base.96saturnSL2", + ["97bushmaster"] = "Base.97bushAmbulance|Base.97bushmaster", + ["98stagea"] = "Base.98stagea260RS|Base.98stagea260RSlhd", + ["99fordCVPI"] = "Base.99fordCVPI|Base.99fordCVPIunmarked", + ["cobbM540"] = "Base.cobbM540", + ["lockMartM577"] = "Base.lockMartM577", +} + +local function getScriptFullName(script) + if not script then return nil end + + local ok, fullName = pcall(function() + return script:getFullName() + end) + if ok and type(fullName) == "string" then return fullName end + + return nil +end + +function Compatibility.isKi5Vehicle(vehicle) + if not vehicle then return false end + + local ok, script = pcall(function() + return vehicle:getScript() + end) + if not ok or not script then return false end + + local fullName = getScriptFullName(script) + if not fullName then return false end + + local activeMods = getActivatedMods and getActivatedMods() or nil + if not activeMods or not activeMods.contains then return false end + + for modId, vehicleNames in pairs(Ki5VehiclesByModId) do + if activeMods:contains(modId) then + for candidate in string.gmatch(vehicleNames, "[^|]+") do + if candidate == fullName then return true end + end + end + end + + return false +end + diff --git a/42.20/media/scripts/vehicles/template_battery.txt b/42.20/media/scripts/vehicles/template_battery.txt index d5a679a..2355780 100644 --- a/42.20/media/scripts/vehicles/template_battery.txt +++ b/42.20/media/scripts/vehicles/template_battery.txt @@ -263,6 +263,36 @@ module Base create = BTtow.Create.towbar, init = BTtow.Init.towbar, } + } + part towbarKI5 + { + model towbar0 { file = towbarModelKI5, offset = 0 -0.3 1.0, } + model towbar1 { file = towbarModelKI5, offset = 0 -0.3 1.1, } + model towbar2 { file = towbarModelKI5, offset = 0 -0.3 1.2, } + model towbar3 { file = towbarModelKI5, offset = 0 -0.3 1.3, } + model towbar4 { file = towbarModelKI5, offset = 0 -0.3 1.4, } + model towbar5 { file = towbarModelKI5, offset = 0 -0.3 1.5, } + model towbar6 { file = towbarModelKI5, offset = 0 -0.3 1.6, } + model towbar7 { file = towbarModelKI5, offset = 0 -0.3 1.7, } + model towbar8 { file = towbarModelKI5, offset = 0 -0.3 1.8, } + model towbar9 { file = towbarModelKI5, offset = 0 -0.3 1.9, } + model towbar10 { file = towbarModelKI5, offset = 0 -0.3 2.0, } + model towbar11 { file = towbarModelKI5, offset = 0 -0.3 2.1, } + model towbar12 { file = towbarModelKI5, offset = 0 -0.3 2.2, } + model towbar13 { file = towbarModelKI5, offset = 0 -0.3 2.3, } + model towbar14 { file = towbarModelKI5, offset = 0 -0.3 2.4, } + model towbar15 { file = towbarModelKI5, offset = 0 -0.3 2.5, } + model towbar16 { file = towbarModelKI5, offset = 0 -0.3 2.6, } + model towbar17 { file = towbarModelKI5, offset = 0 -0.3 2.7, } + model towbar18 { file = towbarModelKI5, offset = 0 -0.3 2.8, } + model towbar19 { file = towbarModelKI5, offset = 0 -0.3 2.9, } + model towbar20 { file = towbarModelKI5, offset = 0 -0.3 3.0, } + model towbar21 { file = towbarModelKI5, offset = 0 -0.3 3.1, } + model towbar22 { file = towbarModelKI5, offset = 0 -0.3 3.2, } + model towbar23 { file = towbarModelKI5, offset = 0 -0.3 3.3, } + area = Engine, + mechanicRequireKey = false, + lua { create = BTtow.Create.towbar, init = BTtow.Init.towbar, } } part Battery { diff --git a/42.20/media/scripts/vehicles/template_towbar.txt b/42.20/media/scripts/vehicles/template_towbar.txt index aeaaae7..f8adf91 100644 --- a/42.20/media/scripts/vehicles/template_towbar.txt +++ b/42.20/media/scripts/vehicles/template_towbar.txt @@ -1,6 +1,13 @@ module Base { model towbarModel + { + mesh = vehicles/Towbar, + texture = Vehicles/Towbar_Texture, + scale = 0.01, + } + + model towbarModelKI5 { mesh = vehicles/Towbar, texture = Vehicles/Towbar_Texture, @@ -280,5 +287,36 @@ module Base } } + part towbarKI5 + { + model towbar0 { file = towbarModelKI5, offset = 0 -0.3 1.0, } + model towbar1 { file = towbarModelKI5, offset = 0 -0.3 1.1, } + model towbar2 { file = towbarModelKI5, offset = 0 -0.3 1.2, } + model towbar3 { file = towbarModelKI5, offset = 0 -0.3 1.3, } + model towbar4 { file = towbarModelKI5, offset = 0 -0.3 1.4, } + model towbar5 { file = towbarModelKI5, offset = 0 -0.3 1.5, } + model towbar6 { file = towbarModelKI5, offset = 0 -0.3 1.6, } + model towbar7 { file = towbarModelKI5, offset = 0 -0.3 1.7, } + model towbar8 { file = towbarModelKI5, offset = 0 -0.3 1.8, } + model towbar9 { file = towbarModelKI5, offset = 0 -0.3 1.9, } + model towbar10 { file = towbarModelKI5, offset = 0 -0.3 2.0, } + model towbar11 { file = towbarModelKI5, offset = 0 -0.3 2.1, } + model towbar12 { file = towbarModelKI5, offset = 0 -0.3 2.2, } + model towbar13 { file = towbarModelKI5, offset = 0 -0.3 2.3, } + model towbar14 { file = towbarModelKI5, offset = 0 -0.3 2.4, } + model towbar15 { file = towbarModelKI5, offset = 0 -0.3 2.5, } + model towbar16 { file = towbarModelKI5, offset = 0 -0.3 2.6, } + model towbar17 { file = towbarModelKI5, offset = 0 -0.3 2.7, } + model towbar18 { file = towbarModelKI5, offset = 0 -0.3 2.8, } + model towbar19 { file = towbarModelKI5, offset = 0 -0.3 2.9, } + model towbar20 { file = towbarModelKI5, offset = 0 -0.3 3.0, } + model towbar21 { file = towbarModelKI5, offset = 0 -0.3 3.1, } + model towbar22 { file = towbarModelKI5, offset = 0 -0.3 3.2, } + model towbar23 { file = towbarModelKI5, offset = 0 -0.3 3.3, } + area = Engine, + mechanicRequireKey = false, + lua { create = BTtow.Create.towbar, init = BTtow.Init.towbar, } + } + } } diff --git a/42.20/mod.info b/42.20/mod.info index c32fb69..8b5b8f5 100644 --- a/42.20/mod.info +++ b/42.20/mod.info @@ -1,11 +1,11 @@ name=Towbars -id=hrsys_towbars_testing +id=hrsys_towbars 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.12 +modversion=1.0.22 versionMin=42.20.0 incompatible=\STowTruck_B42 diff --git a/mod.info b/mod.info index 8b6cebb..8415cab 100644 --- a/mod.info +++ b/mod.info @@ -1,11 +1,11 @@ name=Towbars -id=hrsys_towbars_testing +id=hrsys_towbars 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.12 +modversion=1.0.22 icon=common/media/textures/tow_bar_icon.png incompatible=\STowTruck_B42 diff --git a/tests/driver-entry-force-reattach-42.20-spec.lua b/tests/driver-entry-force-reattach-42.20-spec.lua new file mode 100644 index 0000000..0184609 --- /dev/null +++ b/tests/driver-entry-force-reattach-42.20-spec.lua @@ -0,0 +1,249 @@ +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 = table.concat({ + "42.20/media/lua/client/?.lua", + "42.20/media/lua/shared/?.lua", + package.path +}, ";") + +isServer = function() return false end +isClient = function() return true end + +local serverCommandCallbacks = {} +local enterVehicleCallbacks = {} +local spawnVehicleCallbacks = {} +local tickCallbacks = {} +Events = { + OnServerCommand = { + Add = function(callback) + serverCommandCallbacks[#serverCommandCallbacks + 1] = callback + end + }, + OnEnterVehicle = { + Add = function(callback) + enterVehicleCallbacks[#enterVehicleCallbacks + 1] = callback + end + }, + OnSwitchVehicleSeat = { Add = function() end }, + OnSpawnVehicleEnd = { + Add = function(callback) + spawnVehicleCallbacks[#spawnVehicleCallbacks + 1] = callback + end + }, + OnTick = { + Add = function(callback) + tickCallbacks[#tickCallbacks + 1] = callback + end + } +} + +local localPlayer = { vehicle = nil } +function localPlayer:getVehicle() return self.vehicle end + +local attachmentIds = { + trailer = true, + trailerfront = true, + towbarWreckerHookLow = true, + towbarWreckerHookMid = true, + towbarWreckerHookHigh = true +} + +local function unlink(vehicleA, vehicleB) + vehicleA.towing, vehicleA.towedBy = nil, nil + vehicleB.towing, vehicleB.towedBy = nil, nil +end + +local function link(vehicleA, vehicleB) + vehicleA.towing, vehicleA.towedBy = vehicleB, nil + vehicleB.towing, vehicleB.towedBy = nil, vehicleA +end + +local function newVehicle(id, sqlId) + local value = { + id = id, + sqlId = sqlId, + modData = {}, + scriptName = "Base.DriverEntry" .. tostring(id), + mass = 1000, + brakingForce = 20, + towing = nil, + towedBy = nil, + driver = nil, + addCalls = 0, + breakCalls = 0 + } + + local script = { + getAttachmentById = function(_, attachmentId) + return attachmentIds[attachmentId] and {} or nil + end + } + + function value:getId() return self.id end + function value:getSqlId() return self.sqlId end + function value:getModData() return self.modData end + function value:transmitModData() end + function value:getScript() return script end + function value:getScriptName() return self.scriptName end + function value:setScriptName(scriptName) self.scriptName = scriptName end + function value:getMass() return self.mass end + function value:setMass(mass) self.mass = mass end + function value:getBrakingForce() return self.brakingForce end + function value:setBrakingForce(force) self.brakingForce = force end + function value:isParkingBrakeOn() return false end + function value:setParkingBrakeOn() end + function value:getParkingBrake() return false end + function value:setParkingBrake() end + function value:isHandbrake() return false end + function value:setHandbrake() end + function value:getVehicleTowing() return self.towing end + function value:getVehicleTowedBy() return self.towedBy end + function value:isDriver(player) return self.driver == player end + function value:attachmentExist(attachmentId) + return attachmentIds[attachmentId] == true + end + function value:addPointConstraint(_, other) + self.addCalls = self.addCalls + 1 + link(self, other) + end + function value:breakConstraint() + self.breakCalls = self.breakCalls + 1 + local other = self.towing or self.towedBy + if other then unlink(self, other) end + end + + return value +end + +local portableTowing = newVehicle(101, 1001) +local portableTowed = newVehicle(102, 1002) +local wrecker = newVehicle(201, 2001) +local wreckerTarget = newVehicle(202, 2002) +local unrelated = newVehicle(301, 3001) +local allVehicles = { + portableTowing, + portableTowed, + wrecker, + wreckerTarget, + unrelated +} +local vehiclesById = {} +for _, vehicle in ipairs(allVehicles) do vehiclesById[vehicle:getId()] = vehicle end + +getPlayer = function() return localPlayer end +getVehicleById = function(id) return vehiclesById[tonumber(id)] end +getCell = function() + return { getVehicles = function() return allVehicles end } +end + +TowBarMod = { + Utils = { updateAttachmentsForRigidTow = function() end }, + Hook = { + applyFreeRollingTowState = function() end, + setVehiclePostAttach = function() end, + setVehicleScriptWithTowBarHidden = function(vehicle, scriptName) + vehicle:setScriptName(scriptName) + return true + end, + cleanupDetachedTowBar = function() end + }, + Wrecker = { + getWorldVehicles = function() return allVehicles end, + getHeightAttachmentId = function(level) + return ({ + [0] = "towbarWreckerHookLow", + [1] = "towbarWreckerHookMid", + [2] = "towbarWreckerHookHigh" + })[tonumber(level) or 0] + end + } +} + +-- Model a fully persisted portable pair whose transient sync cache was lost +-- during reload. The exact native relation is deliberately present: entry +-- must replace it with one fresh client-owned rigid constraint. +portableTowing.modData = { + isTowingByTowBar = true, + towed = false, + towBarTowedVehicleId = portableTowed:getId(), + towBarTowedVehicleSqlId = portableTowed:getSqlId(), + towBarExpectedAttachment = "trailer" +} +portableTowed.modData = { + isTowingByTowBar = true, + towed = true, + towBarTowingVehicleId = portableTowing:getId(), + towBarTowingVehicleSqlId = portableTowing:getSqlId(), + towBarExpectedAttachment = "trailerfront", + towBarOriginalScriptName = portableTowed:getScriptName() +} +link(portableTowing, portableTowed) + +-- Model the equivalent persisted wrecker pair, also without applied-level +-- cache state. Both members carry the reciprocal identity written by the +-- authoritative wrecker attach path. +wrecker.modData = { + wreckerTowActive = true, + wreckerTowedVehicleId = wreckerTarget:getId(), + wreckerTowedVehicleSqlId = wreckerTarget:getSqlId(), + wreckerTargetAttachment = "trailerfront", + wreckerHeightLevel = 2 +} +wreckerTarget.modData = { + wreckerTowingVehicleId = wrecker:getId(), + wreckerTowingVehicleSqlId = wrecker:getSqlId(), + wreckerOriginalScriptName = wreckerTarget:getScriptName() +} +link(wrecker, wreckerTarget) + +dofile("42.20/media/lua/client/TowBar/TowSyncClient.lua") +dofile("42.20/media/lua/client/TowBar/WreckerSyncClient.lua") + +expect(next(TowBarMod.Sync.desiredPairs) == nil, + "portable regression setup must not depend on transient desired-pair sync") +expect(next(TowBarMod.Sync.appliedPairs) == nil, + "portable regression setup must begin without an applied-pair cache") +expect(next(TowBarMod.WreckerSync.appliedLevels) == nil, + "wrecker regression setup must begin without an applied-level cache") + +local function enterAsDriver(vehicle) + for _, candidate in ipairs(allVehicles) do candidate.driver = nil end + vehicle.driver = localPlayer + localPlayer.vehicle = vehicle + for i = 1, #enterVehicleCallbacks do + enterVehicleCallbacks[i](localPlayer) + end +end + +enterAsDriver(portableTowing) +expect(portableTowing.breakCalls == 1 and portableTowing.addCalls == 1, + "portable driver entry must force exactly one rebuild from persisted modData") +enterAsDriver(portableTowing) +expect(portableTowing.breakCalls == 2 and portableTowing.addCalls == 2, + "each later portable driver entry must force one new rebuild, not remain cache-idempotent") + +enterAsDriver(wrecker) +expect(wrecker.breakCalls == 1 and wrecker.addCalls == 1, + "wrecker driver entry must force exactly one rebuild from persisted modData") +enterAsDriver(wrecker) +expect(wrecker.breakCalls == 2 and wrecker.addCalls == 2, + "each later wrecker driver entry must force one new rebuild, not remain cache-idempotent") + +local portableCounts = portableTowing.breakCalls + portableTowing.addCalls +local wreckerCounts = wrecker.breakCalls + wrecker.addCalls +enterAsDriver(unrelated) +expect(portableTowing.breakCalls + portableTowing.addCalls == portableCounts + and wrecker.breakCalls + wrecker.addCalls == wreckerCounts, + "entering an unrelated vehicle must not rebuild any persisted tow pair") +expect(#tickCallbacks == 0, + "driver-entry recovery must remain event-driven and must not install a tick retry loop") + +if failures > 0 then os.exit(1) end +print("PASS: driver entry force-reattaches persisted portable and wrecker pairs once") diff --git a/tests/dual-mode-42.20-spec.lua b/tests/dual-mode-42.20-spec.lua new file mode 100644 index 0000000..958a2e9 --- /dev/null +++ b/tests/dual-mode-42.20-spec.lua @@ -0,0 +1,84 @@ +local failures = 0 + +local function expect(condition, message) + if not condition then + failures = failures + 1 + io.stderr:write("FAIL: " .. message .. "\n") + end +end + +local activeModIds = {} + +getActivatedMods = function() + return { + contains = function(_, modId) + return activeModIds[modId] == true + end + } +end + +local function vehicle(fullName, modelScale) + return { + getScript = function() + return { + getFullName = function() return fullName end, + getModelScale = function() return modelScale end + } + end + } +end + +local classifierPath = "42.20/media/lua/shared/TowBar/VehicleCompatibility.lua" +local loaded, loadError = pcall(dofile, classifierPath) +expect(loaded, "dual-mode classifier must load from " .. classifierPath .. ": " .. tostring(loadError)) + +local Compatibility = TowBarMod and TowBarMod.Compatibility +expect(Compatibility ~= nil, "classifier must expose TowBarMod.Compatibility") +expect(Compatibility and type(Compatibility.isKi5Vehicle) == "function", + "classifier must expose isKi5Vehicle(vehicle)") + +if Compatibility and type(Compatibility.isKi5Vehicle) == "function" then + local function isKi5(fullName, modelScale) + return Compatibility.isKi5Vehicle(vehicle(fullName, modelScale)) + end + + -- Positive contracts use exact mod IDs and exact script full names verified + -- from the locally installed KI5 workshop items. + activeModIds = { ["91range"] = true } + expect(isKi5("Base.91range", 0.01), "active KI5 Range Rover must use KI5 mode") + + activeModIds = { ["87toyotaCorolla"] = true } + expect(isKi5("Base.87toyotaCorollaAE92levin", 1.75), + "active KI5 Corolla must use KI5 mode regardless of model scale") + + activeModIds = { ["76chevyKseries"] = true } + expect(isKi5("Base.76chevyK30CCwrecker", 1.75), + "active KI5 Chevrolet variant must use KI5 mode") + + -- Exact identity is intentional. Scale, name fragments, attachment shape, + -- and an active mod ID by itself must never capture another vehicle. + activeModIds = {} + expect(not isKi5("Base.91range", 0.01), + "a KI5-looking script without its exact active mod must use legacy mode") + + activeModIds = { ["91range"] = true } + expect(not isKi5("Base.91rangeBurnt", 0.01), + "a suffixed KI5 lookalike must use legacy mode") + expect(not isKi5("Base.CarNormal", 0.01), + "vanilla must not be classified by KI5-like model scale") + expect(not isKi5("Base.SmallCar", 1.75), + "vanilla must not be classified by legacy vanilla model scale") + expect(not isKi5("Base.Chevalier_Rhino_TowTruck", 0.01), + "a non-KI5 tow-truck mod must use legacy mode") + + activeModIds = { ["91range-copy"] = true } + expect(not isKi5("Base.91range", 0.01), + "a prefix or suffix match on mod ID must not enable KI5 mode") + + expect(not Compatibility.isKi5Vehicle(nil), "nil vehicle must safely use legacy mode") + expect(not Compatibility.isKi5Vehicle({ getScript = function() return nil end }), + "vehicle without a script must safely use legacy mode") +end + +if failures > 0 then os.exit(1) end +print("PASS: exact KI5 dual-mode classifier") diff --git a/tests/persistence-handlers-42.20-spec.lua b/tests/persistence-handlers-42.20-spec.lua index d1233db..ea3c812 100644 --- a/tests/persistence-handlers-42.20-spec.lua +++ b/tests/persistence-handlers-42.20-spec.lua @@ -156,14 +156,24 @@ local function runPortableHandlerSpec() 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") + expect(metrics.attachSync == 1, + "portable restart must immediately synchronize one wrecker-style rigid restore") + + -- The restore is queued before the native call. Once Build 42 exposes the + -- reciprocal relation, the pending confirmation broadcasts exactly once. + linkPair(towing, towed) + now = 1100 + fireTicks(callbacks.tick, 1) + expect(metrics.addConstraint == 1, + "portable confirmation must adopt the first native add without re-adding") + expect(metrics.attachSync == 1, + "portable native acknowledgement must not introduce a second sync phase") now = 1500 - fireTicks(callbacks.tick, 5) + fireTicks(callbacks.tick, 4) 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") @@ -187,7 +197,23 @@ local function runPortableHandlerSpec() 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.attachSync == 2, + "a reloaded portable peer must receive one fresh wrecker-style restore sync") + + linkPair(towing, towed) + now = 17100 + fireTicks(callbacks.tick, 1) + expect(metrics.addConstraint == 2, + "reloaded portable confirmation must adopt without another native add") + expect(metrics.attachSync == 2, + "reloaded portable native acknowledgement must not duplicate its sync") + + now = 17500 + fireTicks(callbacks.tick, 4) + expect(metrics.addConstraint == 2, + "confirmed reloaded pair must not duplicate its native add") + expect(metrics.attachSync == 2, + "confirmed reloaded pair must not duplicate its authoritative 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 diff --git a/tests/portable-mp-driver-ownership-42.20-spec.lua b/tests/portable-mp-driver-ownership-42.20-spec.lua new file mode 100644 index 0000000..785b97a --- /dev/null +++ b/tests/portable-mp-driver-ownership-42.20-spec.lua @@ -0,0 +1,224 @@ +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 = table.concat({ + "42.20/media/lua/client/?.lua", + "42.20/media/lua/shared/?.lua", + package.path +}, ";") + +isServer = function() return false end +isClient = function() return true end + +local serverCommandCallbacks = {} +local enterVehicleCallbacks = {} +local spawnVehicleCallbacks = {} +Events = { + OnServerCommand = { + Add = function(callback) + serverCommandCallbacks[#serverCommandCallbacks + 1] = callback + end + }, + OnEnterVehicle = { + Add = function(callback) + enterVehicleCallbacks[#enterVehicleCallbacks + 1] = callback + end + }, + OnSpawnVehicleEnd = { + Add = function(callback) + spawnVehicleCallbacks[#spawnVehicleCallbacks + 1] = callback + end + } +} + +local localPlayer = { onlineId = 7 } +local function attachmentScript() + local attachments = { trailer = {}, trailerfront = {} } + return { + getAttachmentById = function(_, attachmentId) + return attachments[attachmentId] + end + } +end + +local function unlink(a, b) + a.towing, a.towedBy = nil, nil + b.towing, b.towedBy = nil, nil +end + +local function vehicle(id) + local value = { + id = id, + script = attachmentScript(), + scriptName = "Base.DriverOwnership" .. tostring(id), + modData = {}, + driver = nil, + towing = nil, + towedBy = nil, + addCalls = 0, + breakCalls = 0, + freeRolling = false, + freeRollingAtAdd = {} + } + + function value:getId() return self.id end + function value:getScript() return self.script end + function value:getScriptName() return self.scriptName end + function value:setScriptName(scriptName) self.scriptName = scriptName end + function value:getModData() return self.modData end + function value:transmitModData() end + function value:getMass() return 1000 end + function value:getBrakingForce() return 20 end + function value:getVehicleTowing() return self.towing end + function value:getVehicleTowedBy() return self.towedBy end + function value:getDriver() return self.driver end + function value:isDriver(player) return self.driver == player end + function value:addPointConstraint(player, other, attachmentA, attachmentB, localOnly) + self.addCalls = self.addCalls + 1 + self.freeRollingAtAdd[#self.freeRollingAtAdd + 1] = other.freeRolling + expect(player == nil, "client rigid attach must not impersonate a player") + expect(attachmentA == "trailer" and attachmentB == "trailerfront", + "deferred attach must preserve its authoritative endpoints") + expect(localOnly == true, "deferred attach must remain client-local") + self.towing = other + other.towedBy = self + end + function value:breakConstraint() + self.breakCalls = self.breakCalls + 1 + local other = self.towing or self.towedBy + if other then unlink(self, other) end + end + + return value +end + +local vehicles = { + [101] = vehicle(101), + [102] = vehicle(102), + [201] = vehicle(201), + [202] = vehicle(202) +} + +getVehicleById = function(id) return vehicles[tonumber(id)] end +getPlayer = function() return localPlayer end + +TowBarMod = { + Utils = { updateAttachmentsForRigidTow = function() end }, + Hook = { + setVehicleScriptWithTowBarHidden = function(target, scriptName) + target:setScriptName(scriptName) + return true + end, + setVehiclePostAttach = function(_, target) + -- This stands in for applyFreeRollingTowState(). The ownership + -- handoff must run it before Bullet receives the local constraint, + -- matching the working wrecker order. + target.freeRolling = true + end, + cleanupDetachedTowBar = function() end + } +} + +dofile("42.20/media/lua/client/TowBar/TowSyncClient.lua") + +local function serverCommand(command, vehicleA, vehicleB) + local args = { + vehicleA = vehicleA:getId(), + vehicleB = vehicleB:getId(), + attachmentA = "trailer", + attachmentB = "trailerfront" + } + for i = 1, #serverCommandCallbacks do + serverCommandCallbacks[i]("towbar", command, args) + end +end + +local function enterVehicle(player) + for i = 1, #enterVehicleCallbacks do + enterVehicleCallbacks[i](player) + end +end + +local function spawnVehicle(value) + for i = 1, #spawnVehicleCallbacks do + spawnVehicleCallbacks[i](value) + end +end + +-- Portable installation finishes while the player is standing beside the two +-- cars. At this point the local client does not own either vehicle's physics. +local towing, towed = vehicles[101], vehicles[102] +-- The server's ordinary towing relation may reach this client before the mod +-- command. It is usable as a visual snap, but replacing it before driver +-- ownership is precisely the multiplayer race this regression covers. +towing.towing = towed +towed.towedBy = towing +serverCommand("forceAttachSync", towing, towed) + +expect(type(TowBarMod.Sync.desiredPairs) == "table", + "authoritative sync must retain desired pairs until this client owns the towing vehicle") +expect(towing.addCalls == 0 and towing.breakCalls == 0, + "sync received outside the driver seat must not create or replace local physics") + +-- Entering as driver transfers physics ownership to this client. That event is +-- the first safe point to replace the server/native relation with rigid towing. +towing.driver = localPlayer +expect(#enterVehicleCallbacks == 1, + "portable sync must register one driver-entry ownership handoff") +enterVehicle(localPlayer) + +expect(towing.addCalls == 1, + "becoming driver must apply the deferred rigid constraint exactly once") +expect(towing.breakCalls == 1, + "driver ownership handoff must replace the pre-existing native relation exactly once") +expect(towing:getVehicleTowing() == towed and towed:getVehicleTowedBy() == towing, + "driver ownership handoff must leave the authoritative pair linked") +expect(towing.freeRollingAtAdd[1] == true, + "towed free-roll state must be applied before the rigid constraint is created") + +-- The user explicitly chose driver entry as a forced repair point. A later +-- entry rebuilds once, while the ordinary server snapshot remains idempotent. +enterVehicle(localPlayer) +serverCommand("forceAttachSync", towing, towed) +expect(towing.addCalls == 2 and towing.breakCalls == 2, + "each driver entry must force one rebuild while snapshots do not add another") + +-- A detach can race the later driver entry. It must cancel the desired pair so +-- that entering the vehicle cannot resurrect a server-rejected tow. +local canceledTowing, canceledTowed = vehicles[201], vehicles[202] +serverCommand("forceAttachSync", canceledTowing, canceledTowed) +expect(canceledTowing.addCalls == 0 and canceledTowing.breakCalls == 0, + "a second outside-seat sync must also remain pending without touching physics") +-- The dedicated server may already have replicated its native relation. A +-- non-owning client must not break that relation while canceling its handoff. +canceledTowing.towing = canceledTowed +canceledTowed.towedBy = canceledTowing +serverCommand("forceDetachSync", canceledTowing, canceledTowed) +expect(TowBarMod.Sync.desiredPairs["201:202"] == nil, + "detach must remove the pending desired pair before any later seat event") +canceledTowing.driver = localPlayer +enterVehicle(localPlayer) +expect(canceledTowing.addCalls == 0 and canceledTowing.breakCalls == 0, + "detach must cancel pending ownership handoff without a non-owner break") + +-- A restart snapshot can arrive before either vehicle has streamed in. Keep +-- that desired state and retry it from vehicle spawn when this player already +-- owns the towing vehicle. +local streamedTowing, streamedTowed = vehicle(301), vehicle(302) +serverCommand("forceAttachSync", streamedTowing, streamedTowed) +expect(TowBarMod.Sync.desiredPairs["301:302"] ~= nil, + "missing streamed peers must not discard the authoritative attach snapshot") +streamedTowing.driver = localPlayer +vehicles[301], vehicles[302] = streamedTowing, streamedTowed +spawnVehicle(streamedTowed) +expect(streamedTowing.addCalls == 1, + "vehicle streaming must apply one saved rigid handoff for an existing local driver") + +if failures > 0 then os.exit(1) end +print("PASS: portable multiplayer rigid tow waits for driver physics ownership") diff --git a/tests/portable-mp-immediate-sync-42.20-spec.lua b/tests/portable-mp-immediate-sync-42.20-spec.lua new file mode 100644 index 0000000..0a302ce --- /dev/null +++ b/tests/portable-mp-immediate-sync-42.20-spec.lua @@ -0,0 +1,290 @@ +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 = table.concat({ + "42.20/media/lua/client/?.lua", + "42.20/media/lua/shared/?.lua", + package.path +}, ";") + +local runtime = "client" +isServer = function() return runtime == "server" end +isClient = function() return runtime == "client" end +getDebug = function() return false end +getTimestampMs = function() return 1000 end + +local serverCommandCallbacks = {} +local clientPlayer = { onlineId = 17 } +Events = { + OnServerCommand = { + Add = function(callback) + serverCommandCallbacks[#serverCommandCallbacks + 1] = callback + end + }, + OnSpawnVehicleEnd = { Add = function() end } +} + +local function attachmentScript() + local attachments = { trailer = {}, trailerfront = {} } + return { + getAttachmentById = function(_, attachmentId) + return attachments[attachmentId] + end + } +end + +local function vehicle(id, sqlId, acknowledgeConstraint) + local value = { + id = id, + sqlId = sqlId, + script = attachmentScript(), + scriptName = "Base.MultiplayerRegression" .. tostring(id), + modData = {}, + towing = nil, + towedBy = nil, + constraintKind = nil, + acknowledgeConstraint = acknowledgeConstraint, + constraintCalls = 0 + } + + function value:getId() return self.id end + function value:getSqlId() return self.sqlId end + function value:getScript() return self.script end + function value:getScriptName() return self.scriptName end + function value:setScriptName(scriptName) self.scriptName = scriptName end + function value:getModData() return self.modData end + function value:transmitModData() end + function value:getMass() return 1000 end + function value:getBrakingForce() return 20 end + function value:getVehicleTowing() return self.towing end + function value:getVehicleTowedBy() return self.towedBy end + function value:isDriver(player) return self.driver == player end + function value:getTowAttachmentSelf() return nil end + function value:attachmentExist(attachmentId) + return self.script:getAttachmentById(attachmentId) ~= nil + end + function value:getX() return 0 end + function value:getY() return 0 end + function value:addPointConstraint(_, other) + self.constraintCalls = self.constraintCalls + 1 + if self.acknowledgeConstraint then + self.towing = other + other.towedBy = self + self.constraintKind = runtime == "client" and "rigid" or "native" + other.constraintKind = self.constraintKind + end + end + function value:breakConstraint() + local other = self.towing or self.towedBy + self.towing, self.towedBy = nil, nil + self.constraintKind = nil + if other then + other.towing, other.towedBy = nil, nil + other.constraintKind = nil + end + end + + return value +end + +local clientVehicles = { + [101] = vehicle(101, 1001, true), + [102] = vehicle(102, 1002, true), + [201] = vehicle(201, 2001, false), + [202] = vehicle(202, 2002, false) +} +clientVehicles[101].driver = clientPlayer +clientVehicles[201].driver = clientPlayer +local serverVehicles = { + -- This models the dedicated-server behavior behind the live regression: + -- addPointConstraint is accepted, but the server getters do not expose the + -- relation before the authoritative client sync must be sent. + [101] = vehicle(101, 1001, false), + [102] = vehicle(102, 1002, false) +} + +getVehicleById = function(id) + local vehicles = runtime == "server" and serverVehicles or clientVehicles + return vehicles[tonumber(id)] +end +getPlayer = function() return clientPlayer end + +TowBarMod = { + Utils = { + updateAttachmentsForRigidTow = function() end + }, + Hook = { + applyFreeRollingTowState = function() end, + setVehicleScriptWithTowBarHidden = function(target, scriptName) + target:setScriptName(scriptName) + return true + end, + setVehiclePostAttach = function() end, + cleanupDetachedTowBar = function() end + } +} + +-- Load the real portable client sync and rigid-constraint implementation. +dofile("42.20/media/lua/client/TowBar/TowSyncClient.lua") + +local globalData = {} +ModData = { + getOrCreate = function(name) + globalData[name] = globalData[name] or {} + return globalData[name] + end, + transmit = function() end +} + +local serverCallbacks = {} +Events = { + OnClientCommand = { Add = function(callback) serverCallbacks.clientCommand = callback end }, + OnTick = { Add = function(callback) serverCallbacks.tick = callback end } +} + +runtime = "server" + +local item = { id = 7001 } +function item:getID() return self.id end +function item:getFullType() return "TowBar.TowBar" end + +local inventory = { items = { item } } +function inventory:getItemWithID(id) + return self.items[1] and self.items[1]:getID() == id and self.items[1] or nil +end +function inventory:getFirstTypeRecurse(fullType) + return self.items[1] and self.items[1]:getFullType() == fullType and self.items[1] or nil +end +function inventory:contains(candidate) return self.items[1] == candidate end +function inventory:Remove(candidate) + if self.items[1] == candidate then self.items[1] = nil end +end +function inventory:AddItem() return nil end + +local player = { vehicle = serverVehicles[101] } +function player:getInventory() return inventory end +function player:getVehicle() return self.vehicle end +function player:getX() return 0 end +function player:getY() return 0 end +function player:isPrimaryHandItem() return false end +function player:isSecondaryHandItem() return false end +function player:removeFromHands() end +function player:setPrimaryHandItem() end + +sendRemoveItemFromContainer = function() end +sendAddItemToContainer = function() end +sendEquip = function() end + +local attachSyncCount = 0 +local attachSyncPhases = {} +sendServerCommand = function(module, command, args) + if module == "towbar" and command == "forceAttachSync" then + attachSyncCount = attachSyncCount + 1 + attachSyncPhases[#attachSyncPhases + 1] = args.phase + end + + local previousRuntime = runtime + runtime = "client" + for i = 1, #serverCommandCallbacks do + serverCommandCallbacks[i](module, command, args) + end + runtime = previousRuntime +end + +getCell = function() + return { getVehicles = function() return nil end } +end + +-- Load the real server command handler after the client event handler has been +-- captured. Both halves now communicate through sendServerCommand above. +dofile("42.20/media/lua/server/TowingCommands.lua") + +serverCallbacks.clientCommand("towbar", "attachTowBar", player, { + vehicleA = 101, + vehicleB = 102, + attachmentA = "trailer", + attachmentB = "trailerfront", + itemId = item:getID() +}) + +expect(serverVehicles[101].modData.isTowingByTowBar == true, + "accepted MP attach must publish towbar state for its visual") +expect(serverVehicles[101].constraintCalls == 1, + "accepted MP attach must still request the server-side logical constraint") +expect(attachSyncCount == 1, + "accepted MP attach must immediately send one forceAttachSync even when server towing getters lag") +expect(attachSyncPhases[1] == nil, + "portable MP attach must use the same single unphased sync as the working wrecker") +expect(clientVehicles[101]:getVehicleTowing() == clientVehicles[102] + and clientVehicles[102]:getVehicleTowedBy() == clientVehicles[101], + "the same command that shows the MP towbar visual must create the client rigid constraint") +expect(clientVehicles[101].constraintKind == "rigid", + "the one-shot sync must create rigid rather than native towing physics") + +-- Like the working wrecker, server acknowledgement is bookkeeping only. It +-- must not introduce a second portable-only attach protocol or rebuild. +serverVehicles[101].towing = serverVehicles[102] +serverVehicles[102].towedBy = serverVehicles[101] +serverCallbacks.tick() +serverCallbacks.tick() + +expect(attachSyncCount == 1, + "server acknowledgement must not send a second portable-only attach phase") +expect(clientVehicles[101].constraintCalls == 1, + "the wrecker-style attach must submit one local rigid constraint") +expect(clientVehicles[101].constraintKind == "rigid", + "the single attach sync must leave the client using rigid towing physics") + +sendServerCommand("towbar", "forceAttachSync", { + vehicleA = 101, + vehicleB = 102, + attachmentA = "trailer", + attachmentB = "trailerfront" +}) +expect(clientVehicles[101].constraintCalls == 1, + "duplicate snapshots must not repeatedly rebuild rigid physics") + +-- The local add itself can also precede the engine's towing-getter update. +-- Reconciliation must adopt the now-visible exact pair without duplicating +-- the working wrecker-style single attach. +runtime = "client" +local delayedProvisional = { + vehicleA = 201, + vehicleB = 202, + attachmentA = "trailer", + attachmentB = "trailerfront" +} +for i = 1, #serverCommandCallbacks do + serverCommandCallbacks[i]("towbar", "forceAttachSync", delayedProvisional) +end +expect(clientVehicles[201].constraintCalls == 1 + and clientVehicles[201]:getVehicleTowing() == nil, + "a rigid add may be accepted before its local getters update") + +clientVehicles[201].towing = clientVehicles[202] +clientVehicles[202].towedBy = clientVehicles[201] +clientVehicles[201].constraintKind = "rigid" +clientVehicles[202].constraintKind = "rigid" +for i = 1, #serverCommandCallbacks do + serverCommandCallbacks[i]("towbar", "forceAttachSync", delayedProvisional) +end +expect(clientVehicles[201].constraintCalls == 1, + "delayed local getter acknowledgement must make the next snapshot idempotent") + +clientVehicles[201].acknowledgeConstraint = true +for i = 1, #serverCommandCallbacks do + serverCommandCallbacks[i]("towbar", "forceAttachSync", delayedProvisional) +end +expect(clientVehicles[201].constraintCalls == 1 + and clientVehicles[201].constraintKind == "rigid", + "duplicate sync after delayed local acceptance must remain idempotent") +runtime = "server" + +if failures > 0 then os.exit(1) end +print("PASS: multiplayer portable towbar immediately synchronizes its rigid client constraint") diff --git a/tests/rigid-tow-contract-42.20-spec.lua b/tests/rigid-tow-contract-42.20-spec.lua new file mode 100644 index 0000000..964db2f --- /dev/null +++ b/tests/rigid-tow-contract-42.20-spec.lua @@ -0,0 +1,178 @@ +local failures = 0 + +local function expect(condition, message) + if not condition then + failures = failures + 1 + io.stderr:write("FAIL: " .. message .. "\n") + end +end + +isServer = function() return false end + +local metrics = { + offsets = 0, + breaks = 0, + adds = 0, + hides = 0, + postAttach = 0, + scriptSwaps = 0, + freeRolling = 0 +} +local trace = {} +local function record(event) trace[#trace + 1] = event end + +local function script() + return { + getAttachmentById = function(_, id) + if id == "trailer" or id == "trailerfront" then return {} end + return nil + end + } +end + +local acknowledgeAdd = true +local function unlink(a, b) + a.towing, a.towedBy = nil, nil + b.towing, b.towedBy = nil, nil +end + +local function link(a, b) + a.towing, a.towedBy = b, nil + b.towing, b.towedBy = nil, a +end + +local function vehicle(id) + local value = { + id = id, + towing = nil, + towedBy = nil, + script = script(), + scriptName = "Base.TestVehicle", + modData = {} + } + function value:getId() return self.id end + function value:getScript() return self.script end + function value:getScriptName() return self.scriptName end + function value:getModData() return self.modData end + function value:getVehicleTowing() return self.towing end + function value:getVehicleTowedBy() return self.towedBy end + function value:breakConstraint() + metrics.breaks = metrics.breaks + 1 + record("break") + local other = self.towing or self.towedBy + if other then unlink(self, other) end + end + function value:addPointConstraint(player, other, attachmentA, attachmentB, localOnly) + metrics.adds = metrics.adds + 1 + record("add") + expect(player == nil, "rigid primitive must not impersonate a player") + expect(attachmentA == "trailer" and attachmentB == "trailerfront", + "rigid primitive must preserve selected attachment IDs") + expect(localOnly == true, "rigid primitive must create the client-local rigid constraint") + if acknowledgeAdd then link(self, other) end + end + return value +end + +TowBarMod = { + Utils = { + updateAttachmentsForRigidTow = function(a, b, attachmentA, attachmentB) + metrics.offsets = metrics.offsets + 1 + expect(a ~= nil and b ~= nil, "rigid offset update must receive both vehicles") + expect(attachmentA == "trailer" and attachmentB == "trailerfront", + "rigid offset update must receive the selected endpoints") + end + }, + Hook = { + applyFreeRollingTowState = function(vehicle) + metrics.freeRolling = metrics.freeRolling + 1 + record("free") + expect(vehicle ~= nil, "rigid primitive must receive the towed vehicle for free-roll setup") + end, + setVehicleScriptWithTowBarHidden = function(vehicle, scriptName) + metrics.hides = metrics.hides + 1 + metrics.scriptSwaps = metrics.scriptSwaps + 1 + vehicle.scriptName = scriptName + record("script:" .. tostring(scriptName)) + expect(vehicle ~= nil, + "rigid primitive must receive a vehicle for each hidden script swap") + end, + setVehiclePostAttach = function(player, vehicle, towingVehicle) + metrics.postAttach = metrics.postAttach + 1 + record("post") + expect(player == nil and vehicle ~= nil and towingVehicle ~= nil, + "rigid primitive must restore post-attach state after acknowledgement") + expect(vehicle:getScriptName() == "Base.TestVehicle", + "rigid primitive must restore the real script before post-attach finalization") + end + } +} + +dofile("42.20/media/lua/client/TowBar/RigidTow.lua") + +expect(TowBarMod.RigidTow ~= nil and type(TowBarMod.RigidTow.attach) == "function", + "production rigid module must expose attach") + +-- First attach with no physical relation uses the exact rigid path once. +local a, b = vehicle(101), vehicle(102) +trace = {} +local attached = TowBarMod.RigidTow.attach(a, b, "trailer", "trailerfront") +expect(attached == true, "acknowledged initial rigid add must succeed") +expect(a:getVehicleTowing() == b and b:getVehicleTowedBy() == a, + "acknowledged rigid add must leave an exact reciprocal relation") +expect(metrics.offsets == 1 and metrics.adds == 1 and metrics.breaks == 0, + "initial rigid add must update offsets and add once without a needless break") +expect(metrics.scriptSwaps == 2 and metrics.postAttach == 1, + "successful rigid add must select the fake script and restore the real script exactly once") +expect(table.concat(trace, ",") == "free,script:notTowingA_Trailer,add,script:Base.TestVehicle,post", + "initial rigid sequence must be free-roll, fake script, local add, real script, then post-attach") + +-- A restored native relation is replaced inside this primitive. The lifecycle +-- controller must not grow its own second copy of this physics sequence. +local restoredA, restoredB = vehicle(201), vehicle(202) +link(restoredA, restoredB) +local beforeOffsets, beforeAdds = metrics.offsets, metrics.adds +local beforeBreaks, beforePost = metrics.breaks, metrics.postAttach +trace = {} +attached = TowBarMod.RigidTow.attach(restoredA, restoredB, "trailer", "trailerfront") +expect(attached == true, "restored native relation must be replaced by rigid mechanics") +expect(metrics.offsets == beforeOffsets + 1 and metrics.adds == beforeAdds + 1, + "native replacement must update offsets and add one rigid constraint") +expect(metrics.breaks == beforeBreaks + 1, + "native replacement must break the exact old pair once") +expect(metrics.postAttach == beforePost + 1, + "native replacement must run post-attach only after the new link exists") +expect(table.concat(trace, ",") == "break,free,script:notTowingA_Trailer,add,script:Base.TestVehicle,post", + "native replacement sequence must break, free-roll, use the fake script, add, restore the real script, then finalize") + +-- A link to a different vehicle is never collateral damage. +local conflictA, expectedB, unrelated = vehicle(301), vehicle(302), vehicle(399) +link(conflictA, unrelated) +local beforeConflictBreaks, beforeConflictAdds = metrics.breaks, metrics.adds +attached = TowBarMod.RigidTow.attach(conflictA, expectedB, "trailer", "trailerfront") +expect(attached == false, "conflicting physical link must reject rigid attachment") +expect(metrics.breaks == beforeConflictBreaks and metrics.adds == beforeConflictAdds, + "conflicting physical link must not break or layer another constraint") + +-- Invalid endpoints are rejected before modifying a valid existing pair. +local missingA, missingB = vehicle(351), vehicle(352) +link(missingA, missingB) +local beforeMissingBreaks, beforeMissingAdds = metrics.breaks, metrics.adds +attached = TowBarMod.RigidTow.attach(missingA, missingB, "missing", "trailerfront") +expect(attached == false, "missing attachment must reject rigid attachment") +expect(metrics.breaks == beforeMissingBreaks and metrics.adds == beforeMissingAdds, + "missing attachment must not break the existing pair or attempt an add") + +-- In multiplayer Java/Bullet may accept the local constraint before the Lua +-- reciprocal getters update. Submission is success; waiting for those getters +-- here strands an already-streamed pair with only its visual state applied. +local delayedA, delayedB = vehicle(401), vehicle(402) +acknowledgeAdd = false +local beforeDelayedPost = metrics.postAttach +attached = TowBarMod.RigidTow.attach(delayedA, delayedB, "trailer", "trailerfront") +expect(attached == true, "submitted rigid add must tolerate delayed multiplayer getters") +expect(metrics.postAttach == beforeDelayedPost + 1, + "delayed multiplayer getters must not block post-attach restoration") + +if failures > 0 then os.exit(1) end +print("PASS: single rigid-tow contract preserves exact-pair mechanics and delayed MP submission") diff --git a/tests/towbar-item-accounting-42.20-spec.lua b/tests/towbar-item-accounting-42.20-spec.lua new file mode 100644 index 0000000..7bb7f69 --- /dev/null +++ b/tests/towbar-item-accounting-42.20-spec.lua @@ -0,0 +1,271 @@ +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 callbacks = {} +Events = { + OnClientCommand = { Add = function(callback) callbacks.clientCommand = callback end }, + OnTick = { Add = function(callback) callbacks.tick = callback end } +} + +isClient = function() return false end +isServer = function() return true end +getDebug = function() return false end +getTimestampMs = function() return 1000 end + +local metrics = { + removals = 0, + refunds = 0, + attachSyncs = 0, + detachSyncs = 0 +} + +local nextItemId = 700 +local function towbarItem() + nextItemId = nextItemId + 1 + local item = { id = nextItemId } + function item:getID() return self.id end + function item:getFullType() return "TowBar.TowBar" end + return item +end + +local inventory = { items = { towbarItem() } } +function inventory:getItemWithID(id) + for i = 1, #self.items do + if self.items[i]:getID() == id then return self.items[i] end + end +end +function inventory:getFirstTypeRecurse(fullType) + for i = 1, #self.items do + if self.items[i]:getFullType() == fullType then return self.items[i] end + end +end +function inventory:contains(item) + for i = 1, #self.items do + if self.items[i] == item then return true end + end + return false +end +function inventory:Remove(item) + for i = #self.items, 1, -1 do + if self.items[i] == item then table.remove(self.items, i) end + end +end +function inventory:AddItem(fullType) + expect(fullType == "TowBar.TowBar", "refund must create the correct item type") + local item = towbarItem() + table.insert(self.items, item) + return item +end + +local player = { primary = nil } +function player:getInventory() return inventory end +function player:getVehicle() return self.vehicle end +function player:getX() return 0 end +function player:getY() return 0 end +function player:isPrimaryHandItem(item) return self.primary == item end +function player:isSecondaryHandItem() return false end +function player:removeFromHands(item) + if self.primary == item then self.primary = nil end +end +function player:setPrimaryHandItem(item) self.primary = item end + +sendRemoveItemFromContainer = function() + metrics.removals = metrics.removals + 1 +end +sendAddItemToContainer = function() + metrics.refunds = metrics.refunds + 1 +end +sendEquip = function() end + +local function vehicle(id, sqlId, acknowledgeAttach) + local value = { + id = id, + sqlId = sqlId, + modData = {}, + towing = nil, + towedBy = nil, + acknowledgeAttach = acknowledgeAttach + } + function value:getId() return self.id end + function value:getSqlId() return self.sqlId end + function value:getModData() return self.modData end + function value:transmitModData() end + function value:getVehicleTowing() return self.towing end + function value:getVehicleTowedBy() return self.towedBy end + function value:getTowAttachmentSelf() return nil end + function value:attachmentExist() return true end + function value:getX() return 0 end + function value:getY() return 0 end + function value:addPointConstraint(_, other) + if self.acknowledgeAttach then + self.towing = other + other.towedBy = self + end + end + function value:breakConstraint() + local other = self.towing or self.towedBy + self.towing, self.towedBy = nil, nil + if other then other.towing, other.towedBy = nil, nil end + end + return value +end + +local vehicles = {} +local loaded = {} +getVehicleById = function(id) return vehicles[tonumber(id)] end +getCell = function() + return { + getVehicles = function() + return { + iterator = function() + local index = 0 + return { + hasNext = function() return index < #loaded end, + next = function() + index = index + 1 + return loaded[index] + end + } + end + } + end + } +end + +sendServerCommand = function(module, command) + expect(module == "towbar", "accounting sync must use the towbar module") + if command == "forceAttachSync" then + metrics.attachSyncs = metrics.attachSyncs + 1 + elseif command == "forceDetachSync" then + metrics.detachSyncs = metrics.detachSyncs + 1 + end +end + +dofile("42.20/media/lua/server/TowingCommands.lua") + +local function command(name, args) + callbacks.clientCommand("towbar", name, player, args) +end + +local function ticks(count) + for _ = 1, count do callbacks.tick() end +end + +local function attachArgs(a, b, item) + return { + vehicleA = a:getId(), + vehicleB = b:getId(), + attachmentA = "trailer", + attachmentB = "trailerfront", + itemId = item and item:getID() or nil + } +end + +-- A successful attach consumes exactly one physical item. Duplicate commands +-- and confirmation ticks must never consume another one. +local a, b = vehicle(101, 1001, true), vehicle(102, 1002, true) +vehicles[101], vehicles[102] = a, b +loaded = { a, b } +player.vehicle = a +local firstItem = inventory.items[1] +command("attachTowBar", attachArgs(a, b, firstItem)) +command("attachTowBar", attachArgs(a, b, firstItem)) +ticks(8) +expect(#inventory.items == 0, "successful attach must consume exactly one towbar") +expect(metrics.removals == 1, "duplicate attach must not transmit a second item removal") +expect(metrics.attachSyncs == 1, + "successful pair must broadcast one immediate wrecker-style attach without consuming twice") + +-- A normal detach refunds exactly that one reserved/installed item. Duplicate +-- detaches are stale and cannot duplicate the refund. +local detachArgs = { towingVehicle = a:getId(), vehicle = b:getId(), vehicleA = a:getId(), vehicleB = b:getId() } +command("detachTowBar", detachArgs) +command("detachTowBar", detachArgs) +ticks(4) +expect(#inventory.items == 1, "successful detach must refund exactly one towbar") +expect(metrics.refunds == 1, "duplicate detach must not transmit a second refund") + +-- The refunded object can be used immediately on the same pair. No enter/exit +-- event or stale completion marker may prevent the second lifecycle. +local secondItem = inventory.items[1] +command("attachTowBar", attachArgs(a, b, secondItem)) +ticks(4) +expect(#inventory.items == 0, "direct reattach must consume the one refunded towbar") +expect(metrics.removals == 2, "direct reattach must account for one new removal") +command("detachTowBar", detachArgs) +ticks(4) +expect(#inventory.items == 1, "second detach must return one towbar, not lose it") +expect(metrics.refunds == 2, "second lifecycle must produce one new refund") + +-- An immediate detach after the one-shot attach must remain detached. There is +-- no portable-only queued confirmation allowed to recreate a ghost tow. +local fastA, fastB = vehicle(151, 1501, true), vehicle(152, 1502, true) +vehicles[151], vehicles[152] = fastA, fastB +loaded = { fastA, fastB } +player.vehicle = fastA +local fastItem = inventory.items[1] +local syncsBeforeFastDetach = metrics.attachSyncs +local refundsBeforeFastDetach = metrics.refunds +command("attachTowBar", attachArgs(fastA, fastB, fastItem)) +command("detachTowBar", { + towingVehicle = fastA:getId(), vehicle = fastB:getId(), + vehicleA = fastA:getId(), vehicleB = fastB:getId() +}) +ticks(8) +expect(fastA:getVehicleTowing() == nil and fastB:getVehicleTowedBy() == nil, + "detach before confirmation must not be undone by a stale queued attach") +expect(metrics.attachSyncs == syncsBeforeFastDetach + 1, + "immediate detach must not be followed by another attach sync") +expect(metrics.refunds == refundsBeforeFastDetach + 1 and #inventory.items == 1, + "detach before confirmation must refund exactly one reserved towbar") + +-- Match the wrecker's server-owned persistence model: once a validated attach +-- is accepted, the towbar is installed and the saved pair keeps retrying. A +-- delayed native getter must not refund the installed item behind the player. +local failedA, failedB = vehicle(201, 2001, false), vehicle(202, 2002, false) +vehicles[201], vehicles[202] = failedA, failedB +loaded = { failedA, failedB } +player.vehicle = failedA +local failedItem = inventory.items[1] +local refundsBeforeFailure = metrics.refunds +command("attachTowBar", attachArgs(failedA, failedB, failedItem)) +ticks(30) +expect(#inventory.items == 0, "accepted delayed attach must keep its towbar installed") +expect(metrics.refunds == refundsBeforeFailure, + "persistence retries must not mint an inventory refund") +expect(failedA.modData.isTowingByTowBar == true + and failedB.modData.isTowingByTowBar == true, + "accepted delayed attach must preserve its recoverable logical pair") + +command("detachTowBar", { + towingVehicle = failedA:getId(), vehicle = failedB:getId(), + vehicleA = failedA:getId(), vehicleB = failedB:getId() +}) +expect(#inventory.items == 1 and metrics.refunds == refundsBeforeFailure + 1, + "wrecker-style detach must refund an expected pair even while server getters lag") +expect(failedA.modData.isTowingByTowBar == false + and failedB.modData.isTowingByTowBar == false, + "logical detach must clear delayed pair metadata") + +if failures > 0 then os.exit(1) end +print("PASS: towbar attach, retry, detach, and direct reattach account for exactly one item") diff --git a/tests/towbar-rigid-reattach-42.20-spec.lua b/tests/towbar-rigid-reattach-42.20-spec.lua deleted file mode 100644 index 95c3554..0000000 --- a/tests/towbar-rigid-reattach-42.20-spec.lua +++ /dev/null @@ -1,183 +0,0 @@ -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 fb2c965..e647864 100644 --- a/tests/validate-42.20.ps1 +++ b/tests/validate-42.20.ps1 @@ -29,10 +29,10 @@ 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_testing") "42.20 must retain the current testing mod id." +Assert-True ($releaseInfo -contains "id=hrsys_towbars") "42.20 must retain the stable root mod id." Assert-True ($releaseInfo -contains "versionMin=42.20.0") "42.20 must require Build 42.20." -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." +Assert-True ($releaseInfo -contains "modversion=1.0.22") "42.20 must declare mod version 1.0.22." +Assert-True ($rootInfo -contains "modversion=1.0.22") "Root and release mod versions must match." $baselineFiles = Get-RelativeFileNames $baselineRoot $releaseFiles = Get-RelativeFileNames $releaseRoot @@ -40,8 +40,10 @@ $allowedAdditions = @( "media/lua/client/TowBar/WreckerSyncClient.lua", "media/lua/client/TowBar/WreckerTimedAction.lua", "media/lua/client/TowBar/WreckerUI.lua", + "media/lua/client/TowBar/RigidTow.lua", "media/lua/server/WreckerCommands.lua", "media/lua/shared/TowBar/Persistence.lua", + "media/lua/shared/TowBar/VehicleCompatibility.lua", "media/lua/shared/TowBar/WreckerUtils.lua" ) $expectedReleaseFiles = @($baselineFiles) + $allowedAdditions | Sort-Object @@ -59,6 +61,7 @@ foreach ($jsonFile in Get-ChildItem -LiteralPath $releaseRoot -Recurse -Filter " $serverCommandsPath = Join-Path $releaseRoot "media/lua/server/TowingCommands.lua" $btTowPath = Join-Path $releaseRoot "media/lua/server/BTTow.lua" $clientSyncPath = Join-Path $releaseRoot "media/lua/client/TowBar/TowSyncClient.lua" +$rigidTowPath = Join-Path $releaseRoot "media/lua/client/TowBar/RigidTow.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" @@ -67,6 +70,7 @@ $legacyItemNamePath = Join-Path $repositoryRoot "common/media/lua/shared/Transla $serverCommands = Get-Content -LiteralPath $serverCommandsPath -Raw $btTow = Get-Content -LiteralPath $btTowPath -Raw $clientSync = Get-Content -LiteralPath $clientSyncPath -Raw +$rigidTow = Get-Content -LiteralPath $rigidTowPath -Raw $hooking = Get-Content -LiteralPath $hookingPath -Raw $towbarTemplate = Get-Content -LiteralPath $towbarTemplatePath -Raw $itemNames = Get-Content -LiteralPath $itemNamePath -Raw | ConvertFrom-Json @@ -79,7 +83,7 @@ $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 ($towbarTemplate -match ('(?s)model towbarModelKI5\s*\{{.*?scale = {0},' -f [regex]::Escape($towbarScaledScriptScale.ToString('0.000', [Globalization.CultureInfo]::InvariantCulture)))) "The isolated KI5 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." @@ -102,47 +106,55 @@ Assert-True ($serverCommands -match '(?s)isLegacyTowBarPair.*?hasTowBarState\(ve Assert-True ($serverCommands -match '(?s)local function resolveExpectedTowBarPair.*?towingVehicle and towedVehicle.*?isExpectedTowBarPair\(towingVehicle, towedVehicle\)') "Current-link spontaneous resolution must still require reciprocal towbar identity." Assert-True ($serverCommands -match '(?s)local function snapshotActiveTowbarLinksServer.*?isExpectedTowBarPair\(towingVehicle, towedVehicle\).*?isLegacyTowBarPair\(towingVehicle, towedVehicle\)') "Server snapshots must only preserve reciprocal or clean legacy towbar pairs." Assert-True ($serverCommands -match 'spontaneousDetachSync') "Server must broadcast spontaneous towbar cleanup." -Assert-True ($serverCommands -match 'local vehicleBId = args\.vehicleB or args\.vehicle') "Manual detach sync must include the towed vehicle id." +Assert-True ($serverCommands -match '(?s)function Commands\.detachTowBar.*?broadcastDetach\(towingVehicle:getId\(\), towedVehicle:getId\(\)\)') "Manual detach sync must immediately include both vehicle ids." Assert-True ($serverCommands -notmatch 'function Commands\.giveTowBar') "Clients must not be able to invoke the towbar refund helper." Assert-True ($serverCommands -match 'isPlayerAuthorizedForPair') "Server refunds must validate the requesting player." Assert-True ($serverCommands -match 'rejected towbar attach without item') "Multiplayer attach must require server-side towbar ownership." -Assert-True ($serverCommands -match '(?s)function Commands\.attachTowBar.*?if hasAnyTowLink\(vehicleA\) or hasAnyTowLink\(vehicleB\) then.*?return.*?if isExpectedTowBarPair\(vehicleA, vehicleB\).*?or isExpectedTowBarPair\(vehicleB, vehicleA\).*?or hasPendingAttach\(vehicleA, vehicleB\).*?or hasPendingAttach\(vehicleB, vehicleA\).*?then.*?return.*?local towBarItem = findTowBarItem') "Existing or reversed pending tow pairs must be rejected before consuming another towbar." -Assert-True ($serverCommands -match 'queueSync\("attach", player, args, true\)') "Multiplayer attach must reserve the towbar for delayed confirmation." -Assert-True ($serverCommands -match '(?s)function Commands\.attachTowBar.*?markExpectedTowBarPair\(vehicleA, vehicleB, args\.attachmentA, args\.attachmentB\).*?queueSync\("attach", player, args, true\).*?vehicleA:addPointConstraint') "Attach must record and queue the pair before constraint creation can break spontaneously." -Assert-True ($serverCommands -match 'local function failAttachSync') "Failed multiplayer attach must have a cleanup/refund path." -Assert-True ($serverCommands -match '(?s)local function processAttachSync.*?if isTowBarPairConfirmed\(vehicleA, vehicleB\) then return "broken" end') "A confirmed pair that breaks during attach confirmation must not be reattached." -Assert-True ($serverCommands -match '(?s)local function processPendingSync.*?status == "broken".*?finalizeBrokenTowBarPair') "A break during attach confirmation must use the shared ground-drop finalizer." -Assert-True ($serverCommands -match 'if item\.reservedTowBar then') "Failed multiplayer attach must refund the reserved towbar exactly once." -Assert-True ($serverCommands -match 'Constraint creation may complete on a later server tick') "Multiplayer attach must preserve delayed constraint confirmation." +Assert-True ($serverCommands -match '(?s)function Commands\.attachTowBar.*?if hasAnyTowLink\(vehicleA\) or hasAnyTowLink\(vehicleB\) then.*?return.*?if isExpectedTowBarPair\(vehicleA, vehicleB\).*?or isExpectedTowBarPair\(vehicleB, vehicleA\).*?then.*?return.*?local towBarItem = findTowBarItem') "Existing or reversed logical tow pairs must be rejected before consuming another towbar." +Assert-True ($serverCommands -match '(?s)function Commands\.attachTowBar.*?markExpectedTowBarPair\(vehicleA, vehicleB, args\.attachmentA, args\.attachmentB\).*?vehicleA:addPointConstraint.*?broadcastAttach\(vehicleA, vehicleB, args\.attachmentA, args\.attachmentB\)') "Portable attach must use the working wrecker one-shot server add and immediate sync sequence." +Assert-True ($serverCommands -notmatch 'pendingSync|queueSync|processAttachSync|failAttachSync|phase =') "Portable attach must not retain its failed provisional/confirmed controller." 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.*?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 ($serverCommands -match '(?s)local function restorePersistedTowBarPair.*?towBarExpectedAttachment.*?addPointConstraint.*?broadcastAttach') "A saved towbar pair must use the same immediate wrecker-style restore 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 ($clientSync -match 'TowBarMod\.Sync\.appliedPairs') "Client must retain the wrecker-style applied-pair cache." +Assert-True ($clientSync -match '(?s)local function applyAttachSync.*?isPairLinked\(vehicleA, vehicleB\) and Sync\.appliedPairs\[key\].*?TowBarMod\.RigidTow\.attach.*?Sync\.appliedPairs\[key\] = true') "Portable client sync must use one idempotent wrecker-style rigid conversion." +Assert-True ($clientSync -match 'TowBarMod\.Sync\.desiredPairs = TowBarMod\.Sync\.desiredPairs or \{\}') "Portable MP sync must retain an authoritative pair until its driver owns local physics." +Assert-True ($clientSync -match '(?s)local function applyAttachSync\(args, playerObj, forceReattach\).*?if not isLocalDriver\(vehicleA, localPlayer\) then.*?return true.*?not forceReattach and isPairLinked.*?TowBarMod\.RigidTow\.attach') "Portable MP sync must defer ordinary conversion until driver ownership and allow an explicit forced entry rebuild." +Assert-True ($clientSync -match '(?s)local function getPersistedPairArgsForDriver.*?isTowingByTowBar.*?towed == true.*?towBarTowingVehicleId') "Portable driver entry must require reciprocal persisted towbar identity." +Assert-True ($clientSync -match 'Events\.OnEnterVehicle\.Add\(forceReattachForDriver\)') "Driver entry must force the one-shot portable rigid rebuild." +Assert-True ($clientSync -match 'Events\.OnSpawnVehicleEnd\.Add\(onSpawnVehicle\)') "Vehicle streaming must replay a retained authoritative pair for an existing local driver." +Assert-True ($clientSync -match '(?s)local function clearAppliedPairForVehicle.*?Sync\.appliedPairs\[key\] = nil') "Vehicle streaming must invalidate the applied cache for the next server snapshot." +Assert-True ($clientSync -match '(?s)local function onSpawnVehicle\(vehicle\).*?clearAppliedPairForVehicle\(vehicle\).*?retryDesiredPairsForDriver\(getPlayer\(\)\).*?Events\.OnSpawnVehicleEnd\.Add\(onSpawnVehicle\)') "Vehicle streaming must replay a retained authoritative pair for an existing local driver." +Assert-True ($clientSync -notmatch 'Lifecycle|pending|provisional|confirmed|Events\.OnTick\.Add') "Portable client sync must not retain timing or phase controllers." +Assert-True ($rigidTow -match 'function RigidTow\.attach') "Rigid physics must be preserved behind one public primitive." +Assert-True ($rigidTow -match '(?s)function RigidTow\.attach.*?updateAttachmentsForRigidTow.*?breakExactPair.*?applyFreeRollingTowState\(towedVehicle\).*?setVehicleScriptWithTowBarHidden\(towedVehicle, "notTowingA_Trailer"\).*?addPointConstraint\(nil, towedVehicle, attachmentA, attachmentB, true\).*?setVehicleScriptWithTowBarHidden\(towedVehicle, originalScript\).*?setVehiclePostAttach') "The rigid primitive must preserve the wrecker sequence: free-roll, fake script, rigid add, immediate original-script restore, then finalization." +Assert-True ([regex]::Matches($hooking, 'updateAttachmentsForRigidTow\(').Count -eq 0) "Post-attach presentation must not mutate rigid attachment geometry a second time." +Assert-True ($hooking -match 'storeOriginalVehicleCall\(vehicle, modData, "towBarOriginalParkingBrake", "getParkingBrake"\)') "Portable towing must use the same parking-brake getter as the working wrecker path." +$applyFreeStart = $hooking.IndexOf('local function applyFreeRollingTowState(vehicle)') +$applyFreeEnd = $hooking.IndexOf('local function restoreFreeRollingTowState', $applyFreeStart) +$applyFreeBody = if ($applyFreeStart -ge 0 -and $applyFreeEnd -gt $applyFreeStart) { $hooking.Substring($applyFreeStart, $applyFreeEnd - $applyFreeStart) } else { '' } +Assert-True ($applyFreeBody -notmatch 'updateTotalMass\(|constraintChanged\(') "Portable free-roll must not recalculate away its towing mass before or during rigid movement." +Assert-True ($rigidTow -notmatch 'if not isExactPairLinked\(towingVehicle, towedVehicle\) then return false end') "Rigid submission must not fail solely because MP reciprocal getters update later." +Assert-True ($rigidTow -notmatch 'sendClientCommand|sendServerCommand|TowBarItem|Persistence') "Rigid physics must remain separate from commands, item accounting, and persistence." 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 ($hooking -notmatch 'Events\.OnEnterVehicle\.Add|Events\.OnSwitchVehicleSeat\.Add') "Entering or switching seats must never repair or restart towing." +Assert-True ($hooking -notmatch 'reattachTowBarPair|recoverTowBarVehicleAfterLoad|tryAutoReattachFromCharacter|reattachTowBarFromDriverSeat|lastAutoReattach') "The rewritten attach lifecycle must not retain the old seat-event repair helpers." +Assert-True ($hooking -match '(?s)function TowBarMod\.Hook\.performAttachTowBar.*?sendTowAttachCommand\(playerObj, args\).*?end') "The radial attach action must only submit the authoritative request." +Assert-True ($hooking -notmatch 'sendClientCommand\(playerObj, "towbar", "consumeTowBar"') "The client must not consume the towbar before server attach validation." +Assert-True ($serverCommands -match '(?s)local function processTowBarServerTick.*?reconcileBrokenTowBarPairsServer\(\)') "The physical-link audit must run from the server/SP tick handler." +Assert-True ($serverCommands -match '(?s)local function finalizeBrokenTowBarPair.*?dropTowBarOnGround.*?forgetTowBarPair.*?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 breakTowBarPair' -and $clientSync -match 'breakTowBarPair = function') "Client detach sync must break only the requested pair." +Assert-True ($clientSync -match 'local function hasConflictingLink') "Client detach sync must detect links to unrelated vehicles." +Assert-True ($clientSync -match 'TowBarMod\.RigidTow\.breakExactPair\(vehicleA, vehicleB\)') "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." +Assert-True ($hooking -match '(?s)function TowBarMod\.Hook\.setVehiclePostAttach\(playerObj, towedVehicle, knownTowingVehicle\).*?local towingVehicle = knownTowingVehicle or towedVehicle:getVehicleTowedBy\(\).*?if not towingVehicle then return end.*?setVehicleScriptWithTowBarHidden\(towedVehicle, towedModData\.towBarOriginalScriptName\)') "Post-attach must accept the authoritative towing vehicle while MP reciprocal getters lag." Assert-True ($hooking -match '(?s)if modData\.towBarOriginalParkingBrakeOn ~= nil then.*?setParkingBrakeOn.*?end.*?if modData\.towBarOriginalParkingBrake ~= nil then.*?setParkingBrake.*?end.*?if modData\.towBarOriginalHandbrake ~= nil then.*?setHandbrake.*?end') "Free-rolling state may only change brake controls whose original values were captured." 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." @@ -161,13 +173,11 @@ Assert-True ($hooking -match '(?s)local centerOk, center = pcall.*?if not center 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 -match 'isKi5 and ki5Part') "Client rendering must isolate KI5 visuals from legacy parts." +Assert-True ($btTow -match 'isKi5 and partId == "towbarKI5"') "Part initialization must isolate KI5 visuals from legacy parts." 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 ($towbarTemplate -match '(?s)model towbarModelKI5\s*\{.*?scale = 0\.025,') "The KI5 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." @@ -209,7 +219,7 @@ Assert-True ($hooking -match '(?s)setVehicleScriptWithTowBarHidden.*?setTowBarMo Assert-True ($btTow -match '(?s)function BTtow\.Init\.towbar.*?setModelVisible\("towbar" \.\. j, false\).*?towBarModelSwapInProgress.*?return') "Part initialization must not show a towbar during a script swap." Assert-True ($hooking -notmatch 'towedVehicle:setScriptName\(') "All hook script swaps must use the anti-flicker helper." Assert-True ($clientSync -notmatch 'vehicleB:setScriptName\(') "Client reconciliation must not bypass the anti-flicker helper." -Assert-True ($clientSync -match 'TowBarMod\.Hook\.setVehicleScriptWithTowBarHidden\(vehicleB, "notTowingA_Trailer"\)') "Client reconciliation must hide towbar models while changing scripts." +Assert-True ($rigidTow -match 'TowBarMod\.Hook\.setVehicleScriptWithTowBarHidden\(towedVehicle, "notTowingA_Trailer"\)') "The rigid primitive must hide towbar models while changing scripts." $spontaneousStart = $serverCommands.IndexOf('if module == "vehicle" and command == "detachTrailerSpontaneous" then') $spontaneousEnd = $serverCommands.IndexOf('if module == "towbar" and Commands[command] then', $spontaneousStart) @@ -241,7 +251,7 @@ $manualDetachEnd = $serverCommands.IndexOf('function Commands.consumeTowBar', $m Assert-True ($manualDetachStart -ge 0 -and $manualDetachEnd -gt $manualDetachStart) "Could not isolate the manual detach handler." if ($manualDetachStart -ge 0 -and $manualDetachEnd -gt $manualDetachStart) { $manualDetachBranch = $serverCommands.Substring($manualDetachStart, $manualDetachEnd - $manualDetachStart) - Assert-True ($manualDetachBranch -match 'if not isLinked\(towingVehicle, towedVehicle\) then') "Manual detach must reject mismatched or stale vehicle pairs." + Assert-True ($manualDetachBranch -notmatch 'if not isLinked\(towingVehicle, towedVehicle\) then') "Manual detach must follow wrecker behavior and allow a reciprocal saved pair while MP getters lag." Assert-True ($manualDetachBranch -match '(?s)not isExpectedTowBarPair\(towingVehicle, towedVehicle\).*?not isLegacyTowBarPair\(towingVehicle, towedVehicle\)') "Manual detach must reject non-towbar or stale linked pairs." Assert-True ($manualDetachBranch -match 'breakTowBarConstraint\(towingVehicle, towedVehicle\)') "Manual detach must only break the validated tow pair." Assert-True ($manualDetachBranch -match 'giveTowBar\(player, true\)') "Manual detach must continue returning the towbar to inventory." diff --git a/tests/validate-dual-mode-42.20.ps1 b/tests/validate-dual-mode-42.20.ps1 new file mode 100644 index 0000000..098fd9f --- /dev/null +++ b/tests/validate-dual-mode-42.20.ps1 @@ -0,0 +1,88 @@ +$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) } +} + +function Read-Source { + param([string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { + $script:failures.Add("Missing dual-mode source: $Path") + return "" + } + return Get-Content -LiteralPath $Path -Raw +} + +$compatibilityPath = Join-Path $releaseRoot "media/lua/shared/TowBar/VehicleCompatibility.lua" +$hooking = Read-Source (Join-Path $releaseRoot "media/lua/client/TowBar/TowingHooking.lua") +$btTow = Read-Source (Join-Path $releaseRoot "media/lua/server/BTTow.lua") +$template = Read-Source (Join-Path $releaseRoot "media/scripts/vehicles/template_towbar.txt") +$compatibility = Read-Source $compatibilityPath + +# The boundary must be an exact mod-ID + exact VehicleScript full-name map. +# Model scale remains part of the legacy renderer only; it cannot decide KI5 ownership. +Assert-True ($compatibility -match 'TowBarMod\.Compatibility') "Classifier must expose TowBarMod.Compatibility." +Assert-True ($compatibility -match 'function Compatibility\.isKi5Vehicle\(vehicle\)') "Classifier must expose isKi5Vehicle(vehicle)." +foreach ($identity in @( + '91range', 'Base.91range', + '87toyotaCorolla', 'Base.87toyotaCorollaAE92levin', + '76chevyKseries', 'Base.76chevyK30CCwrecker' +)) { + Assert-True ($compatibility.Contains($identity)) "Classifier is missing verified exact KI5 identity: $identity" +} +Assert-True ($compatibility -match '(?s)getActivatedMods\(\).*?contains') "KI5 mode must require an exact active mod ID." +Assert-True ($compatibility -match 'getFullName\(\)') "KI5 mode must match the complete VehicleScript name." +Assert-True ($compatibility -notmatch 'getModelScale|modelScale|attachmentExist|string\.find|string\.match') "KI5 ownership must not be inferred from scale, attachments, or substring patterns." + +# The visual carrier is classified independently. KI5 keeps current B42.20 +# dynamic hitbox placement; every other vehicle uses the exact B42.13 selector. +foreach ($source in @($hooking, $btTow)) { + Assert-True ($source -match 'Compatibility\.isKi5Vehicle\(vehicle\)') "Each visual path must classify the vehicle carrying the towbar." + Assert-True ($source -match 'getTowbarFrontEdgeZ') "KI5 visual path must retain hitbox-derived placement." + Assert-True ($source -match 'TowbarVisualScale\s*=\s*2\.5') "KI5 visual path must retain 2.5x scale compensation." + Assert-True ($source -match 'getTowbarIndexVanilla') "Legacy visual path must restore the B42.13 vanilla slot formula." + Assert-True ($source -match 'getTowbarIndexSmallScale') "Legacy non-KI5 path must restore B42.13 small-scale slot selection." + Assert-True ($source -match 'VanillaScaleMin\s*=\s*1\.5' -and $source -match 'VanillaScaleMax\s*=\s*2\.0') "Legacy visual classification must preserve the B42.13 scale window." +} + +# One bank cannot represent both scales. Restore the original `towbar` bank for +# legacy vehicles and isolate today's enlarged KI5 mesh in its own part. +Assert-True ($template -match '(?s)model towbarModel\s*\{.*?scale\s*=\s*0\.01,') "Vanilla and normal-scale non-KI5 vehicles must restore the B42.13 0.01 model." +Assert-True ($template -match '(?s)model towbarModelKI5\s*\{.*?scale\s*=\s*0\.025,') "KI5 towbar bank must remain exactly 2.5x its original 0.01 scale." +Assert-True ($template -match '(?s)model towbarModelLarge\s*\{.*?scale\s*=\s*0\.02022,') "Small-scale non-KI5 vehicles must retain the B42.13 large model scale." +Assert-True ($template -match 'part towbarKI5') "Template must provide an isolated KI5 part without resizing the legacy towbar part." + +function Get-Ki5DynamicSlot { + param([double]$ShapeZ, [double]$CenterZ) + $meshHalfLength = (0.9714089036 * 2.5) / 2 + $center = $CenterZ + ($ShapeZ / 2) + $meshHalfLength + return [Math]::Max(0, [Math]::Min(23, [int][Math]::Floor((($center - 1.0) / 0.1) + 0.5))) +} + +function Get-LegacyVanillaSlot { + param([double]$ShapeZ) + $z = ($ShapeZ / 2) - 0.1 + return [Math]::Max(0, [Math]::Min(23, [int][Math]::Floor((($z * 2 / 3) - 1) * 10))) +} + +function Get-LegacySmallScaleSlot { + param([double]$MaxAbsAttachmentZ) + return [Math]::Max(0, [Math]::Min(23, [int][Math]::Floor(($MaxAbsAttachmentZ + 0.1 - 1.0) * 10))) +} + +Assert-True ((Get-Ki5DynamicSlot 4.3111 -0.0444) -eq 23) "KI5 Corolla must retain B42.20 dynamic slot 23." +Assert-True ((Get-Ki5DynamicSlot 4.4444 -0.2556) -eq 22) "KI5 Range Rover must retain B42.20 dynamic slot 22." +Assert-True ((Get-LegacyVanillaSlot 2.6044) -eq 0) "B42 Ranger must return to the B42.13 legacy-normal slot 0, not dynamic slot 15." +Assert-True ((Get-LegacySmallScaleSlot 2.3222) -eq 14) "Non-KI5 small-scale geometry must retain the B42.13 attachment-derived slot." + +if ($failures.Count -gt 0) { + $failures | ForEach-Object { Write-Host "FAIL: $_" -ForegroundColor Red } + exit 1 +} + +Write-Output "PASS: Build 42.20 exact-KI5 dual-mode contracts." diff --git a/tests/validate-wrecker-42.20.ps1 b/tests/validate-wrecker-42.20.ps1 index 924043d..beb9b0f 100644 --- a/tests/validate-wrecker-42.20.ps1 +++ b/tests/validate-wrecker-42.20.ps1 @@ -98,10 +98,13 @@ Assert-True ($server -match '(?s)onClientCommand.*?getTimestampMs.*?CommandCoold 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\].*?if not forceReattach and isPairLinked\(wrecker, target\).*?and appliedLevel == canonicalLevel then.*?return') "Only an ordinary sync may reuse a locally applied wrecker constraint at the requested height." +Assert-True ($sync -match '(?s)local function forceReattachForDriver.*?wreckerTowActive.*?wreckerTowedVehicleId.*?wreckerTowingVehicleId.*?applyAttachSync\(\{.*?\}, true\)') "Wrecker driver entry must force a rebuild only for reciprocal persisted wrecker state." +Assert-True ($sync -match 'Events\.OnEnterVehicle\.Add\(forceReattachForDriver\)') "Entering the wrecker as driver must force one rigid reattach." +Assert-True ($sync -notmatch 'Events\.OnTick\.Add') "Wrecker entry repair must remain event-driven without a retry loop." 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)if not forceReattach and isPairLinked\(wrecker, target\).*?appliedLevel.*?canonicalLevel.*?breakWreckerPair\(wrecker, target\).*?addPointConstraint') "A requested height or forced driver entry must rebuild the local wrecker constraint." 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."