Working 42.20 MP
This commit is contained in:
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user