diff --git a/config/defaultPayrollConfig.xml b/config/defaultPayrollConfig.xml index 7c6e976..2ac6620 100644 --- a/config/defaultPayrollConfig.xml +++ b/config/defaultPayrollConfig.xml @@ -10,7 +10,7 @@ roleType standard @@ -76,6 +76,16 @@ + + + + + + + + + + diff --git a/modDesc.xml b/modDesc.xml index cea446d..409c69f 100644 --- a/modDesc.xml +++ b/modDesc.xml @@ -1,12 +1,12 @@ SimGamerJen - 0.4.1.1 + 0.4.2.0 <en>Helper Payroll Alpha</en> - 0.4.1.1 Alpha 2: Publishes the role-assignment API globally and on the active mission so HelperProfiles can discover it reliably. Retains the public role-assignment API for HelperProfiles while retaining hourly or daily compensation and per-worker overrides. + 0.4.2.0 Alpha 2: Extends HelperPayroll identity, role assignment, worker overrides and job-slot detection from A-J to A-T for the twenty-helper HelperProfiles roster. Existing A-J save mappings remain compatible. Alpha 2 records the completed A-T integration tests and retains job-start snapshots when roles or appearance bindings change during active work. HelperPayroll_icon.dds diff --git a/scripts/HelperPayroll.lua b/scripts/HelperPayroll.lua index 7286bb8..0305d39 100644 --- a/scripts/HelperPayroll.lua +++ b/scripts/HelperPayroll.lua @@ -1,5 +1,5 @@ -- Helper Payroll --- Version: 0.4.1.1-alpha2 +-- Version: 0.4.2.0-alpha2 -- Purpose: -- 1. Suppress vanilla AI worker payments. -- 2. Track active AI jobs. @@ -14,8 +14,9 @@ local hpGetTimeMs HelperPayroll = {} HelperPayroll.MOD_NAME = g_currentModName or "FS25_HelperPayroll" -HelperPayroll.VERSION = "0.4.1.1" +HelperPayroll.VERSION = "0.4.2.0" HelperPayroll.RELEASE_CHANNEL = "alpha2" +HelperPayroll.TARGET_HELPER_SLOTS = 20 HelperPayroll.MOD_DIRECTORY = g_currentModDirectory or "" HelperPayroll.BUNDLED_CONFIG_FILE = HelperPayroll.MOD_DIRECTORY .. "config/defaultPayrollConfig.xml" HelperPayroll.CONFIG_FILE = HelperPayroll.BUNDLED_CONFIG_FILE @@ -194,7 +195,7 @@ local HPAY_DEFAULT_POLICY_XML = [=[ roleType standard @@ -260,6 +261,16 @@ local HPAY_DEFAULT_POLICY_XML = [=[ + + + + + + + + + + @@ -1839,21 +1850,78 @@ function HelperPayroll:diagnosticScanAIJobForHelperProfiles(job, tracked) rcLog("HelperProfiles diagnostic end: seq=%s candidatesLogged=%d", tostring(seq), tonumber(printed.count) or 0) end -function HelperPayroll:helperIndexToSlot(helperIndex) - local index = tonumber(helperIndex) +function HelperPayroll:indexToHelperSlot(index) + index = math.floor(tonumber(index) or 0) + if index < 1 or index > (tonumber(self.TARGET_HELPER_SLOTS) or 20) then return nil end - if index == nil then - return nil + local label = "" + while index > 0 do + local remainder = (index - 1) % 26 + label = string.char(string.byte("A") + remainder) .. label + index = math.floor((index - 1) / 26) end + return label +end - index = math.floor(index) +function HelperPayroll:normaliseHelperSlot(slot) + if slot == nil then return nil, nil end + local limit = tonumber(self.TARGET_HELPER_SLOTS) or 20 + local numeric = tonumber(slot) + if numeric ~= nil then + local index = math.floor(numeric) + if index >= 1 and index <= limit then return self:indexToHelperSlot(index), index end + return nil, nil + end - if index < 1 or index > 10 then - return nil + local text = string.upper(tostring(slot or "")):gsub("^%s+", ""):gsub("%s+$", "") + text = text:gsub("^SLOT:%s*", "") + text = text:gsub("^HELPER:%s*", "") + local canonical = text:match("^HELPER0*(%d+)$") + if canonical ~= nil then + local index = tonumber(canonical) + if index ~= nil and index >= 1 and index <= limit then return self:indexToHelperSlot(index), index end + return nil, nil end + local helperLabel = text:match("^HELPER%s+([A-Z]+)$") + if helperLabel ~= nil then text = helperLabel end + if text == "" or text:match("^[A-Z]+$") == nil then return nil, nil end - -- FS helper slots are A-J. Diagnostics confirmed job.helperIndex=2 maps to helper B. - return string.char(string.byte("A") + index - 1) + local index = 0 + for i = 1, #text do index = index * 26 + (string.byte(text, i) - string.byte("A") + 1) end + if index < 1 or index > limit then return nil, nil end + return self:indexToHelperSlot(index), index +end + +function HelperPayroll:getManagedHelperSlotCount() + local api = self:getHelperProfilesAPI(false) + if api ~= nil and type(api.getStatus) == "function" then + local ok, status = pcall(api.getStatus, api) + if ok and type(status) == "table" then + local count = tonumber(status.managedSlotCount or status.profileCount or status.targetSlotCount) + if count ~= nil and count > 0 then return math.min(math.floor(count), tonumber(self.TARGET_HELPER_SLOTS) or 20) end + end + end + + if g_helperManager ~= nil then + local count = tonumber(g_helperManager.numHelpers) + if g_helperManager.getNumOfHelpers ~= nil then + local ok, result = pcall(g_helperManager.getNumOfHelpers, g_helperManager) + if ok and tonumber(result) ~= nil then count = tonumber(result) end + end + if count ~= nil and count > 0 then return math.min(math.floor(count), tonumber(self.TARGET_HELPER_SLOTS) or 20) end + end + return 10 +end + +function HelperPayroll:getManagedHelperSlots() + local slots = {} + for index = 1, self:getManagedHelperSlotCount() do slots[index] = self:indexToHelperSlot(index) end + return slots +end + +function HelperPayroll:helperIndexToSlot(helperIndex) + local slot = self:normaliseHelperSlot(helperIndex) + return slot end function HelperPayroll:detectHelperSlotForJob(job, tracked) @@ -1956,10 +2024,10 @@ function HelperPayroll:isHelperProfilesModLoaded() end function HelperPayroll:getHelperProfilesAPI(validateVersion) - local mission = g_currentMission - if mission == nil then return nil end - - local api = mission.fs25HelperProfilesAPI or mission.helperProfilesAPI + local api = rawget(_G, "FS25_HelperProfiles_API") or rawget(_G, "FS25_HelperProfilesAPI") + if type(api) ~= "table" and g_currentMission ~= nil then + api = g_currentMission.fs25HelperProfilesAPI or g_currentMission.helperProfilesAPI + end if type(api) ~= "table" then return nil end if validateVersion ~= false then @@ -1998,12 +2066,8 @@ function HelperPayroll:isHelperProfilesAvailable() end function HelperPayroll:slotToHelperProfilesIndex(slot) - if slot == nil then return nil end - local s = string.upper(tostring(slot or "")) - if #s ~= 1 then return nil end - local idx = string.byte(s) - string.byte("A") + 1 - if idx < 1 or idx > 10 then return nil end - return idx + local _, index = self:normaliseHelperSlot(slot) + return index end function HelperPayroll:getHelperProfilesSlotInfo(slot) @@ -2019,7 +2083,9 @@ function HelperPayroll:getHelperProfilesSlotInfo(slot) slot = tostring(data.slot or string.upper(tostring(slot))), index = tonumber(data.index) or idx, helper = nil, + canonicalId = data.canonicalId ~= nil and tostring(data.canonicalId) or nil, identityId = data.identityId ~= nil and tostring(data.identityId) or nil, + identityAliases = type(data.identityAliases) == "table" and data.identityAliases or {}, identitySource = data.identitySource ~= nil and tostring(data.identitySource) or nil, baseName = data.baseName, displayName = tostring(data.displayName or data.baseName or ("Helper " .. string.upper(tostring(slot)))), @@ -2126,6 +2192,10 @@ function HelperPayroll:rebuildHelperProfilesMappingIndexes() if mapping.identityId ~= nil and tostring(mapping.identityId) ~= "" then self.helperProfilesMappingsByIdentity[tostring(mapping.identityId)] = mapping end + if mapping.canonicalId ~= nil and tostring(mapping.canonicalId) ~= "" then + self.helperProfilesMappingsByIdentity[tostring(mapping.canonicalId)] = mapping + self.helperProfilesMappingsByIdentity["helper:" .. tostring(mapping.canonicalId)] = mapping + end if mapping.slot ~= nil and tostring(mapping.slot) ~= "" then self.helperProfilesMappingsBySlot[string.upper(tostring(mapping.slot))] = mapping end @@ -2138,13 +2208,16 @@ function HelperPayroll:getHelperProfilesPayrollMapping(slotInfo, slot) local identityId = slotInfo ~= nil and slotInfo.identityId ~= nil and tostring(slotInfo.identityId) or nil if identityId ~= nil and identityId ~= "" then local mapping = self.helperProfilesMappingsByIdentity[identityId] - if mapping ~= nil then - return mapping, "identity" - end - -- API v2 preset identities are authoritative. Do not inherit a stale mapping - -- merely because a different named worker has moved into the old A-J slot. - if string.sub(identityId, 1, 5) ~= "slot:" then - return nil, "none" + if mapping ~= nil then return mapping, "identity" end + + -- Preset identities are authoritative. Do not inherit a stale mapping merely + -- because a different named worker has moved into the old slot. Canonical + -- aliases are only consulted for slot-fallback identities. + if string.sub(identityId, 1, 5) ~= "slot:" then return nil, "none" end + + for _, alias in ipairs(slotInfo ~= nil and slotInfo.identityAliases or {}) do + mapping = self.helperProfilesMappingsByIdentity[tostring(alias)] + if mapping ~= nil then return mapping, "identity-alias" end end end if slot ~= nil then @@ -2167,6 +2240,7 @@ function HelperPayroll:setHelperProfilesPayrollMapping(slotInfo, slot, roleId) table.insert(self.helperProfilesMappings, mapping) end mapping.identityId = identityId + mapping.canonicalId = slotInfo ~= nil and slotInfo.canonicalId or nil mapping.identitySource = slotInfo ~= nil and slotInfo.identitySource or "slotFallback" mapping.slot = slot mapping.helperName = slotInfo ~= nil and slotInfo.displayName or (slot ~= nil and ("Helper " .. slot) or "Helper") @@ -2200,11 +2274,8 @@ end -- The API owns all payroll validation and persistence; callers never edit payroll XML -- or internal mapping tables directly. function HelperPayroll:normaliseIntegrationSlot(slot) - local text = string.upper(tostring(slot or "")):gsub("^%s+", ""):gsub("%s+$", "") - if #text ~= 1 or text < "A" or text > "J" then - return nil - end - return text + local normalized = self:normaliseHelperSlot(slot) + return normalized end function HelperPayroll:getIntegrationRoleRows() @@ -2246,7 +2317,9 @@ function HelperPayroll:getIntegrationRoleForSlot(slot) return { slot = normalizedSlot, + canonicalId = slotInfo ~= nil and slotInfo.canonicalId or nil, identityId = slotInfo ~= nil and slotInfo.identityId or ("slot:" .. normalizedSlot), + identityAliases = slotInfo ~= nil and slotInfo.identityAliases or {"slot:" .. normalizedSlot}, identitySource = slotInfo ~= nil and slotInfo.identitySource or "slotFallback", helperName = slotInfo ~= nil and slotInfo.displayName or ("Helper " .. normalizedSlot), roleId = tostring(roleId), @@ -2307,9 +2380,9 @@ end function HelperPayroll:buildIntegrationAPI() local owner = self local api = { - apiVersion = 1, + apiVersion = 2, modName = "FS25_HelperPayroll", - modVersion = tostring(self.VERSION or "0.4.1.1"), + modVersion = tostring(self.VERSION or "0.4.2.0"), readOnly = false } @@ -2321,7 +2394,9 @@ function HelperPayroll:buildIntegrationAPI() modVersion = self.modVersion, activePayrollProfile = tostring(owner.settings.activePayrollProfile or "default"), payrollMode = tostring(owner.settings.payrollMode or "roleType"), - billingMode = tostring(owner.settings.billingMode or "onJobFinish") + billingMode = tostring(owner.settings.billingMode or "onJobFinish"), + managedSlotCount = owner:getManagedHelperSlotCount(), + targetSlotCount = tonumber(owner.TARGET_HELPER_SLOTS) or 20 } end @@ -2333,6 +2408,14 @@ function HelperPayroll:buildIntegrationAPI() return owner:getIntegrationRoleForSlot(slot) end + function api:getSlots() + local rows = {} + for index, slot in ipairs(owner:getManagedHelperSlots()) do + rows[index] = owner:getIntegrationRoleForSlot(slot) + end + return rows + end + function api:applyRoleMappings(roleMappings, reason) return owner:applyIntegrationRoleMappings(roleMappings, reason) end @@ -2344,7 +2427,7 @@ function HelperPayroll:publishIntegrationAPI(reason) if self.integrationAPI == nil then self.integrationAPI = self:buildIntegrationAPI() end - self.integrationAPI.modVersion = tostring(self.VERSION or "0.4.1.1") + self.integrationAPI.modVersion = tostring(self.VERSION or "0.4.2.0") -- Publish globally as well as on the mission. GUI dialogs can be created -- during a different mission lifecycle phase, so mission-only publication @@ -3735,6 +3818,7 @@ function HelperPayroll:loadSavegameSettings() if not hasXMLProperty(xmlFile, key) then break end local mapping = { identityId = getXmlStringOrDefault(xmlFile, key .. "#identityId", ""), + canonicalId = getXmlStringOrDefault(xmlFile, key .. "#canonicalId", ""), identitySource = getXmlStringOrDefault(xmlFile, key .. "#identitySource", ""), slot = string.upper(getXmlStringOrDefault(xmlFile, key .. "#slot", "")), helperName = getXmlStringOrDefault(xmlFile, key .. "#helperName", ""), @@ -3914,6 +3998,7 @@ function HelperPayroll:saveSavegameSettings(reason) for i, mapping in ipairs(mappings) do local key = string.format("helperPayrollSave.helperProfilesMappings.worker(%d)", i - 1) setXMLString(xmlFile, key .. "#identityId", tostring(mapping.identityId or "")) + setXMLString(xmlFile, key .. "#canonicalId", tostring(mapping.canonicalId or "")) setXMLString(xmlFile, key .. "#identitySource", tostring(mapping.identitySource or "")) setXMLString(xmlFile, key .. "#slot", tostring(mapping.slot or "")) setXMLString(xmlFile, key .. "#helperName", tostring(mapping.helperName or "")) @@ -5057,7 +5142,7 @@ function HelperPayroll:hpayProfiles(...) if a == "help" or a == "" then hpayPrintf("hpayProfiles commands:") hpayPrintf(" status show HelperProfiles mod/API integration status") - hpayPrintf(" slots list HelperProfiles A-J identities and HelperPayroll slot mappings") + hpayPrintf(" slots list HelperProfiles A-T identities and HelperPayroll slot mappings") hpayPrintf(" refresh refresh and display integration status") return end @@ -5096,7 +5181,7 @@ function HelperPayroll:hpayProfiles(...) if status.available then if self:isHelperSlotPayrollMode() then - hpayPrintf("helperSlot mode active. job.helperIndex selects A-J; HelperProfiles API supplies live identity and HelperPayroll applies the per-save identity/slot payroll mapping.") + hpayPrintf("helperSlot mode active. job.helperIndex selects A-T; HelperProfiles API supplies live identity and HelperPayroll applies the per-save identity/slot payroll mapping.") else hpayPrintf("HelperProfiles is integrated, but roleType mode remains active and uses the selected payroll role.") end @@ -5110,8 +5195,7 @@ function HelperPayroll:hpayProfiles(...) elseif a == "slots" or a == "list" then local _, profileId = self:getActiveProfile() hpayPrintf("HelperProfiles/HelperPayroll slot mapping: profile=%s payrollMode=%s source=%s", tostring(profileId), tostring(self.settings.payrollMode), tostring(status.source)) - for i = 1, 10 do - local slot = string.char(string.byte("A") + i - 1) + for _, slot in ipairs(self:getManagedHelperSlots()) do local hpInfo = self:getHelperProfilesSlotInfo(slot) local hpName = hpInfo ~= nil and hpInfo.displayName or ("Helper " .. slot) local identityId = hpInfo ~= nil and hpInfo.identityId or ("slot:" .. slot) diff --git a/scripts/gui/HelperPayrollMenu.lua b/scripts/gui/HelperPayrollMenu.lua index d8c2551..279d327 100644 --- a/scripts/gui/HelperPayrollMenu.lua +++ b/scripts/gui/HelperPayrollMenu.lua @@ -174,8 +174,7 @@ function HelperPayrollMenu:refreshDraftFromRuntime() end end if hp ~= nil then - for index = 1, 10 do - local slot = string.char(string.byte("A") + index - 1) + for _, slot in ipairs(hp:getManagedHelperSlots()) do local slotInfo = hp.getHelperProfilesSlotInfo ~= nil and hp:getHelperProfilesSlotInfo(slot) or nil local roleId = hp.getEffectiveHelperProfilesRole ~= nil and select(1, hp:getEffectiveHelperProfilesRole(slotInfo, slot, profileId)) or (hp.settings.fallbackRole or "standard") self.draftMappings[slot] = roleId @@ -254,7 +253,7 @@ function HelperPayrollMenu:buildRows() if topic == "billing" then rows = { - {id="payrollMode", label="Payroll mode", value=tostring(self.draftSettings.payrollMode or "roleType"), status="Editable", source="Savegame", editType="option", options={"roleType", "helperSlot"}, info="roleType assigns every new job to the selected payroll role. helperSlot uses the deployed A-J worker, with live HelperProfiles identity data when available."}, + {id="payrollMode", label="Payroll mode", value=tostring(self.draftSettings.payrollMode or "roleType"), status="Editable", source="Savegame", editType="option", options={"roleType", "helperSlot"}, info="roleType assigns every new job to the selected payroll role. helperSlot uses the deployed A-T worker, with live HelperProfiles identity data when available."}, {id="billingMode", label="Billing mode", value=tostring(self.draftSettings.billingMode or "onJobFinish"), status="Editable", source="Savegame", editType="option", options={"onJobFinish", "dailyPayroll"}, info="onJobFinish charges each completed job immediately. dailyPayroll aggregates each worker's completed work for the game day and settles it through payroll."}, {id="payrollHour", label="Payroll hour", value=fmtHour(self.draftSettings.payrollHour), status="Editable", source="Savegame", editType="number", step=1, min=0, max=23, info="In dailyPayroll mode, pending rows settle at this in-game hour. Any unpaid row from an earlier game day is treated as overdue and settles automatically."}, {id="minimumWorkerCharge", label="Legacy minimum", value=fmtMoney(self.draftSettings.minimumWorkerCharge), status="Editable", source="Savegame", editType="number", step=0.5, min=0, max=999, info="Compatibility fallback for old save data that has no per-role minimum. New payroll policies use the minimum call-out configured on each role or worker."}, @@ -277,8 +276,7 @@ function HelperPayrollMenu:buildRows() if roleOrder == nil or #roleOrder == 0 then roleOrder = {tostring(hp ~= nil and hp.settings ~= nil and hp.settings.fallbackRole or "standard")} end - for index = 1, 10 do - local slot = string.char(string.byte("A") + index - 1) + for _, slot in ipairs(hp:getManagedHelperSlots()) do local slotInfo = hp ~= nil and hp.getHelperProfilesSlotInfo ~= nil and hp:getHelperProfilesSlotInfo(slot) or nil local displayName = slotInfo ~= nil and slotInfo.displayName or ("Helper " .. slot) local identityId = slotInfo ~= nil and slotInfo.identityId or ("slot:" .. slot) @@ -350,7 +348,7 @@ function HelperPayrollMenu:buildBodyText() end return string.format("Profile: %s\nPayroll mode: %s\nPayment schedule: %s\nSelected role: %s (%.2f/%s)\nGlobal callout fee: %.2f\nHelperProfiles: %s\nPending payroll rows: %d\nCurrent-save settings: %s\n\nUse BILLING for payment timing, ROLES for default compensation, WORKERS for named-worker overrides, and LEDGER for accumulated history.", tostring(hp and hp.settings and hp.settings.activePayrollProfile or "-"), tostring(hp and hp.settings and hp.settings.payrollMode or "-"), tostring(hp and hp.settings and hp.settings.billingMode or "-"), tostring(roleName), tonumber(rate) or 0, roleBasis == "daily" and "day" or "hr", tonumber(hp and hp.settings and hp.settings.workerCalloutFee or 0) or 0, tostring(integration), tonumber(pendingRows) or 0, tostring(hp and hp.persistence and hp.persistence.filePath or "-")) elseif topic == "help" then - return "HelperPayroll Management\n\nChanges are staged until APPLY is pressed. APPLY writes gameplay settings to the current save only; it does not overwrite the global default policy. DISCARD restores the currently loaded values.\n\nROLES defines the default pay basis, rate, and hourly minimum call-out. WORKERS can inherit those settings or override all three for a named A-J worker.\n\nHourly pay is hours multiplied by rate, subject to the minimum call-out. Daily pay is charged once per worker per game day when that worker completes work. Payment schedule is separate: onJobFinish settles immediately, while dailyPayroll settles at the configured payroll hour." + return "HelperPayroll Management\n\nChanges are staged until APPLY is pressed. APPLY writes gameplay settings to the current save only; it does not overwrite the global default policy. DISCARD restores the currently loaded values.\n\nROLES defines the default pay basis, rate, and hourly minimum call-out. WORKERS can inherit those settings or override all three for a named A-T worker.\n\nHourly pay is hours multiplied by rate, subject to the minimum call-out. Daily pay is charged once per worker per game day when that worker completes work. Payment schedule is separate: onJobFinish settles immediately, while dailyPayroll settles at the configured payroll hour." end return "" end