272 lines
9.6 KiB
Lua
272 lines
9.6 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 = "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")
|