Files
Towbar/tests/towbar-item-accounting-42.20-spec.lua
2026-08-19 12:12:12 -04:00

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