Files
Towbar/tests/portable-mp-immediate-sync-42.20-spec.lua
2026-08-19 12:12:12 -04:00

291 lines
10 KiB
Lua

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")