local luaRoot = PROJECT_ROOT .. "/42.20/media/lua" package.path = table.concat({ luaRoot .. "/shared/?.lua", luaRoot .. "/shared/?/?.lua", package.path, }, ";") local passed = 0 local function test(name, body) local ok, errorMessage = pcall(body) if not ok then error(name .. ": " .. tostring(errorMessage), 0) end passed = passed + 1 end local function assertNear(actual, expected) assert(math.abs(actual - expected) < 0.0001, "expected " .. tostring(expected) .. ", got " .. tostring(actual)) end local function javaList(values) return { size = function() return #values end, get = function(_, index) return values[index + 1] end, } end PaintMyKI5 = { VehiclePaintData = { bucketUses = 10, paintCans = {}, vehicles = { ["Base.TestCar"] = { textures = { { skinIndex = 2, texture = "Vehicles/TestCar_Green", paints = { { item = "Base.PaintGreen", percent = 97.9, uses = 9.79 }, { item = "Base.PaintWhite", percent = 2.1, uses = 0.21 }, }, }, }, }, }, }, } package.loaded["PaintMyKI5/PaintMyKI5VehiclePaintData"] = true require "PaintMyKI5/PaintRequirements" require "PaintMyKI5/PaintInventory" require "PaintMyKI5/TextureName" test("texture labels use the unique suffix of a shared filename prefix", function() local labels = PaintMyKI5.TextureName.buildLabels({ { texture = "Vehicles/Vehicles_93mustangGT_Shell_Green" }, { texture = "Vehicles/Vehicles_93mustangGT_Shell_Seafoam" }, { texture = "Vehicles/Vehicles_93mustangGT_Shell_SSP" }, }) assert(labels[1] == "Green") assert(labels[2] == "Seafoam") assert(labels[3] == "SSP") end) test("texture labels keep filenames when there is no useful shared prefix", function() local labels = PaintMyKI5.TextureName.buildLabels({ { texture = "Vehicles/Ford_Green.png" }, { texture = "Vehicles/Chevrolet_Red.png" }, }) assert(labels[1] == "Ford_Green") assert(labels[2] == "Chevrolet_Red") local single = PaintMyKI5.TextureName.buildLabels({ { texture = "Vehicles/Only_Skin" }, }) assert(single[1] == "Only_Skin") local duplicates = PaintMyKI5.TextureName.buildLabels({ { texture = "Vehicles/Repeated_Green" }, { texture = "Vehicles/Repeated_Green" }, }) assert(duplicates[1] == "Repeated_Green") assert(duplicates[2] == "Repeated_Green") end) local function fakeVehicle() return { getScript = function() return { getFullName = function() return "Base.TestCar" end, getAreaById = function() return true end, } end, getSkinCount = function() return 3 end, getSkinIndex = function() return 0 end, getCurrentSpeedKmHour = function() return 0 end, isRemovedFromWorld = function() return false end, isInArea = function() return true end, getUseablePart = function() return nil end, } end test("requirements retain fractional proportional uses", function() local requirements = PaintMyKI5.PaintRequirements.getRequirements(fakeVehicle(), 2) assert(#requirements == 2) assertNear(requirements[1].uses, 9.79) assertNear(requirements[2].uses, 0.21) assertNear(requirements[1].uses + requirements[2].uses, 10) end) test("invalid skin is rejected", function() assert(PaintMyKI5.PaintRequirements.getRequirements(fakeVehicle(), 1) == nil) end) test("a paint selection is valid before the player reaches the vehicle area", function() local vehicle = fakeVehicle() vehicle.isInArea = function() return false end local character = { getVehicle = function() return nil end } assert(PaintMyKI5.PaintRequirements.isValidSelection(character, vehicle, 2)) assert(not PaintMyKI5.PaintRequirements.isValidTarget( character, vehicle, 2, "Engine" )) end) local syncCount = 0 local removedCount = 0 local addedCount = 0 sendItemStats = function() syncCount = syncCount + 1 end sendRemoveItemFromContainer = function() removedCount = removedCount + 1 end sendAddItemToContainer = function() addedCount = addedCount + 1 end instanceItem = function() return {} end local function fakeItem(itemType, delta, failAdd) local item = { itemType = itemType, delta = delta, inContainer = true } local container = { DoRemoveItem = function(_, target) target.inContainer = false end, AddItem = function(_, target) if failAdd then return nil end target.inContainer = true return target end, } item.getUseDelta = function() return 0.1 end item.getCurrentUsesFloat = function(self) return self.delta end item.setUsedDelta = function(self, value) self.delta = value end item.getContainer = function() return container end return item end test("inventory aggregates partial cans and consumes exact fractions", function() local greenA = fakeItem("Base.PaintGreen", 0.5) local greenB = fakeItem("Base.PaintGreen", 0.5) local white = fakeItem("Base.PaintWhite", 1.0) local byType = { ["Base.PaintGreen"] = javaList({ greenA, greenB }), ["Base.PaintWhite"] = javaList({ white }), } local character = { getInventory = function() return { getAllTypeRecurse = function(_, itemType) return byType[itemType] end } end, removeFromHands = function() end, } local requirements = { { item = "Base.PaintGreen", uses = 9.79 }, { item = "Base.PaintWhite", uses = 0.21 }, } assert(PaintMyKI5.PaintInventory.consumeRequirements(character, requirements)) assertNear(greenB.delta, 0.021) assertNear(white.delta, 0.979) assert(removedCount == 1 and addedCount == 1) end) test("a missing color prevents every mutation", function() local green = fakeItem("Base.PaintGreen", 1.0) local byType = { ["Base.PaintGreen"] = javaList({ green }), ["Base.PaintWhite"] = javaList({}), } local character = { getInventory = function() return { getAllTypeRecurse = function(_, itemType) return byType[itemType] end } end, removeFromHands = function() end, } assert(not PaintMyKI5.PaintInventory.consumeRequirements(character, { { item = "Base.PaintGreen", uses = 5 }, { item = "Base.PaintWhite", uses = 1 }, })) assertNear(green.delta, 1.0) end) test("painting readiness requires reusable sandpaper and a paintbrush", function() local green = fakeItem("Base.PaintGreen", 1.0) local sandpaper = { marker = "sandpaper" } local craftedBrush = { marker = "crafted brush" } local byType = { ["Base.PaintGreen"] = javaList({ green }), } local inventory = { getAllTypeRecurse = function(_, itemType) return byType[itemType] or javaList({}) end, } local character = { getInventory = function() return inventory end } local requirements = { { item = "Base.PaintGreen", uses = 1 } } local ready, missingPaint, missingTools = PaintMyKI5.PaintInventory.checkPaintingRequirements(character, requirements) assert(not ready and #missingPaint == 0 and #missingTools == 2) assert(missingTools[1].item == "PaintMyKI5.Sandpaper") assert(missingTools[2].item == "Base.Paintbrush") byType["PaintMyKI5.Sandpaper"] = javaList({ sandpaper }) byType["Base.PaintbrushCrafted"] = javaList({ craftedBrush }) ready, missingPaint, missingTools = PaintMyKI5.PaintInventory.checkPaintingRequirements(character, requirements) assert(ready and #missingPaint == 0 and #missingTools == 0) assert(sandpaper.marker == "sandpaper" and craftedBrush.marker == "crafted brush") end) test("replacement failure rolls back earlier paint", function() local green = fakeItem("Base.PaintGreen", 1.0) local white = fakeItem("Base.PaintWhite", 0.1, true) local byType = { ["Base.PaintGreen"] = javaList({ green }), ["Base.PaintWhite"] = javaList({ white }), } local character = { getInventory = function() return { getAllTypeRecurse = function(_, itemType) return byType[itemType] end } end, removeFromHands = function() end, } assert(not PaintMyKI5.PaintInventory.consumeRequirements(character, { { item = "Base.PaintGreen", uses = 1 }, { item = "Base.PaintWhite", uses = 1 }, })) assertNear(green.delta, 1.0) assert(white.inContainer) end) ISBaseTimedAction = { derive = function(_, name) local derived = { Type = name } derived.__index = derived return setmetatable(derived, { __index = ISBaseTimedAction }) end, new = function(actionClass, character) return setmetatable({ character = character }, actionClass) end, stop = function() end, perform = function() end, } package.loaded["TimedActions/ISBaseTimedAction"] = true isClient = function() return false end isServer = function() return true end dofile(luaRoot .. "/shared/PaintMyKI5/TimedActions/ISPaintMyKI5Vehicle.lua") PaintMyKI5.PaintInventory.checkRequirements = function() return true end PaintMyKI5.PaintInventory.checkPaintingRequirements = function() return true end local function serverCharacter() return { getVehicle = function() return nil end, isTimedActionInstant = function() return false end, } end local function mutableVehicle() local vehicle = fakeVehicle() vehicle.skin = 0 vehicle.transmitted = 0 vehicle.updated = 0 vehicle.getSkinIndex = function(self) return self.skin end vehicle.setSkinIndex = function(self, skin) self.skin = skin end vehicle.transmitSkinIndex = function(self) self.transmitted = self.transmitted + 1 end vehicle.updateSkin = function(self) self.updated = self.updated + 1 end return vehicle end test("server completion rejects missing tools before changing skin or paint", function() local vehicle = mutableVehicle() local consumed = 0 PaintMyKI5.PaintInventory.checkPaintingRequirements = function() return false, {}, { { item = "PaintMyKI5.Sandpaper" } } end PaintMyKI5.PaintInventory.consumeRequirements = function() consumed = consumed + 1 return true end local action = ISPaintMyKI5Vehicle:new(serverCharacter(), vehicle, 2, "Engine") action:serverStart() assert(not action:complete()) assert(vehicle.skin == 0 and vehicle.transmitted == 0 and consumed == 0) PaintMyKI5.PaintInventory.checkPaintingRequirements = function() return true end end) test("server completion consumes then transmits the selected skin", function() local vehicle = mutableVehicle() local character = serverCharacter() local consumed = 0 PaintMyKI5.PaintInventory.consumeRequirements = function() consumed = consumed + 1 return true end local action = ISPaintMyKI5Vehicle:new(character, vehicle, 2, "Engine") action:serverStart() assert(action:complete()) assert(consumed == 1 and vehicle.skin == 2 and vehicle.transmitted == 1) end) test("server rejects forged and raced skin changes without consuming", function() local character = serverCharacter() local consumed = 0 PaintMyKI5.PaintInventory.consumeRequirements = function() consumed = consumed + 1 return true end local forgedVehicle = mutableVehicle() local forged = ISPaintMyKI5Vehicle:new(character, forgedVehicle, 99, "Engine") forged:serverStart() assert(not forged:complete()) local racedVehicle = mutableVehicle() local raced = ISPaintMyKI5Vehicle:new(character, racedVehicle, 2, "Engine") raced:serverStart() racedVehicle.skin = 1 assert(not raced:complete()) assert(consumed == 0) end) test("failed skin or inventory commit restores state without losing paint", function() local character = serverCharacter() local consumed = 0 PaintMyKI5.PaintInventory.consumeRequirements = function() consumed = consumed + 1 return false end local vehicle = mutableVehicle() local action = ISPaintMyKI5Vehicle:new(character, vehicle, 2, "Engine") action:serverStart() assert(not action:complete()) assert(vehicle.skin == 0 and vehicle.transmitted == 2 and consumed == 1) local broken = mutableVehicle() broken.setSkinIndex = function(self, skin) if skin == 2 then error("skin mutation failed") end self.skin = skin end consumed = 0 local brokenAction = ISPaintMyKI5Vehicle:new(character, broken, 2, "Engine") brokenAction:serverStart() assert(not brokenAction:complete()) assert(broken.skin == 0 and consumed == 0) end) test("singleplayer completion updates the local skin", function() isServer = function() return false end local vehicle = mutableVehicle() PaintMyKI5.PaintInventory.consumeRequirements = function() return true end local action = ISPaintMyKI5Vehicle:new(serverCharacter(), vehicle, 2, "Engine") action:serverStart() assert(action:complete()) assert(vehicle.skin == 2 and vehicle.updated == 1 and vehicle.transmitted == 0) isServer = function() return true end end) test("client completion never mutates inventory or the vehicle", function() isClient = function() return true end local vehicle = mutableVehicle() local consumed = 0 PaintMyKI5.PaintInventory.consumeRequirements = function() consumed = consumed + 1 return true end local action = ISPaintMyKI5Vehicle:new(serverCharacter(), vehicle, 1, "Engine") assert(action:complete()) assert(consumed == 0 and vehicle.skin == 0 and vehicle.transmitted == 0) isClient = function() return false end end) test("sandpaper is rare wherever full paint cans spawn", function() ProceduralDistributions = { list = { PaintShelf = { items = { "PaintBlack", 10, "Paintbrush", 10 } }, PaintJunk = { items = { "Hammer", 1 }, junk = { items = { "Base.PaintGreen", 0.4 } }, }, EmptyBuckets = { items = { "PaintbucketEmpty", 10, "Paintbrush", 10 } }, OtherModPaint = { items = { "OtherMod.PaintGreen", 10 } }, AlreadyAdded = { items = { "PaintWhite", 1, "PaintMyKI5.Sandpaper", 0.1 }, }, }, } ClutterTables = { ClosetItems = { "PaintPurple", 0.1, "Pillow", 1 }, } dofile(luaRoot .. "/server/Items/PaintMyKI5_SandpaperDistribution.lua") local function countItem(items, itemType) local count = 0 local weight = nil for index = 1, #items, 2 do if items[index] == itemType then count = count + 1 weight = items[index + 1] end end return count, weight end local shelfCount, shelfWeight = countItem( ProceduralDistributions.list.PaintShelf.items, "PaintMyKI5.Sandpaper" ) assert(shelfCount == 1 and shelfWeight == 0.1) local junkCount, junkWeight = countItem( ProceduralDistributions.list.PaintJunk.junk.items, "PaintMyKI5.Sandpaper" ) assert(junkCount == 1 and junkWeight == 0.1) local emptyCount = countItem( ProceduralDistributions.list.EmptyBuckets.items, "PaintMyKI5.Sandpaper" ) assert(emptyCount == 0) local otherModCount = countItem( ProceduralDistributions.list.OtherModPaint.items, "PaintMyKI5.Sandpaper" ) assert(otherModCount == 0) local existingCount = countItem( ProceduralDistributions.list.AlreadyAdded.items, "PaintMyKI5.Sandpaper" ) assert(existingCount == 1) local clutterCount, clutterWeight = countItem( ClutterTables.ClosetItems, "PaintMyKI5.Sandpaper" ) assert(clutterCount == 1 and clutterWeight == 0.1) end) print("Lua tests passed: " .. passed)