Working 42.20 MP

This commit is contained in:
2026-08-19 12:12:12 -04:00
parent e403323017
commit e99f3bbff9
22 changed files with 2262 additions and 825 deletions
@@ -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")
+84
View File
@@ -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")
+30 -4
View File
@@ -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
@@ -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")
@@ -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")
+178
View File
@@ -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")
+271
View File
@@ -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")
-183
View File
@@ -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")
+47 -37
View File
@@ -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."
+88
View File
@@ -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."
+5 -2
View File
@@ -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."