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