Initial Commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
*.lua text eol=lf
|
||||||
|
*.txt text eol=lf
|
||||||
|
*.md text eol=lf
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.coverage
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
require "ISUI/ISCollapsableWindow"
|
||||||
|
require "ISUI/ISScrollingListBox"
|
||||||
|
require "ISUI/ISButton"
|
||||||
|
require "Vehicles/TimedActions/ISPathFindAction"
|
||||||
|
require "PaintMyKI5/PaintRequirements"
|
||||||
|
require "PaintMyKI5/PaintInventory"
|
||||||
|
require "PaintMyKI5/TimedActions/ISPaintMyKI5Vehicle"
|
||||||
|
|
||||||
|
ISPaintMyKI5UI = ISCollapsableWindow:derive("ISPaintMyKI5UI")
|
||||||
|
ISPaintMyKI5UI.instances = ISPaintMyKI5UI.instances or {}
|
||||||
|
|
||||||
|
local FONT_SMALL = UIFont.Small
|
||||||
|
local FONT_MEDIUM = UIFont.Medium
|
||||||
|
local SMALL_HEIGHT = getTextManager():getFontHeight(FONT_SMALL)
|
||||||
|
local MEDIUM_HEIGHT = getTextManager():getFontHeight(FONT_MEDIUM)
|
||||||
|
|
||||||
|
local function textureLabel(textureData)
|
||||||
|
local name = string.match(textureData.texture or "", "([^/]+)$") or ""
|
||||||
|
return getText("IGUI_PaintMyKI5_Skin", textureData.skinIndex + 1) .. " - " .. name
|
||||||
|
end
|
||||||
|
|
||||||
|
local function getPaletteColor(itemType)
|
||||||
|
for _, paint in ipairs(PaintMyKI5.VehiclePaintData.paintCans or {}) do
|
||||||
|
if paint.item == itemType and paint.rgb then
|
||||||
|
return paint.rgb[1], paint.rgb[2], paint.rgb[3]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return 0.5, 0.5, 0.5
|
||||||
|
end
|
||||||
|
|
||||||
|
function ISPaintMyKI5UI.drawSkinItem(list, y, item, alt)
|
||||||
|
if item.itemindex == list.selected then
|
||||||
|
list:drawRect(0, y, list:getWidth(), item.height, 0.25, 0.3, 0.7, 1.0)
|
||||||
|
elseif alt then
|
||||||
|
list:drawRect(0, y, list:getWidth(), item.height, 0.08, 1, 1, 1)
|
||||||
|
end
|
||||||
|
list:drawText(item.text, 8, y + 3, 1, 1, 1, 1, FONT_SMALL)
|
||||||
|
return y + item.height
|
||||||
|
end
|
||||||
|
|
||||||
|
function ISPaintMyKI5UI:createChildren()
|
||||||
|
ISCollapsableWindow.createChildren(self)
|
||||||
|
local top = self:titleBarHeight() + 10
|
||||||
|
self.skinList = ISScrollingListBox:new(10, top, 275, self.height - top - 52)
|
||||||
|
self.skinList:initialise()
|
||||||
|
self.skinList:instantiate()
|
||||||
|
self.skinList.itemheight = SMALL_HEIGHT + 8
|
||||||
|
self.skinList.doDrawItem = ISPaintMyKI5UI.drawSkinItem
|
||||||
|
self.skinList.drawBorder = true
|
||||||
|
self:addChild(self.skinList)
|
||||||
|
|
||||||
|
for _, textureData in ipairs(self.vehicleData.textures or {}) do
|
||||||
|
self.skinList:addItem(textureLabel(textureData), textureData)
|
||||||
|
end
|
||||||
|
local currentIndex = self.vehicle:getSkinIndex()
|
||||||
|
for index, row in ipairs(self.skinList.items) do
|
||||||
|
if row.item.skinIndex == currentIndex then self.skinList.selected = index end
|
||||||
|
end
|
||||||
|
if self.skinList.selected <= 0 and #self.skinList.items > 0 then self.skinList.selected = 1 end
|
||||||
|
|
||||||
|
self.paintButton = ISButton:new(
|
||||||
|
self.width - 210, self.height - 38, 95, 28,
|
||||||
|
getText("IGUI_PaintMyKI5_Paint"), self, ISPaintMyKI5UI.onPaint
|
||||||
|
)
|
||||||
|
self.paintButton:initialise()
|
||||||
|
self.paintButton:instantiate()
|
||||||
|
self:addChild(self.paintButton)
|
||||||
|
|
||||||
|
self.cancelButton = ISButton:new(
|
||||||
|
self.width - 105, self.height - 38, 95, 28,
|
||||||
|
getText("UI_btn_close"), self, ISPaintMyKI5UI.close
|
||||||
|
)
|
||||||
|
self.cancelButton:initialise()
|
||||||
|
self.cancelButton:instantiate()
|
||||||
|
self:addChild(self.cancelButton)
|
||||||
|
end
|
||||||
|
|
||||||
|
function ISPaintMyKI5UI:getSelectedTexture()
|
||||||
|
local row = self.skinList and self.skinList.items[self.skinList.selected] or nil
|
||||||
|
return row and row.item or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function ISPaintMyKI5UI:prerender()
|
||||||
|
ISCollapsableWindow.prerender(self)
|
||||||
|
local textureData = self:getSelectedTexture()
|
||||||
|
local enabled = false
|
||||||
|
if textureData then
|
||||||
|
local area = PaintMyKI5.PaintRequirements.findInteractionArea(
|
||||||
|
self.vehicle, self.character
|
||||||
|
)
|
||||||
|
local requirements = PaintMyKI5.PaintRequirements.getRequirements(
|
||||||
|
self.vehicle, textureData.skinIndex
|
||||||
|
)
|
||||||
|
enabled = requirements ~= nil
|
||||||
|
and PaintMyKI5.PaintRequirements.isValidTarget(
|
||||||
|
self.character, self.vehicle, textureData.skinIndex, area
|
||||||
|
)
|
||||||
|
and PaintMyKI5.PaintInventory.checkRequirements(self.character, requirements)
|
||||||
|
end
|
||||||
|
self.paintButton:setEnable(enabled)
|
||||||
|
end
|
||||||
|
|
||||||
|
function ISPaintMyKI5UI:render()
|
||||||
|
ISCollapsableWindow.render(self)
|
||||||
|
local textureData = self:getSelectedTexture()
|
||||||
|
if not textureData then return end
|
||||||
|
|
||||||
|
local rightX = 300
|
||||||
|
local contentWidth = self.width - rightX - 10
|
||||||
|
local previewY = self:titleBarHeight() + 10
|
||||||
|
local requirements = PaintMyKI5.PaintRequirements.getRequirements(
|
||||||
|
self.vehicle, textureData.skinIndex
|
||||||
|
) or {}
|
||||||
|
local requirementHeight = #requirements * (SMALL_HEIGHT + 5)
|
||||||
|
local availablePreviewHeight = self.height - previewY - requirementHeight - 110
|
||||||
|
local previewHeight = math.max(120, math.min(245, availablePreviewHeight))
|
||||||
|
self:drawRectBorder(rightX, previewY, contentWidth, previewHeight, 0.7, 0.6, 0.6, 0.6)
|
||||||
|
local preview = getTexture("media/textures/" .. textureData.texture .. ".png")
|
||||||
|
or getTexture(textureData.texture)
|
||||||
|
if preview then
|
||||||
|
self:drawTextureScaledAspect(preview, rightX + 5, previewY + 5, contentWidth - 10, previewHeight - 10, 1, 1, 1, 1)
|
||||||
|
else
|
||||||
|
self:drawTextCentre(
|
||||||
|
getText("IGUI_PaintMyKI5_PreviewMissing"),
|
||||||
|
rightX + contentWidth / 2, previewY + previewHeight / 2,
|
||||||
|
0.8, 0.8, 0.8, 1, FONT_SMALL
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
local y = previewY + previewHeight + 12
|
||||||
|
self:drawText(getText("IGUI_PaintMyKI5_Requirements"), rightX, y, 1, 1, 1, 1, FONT_MEDIUM)
|
||||||
|
y = y + MEDIUM_HEIGHT + 6
|
||||||
|
for _, requirement in ipairs(requirements) do
|
||||||
|
local available = PaintMyKI5.PaintInventory.countUses(self.character, requirement.item)
|
||||||
|
local enough = available + 0.0001 >= requirement.uses
|
||||||
|
local red, green, blue = getPaletteColor(requirement.item)
|
||||||
|
self:drawRect(rightX, y + 2, 14, 14, 1, red, green, blue)
|
||||||
|
self:drawRectBorder(rightX, y + 2, 14, 14, 1, 0.9, 0.9, 0.9)
|
||||||
|
local itemName = getItemNameFromFullType(requirement.item)
|
||||||
|
available = math.floor(available * 100 + 0.5) / 100
|
||||||
|
local text = getText(
|
||||||
|
"IGUI_PaintMyKI5_RequirementLine",
|
||||||
|
itemName, requirement.percent, requirement.uses, available
|
||||||
|
)
|
||||||
|
if enough then
|
||||||
|
self:drawText(text, rightX + 22, y, 0.7, 1, 0.7, 1, FONT_SMALL)
|
||||||
|
else
|
||||||
|
self:drawText(text, rightX + 22, y, 1, 0.4, 0.4, 1, FONT_SMALL)
|
||||||
|
end
|
||||||
|
y = y + SMALL_HEIGHT + 5
|
||||||
|
end
|
||||||
|
self:drawText(
|
||||||
|
getText("IGUI_PaintMyKI5_OneBucket", PaintMyKI5.PaintRequirements.getBucketUses()),
|
||||||
|
rightX, y + 4, 1, 1, 1, 1, FONT_SMALL
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
function ISPaintMyKI5UI:onPaint()
|
||||||
|
local textureData = self:getSelectedTexture()
|
||||||
|
if not textureData then return end
|
||||||
|
local requirements = PaintMyKI5.PaintRequirements.getRequirements(
|
||||||
|
self.vehicle, textureData.skinIndex
|
||||||
|
)
|
||||||
|
if not requirements then return end
|
||||||
|
local enough = PaintMyKI5.PaintInventory.checkRequirements(self.character, requirements)
|
||||||
|
if not enough then return end
|
||||||
|
local area = PaintMyKI5.PaintRequirements.findInteractionArea(self.vehicle, self.character)
|
||||||
|
if not area then return end
|
||||||
|
if not PaintMyKI5.PaintRequirements.isValidTarget(
|
||||||
|
self.character, self.vehicle, textureData.skinIndex, area
|
||||||
|
) then return end
|
||||||
|
|
||||||
|
ISTimedActionQueue.add(ISPathFindAction:pathToVehicleArea(self.character, self.vehicle, area))
|
||||||
|
ISTimedActionQueue.add(ISPaintMyKI5Vehicle:new(
|
||||||
|
self.character, self.vehicle, textureData.skinIndex, area
|
||||||
|
))
|
||||||
|
self:close()
|
||||||
|
end
|
||||||
|
|
||||||
|
function ISPaintMyKI5UI:close()
|
||||||
|
ISPaintMyKI5UI.instances[self.playerNum] = nil
|
||||||
|
self:setVisible(false)
|
||||||
|
self:removeFromUIManager()
|
||||||
|
end
|
||||||
|
|
||||||
|
function ISPaintMyKI5UI.open(playerNum, vehicle)
|
||||||
|
local existing = ISPaintMyKI5UI.instances[playerNum]
|
||||||
|
if existing then existing:close() end
|
||||||
|
local character = getSpecificPlayer(playerNum)
|
||||||
|
local window = ISPaintMyKI5UI:new(character, vehicle, playerNum)
|
||||||
|
window:initialise()
|
||||||
|
window:addToUIManager()
|
||||||
|
ISPaintMyKI5UI.instances[playerNum] = window
|
||||||
|
end
|
||||||
|
|
||||||
|
function ISPaintMyKI5UI:new(character, vehicle, playerNum)
|
||||||
|
local width = math.min(800, getCore():getScreenWidth() - 40)
|
||||||
|
local height = math.min(600, getCore():getScreenHeight() - 40)
|
||||||
|
local x = (getCore():getScreenWidth() - width) / 2
|
||||||
|
local y = (getCore():getScreenHeight() - height) / 2
|
||||||
|
local window = ISCollapsableWindow:new(x, y, width, height)
|
||||||
|
setmetatable(window, self)
|
||||||
|
self.__index = self
|
||||||
|
window.title = getText("IGUI_PaintMyKI5_Title")
|
||||||
|
window.resizable = false
|
||||||
|
window.character = character
|
||||||
|
window.vehicle = vehicle
|
||||||
|
window.vehicleData = PaintMyKI5.PaintRequirements.getVehicleData(vehicle)
|
||||||
|
window.playerNum = playerNum
|
||||||
|
return window
|
||||||
|
end
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
require "Vehicles/ISUI/ISVehicleMenu"
|
||||||
|
require "PaintMyKI5/PaintRequirements"
|
||||||
|
require "PaintMyKI5/ISPaintMyKI5UI"
|
||||||
|
|
||||||
|
PaintMyKI5 = PaintMyKI5 or {}
|
||||||
|
PaintMyKI5.PaintVehicleContextMenu = PaintMyKI5.PaintVehicleContextMenu or {}
|
||||||
|
|
||||||
|
local PaintVehicleContextMenu = PaintMyKI5.PaintVehicleContextMenu
|
||||||
|
|
||||||
|
local function getTargetVehicle(playerObj)
|
||||||
|
if JoypadState.players[playerObj:getPlayerNum() + 1] then
|
||||||
|
return ISVehicleMenu.getVehicleToInteractWith(playerObj)
|
||||||
|
end
|
||||||
|
return IsoObjectPicker.Instance:PickVehicle(getMouseXScaled(), getMouseYScaled())
|
||||||
|
end
|
||||||
|
|
||||||
|
function PaintVehicleContextMenu.open(playerObj, vehicle)
|
||||||
|
ISPaintMyKI5UI.open(playerObj:getPlayerNum(), vehicle)
|
||||||
|
end
|
||||||
|
|
||||||
|
function PaintVehicleContextMenu.onFillWorldObjectContextMenu(player, context, worldobjects, test)
|
||||||
|
if test and ISWorldObjectContextMenu.Test then return true end
|
||||||
|
local playerObj = getSpecificPlayer(player)
|
||||||
|
if not playerObj or playerObj:getVehicle() then return false end
|
||||||
|
local vehicle = getTargetVehicle(playerObj)
|
||||||
|
local script = vehicle and vehicle:getScript() or nil
|
||||||
|
local vehicleId = script and script:getFullName() or nil
|
||||||
|
local vehicles = PaintMyKI5.VehiclePaintData and PaintMyKI5.VehiclePaintData.vehicles
|
||||||
|
local shortId = vehicleId and string.match(vehicleId, "[^.]+$") or nil
|
||||||
|
if not shortId or string.match(string.lower(shortId), "^trailer") then return false end
|
||||||
|
if not vehicles or not vehicles[vehicleId] then return false end
|
||||||
|
if math.abs(vehicle:getCurrentSpeedKmHour()) > 0.8 then return false end
|
||||||
|
if test then return ISWorldObjectContextMenu.setTest() end
|
||||||
|
|
||||||
|
local option = context:addOption(
|
||||||
|
getText("ContextMenu_PaintMyKI5"),
|
||||||
|
playerObj,
|
||||||
|
PaintVehicleContextMenu.open,
|
||||||
|
vehicle
|
||||||
|
)
|
||||||
|
option.iconTexture = getTexture("media/textures/PaintBrush.png")
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
Events.OnFillWorldObjectContextMenu.Add(PaintVehicleContextMenu.onFillWorldObjectContextMenu)
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
PaintMyKI5 = PaintMyKI5 or {}
|
||||||
|
PaintMyKI5.PaintInventory = PaintMyKI5.PaintInventory or {}
|
||||||
|
|
||||||
|
local PaintInventory = PaintMyKI5.PaintInventory
|
||||||
|
local EPSILON = 0.0001
|
||||||
|
local EMPTY_BUCKET = "Base.PaintbucketEmpty"
|
||||||
|
|
||||||
|
local function getItems(character, itemType)
|
||||||
|
if not character or not character:getInventory() then return nil end
|
||||||
|
return character:getInventory():getAllTypeRecurse(itemType)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function getUses(item)
|
||||||
|
local useDelta = item:getUseDelta()
|
||||||
|
if not useDelta or useDelta <= 0 then return 0 end
|
||||||
|
return item:getCurrentUsesFloat() / useDelta
|
||||||
|
end
|
||||||
|
|
||||||
|
function PaintInventory.countUses(character, itemType)
|
||||||
|
local items = getItems(character, itemType)
|
||||||
|
if not items then return 0 end
|
||||||
|
local total = 0
|
||||||
|
for index = 0, items:size() - 1 do
|
||||||
|
total = total + getUses(items:get(index))
|
||||||
|
end
|
||||||
|
return total
|
||||||
|
end
|
||||||
|
|
||||||
|
function PaintInventory.checkRequirements(character, requirements)
|
||||||
|
local missing = {}
|
||||||
|
for _, requirement in ipairs(requirements or {}) do
|
||||||
|
local available = PaintInventory.countUses(character, requirement.item)
|
||||||
|
if available + EPSILON < requirement.uses then
|
||||||
|
table.insert(missing, {
|
||||||
|
item = requirement.item,
|
||||||
|
required = requirement.uses,
|
||||||
|
available = available,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return #missing == 0, missing
|
||||||
|
end
|
||||||
|
|
||||||
|
local function buildPlan(character, requirements)
|
||||||
|
local enough = PaintInventory.checkRequirements(character, requirements)
|
||||||
|
if not enough then return nil end
|
||||||
|
local plan = {}
|
||||||
|
for _, requirement in ipairs(requirements) do
|
||||||
|
local remaining = requirement.uses
|
||||||
|
local items = getItems(character, requirement.item)
|
||||||
|
for index = 0, items:size() - 1 do
|
||||||
|
if remaining <= EPSILON then break end
|
||||||
|
local item = items:get(index)
|
||||||
|
local oldDelta = item:getCurrentUsesFloat()
|
||||||
|
local consumed = math.min(remaining, getUses(item))
|
||||||
|
local newDelta = oldDelta - consumed * item:getUseDelta()
|
||||||
|
local mutation = {
|
||||||
|
item = item,
|
||||||
|
container = item:getContainer(),
|
||||||
|
oldDelta = oldDelta,
|
||||||
|
newDelta = newDelta,
|
||||||
|
}
|
||||||
|
if not mutation.container then return nil end
|
||||||
|
if newDelta <= EPSILON then
|
||||||
|
mutation.replacement = instanceItem(EMPTY_BUCKET)
|
||||||
|
if not mutation.replacement then return nil end
|
||||||
|
end
|
||||||
|
table.insert(plan, mutation)
|
||||||
|
remaining = remaining - consumed
|
||||||
|
end
|
||||||
|
if remaining > EPSILON then return nil end
|
||||||
|
end
|
||||||
|
return plan
|
||||||
|
end
|
||||||
|
|
||||||
|
local function applyMutation(character, mutation)
|
||||||
|
if not mutation.replacement then
|
||||||
|
mutation.item:setUsedDelta(mutation.newDelta)
|
||||||
|
mutation.deltaApplied = true
|
||||||
|
sendItemStats(mutation.item)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local added = mutation.container:AddItem(mutation.replacement)
|
||||||
|
if not added then error("could not add empty paint bucket") end
|
||||||
|
mutation.replacementAdded = true
|
||||||
|
sendAddItemToContainer(mutation.container, mutation.replacement)
|
||||||
|
character:removeFromHands(mutation.item)
|
||||||
|
mutation.container:DoRemoveItem(mutation.item)
|
||||||
|
mutation.originalRemoved = true
|
||||||
|
sendRemoveItemFromContainer(mutation.container, mutation.item)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function rollBackMutation(mutation)
|
||||||
|
if mutation.replacement then
|
||||||
|
if mutation.originalRemoved then
|
||||||
|
mutation.item:setUsedDelta(mutation.oldDelta)
|
||||||
|
local restored = mutation.container:AddItem(mutation.item)
|
||||||
|
if not restored then return false end
|
||||||
|
sendAddItemToContainer(mutation.container, mutation.item)
|
||||||
|
mutation.originalRemoved = false
|
||||||
|
end
|
||||||
|
if mutation.replacementAdded then
|
||||||
|
mutation.container:DoRemoveItem(mutation.replacement)
|
||||||
|
sendRemoveItemFromContainer(mutation.container, mutation.replacement)
|
||||||
|
mutation.replacementAdded = false
|
||||||
|
end
|
||||||
|
elseif mutation.deltaApplied then
|
||||||
|
mutation.item:setUsedDelta(mutation.oldDelta)
|
||||||
|
sendItemStats(mutation.item)
|
||||||
|
mutation.deltaApplied = false
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function PaintInventory.consumeRequirements(character, requirements)
|
||||||
|
local plan = buildPlan(character, requirements)
|
||||||
|
if not plan then return false end
|
||||||
|
local applied = {}
|
||||||
|
for _, mutation in ipairs(plan) do
|
||||||
|
local ok = pcall(applyMutation, character, mutation)
|
||||||
|
if not ok then
|
||||||
|
pcall(rollBackMutation, mutation)
|
||||||
|
for index = #applied, 1, -1 do
|
||||||
|
pcall(rollBackMutation, applied[index])
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
table.insert(applied, mutation)
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,92 @@
|
|||||||
|
require "PaintMyKI5/PaintMyKI5VehiclePaintData"
|
||||||
|
|
||||||
|
PaintMyKI5 = PaintMyKI5 or {}
|
||||||
|
PaintMyKI5.PaintRequirements = PaintMyKI5.PaintRequirements or {}
|
||||||
|
|
||||||
|
local PaintRequirements = PaintMyKI5.PaintRequirements
|
||||||
|
local INTERACTION_AREAS = {
|
||||||
|
"Engine", "TruckBed", "SeatFrontLeft", "SeatLeft", "SeatFrontRight", "SeatRight"
|
||||||
|
}
|
||||||
|
|
||||||
|
local function getVehicleId(vehicle)
|
||||||
|
if not vehicle or not vehicle.getScript then return nil end
|
||||||
|
local script = vehicle:getScript()
|
||||||
|
return script and script:getFullName() or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function isCarId(vehicleId)
|
||||||
|
local shortId = vehicleId and string.match(vehicleId, "[^.]+$") or nil
|
||||||
|
return shortId and not string.match(string.lower(shortId), "^trailer")
|
||||||
|
end
|
||||||
|
|
||||||
|
function PaintRequirements.getVehicleData(vehicle)
|
||||||
|
local data = PaintMyKI5.VehiclePaintData
|
||||||
|
local vehicleId = getVehicleId(vehicle)
|
||||||
|
if not data or not data.vehicles or not isCarId(vehicleId) then return nil end
|
||||||
|
return data.vehicles[vehicleId]
|
||||||
|
end
|
||||||
|
|
||||||
|
function PaintRequirements.getTextureData(vehicle, skinIndex)
|
||||||
|
local vehicleData = PaintRequirements.getVehicleData(vehicle)
|
||||||
|
if not vehicleData or type(skinIndex) ~= "number" or skinIndex % 1 ~= 0 then return nil end
|
||||||
|
for _, textureData in ipairs(vehicleData.textures or {}) do
|
||||||
|
if textureData.skinIndex == skinIndex then return textureData end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function PaintRequirements.getBucketUses()
|
||||||
|
local data = PaintMyKI5.VehiclePaintData
|
||||||
|
local uses = data and tonumber(data.bucketUses) or nil
|
||||||
|
if not uses or uses <= 0 then return nil end
|
||||||
|
return uses
|
||||||
|
end
|
||||||
|
|
||||||
|
function PaintRequirements.getRequirements(vehicle, skinIndex)
|
||||||
|
local textureData = PaintRequirements.getTextureData(vehicle, skinIndex)
|
||||||
|
local bucketUses = PaintRequirements.getBucketUses()
|
||||||
|
if not textureData or not bucketUses then return nil end
|
||||||
|
|
||||||
|
local requirements = {}
|
||||||
|
local total = 0
|
||||||
|
for _, paint in ipairs(textureData.paints or {}) do
|
||||||
|
local uses = tonumber(paint.uses)
|
||||||
|
if type(paint.item) ~= "string" or not uses or uses <= 0 then return nil end
|
||||||
|
table.insert(requirements, {
|
||||||
|
item = paint.item,
|
||||||
|
percent = tonumber(paint.percent) or 0,
|
||||||
|
uses = uses,
|
||||||
|
})
|
||||||
|
total = total + uses
|
||||||
|
end
|
||||||
|
if math.abs(total - bucketUses) > 0.001 then return nil end
|
||||||
|
return requirements, textureData
|
||||||
|
end
|
||||||
|
|
||||||
|
function PaintRequirements.isSupported(vehicle)
|
||||||
|
return PaintRequirements.getVehicleData(vehicle) ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function PaintRequirements.findInteractionArea(vehicle, character)
|
||||||
|
if not vehicle or not character then return nil end
|
||||||
|
local part = vehicle:getUseablePart(character)
|
||||||
|
if part and part:getArea() then return part:getArea() end
|
||||||
|
local script = vehicle:getScript()
|
||||||
|
if not script then return nil end
|
||||||
|
for _, area in ipairs(INTERACTION_AREAS) do
|
||||||
|
if script:getAreaById(area) then return area end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function PaintRequirements.isValidTarget(character, vehicle, skinIndex, area)
|
||||||
|
if not character or not vehicle or vehicle:isRemovedFromWorld() then return false end
|
||||||
|
if character:getVehicle() then return false end
|
||||||
|
if math.abs(vehicle:getCurrentSpeedKmHour()) > 0.8 then return false end
|
||||||
|
if type(skinIndex) ~= "number" or skinIndex % 1 ~= 0 then return false end
|
||||||
|
if skinIndex < 0 or skinIndex >= vehicle:getSkinCount() then return false end
|
||||||
|
if vehicle:getSkinIndex() == skinIndex then return false end
|
||||||
|
if not PaintRequirements.getTextureData(vehicle, skinIndex) then return false end
|
||||||
|
if not area or not vehicle:isInArea(area, character) then return false end
|
||||||
|
return true
|
||||||
|
end
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
require "TimedActions/ISBaseTimedAction"
|
||||||
|
require "PaintMyKI5/PaintRequirements"
|
||||||
|
require "PaintMyKI5/PaintInventory"
|
||||||
|
|
||||||
|
ISPaintMyKI5Vehicle = ISBaseTimedAction:derive("ISPaintMyKI5Vehicle")
|
||||||
|
|
||||||
|
function ISPaintMyKI5Vehicle:isValid()
|
||||||
|
local area = PaintMyKI5.PaintRequirements.findInteractionArea(
|
||||||
|
self.vehicle, self.character
|
||||||
|
)
|
||||||
|
if not PaintMyKI5.PaintRequirements.isValidTarget(
|
||||||
|
self.character, self.vehicle, self.skinIndex, area
|
||||||
|
) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local requirements = PaintMyKI5.PaintRequirements.getRequirements(
|
||||||
|
self.vehicle, self.skinIndex
|
||||||
|
)
|
||||||
|
if not requirements then return false end
|
||||||
|
return PaintMyKI5.PaintInventory.checkRequirements(self.character, requirements)
|
||||||
|
end
|
||||||
|
|
||||||
|
function ISPaintMyKI5Vehicle:waitToStart()
|
||||||
|
self.character:faceThisObject(self.vehicle)
|
||||||
|
return self.character:shouldBeTurning()
|
||||||
|
end
|
||||||
|
|
||||||
|
function ISPaintMyKI5Vehicle:update()
|
||||||
|
self.character:faceThisObject(self.vehicle)
|
||||||
|
self.character:setMetabolicTarget(Metabolics.MediumWork)
|
||||||
|
end
|
||||||
|
|
||||||
|
function ISPaintMyKI5Vehicle:start()
|
||||||
|
self.originalSkinIndex = self.vehicle:getSkinIndex()
|
||||||
|
self:setActionAnim(CharacterActionAnims.Paint)
|
||||||
|
self:setOverrideHandModels("PaintBrush", nil)
|
||||||
|
self.sound = self.character:playSound("Painting")
|
||||||
|
end
|
||||||
|
|
||||||
|
function ISPaintMyKI5Vehicle:serverStart()
|
||||||
|
self.originalSkinIndex = self.vehicle and self.vehicle:getSkinIndex() or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function ISPaintMyKI5Vehicle:stop()
|
||||||
|
if self.sound then self.character:stopOrTriggerSound(self.sound) end
|
||||||
|
ISBaseTimedAction.stop(self)
|
||||||
|
end
|
||||||
|
|
||||||
|
function ISPaintMyKI5Vehicle:perform()
|
||||||
|
if self.sound then self.character:stopOrTriggerSound(self.sound) end
|
||||||
|
ISBaseTimedAction.perform(self)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function applySkin(vehicle, skinIndex)
|
||||||
|
vehicle:setSkinIndex(skinIndex)
|
||||||
|
if isServer() then
|
||||||
|
vehicle:transmitSkinIndex()
|
||||||
|
else
|
||||||
|
vehicle:updateSkin()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function ISPaintMyKI5Vehicle:complete()
|
||||||
|
if isClient() then return true end
|
||||||
|
local area = PaintMyKI5.PaintRequirements.findInteractionArea(
|
||||||
|
self.vehicle, self.character
|
||||||
|
)
|
||||||
|
if not PaintMyKI5.PaintRequirements.isValidTarget(
|
||||||
|
self.character, self.vehicle, self.skinIndex, area
|
||||||
|
) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if self.originalSkinIndex == nil
|
||||||
|
or self.vehicle:getSkinIndex() ~= self.originalSkinIndex then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local requirements = PaintMyKI5.PaintRequirements.getRequirements(
|
||||||
|
self.vehicle, self.skinIndex
|
||||||
|
)
|
||||||
|
if not requirements then return false end
|
||||||
|
local enough = PaintMyKI5.PaintInventory.checkRequirements(
|
||||||
|
self.character, requirements
|
||||||
|
)
|
||||||
|
if not enough then return false end
|
||||||
|
|
||||||
|
local vehicle = self.vehicle
|
||||||
|
local changed = pcall(applySkin, vehicle, self.skinIndex)
|
||||||
|
if not changed then
|
||||||
|
pcall(applySkin, vehicle, self.originalSkinIndex)
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if not PaintMyKI5.PaintInventory.consumeRequirements(self.character, requirements) then
|
||||||
|
pcall(applySkin, vehicle, self.originalSkinIndex)
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function ISPaintMyKI5Vehicle:getDuration()
|
||||||
|
if self.character:isTimedActionInstant() then return 1 end
|
||||||
|
return 500
|
||||||
|
end
|
||||||
|
|
||||||
|
function ISPaintMyKI5Vehicle:new(character, vehicle, skinIndex, area)
|
||||||
|
local action = ISBaseTimedAction.new(self, character)
|
||||||
|
action.vehicle = vehicle
|
||||||
|
action.skinIndex = skinIndex
|
||||||
|
action.area = area
|
||||||
|
action.originalSkinIndex = vehicle and vehicle:getSkinIndex() or nil
|
||||||
|
action.maxTime = action:getDuration()
|
||||||
|
action.stopOnWalk = true
|
||||||
|
action.stopOnRun = true
|
||||||
|
action.caloriesModifier = 4
|
||||||
|
return action
|
||||||
|
end
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"ContextMenu_PaintMyKI5": "Paint vehicle"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"IGUI_PaintMyKI5_Title": "Paint KI5 Vehicle",
|
||||||
|
"IGUI_PaintMyKI5_Skin": "Skin %1",
|
||||||
|
"IGUI_PaintMyKI5_Paint": "Paint",
|
||||||
|
"IGUI_PaintMyKI5_Requirements": "Paint requirements",
|
||||||
|
"IGUI_PaintMyKI5_RequirementLine": "%1: %2%%, %3 uses needed, %4 available",
|
||||||
|
"IGUI_PaintMyKI5_OneBucket": "Total: %1 uses — one full bucket",
|
||||||
|
"IGUI_PaintMyKI5_PreviewMissing": "Texture preview unavailable"
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
name=Paint my KI5
|
||||||
|
id=hrsys_paint_my_ki5
|
||||||
|
poster=../common/media/textures/preview.png
|
||||||
|
description=A mod allowing for painting of KI5 vehicles.
|
||||||
|
author=Riggs0
|
||||||
|
category=vehicle
|
||||||
|
require=damnlib
|
||||||
|
icon=../common/media/textures/paint_my_ki5_icon.png
|
||||||
|
url=https://hudsonriggs.systems
|
||||||
|
modversion=1.1.0
|
||||||
|
versionMin=42.20
|
||||||
@@ -1,3 +1,93 @@
|
|||||||
# PaintMyKI5
|
# PaintMyKI5
|
||||||
|
|
||||||
A mod that allows you to paint your KI5 Ride!
|
A Project Zomboid Build 42 mod that lets players repaint supported KI5 vehicles
|
||||||
|
in singleplayer, hosted multiplayer, and dedicated servers.
|
||||||
|
|
||||||
|
## Painting vehicles
|
||||||
|
|
||||||
|
Stand outside a supported, stopped vehicle and right-click it, then choose
|
||||||
|
**Paint vehicle**. The window shows every scanned skin, its raw texture preview,
|
||||||
|
and the paint available in your carried inventory (including nested bags).
|
||||||
|
|
||||||
|
Every repaint costs exactly one full B42 paint bucket worth of material. B42
|
||||||
|
paint buckets use `UseDelta = 0.1`, giving 10 uses per bucket. Those 10 uses are
|
||||||
|
split fractionally across the selected texture's scanned colors. A
|
||||||
|
60% gray/20% green/20% white skin therefore costs 6 gray, 2 green, and 2 white
|
||||||
|
uses. Small color percentages are retained rather than rounded away.
|
||||||
|
|
||||||
|
Painting uses a networked timed action. On multiplayer servers, the server
|
||||||
|
rechecks the vehicle, selected skin, distance, movement, unchanged starting
|
||||||
|
skin, and the acting player's paint immediately before it consumes anything.
|
||||||
|
The server then applies and broadcasts the skin. The painting action never
|
||||||
|
accepts costs or inventory totals supplied by the client.
|
||||||
|
|
||||||
|
This protects the mod's normal painting flow. Build 42 also contains a generic
|
||||||
|
vanilla `vehicle/setSkinIndex` command used by other game tooling; hardening or
|
||||||
|
removing that unrelated base-game command is outside this mod's scope.
|
||||||
|
|
||||||
|
## Vehicle color scanner
|
||||||
|
|
||||||
|
The scanner:
|
||||||
|
|
||||||
|
- searches `D:\SteamLibrary\steamapps\workshop\content\108600` by default;
|
||||||
|
- deduplicates root and versioned `mod.info` files requiring `damnlib`;
|
||||||
|
- selects the newest installed `42.x` content layer;
|
||||||
|
- identifies self-propelled cars from their vehicle scripts;
|
||||||
|
- maps each `skin` texture to its exact in-game ID, such as
|
||||||
|
`Base.92nissanGTR`; and
|
||||||
|
- discovers the 15 full paint buckets from the installed B42 game data;
|
||||||
|
- prints pixel-derived percentages using exact item IDs such as
|
||||||
|
`Base.PaintGreen`, `Base.PaintGrey`, and `Base.PaintWhite`; and
|
||||||
|
- writes the exact proportional use requirements consumed by the in-game menu.
|
||||||
|
|
||||||
|
The tool reads the authoritative palette from `ISPaintMenu.lua` and verifies
|
||||||
|
each entry against the bucket definitions in `drainable.txt`. Empty buckets and
|
||||||
|
spray paint are excluded. Pixels are matched to the nearest paint color in
|
||||||
|
perceptual Lab color space.
|
||||||
|
|
||||||
|
The development tool and its tests live under [`scanner/`](scanner/). Install
|
||||||
|
Python 3.11 or newer, then run it from the repository root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m pip install -r scanner/requirements.txt
|
||||||
|
python -m scanner --quiet
|
||||||
|
```
|
||||||
|
|
||||||
|
By default it writes the complete runtime data table to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
42.20/media/lua/shared/PaintMyKI5/PaintMyKI5VehiclePaintData.lua
|
||||||
|
```
|
||||||
|
|
||||||
|
The in-game menu reads `PaintMyKI5.VehiclePaintData`. The generated table
|
||||||
|
contains the palette, 10-use bucket capacity, exact vehicle IDs, zero-based skin
|
||||||
|
indices, texture references, Workshop/mod metadata, paint item IDs,
|
||||||
|
percentages, and proportional uses. It excludes local absolute paths and
|
||||||
|
timestamps, so identical inputs produce identical Lua.
|
||||||
|
|
||||||
|
To scan another Steam library:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m scanner --quiet --workshop-root "E:\SteamLibrary\steamapps\workshop\content\108600"
|
||||||
|
```
|
||||||
|
|
||||||
|
If Project Zomboid itself is installed elsewhere, pass both locations:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m scanner --quiet `
|
||||||
|
--game-root "E:\SteamLibrary\steamapps\common\ProjectZomboid" `
|
||||||
|
--workshop-root "E:\SteamLibrary\steamapps\workshop\content\108600"
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the tests with:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m unittest discover -s scanner/tests -v
|
||||||
|
```
|
||||||
|
|
||||||
|
The headless Lua logic and syntax checks use Lupa without adding a runtime
|
||||||
|
dependency to the mod:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run --with lupa python scanner/tests/run_lua_tests.py
|
||||||
|
```
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
name=Paint my KI5
|
||||||
|
id=hrsys_paint_my_ki5
|
||||||
|
poster=/common/media/textures/preview.png
|
||||||
|
description=A mod allowing for painting of KI5 vehicles.
|
||||||
|
author=Riggs0
|
||||||
|
category=vehicle
|
||||||
|
require=damnlib
|
||||||
|
icon=/common/media/textures/paint_my_ki5_icon.png
|
||||||
|
url=https://hudsonriggs.systems
|
||||||
|
modversion=1.1.0
|
||||||
|
versionMin=42.20
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Development scanner for generating PaintMyKI5 vehicle paint data."""
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from scanner.scan_ki5_workshop import main
|
||||||
|
|
||||||
|
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Pillow>=10.0,<13.0
|
||||||
@@ -0,0 +1,976 @@
|
|||||||
|
"""Scan Project Zomboid Workshop mods for KI5 vehicle skin colors."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import warnings
|
||||||
|
from collections import Counter
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable, Sequence, TextIO
|
||||||
|
|
||||||
|
from PIL import Image, UnidentifiedImageError
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_WORKSHOP_ROOT = Path(r"D:\SteamLibrary\steamapps\workshop\content\108600")
|
||||||
|
DEFAULT_GAME_ROOT = Path(r"D:\SteamLibrary\steamapps\common\ProjectZomboid")
|
||||||
|
DEFAULT_OUTPUT_PATH = (
|
||||||
|
Path(__file__).resolve().parent.parent
|
||||||
|
/ "42.20/media/lua/shared/PaintMyKI5/PaintMyKI5VehiclePaintData.lua"
|
||||||
|
)
|
||||||
|
DEPENDENCY_SPLIT = re.compile(r"[,;\s]+")
|
||||||
|
MODULE_PATTERN = re.compile(r"(?im)^\s*module\s+([\w.-]+)\s*(?=\{)")
|
||||||
|
VEHICLE_PATTERN = re.compile(r"(?im)^\s*vehicle\s+([\w.-]+)\s*(?=\{)")
|
||||||
|
SKIN_PATTERN = re.compile(r"(?im)^\s*skin(?:\s+[\w.-]+)?\s*(?=\{)")
|
||||||
|
TEXTURE_PATTERN = re.compile(r"(?im)^\s*texture\s*=\s*([^,\r\n}]+)")
|
||||||
|
DRIVABLE_PATTERN = re.compile(
|
||||||
|
r"(?im)^\s*(?:engineForce|engineLoudness|engineQuality|engineRPMType|maxSpeed)\s*="
|
||||||
|
)
|
||||||
|
ITEM_PATTERN = re.compile(r"(?im)^\s*item\s+([\w.-]+)\s*(?=\{)")
|
||||||
|
PROPERTY_PATTERN = re.compile(r"(?im)(?:^|[,{\r\n])\s*([\w.-]+)\s*=\s*([^,\r\n}]+)")
|
||||||
|
PAINT_MENU_PATTERN = re.compile(
|
||||||
|
r'paint\s*=\s*["\']([\w.-]+)["\'][^}\r\n]*'
|
||||||
|
r'color\s*=\s*\{\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*,'
|
||||||
|
r'\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*,'
|
||||||
|
r'\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*\}',
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
MAX_TEXT_BYTES = 8 * 1024 * 1024
|
||||||
|
MAX_IMAGE_PIXELS = 4096 * 4096
|
||||||
|
MAX_DISCOVERED_FILES = 20_000
|
||||||
|
MAX_TREE_ENTRIES = 500_000
|
||||||
|
MAX_UNIQUE_COLORS = 262_144
|
||||||
|
MAX_TEXTURE_RESULTS = 20_000
|
||||||
|
MAX_PAINT_CANS = 256
|
||||||
|
MAX_VEHICLES = 20_000
|
||||||
|
MAX_SKINS_PER_VEHICLE = 2_000
|
||||||
|
MAX_WARNINGS = 20_000
|
||||||
|
MAX_METADATA_CHARS = 2_048
|
||||||
|
MAX_MANIFEST_BYTES = 64 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
class ScanFileError(ValueError):
|
||||||
|
"""Raised when an untrusted Workshop file exceeds scanner safety limits."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ModCandidate:
|
||||||
|
workshop_id: str
|
||||||
|
mod_id: str
|
||||||
|
name: str
|
||||||
|
mod_root: Path
|
||||||
|
content_root: Path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SkinDefinition:
|
||||||
|
skin_index: int
|
||||||
|
texture_reference: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class VehicleDefinition:
|
||||||
|
vehicle_id: str
|
||||||
|
skins: tuple[SkinDefinition, ...]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def texture_references(self) -> tuple[str, ...]:
|
||||||
|
return tuple(skin.texture_reference for skin in self.skins)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PaintCan:
|
||||||
|
item_id: str
|
||||||
|
display_name: str
|
||||||
|
rgb: tuple[float, float, float]
|
||||||
|
use_delta: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PaintPercentage:
|
||||||
|
paint_can: PaintCan
|
||||||
|
percentage: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TextureResult:
|
||||||
|
workshop_id: str
|
||||||
|
mod_id: str
|
||||||
|
mod_name: str
|
||||||
|
vehicle_id: str
|
||||||
|
skin_index: int
|
||||||
|
texture_reference: str
|
||||||
|
texture_path: Path
|
||||||
|
paints: tuple[PaintPercentage, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ScanReport:
|
||||||
|
mods_scanned: int
|
||||||
|
cars_found: int
|
||||||
|
textures: tuple[TextureResult, ...]
|
||||||
|
warnings: tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
|
def paint_bucket_uses(paint_cans: Sequence[PaintCan]) -> int:
|
||||||
|
"""Return the full-bucket capacity shared by the discovered B42 paints."""
|
||||||
|
if not paint_cans:
|
||||||
|
raise ValueError("The B42 paint palette is empty")
|
||||||
|
use_deltas = {Decimal(str(paint.use_delta)) for paint in paint_cans}
|
||||||
|
if len(use_deltas) != 1:
|
||||||
|
raise ValueError("B42 paint cans do not share one bucket capacity")
|
||||||
|
use_delta = use_deltas.pop()
|
||||||
|
if not use_delta.is_finite() or use_delta <= 0:
|
||||||
|
raise ValueError("B42 paint use delta must be finite and positive")
|
||||||
|
capacity = Decimal("1") / use_delta
|
||||||
|
rounded_capacity = capacity.to_integral_value(rounding=ROUND_HALF_UP)
|
||||||
|
if capacity != rounded_capacity or rounded_capacity <= 0:
|
||||||
|
raise ValueError("B42 paint use delta does not produce whole bucket uses")
|
||||||
|
return int(rounded_capacity)
|
||||||
|
|
||||||
|
|
||||||
|
def allocate_paint_uses(
|
||||||
|
paints: Sequence[PaintPercentage],
|
||||||
|
bucket_uses: int,
|
||||||
|
) -> list[tuple[PaintPercentage, float]]:
|
||||||
|
"""Spread one bucket's uses across every represented paint color."""
|
||||||
|
if bucket_uses <= 0:
|
||||||
|
raise ValueError("Paint bucket capacity must be positive")
|
||||||
|
combined: dict[str, tuple[PaintPercentage, Decimal]] = {}
|
||||||
|
for paint in paints:
|
||||||
|
percentage = Decimal(str(paint.percentage))
|
||||||
|
if not percentage.is_finite() or percentage < 0:
|
||||||
|
raise ValueError("Paint percentages must be finite and non-negative")
|
||||||
|
previous = combined.get(paint.paint_can.item_id)
|
||||||
|
total = percentage + (previous[1] if previous else Decimal("0"))
|
||||||
|
combined[paint.paint_can.item_id] = (paint, total)
|
||||||
|
percentage_total = sum((entry[1] for entry in combined.values()), Decimal("0"))
|
||||||
|
if percentage_total <= 0:
|
||||||
|
return []
|
||||||
|
allocations = [
|
||||||
|
(
|
||||||
|
entry,
|
||||||
|
(percentage * Decimal(bucket_uses) / percentage_total).quantize(
|
||||||
|
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for entry, percentage in combined.values()
|
||||||
|
]
|
||||||
|
difference = Decimal(bucket_uses) - sum(
|
||||||
|
(uses for _entry, uses in allocations), Decimal("0")
|
||||||
|
)
|
||||||
|
if difference:
|
||||||
|
target_index = min(
|
||||||
|
range(len(allocations)),
|
||||||
|
key=lambda index: (
|
||||||
|
-Decimal(str(allocations[index][0].percentage)),
|
||||||
|
allocations[index][0].paint_can.item_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
entry, uses = allocations[target_index]
|
||||||
|
allocations[target_index] = (entry, uses + difference)
|
||||||
|
return [
|
||||||
|
(entry, float(uses))
|
||||||
|
for entry, uses in sorted(
|
||||||
|
allocations,
|
||||||
|
key=lambda allocation: (
|
||||||
|
-allocation[1],
|
||||||
|
allocation[0].paint_can.item_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if uses > 0
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _read_text(path: Path) -> str:
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
contents = stream.read(MAX_TEXT_BYTES + 1)
|
||||||
|
if len(contents) > MAX_TEXT_BYTES:
|
||||||
|
raise ScanFileError(f"Text file exceeds {MAX_TEXT_BYTES} bytes: {path}")
|
||||||
|
return contents.decode("utf-8-sig", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_files(
|
||||||
|
root: Path,
|
||||||
|
*,
|
||||||
|
name: str | None = None,
|
||||||
|
suffix: str | None = None,
|
||||||
|
) -> Iterable[Path]:
|
||||||
|
visited_entries = 0
|
||||||
|
yielded_files = 0
|
||||||
|
pending_directories = [root]
|
||||||
|
while pending_directories:
|
||||||
|
directory = pending_directories.pop()
|
||||||
|
try:
|
||||||
|
entries = os.scandir(directory)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
with entries:
|
||||||
|
for entry in entries:
|
||||||
|
visited_entries += 1
|
||||||
|
if visited_entries > MAX_TREE_ENTRIES:
|
||||||
|
raise ValueError(
|
||||||
|
f"File traversal exceeded the {MAX_TREE_ENTRIES}-entry safety limit"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if entry.is_dir(follow_symlinks=False):
|
||||||
|
pending_directories.append(Path(entry.path))
|
||||||
|
continue
|
||||||
|
if not entry.is_file(follow_symlinks=False):
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
file_name = entry.name
|
||||||
|
if name is not None and file_name.casefold() != name.casefold():
|
||||||
|
continue
|
||||||
|
if suffix is not None and not file_name.casefold().endswith(suffix.casefold()):
|
||||||
|
continue
|
||||||
|
yielded_files += 1
|
||||||
|
if yielded_files > MAX_DISCOVERED_FILES:
|
||||||
|
raise ValueError(
|
||||||
|
f"File discovery exceeded the {MAX_DISCOVERED_FILES}-file safety limit"
|
||||||
|
)
|
||||||
|
yield Path(entry.path)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_mod_info(path: Path) -> dict[str, str]:
|
||||||
|
fields: dict[str, str] = {}
|
||||||
|
for raw_line in _read_text(path).splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith(("#", ";", "//")) or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
normalized_key = key.strip().casefold()
|
||||||
|
normalized_value = value.strip().strip("'\"")
|
||||||
|
if len(normalized_key) > 128 or len(normalized_value) > MAX_METADATA_CHARS:
|
||||||
|
raise ScanFileError(f"Oversized mod.info field: {path}")
|
||||||
|
fields[normalized_key] = normalized_value
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
def _requires_damnlib(fields: dict[str, str]) -> bool:
|
||||||
|
dependencies = fields.get("require", "")
|
||||||
|
tokens = (
|
||||||
|
token.strip("\\/'\"").casefold()
|
||||||
|
for token in DEPENDENCY_SPLIT.split(dependencies)
|
||||||
|
if token
|
||||||
|
)
|
||||||
|
return "damnlib" in tokens
|
||||||
|
|
||||||
|
|
||||||
|
def _find_mod_root(info_path: Path) -> tuple[Path, str] | None:
|
||||||
|
parts = info_path.parts
|
||||||
|
mod_indexes = [index for index, part in enumerate(parts) if part.casefold() == "mods"]
|
||||||
|
if not mod_indexes:
|
||||||
|
return None
|
||||||
|
mods_index = mod_indexes[-1]
|
||||||
|
if mods_index == 0 or mods_index + 1 >= len(parts):
|
||||||
|
return None
|
||||||
|
return Path(*parts[: mods_index + 2]), parts[mods_index - 1]
|
||||||
|
|
||||||
|
|
||||||
|
def _version_key(path: Path) -> tuple[int, ...] | None:
|
||||||
|
if not re.fullmatch(r"42(?:\.\d+)*", path.name, flags=re.IGNORECASE):
|
||||||
|
return None
|
||||||
|
return tuple(int(part) for part in path.name.split("."))
|
||||||
|
|
||||||
|
|
||||||
|
def _select_content_root(mod_root: Path) -> Path:
|
||||||
|
resolved_mod_root = mod_root.resolve(strict=True)
|
||||||
|
version_roots = [
|
||||||
|
child
|
||||||
|
for child in mod_root.iterdir()
|
||||||
|
if child.is_dir()
|
||||||
|
and _version_key(child) is not None
|
||||||
|
and not child.is_symlink()
|
||||||
|
and child.resolve(strict=True).is_relative_to(resolved_mod_root)
|
||||||
|
and (child / "media" / "scripts" / "vehicles").is_dir()
|
||||||
|
]
|
||||||
|
if version_roots:
|
||||||
|
return max(version_roots, key=lambda path: _version_key(path) or ())
|
||||||
|
return mod_root
|
||||||
|
|
||||||
|
|
||||||
|
def discover_mods(workshop_root: Path) -> list[ModCandidate]:
|
||||||
|
"""Return unique DamnLib mods, selecting their newest installed B42 overlay."""
|
||||||
|
workshop_root = workshop_root.expanduser()
|
||||||
|
if not workshop_root.is_dir():
|
||||||
|
raise ValueError(f"Workshop root does not exist or is not a directory: {workshop_root}")
|
||||||
|
|
||||||
|
grouped: dict[Path, tuple[str, Path]] = {}
|
||||||
|
for info_path in _bounded_files(workshop_root, name="mod.info"):
|
||||||
|
try:
|
||||||
|
fields = _parse_mod_info(info_path)
|
||||||
|
except (OSError, ScanFileError):
|
||||||
|
continue
|
||||||
|
if not _requires_damnlib(fields):
|
||||||
|
continue
|
||||||
|
location = _find_mod_root(info_path)
|
||||||
|
if location is None:
|
||||||
|
continue
|
||||||
|
mod_root, workshop_id = location
|
||||||
|
previous = grouped.get(mod_root)
|
||||||
|
fallback_info = (
|
||||||
|
info_path
|
||||||
|
if previous is None or len(info_path.parts) < len(previous[1].parts)
|
||||||
|
else previous[1]
|
||||||
|
)
|
||||||
|
grouped[mod_root] = (workshop_id, fallback_info)
|
||||||
|
|
||||||
|
candidates: list[ModCandidate] = []
|
||||||
|
for mod_root, (workshop_id, fallback_info) in grouped.items():
|
||||||
|
try:
|
||||||
|
content_root = _select_content_root(mod_root)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
selected_info = content_root / "mod.info"
|
||||||
|
if not selected_info.is_file():
|
||||||
|
selected_info = fallback_info
|
||||||
|
try:
|
||||||
|
fields = _parse_mod_info(selected_info)
|
||||||
|
except (OSError, ScanFileError):
|
||||||
|
continue
|
||||||
|
if not _requires_damnlib(fields):
|
||||||
|
continue
|
||||||
|
candidates.append(
|
||||||
|
ModCandidate(
|
||||||
|
workshop_id=workshop_id,
|
||||||
|
mod_id=fields.get("id", mod_root.name),
|
||||||
|
name=fields.get("name", fields.get("id", mod_root.name)),
|
||||||
|
mod_root=mod_root,
|
||||||
|
content_root=content_root,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return sorted(candidates, key=lambda mod: (mod.workshop_id.casefold(), mod.mod_id.casefold()))
|
||||||
|
|
||||||
|
|
||||||
|
def _without_comments(text: str) -> str:
|
||||||
|
text = re.sub(r"/\*.*?\*/", "", text, flags=re.DOTALL)
|
||||||
|
return re.sub(r"//.*$", "", text, flags=re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_braced_block(text: str, opening_brace: int) -> str | None:
|
||||||
|
if opening_brace < 0 or opening_brace >= len(text) or text[opening_brace] != "{":
|
||||||
|
return None
|
||||||
|
depth = 0
|
||||||
|
for index in range(opening_brace, len(text)):
|
||||||
|
if text[index] == "{":
|
||||||
|
depth += 1
|
||||||
|
elif text[index] == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return text[opening_brace + 1 : index]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _property_map(block: str) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
match.group(1).casefold(): match.group(2).strip().strip("'\"")
|
||||||
|
for match in PROPERTY_PATTERN.finditer(block)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_paint_item_ids(item_script: Path) -> dict[str, float]:
|
||||||
|
text = _without_comments(_read_text(item_script))
|
||||||
|
paint_items: dict[str, float] = {}
|
||||||
|
for item_match in ITEM_PATTERN.finditer(text):
|
||||||
|
opening_brace = text.find("{", item_match.end())
|
||||||
|
block = _extract_braced_block(text, opening_brace)
|
||||||
|
if block is None:
|
||||||
|
continue
|
||||||
|
properties = _property_map(block)
|
||||||
|
tags = {
|
||||||
|
tag.strip().casefold()
|
||||||
|
for tag in re.split(r"[,;\s]+", properties.get("tags", ""))
|
||||||
|
if tag.strip()
|
||||||
|
}
|
||||||
|
if properties.get("itemtype", "").casefold() != "base:drainable":
|
||||||
|
continue
|
||||||
|
if properties.get("pourtype", "").casefold() != "bucket":
|
||||||
|
continue
|
||||||
|
if properties.get("replaceondeplete", "").casefold() != "base.paintbucketempty":
|
||||||
|
continue
|
||||||
|
if "base:paint" not in tags:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
use_delta = float(properties["usedelta"])
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
continue
|
||||||
|
if not 0 < use_delta <= 1:
|
||||||
|
continue
|
||||||
|
module_name = _module_before(text, item_match.start())
|
||||||
|
short_id = item_match.group(1)
|
||||||
|
item_id = short_id if "." in short_id else f"{module_name}.{short_id}"
|
||||||
|
paint_items[item_id] = use_delta
|
||||||
|
return paint_items
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_paint_menu(menu_path: Path) -> dict[str, tuple[float, float, float]]:
|
||||||
|
text = _without_comments(_read_text(menu_path))
|
||||||
|
colors: dict[str, tuple[float, float, float]] = {}
|
||||||
|
for match in PAINT_MENU_PATTERN.finditer(text):
|
||||||
|
short_id = match.group(1)
|
||||||
|
item_id = short_id if "." in short_id else f"Base.{short_id}"
|
||||||
|
rgb = tuple(float(match.group(index)) for index in range(2, 5))
|
||||||
|
if any(not math.isfinite(channel) or not 0 <= channel <= 1 for channel in rgb):
|
||||||
|
raise ValueError(f"Invalid B42 paint RGB for {item_id}: {rgb}")
|
||||||
|
if item_id in colors and colors[item_id] != rgb:
|
||||||
|
raise ValueError(f"Conflicting B42 paint RGB values for {item_id}")
|
||||||
|
colors[item_id] = rgb
|
||||||
|
return colors
|
||||||
|
|
||||||
|
|
||||||
|
def discover_paint_cans(game_root: Path) -> tuple[PaintCan, ...]:
|
||||||
|
"""Load full B42 paint buckets and canonical RGB values from the game files."""
|
||||||
|
game_root = game_root.expanduser()
|
||||||
|
if not game_root.is_dir():
|
||||||
|
raise ValueError(f"Game root does not exist or is not a directory: {game_root}")
|
||||||
|
item_script = game_root / "media/scripts/generated/items/drainable.txt"
|
||||||
|
menu_path = game_root / "media/lua/shared/BuildingObjects/ISPaintMenu.lua"
|
||||||
|
names_path = game_root / "media/lua/shared/Translate/EN/ItemName.json"
|
||||||
|
missing = [path for path in (item_script, menu_path) if not path.is_file()]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"Missing B42 paint data: {missing[0]}")
|
||||||
|
try:
|
||||||
|
paint_items = _parse_paint_item_ids(item_script)
|
||||||
|
menu_colors = _parse_paint_menu(menu_path)
|
||||||
|
names = json.loads(_read_text(names_path)) if names_path.is_file() else {}
|
||||||
|
except (OSError, ScanFileError, json.JSONDecodeError) as error:
|
||||||
|
raise ValueError(f"Could not read B42 paint data: {error}") from error
|
||||||
|
if not isinstance(names, dict) or not all(
|
||||||
|
isinstance(key, str) and isinstance(value, str) for key, value in names.items()
|
||||||
|
):
|
||||||
|
raise ValueError(f"Invalid B42 item-name data: {names_path}")
|
||||||
|
cans = tuple(
|
||||||
|
PaintCan(
|
||||||
|
item_id=item_id,
|
||||||
|
display_name=str(names.get(item_id, item_id.removeprefix("Base.Paint") or item_id)),
|
||||||
|
rgb=menu_colors[item_id],
|
||||||
|
use_delta=use_delta,
|
||||||
|
)
|
||||||
|
for item_id, use_delta in sorted(paint_items.items())
|
||||||
|
if item_id in menu_colors
|
||||||
|
)
|
||||||
|
if not cans:
|
||||||
|
raise ValueError("No complete B42 paint cans were found in the game data")
|
||||||
|
missing_colors = sorted(set(paint_items) - set(menu_colors))
|
||||||
|
if missing_colors:
|
||||||
|
raise ValueError(f"B42 paint cans are missing RGB values: {', '.join(missing_colors)}")
|
||||||
|
if len(cans) > MAX_PAINT_CANS:
|
||||||
|
raise ValueError(f"B42 paint palette exceeds the {MAX_PAINT_CANS}-can safety limit")
|
||||||
|
return cans
|
||||||
|
|
||||||
|
|
||||||
|
def _module_before(text: str, position: int) -> str:
|
||||||
|
modules = [match.group(1) for match in MODULE_PATTERN.finditer(text, 0, position)]
|
||||||
|
return modules[-1] if modules else "Base"
|
||||||
|
|
||||||
|
|
||||||
|
def _vehicle_skins(vehicle_block: str) -> tuple[SkinDefinition, ...]:
|
||||||
|
skins: list[SkinDefinition] = []
|
||||||
|
for skin_match in SKIN_PATTERN.finditer(vehicle_block):
|
||||||
|
opening_brace = vehicle_block.find("{", skin_match.end())
|
||||||
|
skin_block = _extract_braced_block(vehicle_block, opening_brace)
|
||||||
|
if skin_block is None:
|
||||||
|
continue
|
||||||
|
texture_match = TEXTURE_PATTERN.search(skin_block)
|
||||||
|
if texture_match:
|
||||||
|
reference = texture_match.group(1).strip().strip("'\"")
|
||||||
|
if reference:
|
||||||
|
if len(reference) > MAX_METADATA_CHARS:
|
||||||
|
raise ValueError("Vehicle texture reference exceeds the safety limit")
|
||||||
|
skins.append(SkinDefinition(len(skins), reference))
|
||||||
|
if len(skins) > MAX_SKINS_PER_VEHICLE:
|
||||||
|
raise ValueError(
|
||||||
|
f"Vehicle exceeds the {MAX_SKINS_PER_VEHICLE}-skin safety limit"
|
||||||
|
)
|
||||||
|
return tuple(skins)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_vehicle_scripts(scripts_root: Path) -> list[VehicleDefinition]:
|
||||||
|
"""Parse drivable vehicle IDs and skin texture references from PZ scripts."""
|
||||||
|
if not scripts_root.is_dir():
|
||||||
|
return []
|
||||||
|
vehicles: list[VehicleDefinition] = []
|
||||||
|
script_paths = sorted(
|
||||||
|
_bounded_files(scripts_root, suffix=".txt"),
|
||||||
|
key=lambda path: str(path).casefold(),
|
||||||
|
)
|
||||||
|
for script_path in script_paths:
|
||||||
|
try:
|
||||||
|
text = _without_comments(_read_text(script_path))
|
||||||
|
except (OSError, ScanFileError):
|
||||||
|
continue
|
||||||
|
for vehicle_match in VEHICLE_PATTERN.finditer(text):
|
||||||
|
short_vehicle_id = vehicle_match.group(1)
|
||||||
|
if short_vehicle_id.casefold().startswith("trailer"):
|
||||||
|
continue
|
||||||
|
opening_brace = text.find("{", vehicle_match.end())
|
||||||
|
block = _extract_braced_block(text, opening_brace)
|
||||||
|
if block is None or not DRIVABLE_PATTERN.search(block):
|
||||||
|
continue
|
||||||
|
skins = _vehicle_skins(block)
|
||||||
|
if not skins:
|
||||||
|
continue
|
||||||
|
module_name = _module_before(text, vehicle_match.start())
|
||||||
|
vehicles.append(
|
||||||
|
VehicleDefinition(
|
||||||
|
vehicle_id=f"{module_name}.{short_vehicle_id}",
|
||||||
|
skins=skins,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if len(vehicles) > MAX_VEHICLES:
|
||||||
|
raise ValueError(f"Script scan exceeds the {MAX_VEHICLES}-vehicle safety limit")
|
||||||
|
unique = {(vehicle.vehicle_id, vehicle.skins): vehicle for vehicle in vehicles}
|
||||||
|
return sorted(unique.values(), key=lambda vehicle: vehicle.vehicle_id.casefold())
|
||||||
|
|
||||||
|
|
||||||
|
def _case_insensitive_file(root: Path, relative_path: Path) -> Path | None:
|
||||||
|
current = root
|
||||||
|
for part in relative_path.parts:
|
||||||
|
if part in ("", ".", ".."):
|
||||||
|
return None
|
||||||
|
direct = current / part
|
||||||
|
if direct.exists():
|
||||||
|
current = direct
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
match = next(
|
||||||
|
(child for child in current.iterdir() if child.name.casefold() == part.casefold()),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
if match is None:
|
||||||
|
return None
|
||||||
|
current = match
|
||||||
|
if not current.is_file():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
resolved_root = root.resolve(strict=True)
|
||||||
|
resolved_file = current.resolve(strict=True)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return resolved_file if resolved_file.is_relative_to(resolved_root) else None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_texture(mod: ModCandidate, reference: str) -> Path | None:
|
||||||
|
normalized = reference.replace("\\", "/").strip().lstrip("/")
|
||||||
|
if ":" in normalized:
|
||||||
|
return None
|
||||||
|
if not normalized.casefold().endswith(".png"):
|
||||||
|
normalized += ".png"
|
||||||
|
relative_path = Path(*normalized.split("/"))
|
||||||
|
if relative_path.is_absolute() or relative_path.drive or relative_path.anchor:
|
||||||
|
return None
|
||||||
|
texture_roots = (
|
||||||
|
mod.content_root / "media" / "textures",
|
||||||
|
mod.mod_root / "common" / "media" / "textures",
|
||||||
|
mod.mod_root / "media" / "textures",
|
||||||
|
)
|
||||||
|
for texture_root in texture_roots:
|
||||||
|
resolved = _case_insensitive_file(texture_root, relative_path)
|
||||||
|
if resolved is not None:
|
||||||
|
return resolved
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _srgb_to_lab(rgb: tuple[float, float, float]) -> tuple[float, float, float]:
|
||||||
|
linear = tuple(
|
||||||
|
channel / 12.92
|
||||||
|
if channel <= 0.04045
|
||||||
|
else ((channel + 0.055) / 1.055) ** 2.4
|
||||||
|
for channel in rgb
|
||||||
|
)
|
||||||
|
red, green, blue = linear
|
||||||
|
x = (0.4124564 * red + 0.3575761 * green + 0.1804375 * blue) / 0.95047
|
||||||
|
y = 0.2126729 * red + 0.7151522 * green + 0.0721750 * blue
|
||||||
|
z = (0.0193339 * red + 0.1191920 * green + 0.9503041 * blue) / 1.08883
|
||||||
|
|
||||||
|
def transform(channel: float) -> float:
|
||||||
|
return channel ** (1 / 3) if channel > 216 / 24389 else (24389 / 27 * channel + 16) / 116
|
||||||
|
|
||||||
|
x_value, y_value, z_value = transform(x), transform(y), transform(z)
|
||||||
|
return 116 * y_value - 16, 500 * (x_value - y_value), 200 * (y_value - z_value)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=131_072)
|
||||||
|
def _nearest_paint(
|
||||||
|
red: int,
|
||||||
|
green: int,
|
||||||
|
blue: int,
|
||||||
|
palette_labs: tuple[tuple[PaintCan, tuple[float, float, float]], ...],
|
||||||
|
) -> PaintCan:
|
||||||
|
pixel_lab = _srgb_to_lab((red / 255, green / 255, blue / 255))
|
||||||
|
return min(
|
||||||
|
palette_labs,
|
||||||
|
key=lambda entry: (
|
||||||
|
sum((pixel_lab[index] - entry[1][index]) ** 2 for index in range(3)),
|
||||||
|
entry[0].item_id,
|
||||||
|
),
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_colors(
|
||||||
|
texture_path: Path,
|
||||||
|
paint_cans: Sequence[PaintCan],
|
||||||
|
) -> list[PaintPercentage]:
|
||||||
|
"""Map non-transparent PNG pixels to their nearest available B42 paint cans."""
|
||||||
|
if not paint_cans:
|
||||||
|
raise ValueError("The B42 paint palette is empty")
|
||||||
|
palette_labs = tuple((paint, _srgb_to_lab(paint.rgb)) for paint in paint_cans)
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("error", Image.DecompressionBombWarning)
|
||||||
|
image = Image.open(texture_path, formats=("PNG",))
|
||||||
|
with image:
|
||||||
|
if image.format != "PNG":
|
||||||
|
raise UnidentifiedImageError(f"Not a PNG image: {texture_path}")
|
||||||
|
width, height = image.size
|
||||||
|
if width <= 0 or height <= 0 or width * height > MAX_IMAGE_PIXELS:
|
||||||
|
raise ScanFileError(
|
||||||
|
f"Image exceeds the {MAX_IMAGE_PIXELS}-pixel safety limit: {texture_path}"
|
||||||
|
)
|
||||||
|
rgba = image.convert("RGBA")
|
||||||
|
counts: Counter[PaintCan] = Counter()
|
||||||
|
rgba_counts = rgba.getcolors(maxcolors=MAX_UNIQUE_COLORS)
|
||||||
|
if rgba_counts is None:
|
||||||
|
raise ScanFileError(
|
||||||
|
f"Image exceeds the {MAX_UNIQUE_COLORS}-unique-color safety limit: {texture_path}"
|
||||||
|
)
|
||||||
|
for pixel_count, (red, green, blue, opacity) in rgba_counts:
|
||||||
|
if not opacity:
|
||||||
|
continue
|
||||||
|
paint = _nearest_paint(red, green, blue, palette_labs)
|
||||||
|
counts[paint] += pixel_count * opacity
|
||||||
|
total = sum(counts.values())
|
||||||
|
if total == 0:
|
||||||
|
return []
|
||||||
|
sorted_counts = sorted(counts.items(), key=lambda item: (-item[1], item[0].item_id))
|
||||||
|
percentage_units = {paint: count * 1000 // total for paint, count in counts.items()}
|
||||||
|
remaining_units = 1000 - sum(percentage_units.values())
|
||||||
|
remainders = sorted(
|
||||||
|
counts,
|
||||||
|
key=lambda paint: (-(counts[paint] * 1000 % total), paint.item_id),
|
||||||
|
)
|
||||||
|
for paint in remainders[:remaining_units]:
|
||||||
|
percentage_units[paint] += 1
|
||||||
|
return [
|
||||||
|
PaintPercentage(paint_can=paint, percentage=percentage_units[paint] / 10)
|
||||||
|
for paint, _count in sorted_counts
|
||||||
|
if percentage_units[paint] > 0
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def scan_workshop(workshop_root: Path, paint_cans: Sequence[PaintCan]) -> ScanReport:
|
||||||
|
mods = discover_mods(workshop_root)
|
||||||
|
results: list[TextureResult] = []
|
||||||
|
warnings: list[str] = []
|
||||||
|
cars_found = 0
|
||||||
|
paint_cache: dict[Path, tuple[PaintPercentage, ...]] = {}
|
||||||
|
for mod in mods:
|
||||||
|
scripts_root = mod.content_root / "media" / "scripts" / "vehicles"
|
||||||
|
vehicles = parse_vehicle_scripts(scripts_root)
|
||||||
|
cars_found += len(vehicles)
|
||||||
|
for vehicle in vehicles:
|
||||||
|
for skin in vehicle.skins:
|
||||||
|
reference = skin.texture_reference
|
||||||
|
texture_path = resolve_texture(mod, reference)
|
||||||
|
if texture_path is None:
|
||||||
|
if len(warnings) >= MAX_WARNINGS:
|
||||||
|
raise ValueError(f"Scan exceeded the {MAX_WARNINGS}-warning safety limit")
|
||||||
|
warnings.append(f"Missing texture for {vehicle.vehicle_id}: {reference}")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
paints = paint_cache.get(texture_path)
|
||||||
|
if paints is None:
|
||||||
|
paints = tuple(analyze_colors(texture_path, paint_cans))
|
||||||
|
paint_cache[texture_path] = paints
|
||||||
|
except (
|
||||||
|
OSError,
|
||||||
|
ScanFileError,
|
||||||
|
UnidentifiedImageError,
|
||||||
|
Image.DecompressionBombError,
|
||||||
|
Image.DecompressionBombWarning,
|
||||||
|
) as error:
|
||||||
|
if len(warnings) >= MAX_WARNINGS:
|
||||||
|
raise ValueError(f"Scan exceeded the {MAX_WARNINGS}-warning safety limit")
|
||||||
|
warnings.append(f"Could not read {texture_path}: {error}")
|
||||||
|
continue
|
||||||
|
results.append(
|
||||||
|
TextureResult(
|
||||||
|
workshop_id=mod.workshop_id,
|
||||||
|
mod_id=mod.mod_id,
|
||||||
|
mod_name=mod.name,
|
||||||
|
vehicle_id=vehicle.vehicle_id,
|
||||||
|
skin_index=skin.skin_index,
|
||||||
|
texture_reference=reference,
|
||||||
|
texture_path=texture_path,
|
||||||
|
paints=paints,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if len(results) > MAX_TEXTURE_RESULTS:
|
||||||
|
raise ValueError(
|
||||||
|
f"Scan exceeded the {MAX_TEXTURE_RESULTS}-texture safety limit"
|
||||||
|
)
|
||||||
|
return ScanReport(
|
||||||
|
mods_scanned=len(mods),
|
||||||
|
cars_found=cars_found,
|
||||||
|
textures=tuple(results),
|
||||||
|
warnings=tuple(warnings),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_paints(paints: Iterable[PaintPercentage]) -> str:
|
||||||
|
formatted = ", ".join(
|
||||||
|
f"{_safe_console_text(paint.paint_can.display_name)} "
|
||||||
|
f"[{_safe_console_text(paint.paint_can.item_id)}] {paint.percentage:.1f}%"
|
||||||
|
for paint in paints
|
||||||
|
)
|
||||||
|
return formatted or "no visible pixels"
|
||||||
|
|
||||||
|
|
||||||
|
def _lua_string(value: object) -> str:
|
||||||
|
text = str(value)
|
||||||
|
if len(text) > MAX_METADATA_CHARS:
|
||||||
|
raise ValueError("Lua manifest field exceeds the safety limit")
|
||||||
|
escaped = (
|
||||||
|
text
|
||||||
|
.replace("\\", "\\\\")
|
||||||
|
.replace('"', '\\"')
|
||||||
|
.replace("\r", "\\r")
|
||||||
|
.replace("\n", "\\n")
|
||||||
|
.replace("\t", "\\t")
|
||||||
|
)
|
||||||
|
escaped = re.sub(
|
||||||
|
r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]",
|
||||||
|
lambda match: f"\\{ord(match.group(0)):03d}",
|
||||||
|
escaped,
|
||||||
|
)
|
||||||
|
return f'"{escaped}"'
|
||||||
|
|
||||||
|
|
||||||
|
def _render_lua_manifest(
|
||||||
|
report: ScanReport,
|
||||||
|
paint_cans: Sequence[PaintCan],
|
||||||
|
) -> str:
|
||||||
|
bucket_uses = paint_bucket_uses(paint_cans)
|
||||||
|
lines = [
|
||||||
|
"-- Generated by scanner/scan_ki5_workshop.py. Do not edit by hand.",
|
||||||
|
"PaintMyKI5 = PaintMyKI5 or {}",
|
||||||
|
"PaintMyKI5.VehiclePaintData = {",
|
||||||
|
" schemaVersion = 2,",
|
||||||
|
f" bucketUses = {bucket_uses},",
|
||||||
|
f" modCount = {report.mods_scanned},",
|
||||||
|
f" carCount = {report.cars_found},",
|
||||||
|
f" textureCount = {len(report.textures)},",
|
||||||
|
" paintCans = {",
|
||||||
|
]
|
||||||
|
for paint in sorted(paint_cans, key=lambda entry: entry.item_id.casefold()):
|
||||||
|
red, green, blue = paint.rgb
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
" {",
|
||||||
|
f" item = {_lua_string(paint.item_id)},",
|
||||||
|
f" name = {_lua_string(paint.display_name)},",
|
||||||
|
f" rgb = {{ {red:.2f}, {green:.2f}, {blue:.2f} }},",
|
||||||
|
f" useDelta = {paint.use_delta:.3f},",
|
||||||
|
" },",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
lines.extend([" },", " vehicles = {"])
|
||||||
|
|
||||||
|
sorted_textures = sorted(
|
||||||
|
report.textures,
|
||||||
|
key=lambda texture: (
|
||||||
|
texture.vehicle_id.casefold(),
|
||||||
|
texture.skin_index,
|
||||||
|
texture.texture_reference.casefold(),
|
||||||
|
texture.workshop_id.casefold(),
|
||||||
|
texture.mod_id.casefold(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
current_vehicle: str | None = None
|
||||||
|
for texture in sorted_textures:
|
||||||
|
if texture.vehicle_id != current_vehicle:
|
||||||
|
if current_vehicle is not None:
|
||||||
|
lines.extend([" },", " },"])
|
||||||
|
current_vehicle = texture.vehicle_id
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
f" [{_lua_string(texture.vehicle_id)}] = {{",
|
||||||
|
f" vehicleId = {_lua_string(texture.vehicle_id)},",
|
||||||
|
f" workshopId = {_lua_string(texture.workshop_id)},",
|
||||||
|
f" modId = {_lua_string(texture.mod_id)},",
|
||||||
|
f" modName = {_lua_string(texture.mod_name)},",
|
||||||
|
" textures = {",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
" {",
|
||||||
|
f" skinIndex = {texture.skin_index},",
|
||||||
|
f" texture = {_lua_string(texture.texture_reference)},",
|
||||||
|
" paints = {",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for paint, uses in allocate_paint_uses(texture.paints, bucket_uses):
|
||||||
|
lines.append(
|
||||||
|
" { item = "
|
||||||
|
f"{_lua_string(paint.paint_can.item_id)}, percent = {paint.percentage:.1f}, "
|
||||||
|
f"uses = {uses:.2f} }},"
|
||||||
|
)
|
||||||
|
lines.extend([" },", " },"])
|
||||||
|
if current_vehicle is not None:
|
||||||
|
lines.extend([" },", " },"])
|
||||||
|
lines.extend([" },", "}", ""])
|
||||||
|
manifest = "\n".join(lines)
|
||||||
|
if len(manifest.encode("utf-8")) > MAX_MANIFEST_BYTES:
|
||||||
|
raise ValueError(
|
||||||
|
f"Lua manifest exceeds the {MAX_MANIFEST_BYTES}-byte safety limit"
|
||||||
|
)
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
def write_lua_manifest(
|
||||||
|
output_path: Path,
|
||||||
|
report: ScanReport,
|
||||||
|
paint_cans: Sequence[PaintCan],
|
||||||
|
) -> None:
|
||||||
|
"""Atomically write a deterministic shared-Lua manifest for the in-game menu."""
|
||||||
|
output_path = output_path.expanduser()
|
||||||
|
temporary_path: Path | None = None
|
||||||
|
try:
|
||||||
|
manifest = _render_lua_manifest(report, paint_cans)
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w",
|
||||||
|
encoding="utf-8",
|
||||||
|
newline="\n",
|
||||||
|
dir=output_path.parent,
|
||||||
|
prefix=f".{output_path.name}.",
|
||||||
|
suffix=".tmp",
|
||||||
|
delete=False,
|
||||||
|
) as temporary_file:
|
||||||
|
temporary_path = Path(temporary_file.name)
|
||||||
|
temporary_file.write(manifest)
|
||||||
|
temporary_file.flush()
|
||||||
|
os.fsync(temporary_file.fileno())
|
||||||
|
os.replace(temporary_path, output_path)
|
||||||
|
except OSError as error:
|
||||||
|
raise ValueError(f"Could not write Lua manifest {output_path}: {error}") from error
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if temporary_path is not None:
|
||||||
|
temporary_path.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_console_text(value: object) -> str:
|
||||||
|
text = str(value)
|
||||||
|
return "".join(
|
||||||
|
character
|
||||||
|
if character in "\t" or 0x20 <= ord(character) <= 0x7E or ord(character) >= 0xA0
|
||||||
|
else "?"
|
||||||
|
for character in text
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _print_report(report: ScanReport, paint_cans: Sequence[PaintCan], output: TextIO) -> None:
|
||||||
|
print(f"B42 paint cans: {len(paint_cans)}", file=output)
|
||||||
|
for paint in paint_cans:
|
||||||
|
red, green, blue = (round(channel * 255) for channel in paint.rgb)
|
||||||
|
print(
|
||||||
|
f" {_safe_console_text(paint.display_name)} "
|
||||||
|
f"[{_safe_console_text(paint.item_id)}] RGB({red}, {green}, {blue})",
|
||||||
|
file=output,
|
||||||
|
)
|
||||||
|
print(file=output)
|
||||||
|
previous_mod: tuple[str, str] | None = None
|
||||||
|
previous_vehicle: str | None = None
|
||||||
|
for texture in report.textures:
|
||||||
|
mod_key = (texture.workshop_id, texture.mod_id)
|
||||||
|
if mod_key != previous_mod:
|
||||||
|
if previous_mod is not None:
|
||||||
|
print(file=output)
|
||||||
|
print(
|
||||||
|
f"Workshop {_safe_console_text(texture.workshop_id)} | "
|
||||||
|
f"{_safe_console_text(texture.mod_name)} ({_safe_console_text(texture.mod_id)})",
|
||||||
|
file=output,
|
||||||
|
)
|
||||||
|
previous_mod = mod_key
|
||||||
|
previous_vehicle = None
|
||||||
|
if texture.vehicle_id != previous_vehicle:
|
||||||
|
print(f" Vehicle: {_safe_console_text(texture.vehicle_id)}", file=output)
|
||||||
|
previous_vehicle = texture.vehicle_id
|
||||||
|
print(f" Texture: {_safe_console_text(texture.texture_reference)}", file=output)
|
||||||
|
print(f" File: {_safe_console_text(texture.texture_path)}", file=output)
|
||||||
|
print(f" Nearest B42 paints: {_format_paints(texture.paints)}", file=output)
|
||||||
|
for warning in report.warnings:
|
||||||
|
print(f"WARNING: {_safe_console_text(warning)}", file=output)
|
||||||
|
print(
|
||||||
|
f"Scanned {report.mods_scanned} mods, {report.cars_found} cars, "
|
||||||
|
f"{len(report.textures)} textures; {len(report.warnings)} warnings.",
|
||||||
|
file=output,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_argument_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="List DamnLib KI5 car skin textures and their color percentages."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--workshop-root",
|
||||||
|
type=Path,
|
||||||
|
default=DEFAULT_WORKSHOP_ROOT,
|
||||||
|
help=f"Project Zomboid Workshop content directory (default: {DEFAULT_WORKSHOP_ROOT})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
type=Path,
|
||||||
|
default=DEFAULT_OUTPUT_PATH,
|
||||||
|
help=f"Generated shared-Lua data file (default: {DEFAULT_OUTPUT_PATH})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--quiet",
|
||||||
|
action="store_true",
|
||||||
|
help="Only print the output path and final scan counts.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--game-root",
|
||||||
|
type=Path,
|
||||||
|
default=DEFAULT_GAME_ROOT,
|
||||||
|
help=f"Project Zomboid game directory (default: {DEFAULT_GAME_ROOT})",
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(arguments: Sequence[str] | None = None, *, output: TextIO = sys.stdout) -> int:
|
||||||
|
options = build_argument_parser().parse_args(arguments)
|
||||||
|
try:
|
||||||
|
paint_cans = discover_paint_cans(options.game_root)
|
||||||
|
report = scan_workshop(options.workshop_root, paint_cans)
|
||||||
|
write_lua_manifest(options.output, report, paint_cans)
|
||||||
|
except ValueError as error:
|
||||||
|
print(f"ERROR: {_safe_console_text(error)}", file=output)
|
||||||
|
return 2
|
||||||
|
if not options.quiet:
|
||||||
|
_print_report(report, paint_cans, output)
|
||||||
|
print(
|
||||||
|
f"Wrote {len(report.textures)} textures for {report.cars_found} cars to "
|
||||||
|
f"{_safe_console_text(options.output.resolve())}",
|
||||||
|
file=output,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Tests for the PaintMyKI5 scanner package."""
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
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"
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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("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
|
||||||
|
|
||||||
|
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 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)
|
||||||
|
|
||||||
|
print("Lua tests passed: " .. passed)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from lupa import LuaRuntime
|
||||||
|
|
||||||
|
|
||||||
|
project_root = Path(__file__).resolve().parents[2]
|
||||||
|
runner = Path(__file__).with_name("lua") / "run.lua"
|
||||||
|
runtime = LuaRuntime(unpack_returned_tuples=True)
|
||||||
|
runtime.globals().PROJECT_ROOT = project_root.as_posix()
|
||||||
|
compile_file = runtime.eval(
|
||||||
|
"function(path) local chunk, message = loadfile(path); "
|
||||||
|
"if not chunk then error(message) end end"
|
||||||
|
)
|
||||||
|
for lua_file in sorted((project_root / "42.20/media/lua").rglob("*.lua")):
|
||||||
|
compile_file(lua_file.as_posix())
|
||||||
|
runtime.execute(runner.read_text(encoding="utf-8"))
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from scanner import scan_ki5_workshop as scanner
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
LUA_ROOT = PROJECT_ROOT / "42.20/media/lua"
|
||||||
|
|
||||||
|
|
||||||
|
class PaintUseAllocationTests(unittest.TestCase):
|
||||||
|
def test_allocates_one_full_bucket_proportionally(self) -> None:
|
||||||
|
paints = (
|
||||||
|
scanner.PaintPercentage(scanner.PaintCan("Base.PaintGrey", "Grey", (0.5,) * 3, 0.1), 60.0),
|
||||||
|
scanner.PaintPercentage(scanner.PaintCan("Base.PaintGreen", "Green", (0.4,) * 3, 0.1), 20.0),
|
||||||
|
scanner.PaintPercentage(scanner.PaintCan("Base.PaintWhite", "White", (0.9,) * 3, 0.1), 20.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
allocated = scanner.allocate_paint_uses(paints, 10)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[("Base.PaintGrey", 6.0), ("Base.PaintGreen", 2.0), ("Base.PaintWhite", 2.0)],
|
||||||
|
[(entry.paint_can.item_id, uses) for entry, uses in allocated],
|
||||||
|
)
|
||||||
|
self.assertAlmostEqual(10.0, sum(uses for _entry, uses in allocated))
|
||||||
|
|
||||||
|
def test_equal_remainders_are_deterministic(self) -> None:
|
||||||
|
paints = tuple(
|
||||||
|
scanner.PaintPercentage(
|
||||||
|
scanner.PaintCan(item_id, item_id, (0.5,) * 3, 0.1),
|
||||||
|
percentage,
|
||||||
|
)
|
||||||
|
for item_id, percentage in (
|
||||||
|
("Base.PaintRed", 33.3),
|
||||||
|
("Base.PaintBlue", 33.3),
|
||||||
|
("Base.PaintGreen", 33.4),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
allocated = scanner.allocate_paint_uses(paints, 10)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[("Base.PaintGreen", 3.34), ("Base.PaintBlue", 3.33), ("Base.PaintRed", 3.33)],
|
||||||
|
[(entry.paint_can.item_id, uses) for entry, uses in allocated],
|
||||||
|
)
|
||||||
|
self.assertAlmostEqual(10.0, sum(uses for _entry, uses in allocated))
|
||||||
|
|
||||||
|
def test_bucket_capacity_comes_from_game_use_delta(self) -> None:
|
||||||
|
paints = (
|
||||||
|
scanner.PaintCan("Base.PaintGreen", "Green", (0.4,) * 3, 0.1),
|
||||||
|
scanner.PaintCan("Base.PaintWhite", "White", (0.9,) * 3, 0.1),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(10, scanner.paint_bucket_uses(paints))
|
||||||
|
|
||||||
|
|
||||||
|
class InGameLuaContractTests(unittest.TestCase):
|
||||||
|
EXPECTED_FILES = (
|
||||||
|
"shared/PaintMyKI5/PaintRequirements.lua",
|
||||||
|
"shared/PaintMyKI5/PaintInventory.lua",
|
||||||
|
"shared/PaintMyKI5/TimedActions/ISPaintMyKI5Vehicle.lua",
|
||||||
|
"client/PaintMyKI5/ISPaintMyKI5UI.lua",
|
||||||
|
"client/PaintMyKI5/PaintVehicleContextMenu.lua",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_runtime_files_exist(self) -> None:
|
||||||
|
self.assertEqual([], [name for name in self.EXPECTED_FILES if not (LUA_ROOT / name).is_file()])
|
||||||
|
|
||||||
|
def test_server_authoritative_timed_action_contract(self) -> None:
|
||||||
|
source = (LUA_ROOT / self.EXPECTED_FILES[2]).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn("ISBaseTimedAction:derive", source)
|
||||||
|
self.assertIn("function ISPaintMyKI5Vehicle:complete()", source)
|
||||||
|
self.assertIn("PaintRequirements.getRequirements", source)
|
||||||
|
self.assertIn("PaintInventory.consumeRequirements", source)
|
||||||
|
self.assertIn("vehicle:setSkinIndex", source)
|
||||||
|
self.assertIn("vehicle:transmitSkinIndex", source)
|
||||||
|
self.assertNotRegex(source, r"sendClientCommand\([^\n]+setSkinIndex")
|
||||||
|
|
||||||
|
def test_context_menu_and_texture_preview_contract(self) -> None:
|
||||||
|
context_source = (LUA_ROOT / self.EXPECTED_FILES[4]).read_text(encoding="utf-8")
|
||||||
|
ui_source = (LUA_ROOT / self.EXPECTED_FILES[3]).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn("Events.OnFillWorldObjectContextMenu.Add", context_source)
|
||||||
|
self.assertIn("getFullName()", context_source)
|
||||||
|
self.assertIn("getTexture", ui_source)
|
||||||
|
self.assertIn("or getTexture(textureData.texture)", ui_source)
|
||||||
|
self.assertIn("drawTextureScaledAspect", ui_source)
|
||||||
|
self.assertGreaterEqual(ui_source.count("PaintRequirements.isValidTarget"), 2)
|
||||||
|
self.assertIn("ISTimedActionQueue.add", ui_source)
|
||||||
|
|
||||||
|
def test_b42_translation_files_are_valid_json(self) -> None:
|
||||||
|
context = json.loads(
|
||||||
|
(LUA_ROOT / "shared/Translate/EN/ContextMenu.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
ui = json.loads((LUA_ROOT / "shared/Translate/EN/IG_UI.json").read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
self.assertEqual("Paint vehicle", context["ContextMenu_PaintMyKI5"])
|
||||||
|
self.assertIn("IGUI_PaintMyKI5_Title", ui)
|
||||||
|
self.assertIn("IGUI_PaintMyKI5_OneBucket", ui)
|
||||||
|
|
||||||
|
def test_generated_manifest_contains_exact_use_requirements(self) -> None:
|
||||||
|
manifest = (
|
||||||
|
LUA_ROOT / "shared/PaintMyKI5/PaintMyKI5VehiclePaintData.lua"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn("schemaVersion = 2", manifest)
|
||||||
|
self.assertIn("bucketUses = 10", manifest)
|
||||||
|
self.assertRegex(manifest, r'item = "Base\.Paint\w+", percent = \d+\.\d, uses = \d+\.\d\d')
|
||||||
|
use_groups = re.findall(r"paints = \{(.*?)\n \},", manifest, re.S)
|
||||||
|
self.assertTrue(use_groups)
|
||||||
|
self.assertTrue(
|
||||||
|
all(
|
||||||
|
abs(sum(map(float, re.findall(r"uses = (\d+\.\d+)", group))) - 10.0) < 0.001
|
||||||
|
for group in use_groups
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from dataclasses import replace
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from scanner.scan_ki5_workshop import (
|
||||||
|
MAX_METADATA_CHARS,
|
||||||
|
ModCandidate,
|
||||||
|
PaintCan,
|
||||||
|
analyze_colors,
|
||||||
|
discover_paint_cans,
|
||||||
|
discover_mods,
|
||||||
|
main,
|
||||||
|
parse_vehicle_scripts,
|
||||||
|
resolve_texture,
|
||||||
|
scan_workshop,
|
||||||
|
write_lua_manifest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ScannerTests(unittest.TestCase):
|
||||||
|
TEST_PAINTS = (
|
||||||
|
PaintCan("Base.PaintGreen", "Paint - Green", (0.41, 0.80, 0.41), 0.1),
|
||||||
|
PaintCan("Base.PaintGrey", "Paint - Gray", (0.50, 0.50, 0.50), 0.1),
|
||||||
|
PaintCan("Base.PaintWhite", "Paint - White", (0.92, 0.92, 0.92), 0.1),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_discovers_full_b42_paint_cans_and_rgb_values(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
game_root = Path(temporary_directory)
|
||||||
|
self._write_game_palette(game_root)
|
||||||
|
|
||||||
|
paints = discover_paint_cans(game_root)
|
||||||
|
|
||||||
|
self.assertEqual(self.TEST_PAINTS, paints)
|
||||||
|
|
||||||
|
def test_paint_discovery_excludes_spray_paint_and_incomplete_buckets(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
game_root = Path(temporary_directory)
|
||||||
|
self._write_game_palette(game_root, include_decoys=True)
|
||||||
|
|
||||||
|
paints = discover_paint_cans(game_root)
|
||||||
|
|
||||||
|
self.assertEqual(self.TEST_PAINTS, paints)
|
||||||
|
|
||||||
|
def test_paint_discovery_handles_multiple_semicolon_separated_tags(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
game_root = Path(temporary_directory)
|
||||||
|
self._write_game_palette(game_root)
|
||||||
|
item_script = game_root / "media/scripts/generated/items/drainable.txt"
|
||||||
|
contents = item_script.read_text(encoding="utf-8")
|
||||||
|
item_script.write_text(
|
||||||
|
contents.replace("Tags = base:paint", "Tags = base:tool;base:paint"),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
paints = discover_paint_cans(game_root)
|
||||||
|
|
||||||
|
self.assertEqual(self.TEST_PAINTS, paints)
|
||||||
|
|
||||||
|
def test_discovers_damnlib_once_and_selects_latest_b42_overlay(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
root = Path(temporary_directory)
|
||||||
|
mod_root = root / "123" / "mods" / "testCar"
|
||||||
|
self._write(mod_root / "mod.info", "name=Test Car\nid=testCar\nrequire=other, DamnLib\n")
|
||||||
|
self._write(mod_root / "42.13" / "mod.info", "name=Test Car\nid=testCar\nrequire=\\damnlib\n")
|
||||||
|
self._write(mod_root / "42.20" / "mod.info", "name=Test Car\nid=testCar\nrequire=damnlib\n")
|
||||||
|
self._write(mod_root / "42.20" / "media/scripts/vehicles/car.txt", "module Base {}")
|
||||||
|
|
||||||
|
mods = discover_mods(root)
|
||||||
|
|
||||||
|
self.assertEqual(1, len(mods))
|
||||||
|
self.assertEqual("123", mods[0].workshop_id)
|
||||||
|
self.assertEqual("testCar", mods[0].mod_id)
|
||||||
|
self.assertEqual(mod_root / "42.20", mods[0].content_root)
|
||||||
|
|
||||||
|
def test_dependency_token_must_match_exactly(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
root = Path(temporary_directory)
|
||||||
|
self._write(
|
||||||
|
root / "123/mods/notDamnLib/mod.info",
|
||||||
|
"name=No Match\nid=noMatch\nrequire=notdamnlib\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual([], discover_mods(root))
|
||||||
|
|
||||||
|
def test_maps_only_skin_textures_to_full_vehicle_id_and_filters_trailers(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
root = Path(temporary_directory)
|
||||||
|
scripts = root / "media/scripts/vehicles"
|
||||||
|
self._write(
|
||||||
|
scripts / "cars.txt",
|
||||||
|
"""
|
||||||
|
module Base
|
||||||
|
{
|
||||||
|
model Interior { texture = Vehicles/Interior, }
|
||||||
|
vehicle ExampleCar
|
||||||
|
{
|
||||||
|
engineForce = 4000,
|
||||||
|
skin { texture = Vehicles/Example_Shell_green, }
|
||||||
|
skin { texture = Vehicles/Example_Shell_white, }
|
||||||
|
skin { texture = Vehicles/Example_Shell_green, }
|
||||||
|
textureDamage1Shell = Vehicles/Example_damage,
|
||||||
|
}
|
||||||
|
vehicle TrailerExample
|
||||||
|
{
|
||||||
|
engineForce = 10,
|
||||||
|
skin { texture = Vehicles/Trailer_Shell, }
|
||||||
|
attachment trailer { offset = 0 0 0, }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
vehicles = parse_vehicle_scripts(scripts)
|
||||||
|
|
||||||
|
self.assertEqual(1, len(vehicles))
|
||||||
|
self.assertEqual("Base.ExampleCar", vehicles[0].vehicle_id)
|
||||||
|
self.assertEqual(
|
||||||
|
(
|
||||||
|
"Vehicles/Example_Shell_green",
|
||||||
|
"Vehicles/Example_Shell_white",
|
||||||
|
"Vehicles/Example_Shell_green",
|
||||||
|
),
|
||||||
|
vehicles[0].texture_references,
|
||||||
|
)
|
||||||
|
self.assertEqual((0, 1, 2), tuple(skin.skin_index for skin in vehicles[0].skins))
|
||||||
|
|
||||||
|
def test_ignores_malformed_vehicle_blocks(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
root = Path(temporary_directory)
|
||||||
|
scripts = root / "media/scripts/vehicles"
|
||||||
|
self._write(
|
||||||
|
scripts / "broken.txt",
|
||||||
|
"""
|
||||||
|
module Base
|
||||||
|
{
|
||||||
|
vehicle BrokenCar
|
||||||
|
engineForce = 4000,
|
||||||
|
skin { texture = Vehicles/Broken_Shell, }
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual([], parse_vehicle_scripts(scripts))
|
||||||
|
|
||||||
|
def test_analyzes_requested_color_mix(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
image_path = Path(temporary_directory) / "mix.png"
|
||||||
|
image = Image.new("RGB", (10, 1))
|
||||||
|
image.putdata([(128, 128, 128)] * 6 + [(105, 204, 105)] * 2 + [(235, 235, 235)] * 2)
|
||||||
|
image.save(image_path)
|
||||||
|
|
||||||
|
colors = analyze_colors(image_path, self.TEST_PAINTS)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
("Base.PaintGrey", 60.0),
|
||||||
|
("Base.PaintGreen", 20.0),
|
||||||
|
("Base.PaintWhite", 20.0),
|
||||||
|
],
|
||||||
|
[(color.paint_can.item_id, color.percentage) for color in colors],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_ignores_fully_transparent_pixels(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
image_path = Path(temporary_directory) / "alpha.png"
|
||||||
|
image = Image.new("RGBA", (2, 1))
|
||||||
|
image.putdata([(255, 0, 0, 255), (0, 0, 255, 0)])
|
||||||
|
image.save(image_path)
|
||||||
|
|
||||||
|
colors = analyze_colors(image_path, self.TEST_PAINTS)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[("Base.PaintGrey", 100.0)],
|
||||||
|
[(color.paint_can.item_id, color.percentage) for color in colors],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_transparent_colors_do_not_influence_visible_palette(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
image_path = Path(temporary_directory) / "visible.png"
|
||||||
|
image = Image.new("RGBA", (257, 1))
|
||||||
|
transparent_noise = [(index, 255 - index, index // 2, 0) for index in range(256)]
|
||||||
|
image.putdata([*transparent_noise, (0, 160, 0, 255)])
|
||||||
|
image.save(image_path)
|
||||||
|
|
||||||
|
colors = analyze_colors(image_path, self.TEST_PAINTS)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[("Base.PaintGreen", 100.0)],
|
||||||
|
[(color.paint_can.item_id, color.percentage) for color in colors],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_integration_resolves_common_texture_and_prints_mapping(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
root = Path(temporary_directory)
|
||||||
|
mod_root = root / "999" / "mods" / "exampleCar"
|
||||||
|
self._write(
|
||||||
|
mod_root / "42.13/mod.info",
|
||||||
|
"name=Example Car\nid=exampleCar\nrequire=damnlib\ncategory=vehicle\n",
|
||||||
|
)
|
||||||
|
self._write(
|
||||||
|
mod_root / "42.13/media/scripts/vehicles/example.txt",
|
||||||
|
"""
|
||||||
|
module Base
|
||||||
|
{
|
||||||
|
vehicle ExampleCar
|
||||||
|
{
|
||||||
|
engineForce = 4000,
|
||||||
|
skin { texture = Vehicles/Example_Shell, }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
image_path = mod_root / "common/media/textures/Vehicles/Example_Shell.png"
|
||||||
|
image_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
Image.new("RGB", (1, 1), (0, 160, 0)).save(image_path)
|
||||||
|
|
||||||
|
report = scan_workshop(root, self.TEST_PAINTS)
|
||||||
|
|
||||||
|
self.assertEqual(1, len(report.textures))
|
||||||
|
self.assertEqual("Base.ExampleCar", report.textures[0].vehicle_id)
|
||||||
|
self.assertEqual(image_path, report.textures[0].texture_path)
|
||||||
|
self.assertEqual("Base.PaintGreen", report.textures[0].paints[0].paint_can.item_id)
|
||||||
|
|
||||||
|
game_root = root / "game"
|
||||||
|
output_path = root / "generated.lua"
|
||||||
|
self._write_game_palette(game_root)
|
||||||
|
output = io.StringIO()
|
||||||
|
exit_code = main(
|
||||||
|
[
|
||||||
|
"--workshop-root",
|
||||||
|
str(root),
|
||||||
|
"--game-root",
|
||||||
|
str(game_root),
|
||||||
|
"--output",
|
||||||
|
str(output_path),
|
||||||
|
],
|
||||||
|
output=output,
|
||||||
|
)
|
||||||
|
console = output.getvalue()
|
||||||
|
self.assertEqual(0, exit_code)
|
||||||
|
self.assertIn("Workshop 999 | Example Car (exampleCar)", console)
|
||||||
|
self.assertIn("Vehicle: Base.ExampleCar", console)
|
||||||
|
self.assertIn("Paint - Green [Base.PaintGreen] 100.0%", console)
|
||||||
|
self.assertIn("B42 paint cans: 3", console)
|
||||||
|
self.assertIn("1 mods, 1 cars, 1 textures", console)
|
||||||
|
|
||||||
|
def test_missing_texture_adds_warning_and_keeps_scanning(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
root = Path(temporary_directory)
|
||||||
|
mod_root = root / "999" / "mods" / "exampleCar"
|
||||||
|
self._write(
|
||||||
|
mod_root / "42.13/mod.info",
|
||||||
|
"name=Example Car\nid=exampleCar\nrequire=damnlib\ncategory=vehicle\n",
|
||||||
|
)
|
||||||
|
self._write(
|
||||||
|
mod_root / "42.13/media/scripts/vehicles/example.txt",
|
||||||
|
"""
|
||||||
|
module Base
|
||||||
|
{
|
||||||
|
vehicle ExampleCar
|
||||||
|
{
|
||||||
|
engineForce = 4000,
|
||||||
|
skin { texture = Vehicles/Missing_Shell, }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
|
||||||
|
report = scan_workshop(root, self.TEST_PAINTS)
|
||||||
|
|
||||||
|
self.assertEqual(1, report.cars_found)
|
||||||
|
self.assertEqual((), report.textures)
|
||||||
|
self.assertEqual(
|
||||||
|
("Missing texture for Base.ExampleCar: Vehicles/Missing_Shell",),
|
||||||
|
report.warnings,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_cli_rejects_missing_workshop_root(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
root = Path(temporary_directory)
|
||||||
|
game_root = root / "game"
|
||||||
|
self._write_game_palette(game_root)
|
||||||
|
missing_root = root / "missing"
|
||||||
|
output = io.StringIO()
|
||||||
|
|
||||||
|
exit_code = main(
|
||||||
|
[
|
||||||
|
"--workshop-root",
|
||||||
|
str(missing_root),
|
||||||
|
"--game-root",
|
||||||
|
str(game_root),
|
||||||
|
],
|
||||||
|
output=output,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(2, exit_code)
|
||||||
|
self.assertIn("Workshop root does not exist", output.getvalue())
|
||||||
|
|
||||||
|
def test_cli_rejects_missing_game_root(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
root = Path(temporary_directory)
|
||||||
|
output = io.StringIO()
|
||||||
|
|
||||||
|
exit_code = main(
|
||||||
|
["--workshop-root", str(root), "--game-root", str(root / "missing")],
|
||||||
|
output=output,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(2, exit_code)
|
||||||
|
self.assertIn("Game root does not exist", output.getvalue())
|
||||||
|
|
||||||
|
def test_rejects_drive_qualified_texture_reference(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
mod_root = Path(temporary_directory) / "123/mods/example"
|
||||||
|
mod_root.mkdir(parents=True)
|
||||||
|
mod = ModCandidate("123", "example", "Example", mod_root, mod_root)
|
||||||
|
|
||||||
|
self.assertIsNone(resolve_texture(mod, "C:outside.png"))
|
||||||
|
|
||||||
|
def test_nearest_paint_matching_uses_supplied_palette(self) -> None:
|
||||||
|
paints = (
|
||||||
|
PaintCan("Custom.Dark", "Dark", (0.1, 0.1, 0.1), 0.1),
|
||||||
|
PaintCan("Custom.Bright", "Bright", (0.9, 0.9, 0.9), 0.1),
|
||||||
|
)
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
image_path = Path(temporary_directory) / "near.png"
|
||||||
|
image = Image.new("RGB", (2, 1))
|
||||||
|
image.putdata([(30, 30, 30), (225, 225, 225)])
|
||||||
|
image.save(image_path)
|
||||||
|
|
||||||
|
percentages = analyze_colors(image_path, paints)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[("Custom.Bright", 50.0), ("Custom.Dark", 50.0)],
|
||||||
|
[(entry.paint_can.item_id, entry.percentage) for entry in percentages],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_partially_transparent_pixels_are_alpha_weighted(self) -> None:
|
||||||
|
paints = (
|
||||||
|
PaintCan("Custom.Dark", "Dark", (0.1, 0.1, 0.1), 0.1),
|
||||||
|
PaintCan("Custom.Bright", "Bright", (0.9, 0.9, 0.9), 0.1),
|
||||||
|
)
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
image_path = Path(temporary_directory) / "alpha-weighted.png"
|
||||||
|
image = Image.new("RGBA", (2, 1))
|
||||||
|
image.putdata([(25, 25, 25, 255), (230, 230, 230, 64)])
|
||||||
|
image.save(image_path)
|
||||||
|
|
||||||
|
percentages = analyze_colors(image_path, paints)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[("Custom.Dark", 79.9), ("Custom.Bright", 20.1)],
|
||||||
|
[(entry.paint_can.item_id, entry.percentage) for entry in percentages],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_paint_palette_is_rejected(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
image_path = Path(temporary_directory) / "pixel.png"
|
||||||
|
Image.new("RGB", (1, 1), (0, 0, 0)).save(image_path)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "paint palette is empty"):
|
||||||
|
analyze_colors(image_path, ())
|
||||||
|
|
||||||
|
def test_writes_deterministic_lua_manifest_for_ingame_menu(self) -> None:
|
||||||
|
from scanner.scan_ki5_workshop import PaintPercentage, ScanReport, TextureResult
|
||||||
|
|
||||||
|
texture = TextureResult(
|
||||||
|
workshop_id="123",
|
||||||
|
mod_id="example\"mod",
|
||||||
|
mod_name="Example\nCar\x01",
|
||||||
|
vehicle_id="Base.ExampleCar",
|
||||||
|
skin_index=0,
|
||||||
|
texture_reference="Vehicles/Example_Shell",
|
||||||
|
texture_path=Path("ignored/absolute/path.png"),
|
||||||
|
paints=(PaintPercentage(self.TEST_PAINTS[0], 100.0),),
|
||||||
|
)
|
||||||
|
report = ScanReport(1, 1, (texture,), ())
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
output_path = Path(temporary_directory) / "generated.lua"
|
||||||
|
|
||||||
|
write_lua_manifest(output_path, report, self.TEST_PAINTS)
|
||||||
|
first_contents = output_path.read_text(encoding="utf-8")
|
||||||
|
write_lua_manifest(output_path, report, self.TEST_PAINTS)
|
||||||
|
|
||||||
|
self.assertEqual(first_contents, output_path.read_text(encoding="utf-8"))
|
||||||
|
self.assertIn("PaintMyKI5.VehiclePaintData = {", first_contents)
|
||||||
|
self.assertIn('vehicleId = "Base.ExampleCar"', first_contents)
|
||||||
|
self.assertIn('texture = "Vehicles/Example_Shell"', first_contents)
|
||||||
|
self.assertIn("skinIndex = 0", first_contents)
|
||||||
|
self.assertIn('item = "Base.PaintGreen"', first_contents)
|
||||||
|
self.assertIn("percent = 100.0", first_contents)
|
||||||
|
self.assertIn('modId = "example\\\"mod"', first_contents)
|
||||||
|
self.assertIn('modName = "Example\\nCar\\001"', first_contents)
|
||||||
|
self.assertNotIn("ignored/absolute/path.png", first_contents)
|
||||||
|
|
||||||
|
oversized = replace(texture, mod_name="x" * (MAX_METADATA_CHARS + 1))
|
||||||
|
output_path.write_text("preserve me", encoding="utf-8")
|
||||||
|
with self.assertRaisesRegex(ValueError, "field exceeds"):
|
||||||
|
write_lua_manifest(
|
||||||
|
output_path,
|
||||||
|
ScanReport(1, 1, (oversized,), ()),
|
||||||
|
self.TEST_PAINTS,
|
||||||
|
)
|
||||||
|
self.assertEqual("preserve me", output_path.read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual([], list(output_path.parent.glob(".*.tmp")))
|
||||||
|
|
||||||
|
def test_cli_writes_requested_lua_manifest(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||||
|
root = Path(temporary_directory)
|
||||||
|
game_root = root / "game"
|
||||||
|
workshop_root = root / "workshop"
|
||||||
|
output_path = root / "out/generated.lua"
|
||||||
|
self._write_game_palette(game_root)
|
||||||
|
mod_root = workshop_root / "999/mods/exampleCar"
|
||||||
|
self._write(
|
||||||
|
mod_root / "42.13/mod.info",
|
||||||
|
"name=Example Car\nid=exampleCar\nrequire=damnlib\n",
|
||||||
|
)
|
||||||
|
self._write(
|
||||||
|
mod_root / "42.13/media/scripts/vehicles/example.txt",
|
||||||
|
"""
|
||||||
|
module Base
|
||||||
|
{
|
||||||
|
vehicle ExampleCar
|
||||||
|
{
|
||||||
|
engineForce = 1,
|
||||||
|
skin { texture = Vehicles/Example_Shell, }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
image_path = mod_root / "common/media/textures/Vehicles/Example_Shell.png"
|
||||||
|
image_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
Image.new("RGB", (1, 1), (105, 204, 105)).save(image_path)
|
||||||
|
output = io.StringIO()
|
||||||
|
|
||||||
|
exit_code = main(
|
||||||
|
[
|
||||||
|
"--game-root", str(game_root),
|
||||||
|
"--workshop-root", str(workshop_root),
|
||||||
|
"--output", str(output_path),
|
||||||
|
"--quiet",
|
||||||
|
],
|
||||||
|
output=output,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, exit_code)
|
||||||
|
self.assertTrue(output_path.is_file())
|
||||||
|
self.assertIn("Wrote 1 textures for 1 cars", output.getvalue())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _write(path: Path, contents: str) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(contents, encoding="utf-8")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _write_game_palette(cls, game_root: Path, *, include_decoys: bool = False) -> None:
|
||||||
|
item_blocks = []
|
||||||
|
lua_rows = []
|
||||||
|
names = {}
|
||||||
|
for paint in cls.TEST_PAINTS:
|
||||||
|
short_id = paint.item_id.split(".", 1)[1]
|
||||||
|
item_blocks.append(
|
||||||
|
f"""
|
||||||
|
item {short_id}
|
||||||
|
{{
|
||||||
|
ItemType = base:drainable,
|
||||||
|
PourType = Bucket,
|
||||||
|
ReplaceOnDeplete = Base.PaintbucketEmpty,
|
||||||
|
UseDelta = {paint.use_delta},
|
||||||
|
Tags = base:paint,
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
red, green, blue = paint.rgb
|
||||||
|
lua_rows.append(
|
||||||
|
f'{{ paint = "{short_id}", text = "unused", color = {{ {red},{green},{blue} }} }},'
|
||||||
|
)
|
||||||
|
names[paint.item_id] = paint.display_name
|
||||||
|
if include_decoys:
|
||||||
|
item_blocks.extend(
|
||||||
|
[
|
||||||
|
"item SprayPaint { ItemType=base:drainable, DisplayCategory=Paint, }",
|
||||||
|
"item PaintFake { ItemType=base:drainable, PourType=Bucket, Tags=base:paint, }",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
lua_rows.extend(
|
||||||
|
[
|
||||||
|
'{ paint = "SprayPaint", text = "unused", color = { 1,0,0 } },',
|
||||||
|
'{ paint = "PaintFake", text = "unused", color = { 0,0,1 } },',
|
||||||
|
]
|
||||||
|
)
|
||||||
|
cls._write(
|
||||||
|
game_root / "media/scripts/generated/items/drainable.txt",
|
||||||
|
"module Base {\n" + "\n".join(item_blocks) + "\n}",
|
||||||
|
)
|
||||||
|
cls._write(
|
||||||
|
game_root / "media/lua/shared/BuildingObjects/ISPaintMenu.lua",
|
||||||
|
"ISPaintMenu.PaintMenuItems = {\n" + "\n".join(lua_rows) + "\n}",
|
||||||
|
)
|
||||||
|
item_names = game_root / "media/lua/shared/Translate/EN/ItemName.json"
|
||||||
|
item_names.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
item_names.write_text(json.dumps(names), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user