diff --git a/gui/HelperPayrollMenu.xml b/gui/HelperPayrollMenu.xml index bcadea2..90deb46 100644 --- a/gui/HelperPayrollMenu.xml +++ b/gui/HelperPayrollMenu.xml @@ -12,7 +12,7 @@ - + diff --git a/modDesc.xml b/modDesc.xml index 409c69f..db55d62 100644 --- a/modDesc.xml +++ b/modDesc.xml @@ -1,12 +1,12 @@ SimGamerJen - 0.4.2.0 + 0.4.3.0 <en>Helper Payroll Alpha</en> - 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. + 0.4.3.0 Alpha 2: Adds HelperProfiles roster-aware payroll management and a generic external-worker session API for controllers that use helpers outside GIANTS activeJobs, including AutoDrive continuity integration. The Workers tab shows ON-roster workers only while preserving OFF workers' roles, compensation overrides and ledger identities. Payroll snapshots and the public API expose roster availability and enabled/disabled counts. Also includes the Alpha 1 live dashboard, WorkerCosts compatibility protection and future multiplayer-ready state boundaries; multiplayer remains disabled. HelperPayroll_icon.dds @@ -49,6 +49,11 @@ + + + + + diff --git a/scripts/HelperPayroll.lua b/scripts/HelperPayroll.lua index 0305d39..1945afb 100644 --- a/scripts/HelperPayroll.lua +++ b/scripts/HelperPayroll.lua @@ -1,5 +1,5 @@ -- Helper Payroll --- Version: 0.4.2.0-alpha2 +-- Version: 0.4.3.0-alpha2 -- Purpose: -- 1. Suppress vanilla AI worker payments. -- 2. Track active AI jobs. @@ -14,7 +14,7 @@ local hpGetTimeMs HelperPayroll = {} HelperPayroll.MOD_NAME = g_currentModName or "FS25_HelperPayroll" -HelperPayroll.VERSION = "0.4.2.0" +HelperPayroll.VERSION = "0.4.3.0" HelperPayroll.RELEASE_CHANNEL = "alpha2" HelperPayroll.TARGET_HELPER_SLOTS = 20 HelperPayroll.MOD_DIRECTORY = g_currentModDirectory or "" @@ -53,6 +53,10 @@ HelperPayroll.helperProfilesMappingsByIdentity = {} HelperPayroll.helperProfilesMappingsBySlot = {} HelperPayroll.integrationAPI = nil HelperPayroll.integrationAPIPublished = false +HelperPayroll.compatibilityStatus = nil +HelperPayroll.runtimeBillingBlocked = false +HelperPayroll.runtimeBillingBlockReason = nil +HelperPayroll.compatibilityWarningShown = false HelperPayroll.originalFarmAddMoney = nil HelperPayroll.aiWorkerHooksInstalled = false HelperPayroll.aiPriceDebugCount = 0 @@ -152,6 +156,75 @@ local function rcWarn(message, ...) print(string.format("[HelperPayroll][WARN] " .. tostring(message), ...)) end +function HelperPayroll:refreshCompatibilityStatus(reason) + local status = nil + if HelperPayrollCompatibility ~= nil and HelperPayrollCompatibility.apply ~= nil then + status = HelperPayrollCompatibility.apply(self) + else + status = { + schemaVersion = 1, + safe = true, + blocked = false, + detectedCount = 0, + message = "Compatibility module unavailable; no conflict was detected.", + conflicts = {} + } + self.compatibilityStatus = status + self.runtimeBillingBlocked = false + self.runtimeBillingBlockReason = nil + end + + if status.blocked == true then + rcWarn("Compatibility safety block active: reason=%s conflict=%s message=%s", + tostring(reason or "scan"), + tostring(status.primaryConflictName or "unknown"), + tostring(status.message)) + else + rcLog("Compatibility scan clear: reason=%s", tostring(reason or "scan")) + end + return status +end + +function HelperPayroll:getCompatibilityStatus() + if self.compatibilityStatus == nil then + self:refreshCompatibilityStatus("status-request") + end + if HelperPayrollCompatibility ~= nil and HelperPayrollCompatibility.copyStatus ~= nil then + return HelperPayrollCompatibility.copyStatus(self.compatibilityStatus) + end + return self.compatibilityStatus +end + +function HelperPayroll:isPayrollRuntimeEnabled() + if self.runtimeBillingBlocked == true then return false end + if HelperPayrollCompatibility ~= nil and HelperPayrollCompatibility.isRuntimeEnabled ~= nil then + return HelperPayrollCompatibility.isRuntimeEnabled(self) + end + return true +end + +function HelperPayroll:getPayrollSnapshot(options) + if HelperPayrollSnapshot ~= nil and HelperPayrollSnapshot.build ~= nil then + return HelperPayrollSnapshot.build(self, options) + end + return { + schemaVersion = 1, + runtime = { + initialized = self.isInitialized == true, + payrollEnabled = self:isPayrollRuntimeEnabled(), + multiplayerSupported = false, + authority = "singlePlayerMission" + } + } +end + +function HelperPayroll:resolveJobFarmId(job, fallbackFarmId) + if HelperPayrollFarmScope ~= nil and HelperPayrollFarmScope.resolveJobFarmId ~= nil then + return HelperPayrollFarmScope.resolveJobFarmId(job, fallbackFarmId) + end + return tonumber(fallbackFarmId) or self:getActiveFarmId(), "legacyFallback" +end + function HelperPayroll:isDetailedDiagnosticsEnabled() local level = string.lower(tostring(self.settings ~= nil and self.settings.logLevel or "normal")) return level == "debug" or level == "trace" @@ -556,7 +629,7 @@ function HelperPayroll:showManagementMenu() if HelperPayrollMenu ~= nil and HelperPayrollMenu.show ~= nil then self.roleListVisible = false self.reportOverlayVisible = false - return HelperPayrollMenu.show(self.MOD_DIRECTORY, 'overview') + return HelperPayrollMenu.show(self.MOD_DIRECTORY, 'dashboard') end rcWarn('Management GUI is not available') return false @@ -1919,6 +1992,132 @@ function HelperPayroll:getManagedHelperSlots() return slots end +-- The permanent managed identity set must remain distinct from the operational +-- HelperProfiles roster. Payroll mappings, worker overrides and historical +-- identity references are retained for every managed A-T slot even while a +-- worker is OFF roster. Only operational UI and selection surfaces should use +-- getOperationalHelperSlots(). +function HelperPayroll:getAllManagedHelperSlots() + return self:getManagedHelperSlots() +end + +function HelperPayroll:hasHelperProfilesRosterAvailability() + local api = self:getHelperProfilesAPI(true) + if api == nil then return false end + + local version = tonumber(api.apiVersion) or 0 + if version >= 6 and (type(api.getEnabledSlots) == "function" or type(api.isSlotEnabled) == "function") then + return true + end + + local ok, status = self:callHelperProfilesAPI("getStatus") + if ok and type(status) == "table" then + if status.enabledProfileCount ~= nil or status.disabledProfileCount ~= nil or status.rosterStateFile ~= nil then + return true + end + end + + local firstSlot = self:indexToHelperSlot(1) + ok, status = self:callHelperProfilesAPI("getSlotData", firstSlot) + return ok and type(status) == "table" and (status.enabled ~= nil or status.rosterState ~= nil) +end + +function HelperPayroll:isManagedHelperSlotEnabled(slot) + local normalizedSlot = self:normaliseHelperSlot(slot) + if normalizedSlot == nil then return false, "invalid-slot" end + + if not self:hasHelperProfilesRosterAvailability() then + return true, "availability-unavailable" + end + + local info = self:getHelperProfilesSlotInfo(normalizedSlot) + if info == nil or info.availabilityKnown ~= true then + -- Fail open if a companion API is temporarily incomplete. Payroll data + -- must not become inaccessible because of a transient load-order issue. + return true, "availability-unknown" + end + + return info.enabled == true, "helperprofiles-api" +end + +function HelperPayroll:getOperationalHelperSlots() + local allSlots = self:getManagedHelperSlots() + if not self:hasHelperProfilesRosterAvailability() then + return allSlots + end + + local enabledSet = {} + local ok, rows = self:callHelperProfilesAPI("getEnabledSlots") + if ok and type(rows) == "table" then + for _, row in ipairs(rows) do + local value = type(row) == "table" and row.slot or row + local normalizedSlot = self:normaliseHelperSlot(value) + if normalizedSlot ~= nil then enabledSet[normalizedSlot] = true end + end + end + + local hasEnabledSet = next(enabledSet) ~= nil + local operational = {} + for _, slot in ipairs(allSlots) do + local enabled = enabledSet[slot] == true + if not hasEnabledSet then + local info = self:getHelperProfilesSlotInfo(slot) + enabled = info == nil or info.availabilityKnown ~= true or info.enabled == true + end + if enabled then operational[#operational + 1] = slot end + end + + -- HelperProfiles guarantees at least one ON worker. Retain a fail-open + -- fallback if an older or partially initialized API reports none. + if #operational == 0 and #allSlots > 0 then + return allSlots + end + return operational +end + +function HelperPayroll:getManagedHelperRosterSummary() + local allSlots = self:getManagedHelperSlots() + local supported = self:hasHelperProfilesRosterAvailability() + if not supported then + return { + supported = false, + source = "managed-slot fallback", + total = #allSlots, + enabled = #allSlots, + disabled = 0 + } + end + + -- Prefer HelperProfiles' aggregate status so the live dashboard does not + -- issue twenty per-slot API calls every refresh cycle. + local ok, status = self:callHelperProfilesAPI("getStatus") + if ok and type(status) == "table" then + local total = tonumber(status.profileCount or status.managedSlotCount) or #allSlots + local enabled = tonumber(status.enabledProfileCount) + local disabled = tonumber(status.disabledProfileCount) + if enabled ~= nil or disabled ~= nil then + enabled = math.max(0, math.floor(enabled or (total - (disabled or 0)))) + disabled = math.max(0, math.floor(disabled or (total - enabled))) + return { + supported = true, + source = "HelperProfiles API", + total = math.max(0, math.floor(total)), + enabled = enabled, + disabled = disabled + } + end + end + + local enabled = #self:getOperationalHelperSlots() + return { + supported = true, + source = "HelperProfiles API", + total = #allSlots, + enabled = enabled, + disabled = math.max(0, #allSlots - enabled) + } +end + function HelperPayroll:helperIndexToSlot(helperIndex) local slot = self:normaliseHelperSlot(helperIndex) return slot @@ -2079,9 +2278,20 @@ function HelperPayroll:getHelperProfilesSlotInfo(slot) return nil end + local availabilityKnown = data.enabled ~= nil or data.rosterState ~= nil + local enabled = data.enabled ~= false + if data.rosterState ~= nil then + local rosterState = string.lower(tostring(data.rosterState)) + if rosterState == "off" or rosterState == "disabled" then enabled = false end + if rosterState == "on" or rosterState == "enabled" then enabled = true end + end + return { slot = tostring(data.slot or string.upper(tostring(slot))), index = tonumber(data.index) or idx, + stableIndex = tonumber(data.stableIndex) or tonumber(data.index) or idx, + currentIndex = tonumber(data.currentIndex), + enabledIndex = tonumber(data.enabledIndex), helper = nil, canonicalId = data.canonicalId ~= nil and tostring(data.canonicalId) or nil, identityId = data.identityId ~= nil and tostring(data.identityId) or nil, @@ -2092,9 +2302,13 @@ function HelperPayroll:getHelperProfilesSlotInfo(slot) appearanceLabel = data.appearanceLabel, presetId = data.presetId, category = data.category, + enabled = enabled, + availabilityKnown = availabilityKnown, + rosterState = availabilityKnown and tostring(data.rosterState or (enabled and "on" or "off")) or "unknown", inUse = data.inUse == true, selected = data.selected == true, selectedIndex = data.selectedIndex, + resolutionSource = data.resolutionSource ~= nil and tostring(data.resolutionSource) or nil, source = tostring(data.source or "shared-api") } end @@ -2116,6 +2330,8 @@ function HelperPayroll:getHelperProfilesStatus() local selectedName = apiStatus ~= nil and apiStatus.selectedName or nil local selectedIndex = apiStatus ~= nil and tonumber(apiStatus.selectedIndex) or nil local profileCount = apiStatus ~= nil and tonumber(apiStatus.profileCount) or 0 + local enabledProfileCount = apiStatus ~= nil and tonumber(apiStatus.enabledProfileCount) or nil + local disabledProfileCount = apiStatus ~= nil and tonumber(apiStatus.disabledProfileCount) or nil if selectedSlot == nil and apiAvailable then local ok, result = self:callHelperProfilesAPI("getSelectedSlot") @@ -2151,6 +2367,9 @@ function HelperPayroll:getHelperProfilesStatus() source = source, profileCount = profileCount, identityCount = profileCount, + rosterAvailability = self:hasHelperProfilesRosterAvailability(), + enabledProfileCount = enabledProfileCount, + disabledProfileCount = disabledProfileCount, selectedIndex = selectedIndex, selectedSlot = selectedSlot, selectedName = selectedName, @@ -2317,6 +2536,12 @@ function HelperPayroll:getIntegrationRoleForSlot(slot) return { slot = normalizedSlot, + enabled = slotInfo == nil or slotInfo.enabled ~= false, + availabilityKnown = slotInfo ~= nil and slotInfo.availabilityKnown == true, + rosterState = slotInfo ~= nil and tostring(slotInfo.rosterState or "unknown") or "unknown", + enabledIndex = slotInfo ~= nil and slotInfo.enabledIndex or nil, + selected = slotInfo ~= nil and slotInfo.selected == true, + inUse = slotInfo ~= nil and slotInfo.inUse == true, 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}, @@ -2378,48 +2603,28 @@ function HelperPayroll:applyIntegrationRoleMappings(roleMappings, reason) end function HelperPayroll:buildIntegrationAPI() + if HelperPayrollPublicAPI ~= nil and HelperPayrollPublicAPI.build ~= nil then + return HelperPayrollPublicAPI.build(self) + end + + rcWarn("Public API module unavailable; publishing reduced compatibility API") local owner = self local api = { apiVersion = 2, modName = "FS25_HelperPayroll", - modVersion = tostring(self.VERSION or "0.4.2.0"), - readOnly = false + modVersion = tostring(self.VERSION or "0.4.3.0"), + readOnly = true } - function api:getStatus() return { available = owner.isInitialized == true, apiVersion = self.apiVersion, modName = self.modName, modVersion = self.modVersion, - activePayrollProfile = tostring(owner.settings.activePayrollProfile or "default"), - payrollMode = tostring(owner.settings.payrollMode or "roleType"), - billingMode = tostring(owner.settings.billingMode or "onJobFinish"), - managedSlotCount = owner:getManagedHelperSlotCount(), - targetSlotCount = tonumber(owner.TARGET_HELPER_SLOTS) or 20 + payrollRuntimeEnabled = owner:isPayrollRuntimeEnabled(), + multiplayerSupported = false } end - - function api:getRoles() - return owner:getIntegrationRoleRows() - end - - function api:getRoleForSlot(slot) - 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 - return api end @@ -2427,7 +2632,7 @@ function HelperPayroll:publishIntegrationAPI(reason) if self.integrationAPI == nil then self.integrationAPI = self:buildIntegrationAPI() end - self.integrationAPI.modVersion = tostring(self.VERSION or "0.4.2.0") + self.integrationAPI.modVersion = tostring(self.VERSION or "0.4.3.0") -- Publish globally as well as on the mission. GUI dialogs can be created -- during a different mission lifecycle phase, so mission-only publication @@ -2590,7 +2795,7 @@ function HelperPayroll:captureWorkerAssignment(tracked) assignment.gameDate = clock.dateKey assignment.workMonotonicDay = clock.monotonicDay assignment.workDayTime = clock.dayTimeMs - assignment.farmId = self:getActiveFarmId() + assignment.farmId, assignment.farmIdSource = self:resolveJobFarmId(tracked ~= nil and tracked.job or nil, nil) assignment.snapshotSource = "job-start" return assignment end @@ -2605,7 +2810,7 @@ function HelperPayroll:getWorkerAssignmentForBilling(tracked) assignment.gameDate = clock.dateKey assignment.workMonotonicDay = clock.monotonicDay assignment.workDayTime = clock.dayTimeMs - assignment.farmId = self:getActiveFarmId() + assignment.farmId, assignment.farmIdSource = self:resolveJobFarmId(tracked ~= nil and tracked.job or nil, nil) assignment.snapshotSource = "finish-fallback" rcWarn("Worker assignment snapshot was unavailable at billing time; resolved a fallback assignment at job finish") return assignment @@ -2682,21 +2887,13 @@ function HelperPayroll:isOnJobFinishMode() end function HelperPayroll:getActiveFarmId() - local farmId = nil - if g_currentMission ~= nil and g_currentMission.getFarmId ~= nil then - farmId = g_currentMission:getFarmId() + if HelperPayrollFarmScope ~= nil and HelperPayrollFarmScope.getMissionFarmId ~= nil then + local farmId = HelperPayrollFarmScope.getMissionFarmId() + if farmId ~= nil then return farmId end end - if farmId == nil and g_currentMission ~= nil and g_currentMission.player ~= nil and g_currentMission.player.farmId ~= nil then - farmId = g_currentMission.player.farmId - end - - if farmId == nil then - farmId = 1 - rcWarn("Could not resolve active farmId for worker charge; falling back to farmId=1") - end - - return farmId + rcWarn("Could not resolve active farmId for worker charge; falling back to farmId=1") + return 1 end function HelperPayroll:calculateWorkerCharge(tracked) @@ -2976,6 +3173,11 @@ function HelperPayroll:calculateDailyPayrollCharge(daily) end function HelperPayroll:applyMoneyCharge(amount, farmId, context) + if not self:isPayrollRuntimeEnabled() then + rcWarn("Could not apply %s; payroll runtime is blocked by compatibility protection", tostring(context or "worker charge")) + return false + end + if g_currentMission == nil or g_currentMission.addMoney == nil then rcWarn("Could not apply %s; g_currentMission.addMoney is not available", tostring(context or "worker charge")) return false @@ -3053,6 +3255,7 @@ function HelperPayroll:isDailyPayrollRowDue(daily, clock, payrollHour) end function HelperPayroll:processDailyPayroll(dt, force, triggerReason) + if not self:isPayrollRuntimeEnabled() then return end if not self:isDailyPayrollMode() then return end if not self.settings.enableCustomWorkerCosts or not self.settings.chargeCustomWorkerCosts then return end @@ -3166,6 +3369,11 @@ end function HelperPayroll:applyWorkerCharge(tracked) + if not self:isPayrollRuntimeEnabled() then + rcWarn("Worker billing skipped because the runtime compatibility safety block is active") + return false + end + if tracked == nil then return false end @@ -3212,6 +3420,7 @@ function HelperPayroll:applyWorkerCharge(tracked) charge = 0, calculatedJobCharge = chargeToApply, farmId = farmId, + farmIdSource = breakdown.farmIdSource, profileId = breakdown.profileId, gameDate = breakdown.gameDate or self:getGameDateKey(), workMonotonicDay = breakdown.workMonotonicDay, @@ -3399,6 +3608,13 @@ function HelperPayroll:getAIJobDebugName(job) end function HelperPayroll.aiJobGetPricePerMs(job, superFunc, ...) + if not HelperPayroll:isPayrollRuntimeEnabled() or not HelperPayroll.settings.suppressVanillaAIWorkerCosts then + if superFunc ~= nil then + return superFunc(job, ...) + end + return 0 + end + local jobName = HelperPayroll:getAIJobDebugName(job) local stats = HelperPayroll.aiPriceStatsByType[jobName] @@ -3442,6 +3658,11 @@ function HelperPayroll.aiJobGetPricePerMs(job, superFunc, ...) end function HelperPayroll:installAIWorkerHooks() + if not self:isPayrollRuntimeEnabled() then + rcWarn("AI worker price hooks skipped because payroll runtime is blocked by compatibility protection") + return + end + if self.aiWorkerHooksInstalled then rcLog("AI worker price hooks already installed") return @@ -3493,6 +3714,7 @@ function HelperPayroll:installAIWorkerHooks() end function HelperPayroll:scanActiveAIJobs(dt) + if not self:isPayrollRuntimeEnabled() then return end if g_currentMission == nil or g_currentMission.aiSystem == nil then return end @@ -3525,7 +3747,7 @@ function HelperPayroll:scanActiveAIJobs(dt) self.trackedAIJobs[jobId].helperSlotSource = assignment.helperSlotSource self.trackedAIJobs[jobId].helperSlotUsedForPayroll = assignment.helperSlotUsedForPayroll self.trackedAIJobs[jobId].payrollMode = assignment.payrollMode - rcLog("AI job detected #%d: id=%s type=%s payrollMode=%s helperSlot=%s helperSlotSource=%s helperSlotUsedForPayroll=%s identityId=%s identitySource=%s mappingSource=%s helper=%s role=%s workerRate=%s profile=%s rate=%.2f assignmentSnapshot=%s", self.trackedAIJobCount, tostring(jobId), tostring(self.trackedAIJobs[jobId].name), tostring(assignment.payrollMode), tostring(assignment.helperSlot), tostring(assignment.helperSlotSource), tostring(assignment.helperSlotUsedForPayroll), tostring(assignment.helperIdentityId or "-"), tostring(assignment.helperIdentitySource or "unknown"), tostring(assignment.helperMappingSource or "unknown"), tostring(assignment.helperName), tostring(assignment.helperRole), tostring(assignment.workerId), tostring(assignment.profileId), tonumber(assignment.hourlyRate) or 0, tostring(assignment.snapshotSource)) + rcLog("AI job detected #%d: id=%s type=%s payrollMode=%s helperSlot=%s helperSlotSource=%s helperSlotUsedForPayroll=%s identityId=%s identitySource=%s mappingSource=%s helper=%s role=%s workerRate=%s profile=%s rate=%.2f farmId=%s farmIdSource=%s assignmentSnapshot=%s", self.trackedAIJobCount, tostring(jobId), tostring(self.trackedAIJobs[jobId].name), tostring(assignment.payrollMode), tostring(assignment.helperSlot), tostring(assignment.helperSlotSource), tostring(assignment.helperSlotUsedForPayroll), tostring(assignment.helperIdentityId or "-"), tostring(assignment.helperIdentitySource or "unknown"), tostring(assignment.helperMappingSource or "unknown"), tostring(assignment.helperName), tostring(assignment.helperRole), tostring(assignment.workerId), tostring(assignment.profileId), tonumber(assignment.hourlyRate) or 0, tostring(assignment.farmId or "?"), tostring(assignment.farmIdSource or "unknown"), tostring(assignment.snapshotSource)) self:diagnosticScanAIJobForHelperProfiles(job, self.trackedAIJobs[jobId]) end @@ -5166,10 +5388,13 @@ function HelperPayroll:hpayProfiles(...) tostring(self:shouldSuppressStandaloneRoleInputs()) ) hpayPrintf( - "HelperProfiles API: apiVersion=%s modVersion=%s profiles=%d selectedSlot=%s selectedName=%s pickMode=%s", + "HelperProfiles API: apiVersion=%s modVersion=%s profiles=%d enabled=%s disabled=%s rosterAvailability=%s selectedSlot=%s selectedName=%s pickMode=%s", tostring(status.apiVersion or "-"), tostring(status.helperProfilesVersion or "-"), tonumber(status.profileCount) or 0, + tostring(status.enabledProfileCount or "-"), + tostring(status.disabledProfileCount or "-"), + tostring(status.rosterAvailability == true), tostring(status.selectedSlot or "-"), tostring(status.selectedName or "-"), tostring(status.pickMode or "-") @@ -5203,10 +5428,12 @@ function HelperPayroll:hpayProfiles(...) local worker = self:getWorkerRateById(profileId, workerRate) local rate = worker ~= nil and tonumber(worker.hourlyRate) or 0 local marker = hpInfo ~= nil and hpInfo.selected and " *selected" or "" + local rosterState = hpInfo ~= nil and tostring(hpInfo.rosterState or "unknown") or "standalone" hpayPrintf( - " %s HelperProfiles=%s identityId=%s mappingSource=%s workerRate=%s rate=%.2f%s", + " %s HelperProfiles=%s roster=%s identityId=%s mappingSource=%s workerRate=%s rate=%.2f%s", slot, tostring(hpName), + rosterState, tostring(identityId), tostring(mappingSource), tostring(workerRate), @@ -5403,11 +5630,14 @@ function HelperPayroll:hpayDump(...) elseif a == "status" or a == "" then local roleId, roleName, rate, profileId = self:getSelectedRoleInfo() local hpStatus = self:getHelperProfilesStatus() - hpayPrintf("Status: version=%s channel=%s payrollMode=%s billingMode=%s profile=%s selectedRole=%s name=%s rate=%.2f trackedJobs=%d pendingRows=%d helperProfilesLoaded=%s helperProfilesApi=%s helperProfilesApiVersion=%s roleListVisible=%s reportOverlayVisible=%s reportPage=%s saveFile=%s globalPolicySource=%s globalPolicyFile=%s", + local compatibility = self:getCompatibilityStatus() + hpayPrintf("Status: version=%s channel=%s payrollMode=%s billingMode=%s profile=%s selectedRole=%s name=%s rate=%.2f trackedJobs=%d pendingRows=%d helperProfilesLoaded=%s helperProfilesApi=%s helperProfilesApiVersion=%s payrollEnabled=%s compatibilityBlocked=%s conflict=%s authority=singlePlayerMission multiplayerSupported=false snapshotSchema=%s roleListVisible=%s reportOverlayVisible=%s reportPage=%s saveFile=%s globalPolicySource=%s globalPolicyFile=%s", tostring(self.VERSION or "0.3.3.0"), tostring(self.RELEASE_CHANNEL or "beta-rc"), tostring(self.settings.payrollMode), tostring(self.settings.billingMode), tostring(profileId), tostring(roleId), tostring(roleName), tonumber(rate) or 0, tonumber(self.trackedAIJobCount) or 0, self:countPendingDailyPayrollRows(), tostring(hpStatus.modLoaded == true), tostring(hpStatus.apiAvailable == true), tostring(hpStatus.apiVersion or "-"), + tostring(self:isPayrollRuntimeEnabled()), tostring(compatibility.blocked == true), tostring(compatibility.primaryConflictName or "none"), + tostring(HelperPayrollSnapshot ~= nil and HelperPayrollSnapshot.SCHEMA_VERSION or 1), tostring(self.roleListVisible == true), tostring(self.reportOverlayVisible == true), tostring(self.reportOverlayPage or 1), tostring(self.persistence ~= nil and self.persistence.filePath or "nil"), tostring(self.policyConfig ~= nil and self.policyConfig.source or "unknown"), tostring(self.CONFIG_FILE)) return @@ -5452,7 +5682,11 @@ function HelperPayroll:registerConsoleCommands() end function HelperPayroll:installMoneyHooks() - -- Legacy diagnostic retained only as a fallback marker. FS25 helper suppression is now done through AIJob.getPricePerMs hooks. + -- Legacy diagnostic retained only as a fallback marker. FS25 helper suppression is done through AIJob.getPricePerMs hooks. + if not self:isPayrollRuntimeEnabled() then + rcWarn("Farm.addMoney hook skipped. Compatibility protection left vanilla helper wages untouched.") + return + end rcLog("Farm.addMoney hook skipped. Using AIJob.getPricePerMs suppression for vanilla helper costs.") end @@ -5475,6 +5709,12 @@ function HelperPayroll:logRuntimeStartupStatus() tostring(hpStatus.selectedName or "-"), tostring(self.persistence ~= nil and self.persistence.savegameName or "unknown") ) + local compatibility = self:getCompatibilityStatus() + rcLog("Runtime authority: mode=singlePlayerMission multiplayerSupported=false payrollEnabled=%s compatibilityBlocked=%s conflict=%s snapshotSchema=%s", + tostring(self:isPayrollRuntimeEnabled()), + tostring(compatibility.blocked == true), + tostring(compatibility.primaryConflictName or "none"), + tostring(HelperPayrollSnapshot ~= nil and HelperPayrollSnapshot.SCHEMA_VERSION or 1)) end function HelperPayroll:processStartupStatus(dt) @@ -5492,6 +5732,10 @@ function HelperPayroll:processStartupStatus(dt) self.startupStatusLogged = true self:logRuntimeStartupStatus() + if self.runtimeBillingBlocked == true and self.compatibilityWarningShown ~= true then + self.compatibilityWarningShown = true + self:showRoleMessage("HelperPayroll disabled: incompatible worker-cost mod detected") + end end function HelperPayroll:initialize(reason) @@ -5506,8 +5750,10 @@ function HelperPayroll:initialize(reason) self.lastPayrollClockDayTime = nil self.startupStatusElapsedMs = 0 self.startupStatusLogged = false + self.compatibilityWarningShown = false self:loadConfig() self:loadSavegameSettings() + self:refreshCompatibilityStatus("initialize") self:loadLedgerIndex() if self.ledger ~= nil and self.ledger.hasIndexFile == true then self:loadPeriodLedger(self:getLedgerPeriodId(self:getGameDateKey())) diff --git a/scripts/HelperPayrollCompatibility.lua b/scripts/HelperPayrollCompatibility.lua new file mode 100644 index 0000000..20cd6e1 --- /dev/null +++ b/scripts/HelperPayrollCompatibility.lua @@ -0,0 +1,172 @@ +-- FS25_HelperPayroll +-- Runtime compatibility detection and safety gating. +-- Kept independent from payroll calculation so future multiplayer authority +-- checks can use the same runtime gate without changing persisted settings. + +HelperPayrollCompatibility = HelperPayrollCompatibility or {} +HelperPayrollCompatibility.SCHEMA_VERSION = 1 + +HelperPayrollCompatibility.CONFLICTS = { + { + id = "workerCosts", + displayName = "Realistic Worker Costs", + modNames = { + "FS25_WorkerCostsMod", + "FS25_WorkerCosts", + "WorkerCostsMod", + "WorkerCosts" + }, + globalNames = { + "WorkerCostsMod", + "WorkerCosts", + "FS25_WorkerCostsMod" + }, + reason = "Both mods suppress or replace vanilla AI wages and apply their own worker charges. Running both can produce missing deductions or duplicate payroll records." + } +} + +local function copyArray(values) + local result = {} + for i, value in ipairs(values or {}) do + result[i] = value + end + return result +end + +local function isLoadedThroughModTable(name) + if type(g_modIsLoaded) == "table" and g_modIsLoaded[name] == true then + return true, "g_modIsLoaded" + end + return false, nil +end + +local function isLoadedThroughManager(name) + if g_modManager == nil then + return false, nil + end + + if type(g_modManager.getModByName) == "function" then + local ok, mod = pcall(g_modManager.getModByName, g_modManager, name) + if ok and type(mod) == "table" then + if mod.isLoaded == true or mod.isActive == true then + return true, "g_modManager.getModByName" + end + end + end + + if type(g_modManager.mods) == "table" then + for _, mod in pairs(g_modManager.mods) do + if type(mod) == "table" then + local candidate = tostring(mod.modName or mod.name or mod.id or mod.directoryName or "") + if candidate == name and (mod.isLoaded == true or mod.isActive == true) then + return true, "g_modManager.mods" + end + end + end + end + + return false, nil +end + +local function isLoadedThroughGlobal(name) + if rawget(_G, name) ~= nil then + return true, "global" + end + return false, nil +end + +function HelperPayrollCompatibility.findLoadedName(definition) + for _, name in ipairs(definition.modNames or {}) do + local loaded, source = isLoadedThroughModTable(name) + if loaded then return name, source end + + loaded, source = isLoadedThroughManager(name) + if loaded then return name, source end + end + + for _, name in ipairs(definition.globalNames or {}) do + local loaded, source = isLoadedThroughGlobal(name) + if loaded then return name, source end + end + + return nil, nil +end + +function HelperPayrollCompatibility.scan() + local status = { + schemaVersion = HelperPayrollCompatibility.SCHEMA_VERSION, + safe = true, + blocked = false, + detectedCount = 0, + primaryConflictId = nil, + primaryConflictName = nil, + message = "No incompatible worker-cost mod detected.", + conflicts = {} + } + + for _, definition in ipairs(HelperPayrollCompatibility.CONFLICTS) do + local loadedName, source = HelperPayrollCompatibility.findLoadedName(definition) + if loadedName ~= nil then + status.safe = false + status.blocked = true + status.detectedCount = status.detectedCount + 1 + status.primaryConflictId = status.primaryConflictId or definition.id + status.primaryConflictName = status.primaryConflictName or definition.displayName + table.insert(status.conflicts, { + id = definition.id, + displayName = definition.displayName, + loadedName = loadedName, + detectionSource = source, + reason = definition.reason, + modNames = copyArray(definition.modNames) + }) + end + end + + if status.blocked then + status.message = string.format( + "HelperPayroll payroll processing is disabled because %s is active. Disable one of the worker-cost mods and reload the save.", + tostring(status.primaryConflictName or "an incompatible worker-cost mod") + ) + end + + return status +end + +function HelperPayrollCompatibility.apply(owner) + local status = HelperPayrollCompatibility.scan() + owner.compatibilityStatus = status + owner.runtimeBillingBlocked = status.blocked == true + owner.runtimeBillingBlockReason = status.blocked and status.message or nil + return status +end + +function HelperPayrollCompatibility.isRuntimeEnabled(owner) + if owner == nil then return false end + if owner.runtimeBillingBlocked == true then return false end + return true +end + +function HelperPayrollCompatibility.copyStatus(status) + status = status or {} + local copy = { + schemaVersion = tonumber(status.schemaVersion) or HelperPayrollCompatibility.SCHEMA_VERSION, + safe = status.safe ~= false, + blocked = status.blocked == true, + detectedCount = tonumber(status.detectedCount) or 0, + primaryConflictId = status.primaryConflictId, + primaryConflictName = status.primaryConflictName, + message = tostring(status.message or "Compatibility status unavailable."), + conflicts = {} + } + for _, conflict in ipairs(status.conflicts or {}) do + table.insert(copy.conflicts, { + id = conflict.id, + displayName = conflict.displayName, + loadedName = conflict.loadedName, + detectionSource = conflict.detectionSource, + reason = conflict.reason + }) + end + return copy +end diff --git a/scripts/HelperPayrollExternalSessions.lua b/scripts/HelperPayrollExternalSessions.lua new file mode 100644 index 0000000..c7c205c --- /dev/null +++ b/scripts/HelperPayrollExternalSessions.lua @@ -0,0 +1,327 @@ +-- FS25_HelperPayroll +-- Generic externally managed worker-session lifecycle. +-- +-- Controllers such as HelperProfiles may own a worker outside GIANTS +-- g_currentMission.aiSystem.activeJobs. This module lets those controllers +-- declare a logical work session while HelperPayroll remains the sole owner of +-- assignment snapshots, compensation policy, billing and ledger persistence. + +HelperPayrollExternalSessions = HelperPayrollExternalSessions or { + sessions = {}, + sequence = 0 +} + +HelperPayrollExternalSessions.API_VERSION = 1 + +local LOG = "[HelperPayroll/ExternalSession] " + +local function log(message, ...) + print(LOG .. string.format(tostring(message), ...)) +end + +local function warn(message, ...) + print(LOG .. "WARN: " .. string.format(tostring(message), ...)) +end + +local function copyTable(source) + local result = {} + for key, value in pairs(source or {}) do + result[key] = value + end + return result +end + +local function normaliseSessionId(request) + local value = request ~= nil and request.sessionId or nil + if value == nil then return nil end + value = tostring(value) + if value == "" then return nil end + return value +end + +local function normaliseSlot(owner, request) + if owner == nil or request == nil then return nil, nil end + local candidate = request.helperSlot + if candidate == nil then candidate = request.helperIndex end + if owner.normaliseHelperSlot ~= nil then + local slot, index = owner:normaliseHelperSlot(candidate) + return slot, index + end + return nil, tonumber(request.helperIndex) +end + +local function resolveFarmId(owner, request) + local requested = tonumber(request ~= nil and request.farmId or nil) + if requested ~= nil then return requested, "external-request" end + if owner ~= nil and owner.getActiveFarmId ~= nil then + return owner:getActiveFarmId(), "active-farm-fallback" + end + return 1, "fallback" +end + +local function buildAssignment(owner, request) + local _, profileId = owner:getActiveProfile() + local helperSlot, helperIndex = normaliseSlot(owner, request) + local payrollMode = tostring(owner.settings ~= nil and owner.settings.payrollMode or "roleType") + local hpSlotInfo = helperSlot ~= nil and owner.getHelperProfilesSlotInfo ~= nil and owner:getHelperProfilesSlotInfo(helperSlot) or nil + local payrollMapping = nil + local mappingSource = "selected-role" + local helperSlotUsedForPayroll = false + + local workerId = tostring(owner.settings ~= nil and (owner.settings.selectedRole or owner.settings.fallbackRole) or "standard") + + if owner.isHelperSlotPayrollMode ~= nil and owner:isHelperSlotPayrollMode() and helperSlot ~= nil then + local resolvedRoleId, mapping, resolvedSource = owner:getEffectiveHelperProfilesRole(hpSlotInfo, helperSlot, profileId) + if resolvedRoleId ~= nil then workerId = tostring(resolvedRoleId) end + payrollMapping = mapping + mappingSource = tostring(resolvedSource or "helper-slot") + helperSlotUsedForPayroll = true + end + + local worker = owner:getWorkerRateById(profileId, workerId) + if worker == nil and owner.getSelectedWorkerRate ~= nil then + worker, profileId = owner:getSelectedWorkerRate() + if worker ~= nil and worker.id ~= nil then workerId = tostring(worker.id) end + mappingSource = "selected-role-fallback" + end + worker = worker or {id = workerId, name = workerId} + + local compensation = owner:getEffectiveCompensationPolicy(profileId, workerId, payrollMapping) or { + payBasis = "hourly", + rate = 0, + minimumCallout = tonumber(owner.settings ~= nil and owner.settings.minimumWorkerCharge or 0) or 0, + source = "fallback" + } + + local clock = owner:getGameClockSnapshot() + local farmId, farmIdSource = resolveFarmId(owner, request) + local helperName = hpSlotInfo ~= nil and hpSlotInfo.displayName + or (request ~= nil and request.helperName) + or worker.name + or (helperSlot ~= nil and ("Helper " .. tostring(helperSlot))) + or "Worker" + + local identityId = hpSlotInfo ~= nil and hpSlotInfo.identityId + or (request ~= nil and request.helperIdentityId) + or (helperSlot ~= nil and ("slot:" .. tostring(helperSlot))) + or nil + + local identitySource = hpSlotInfo ~= nil and tostring(hpSlotInfo.identitySource or hpSlotInfo.source or "HelperProfilesAPI") + or tostring(request ~= nil and request.helperIdentitySource or "external-session") + + return { + profileId = profileId, + payrollMode = payrollMode, + helperSlot = helperSlot or "unassigned", + helperIndex = helperIndex, + helperSlotSource = tostring(request ~= nil and request.helperSlotSource or "external-session"), + helperSlotUsedForPayroll = helperSlotUsedForPayroll, + helperName = tostring(helperName), + helperRole = tostring(worker.name or workerId or "Worker"), + workerId = tostring(workerId), + workerName = tostring(worker.name or workerId or "Worker"), + payBasis = tostring(compensation.payBasis or "hourly"), + payRate = tonumber(compensation.rate or compensation.payRate or compensation.hourlyRate) or 0, + minimumCallout = tonumber(compensation.minimumCallout) or 0, + compensationSource = tostring(compensation.source or "external-session"), + hourlyRate = tonumber(compensation.rate or compensation.payRate or compensation.hourlyRate) or 0, + helperIdentityId = identityId, + helperIdentitySource = identitySource, + helperMappingSource = mappingSource, + helperProfilesName = hpSlotInfo ~= nil and hpSlotInfo.displayName or nil, + helperProfilesSelected = hpSlotInfo ~= nil and hpSlotInfo.selected == true or false, + gameDate = clock.dateKey, + workMonotonicDay = clock.monotonicDay, + workDayTime = clock.dayTimeMs, + farmId = farmId, + farmIdSource = farmIdSource, + snapshotSource = "external-session-start", + externalSource = tostring(request ~= nil and request.source or "external"), + externalController = tostring(request ~= nil and request.controller or "external"), + externalSessionId = normaliseSessionId(request), + externalLabel = request ~= nil and request.label or nil + } +end + +local function makeResult(session, status) + local assignment = session ~= nil and session.assignmentSnapshot or {} + return { + status = tostring(status or "unknown"), + sessionId = session ~= nil and session.sessionId or nil, + sequence = session ~= nil and session.sequence or nil, + source = session ~= nil and session.source or nil, + controller = session ~= nil and session.controller or nil, + jobType = session ~= nil and session.name or nil, + helperSlot = assignment.helperSlot, + helperIdentityId = assignment.helperIdentityId, + helperName = assignment.helperName, + roleId = assignment.workerId, + payBasis = assignment.payBasis, + payRate = assignment.payRate, + elapsedHours = session ~= nil and ((tonumber(session.elapsedMs) or 0) / 3600000) or 0 + } +end + +function HelperPayrollExternalSessions.begin(owner, request) + if owner == nil or owner.isInitialized ~= true then + return false, {status = "payroll-not-ready"} + end + if owner.isPayrollRuntimeEnabled ~= nil and not owner:isPayrollRuntimeEnabled() then + return false, {status = "payroll-runtime-disabled"} + end + if type(request) ~= "table" then + return false, {status = "invalid-request"} + end + + local sessionId = normaliseSessionId(request) + if sessionId == nil then + return false, {status = "missing-session-id"} + end + + local existing = HelperPayrollExternalSessions.sessions[sessionId] + if existing ~= nil then + return true, makeResult(existing, "already-active") + end + + HelperPayrollExternalSessions.sequence = (tonumber(HelperPayrollExternalSessions.sequence) or 0) + 1 + local assignment = buildAssignment(owner, request) + local session = { + sessionId = sessionId, + sequence = 1000000 + HelperPayrollExternalSessions.sequence, + name = tostring(request.jobType or request.label or "External worker session"), + source = tostring(request.source or "external"), + controller = tostring(request.controller or "external"), + elapsedMs = 0, + lastSummaryMs = 0, + billed = false, + external = true, + assignmentSnapshot = assignment, + metadata = { + vehicleName = request.vehicleName, + label = request.label + } + } + HelperPayrollExternalSessions.sessions[sessionId] = session + + log( + "Started: id=%s controller=%s type=%s helperSlot=%s identityId=%s helper=%s role=%s payBasis=%s rate=%.2f farmId=%s", + tostring(sessionId), + tostring(session.controller), + tostring(session.name), + tostring(assignment.helperSlot), + tostring(assignment.helperIdentityId or "-"), + tostring(assignment.helperName), + tostring(assignment.helperRole), + tostring(assignment.payBasis), + tonumber(assignment.payRate) or 0, + tostring(assignment.farmId or "?") + ) + return true, makeResult(session, "started") +end + +function HelperPayrollExternalSessions.finish(owner, sessionId, reason) + sessionId = sessionId ~= nil and tostring(sessionId) or nil + if sessionId == nil or sessionId == "" then + return false, {status = "missing-session-id"} + end + + local session = HelperPayrollExternalSessions.sessions[sessionId] + if session == nil then + return false, {status = "not-active", sessionId = sessionId} + end + + local elapsedHours = (tonumber(session.elapsedMs) or 0) / 3600000 + local billingHandled = false + local billingError = nil + + if owner ~= nil and owner.applyWorkerCharge ~= nil and not session.billed then + -- Mark first, matching the normal AI-job path, so a downstream ledger or + -- reporting failure cannot cause the same completed session to be billed twice. + session.billed = true + local ok, result = pcall(function() + return owner:applyWorkerCharge(session) + end) + if ok then + billingHandled = result == true + else + billingHandled = true + billingError = tostring(result) + warn("Billing raised an error after external session finish; session will not be retried: id=%s error=%s", tostring(sessionId), billingError) + end + end + + HelperPayrollExternalSessions.sessions[sessionId] = nil + + log( + "Finished: id=%s controller=%s type=%s helperSlot=%s helper=%s elapsedHours=%.3f reason=%s billingHandled=%s billingMode=%s", + tostring(sessionId), + tostring(session.controller), + tostring(session.name), + tostring(session.assignmentSnapshot ~= nil and session.assignmentSnapshot.helperSlot or "unassigned"), + tostring(session.assignmentSnapshot ~= nil and session.assignmentSnapshot.helperName or "Worker"), + elapsedHours, + tostring(reason or "external-finish"), + tostring(billingHandled), + tostring(owner ~= nil and owner.settings ~= nil and owner.settings.billingMode or "unknown") + ) + + local result = makeResult(session, billingError ~= nil and "finished-with-billing-error" or "finished") + result.reason = tostring(reason or "external-finish") + result.billingHandled = billingHandled + result.billingError = billingError + return true, result +end + +function HelperPayrollExternalSessions.getActive(owner) + local rows = {} + for _, session in pairs(HelperPayrollExternalSessions.sessions or {}) do + table.insert(rows, makeResult(session, "active")) + end + table.sort(rows, function(a, b) + return (tonumber(a.sequence) or 0) < (tonumber(b.sequence) or 0) + end) + return rows +end + +function HelperPayrollExternalSessions.getTrackedSessions() + local rows = {} + for _, session in pairs(HelperPayrollExternalSessions.sessions or {}) do + table.insert(rows, session) + end + return rows +end + +function HelperPayrollExternalSessions:update(dt) + local delta = tonumber(dt) or 0 + if delta <= 0 then return end + + for _, session in pairs(self.sessions or {}) do + session.elapsedMs = (tonumber(session.elapsedMs) or 0) + delta + if session.elapsedMs - (tonumber(session.lastSummaryMs) or 0) >= 60000 then + session.lastSummaryMs = session.elapsedMs + local assignment = session.assignmentSnapshot or {} + log( + "Active: id=%s controller=%s helperSlot=%s helper=%s elapsedMinutes=%.1f", + tostring(session.sessionId), + tostring(session.controller), + tostring(assignment.helperSlot or "unassigned"), + tostring(assignment.helperName or "Worker"), + session.elapsedMs / 60000 + ) + end + end +end + +function HelperPayrollExternalSessions:loadMap() + self.sessions = {} + self.sequence = 0 +end + +function HelperPayrollExternalSessions:deleteMap() + if next(self.sessions or {}) ~= nil then + warn("Mission ended with active external worker sessions; clearing runtime sessions without settlement during teardown") + end + self.sessions = {} +end + +addModEventListener(HelperPayrollExternalSessions) diff --git a/scripts/HelperPayrollFarmScope.lua b/scripts/HelperPayrollFarmScope.lua new file mode 100644 index 0000000..d49fec5 --- /dev/null +++ b/scripts/HelperPayrollFarmScope.lua @@ -0,0 +1,82 @@ +-- FS25_HelperPayroll +-- Farm ownership resolution kept separate from billing calculations. +-- The current release remains single-player, but job-start farm snapshots use +-- a network-safe scalar ID so future server authority can be added cleanly. + +HelperPayrollFarmScope = HelperPayrollFarmScope or {} +HelperPayrollFarmScope.SCHEMA_VERSION = 1 + +local function safeMethod(object, methodName) + if object == nil or type(object[methodName]) ~= "function" then return nil end + local ok, value = pcall(object[methodName], object) + if ok then return value end + return nil +end + +local function normalizeFarmId(value) + local farmId = tonumber(value) + if farmId == nil then return nil end + farmId = math.floor(farmId) + if farmId <= 0 then return nil end + return farmId +end + +function HelperPayrollFarmScope.getMissionFarmId() + if g_currentMission == nil then return nil end + + local farmId = safeMethod(g_currentMission, "getFarmId") + farmId = normalizeFarmId(farmId) + if farmId ~= nil then return farmId, "mission.getFarmId" end + + if g_currentMission.player ~= nil then + farmId = normalizeFarmId(g_currentMission.player.farmId) + if farmId ~= nil then return farmId, "mission.player" end + end + + return nil, "unresolved" +end + +function HelperPayrollFarmScope.resolveJobFarmId(job, fallbackFarmId) + if job ~= nil then + local directFields = { + "startedFarmId", + "farmId", + "ownerFarmId" + } + for _, fieldName in ipairs(directFields) do + local farmId = normalizeFarmId(job[fieldName]) + if farmId ~= nil then + return farmId, "job." .. fieldName + end + end + + local methodNames = { + "getStartedFarmId", + "getFarmId", + "getOwnerFarmId" + } + for _, methodName in ipairs(methodNames) do + local farmId = normalizeFarmId(safeMethod(job, methodName)) + if farmId ~= nil then + return farmId, "job." .. methodName + end + end + + local vehicle = job.vehicle or job.vehicleToUse or job.helperVehicle + if vehicle ~= nil then + local farmId = normalizeFarmId(safeMethod(vehicle, "getOwnerFarmId")) + or normalizeFarmId(vehicle.ownerFarmId) + if farmId ~= nil then + return farmId, "job.vehicle" + end + end + end + + local fallback = normalizeFarmId(fallbackFarmId) + if fallback ~= nil then return fallback, "fallback" end + + local missionFarmId, source = HelperPayrollFarmScope.getMissionFarmId() + if missionFarmId ~= nil then return missionFarmId, source end + + return 1, "defaultFarm1" +end diff --git a/scripts/HelperPayrollPublicAPI.lua b/scripts/HelperPayrollPublicAPI.lua new file mode 100644 index 0000000..006142c --- /dev/null +++ b/scripts/HelperPayrollPublicAPI.lua @@ -0,0 +1,135 @@ +-- FS25_HelperPayroll +-- Public integration API construction. Kept outside the main payroll controller +-- so consumers and future network adapters depend on a stable boundary. + +HelperPayrollPublicAPI = HelperPayrollPublicAPI or {} +HelperPayrollPublicAPI.API_VERSION = 5 + +function HelperPayrollPublicAPI.build(owner) + local api = { + apiVersion = HelperPayrollPublicAPI.API_VERSION, + snapshotSchemaVersion = HelperPayrollSnapshot ~= nil and HelperPayrollSnapshot.SCHEMA_VERSION or 1, + modName = "FS25_HelperPayroll", + modVersion = tostring(owner.VERSION or "0.4.3.0"), + readOnly = false, + capabilities = { + roleMappings = true, + payrollSnapshot = true, + dashboardSnapshot = true, + compatibilityStatus = true, + multiplayer = false, + transportReadySnapshot = true, + rosterAvailability = true, + enabledWorkerFiltering = true, + externalWorkerSessions = HelperPayrollExternalSessions ~= nil + } + } + + function api:getStatus() + local compatibility = owner.getCompatibilityStatus ~= nil and owner:getCompatibilityStatus() or {} + local roster = owner.getManagedHelperRosterSummary ~= nil and owner:getManagedHelperRosterSummary() or {} + local externalSessions = HelperPayrollExternalSessions ~= nil and HelperPayrollExternalSessions.getActive ~= nil + and HelperPayrollExternalSessions.getActive(owner) or {} + return { + available = owner.isInitialized == true, + apiVersion = self.apiVersion, + snapshotSchemaVersion = self.snapshotSchemaVersion, + modName = self.modName, + modVersion = self.modVersion, + activePayrollProfile = tostring(owner.settings.activePayrollProfile or "default"), + payrollMode = tostring(owner.settings.payrollMode or "roleType"), + billingMode = tostring(owner.settings.billingMode or "onJobFinish"), + managedSlotCount = owner:getManagedHelperSlotCount(), + enabledSlotCount = tonumber(roster.enabled) or owner:getManagedHelperSlotCount(), + disabledSlotCount = tonumber(roster.disabled) or 0, + rosterAvailabilitySupported = roster.supported == true, + targetSlotCount = tonumber(owner.TARGET_HELPER_SLOTS) or 20, + payrollRuntimeEnabled = owner.isPayrollRuntimeEnabled ~= nil and owner:isPayrollRuntimeEnabled() or true, + compatibilityBlocked = compatibility.blocked == true, + compatibilityMessage = compatibility.message, + externalWorkerSessionsSupported = self.capabilities.externalWorkerSessions == true, + activeExternalWorkerSessions = #externalSessions, + multiplayerSupported = false, + authority = "singlePlayerMission" + } + end + + function api:getCompatibilityStatus() + return owner:getCompatibilityStatus() + end + + function api:getPayrollSnapshot(options) + return owner:getPayrollSnapshot(options) + end + + function api:getDashboardSnapshot() + return owner:getPayrollSnapshot({includeWorkers = false}) + end + + function api:getRoles() + return owner:getIntegrationRoleRows() + end + + function api:getRoleForSlot(slot) + 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:getEnabledSlots() + local rows = {} + for index, slot in ipairs(owner:getOperationalHelperSlots()) do + rows[index] = owner:getIntegrationRoleForSlot(slot) + end + return rows + end + + function api:isSlotEnabled(slot) + local enabled, source = owner:isManagedHelperSlotEnabled(slot) + return enabled == true, source + end + + function api:getRosterStatus() + local roster = owner:getManagedHelperRosterSummary() + return { + availabilitySupported = roster.supported == true, + source = tostring(roster.source or "managed-slot fallback"), + totalWorkers = tonumber(roster.total) or 0, + enabledWorkers = tonumber(roster.enabled) or 0, + disabledWorkers = tonumber(roster.disabled) or 0 + } + end + + function api:beginExternalWorkerSession(request) + if HelperPayrollExternalSessions == nil or HelperPayrollExternalSessions.begin == nil then + return false, {status = "external-session-module-unavailable"} + end + return HelperPayrollExternalSessions.begin(owner, request) + end + + function api:endExternalWorkerSession(sessionId, reason) + if HelperPayrollExternalSessions == nil or HelperPayrollExternalSessions.finish == nil then + return false, {status = "external-session-module-unavailable"} + end + return HelperPayrollExternalSessions.finish(owner, sessionId, reason) + end + + function api:getExternalWorkerSessions() + if HelperPayrollExternalSessions == nil or HelperPayrollExternalSessions.getActive == nil then + return {} + end + return HelperPayrollExternalSessions.getActive(owner) + end + + function api:applyRoleMappings(roleMappings, reason) + return owner:applyIntegrationRoleMappings(roleMappings, reason) + end + + return api +end diff --git a/scripts/HelperPayrollSnapshot.lua b/scripts/HelperPayrollSnapshot.lua new file mode 100644 index 0000000..8a74a3f --- /dev/null +++ b/scripts/HelperPayrollSnapshot.lua @@ -0,0 +1,281 @@ +-- FS25_HelperPayroll +-- Serializable, read-only payroll snapshots for the dashboard and public API. +-- Tables returned here deliberately contain no functions, mission objects, +-- jobs, vehicles or other userdata, making the schema suitable for later +-- server-to-client transport without changing consumers. + +HelperPayrollSnapshot = HelperPayrollSnapshot or {} +HelperPayrollSnapshot.SCHEMA_VERSION = 3 + +local function round(value, places) + local n = tonumber(value) or 0 + local p = 10 ^ (places or 2) + return math.floor(n * p + 0.5) / p +end + +local function copyCompatibility(status) + status = status or {} + local result = { + schemaVersion = tonumber(status.schemaVersion) or 1, + safe = status.safe ~= false, + blocked = status.blocked == true, + detectedCount = tonumber(status.detectedCount) or 0, + primaryConflictId = status.primaryConflictId, + primaryConflictName = status.primaryConflictName, + message = tostring(status.message or "Compatibility status unavailable."), + conflicts = {} + } + for _, conflict in ipairs(status.conflicts or {}) do + table.insert(result.conflicts, { + id = conflict.id, + displayName = conflict.displayName, + loadedName = conflict.loadedName, + detectionSource = conflict.detectionSource, + reason = conflict.reason + }) + end + return result +end + +local function getRoleName(owner, assignment) + local roleId = tostring(assignment.helperRole or assignment.workerId or "standard") + local worker = owner.getWorkerRateById ~= nil and owner:getWorkerRateById(assignment.profileId, assignment.workerId) or nil + return tostring(worker ~= nil and worker.name or roleId) +end + +local function estimateTrackedCharge(owner, tracked) + local assignment = tracked.assignmentSnapshot + if assignment == nil and owner.resolveWorkerAssignment ~= nil then + assignment = owner:resolveWorkerAssignment(tracked) + end + assignment = assignment or {} + + local elapsedHours = (tonumber(tracked.elapsedMs) or 0) / 3600000 + local payBasis = owner.normalisePayBasis ~= nil and owner:normalisePayBasis(assignment.payBasis) or tostring(assignment.payBasis or "hourly") + local payRate = tonumber(assignment.payRate or assignment.hourlyRate) or 0 + local labour = payBasis == "daily" and payRate or elapsedHours * payRate + local callout = payBasis == "daily" and 0 or tonumber(owner.settings ~= nil and owner.settings.workerCalloutFee or 0) or 0 + local minimum = payBasis == "daily" and 0 or tonumber(assignment.minimumCallout or (owner.settings ~= nil and owner.settings.minimumWorkerCharge)) or 0 + local estimate = labour + callout + if estimate > 0 and minimum > 0 and estimate < minimum then estimate = minimum end + + return round(estimate, 2), round(labour, 2), round(elapsedHours, 3), assignment, payBasis, round(payRate, 2) +end + +local function buildActiveJobs(owner) + local jobs = {} + + local function appendTracked(tracked, source) + if tracked == nil then return end + local estimate, labour, elapsedHours, assignment, payBasis, payRate = estimateTrackedCharge(owner, tracked) + table.insert(jobs, { + sequence = tonumber(tracked.sequence) or 0, + source = tostring(source or (tracked.external == true and "external" or "giants-ai")), + controller = tracked.controller ~= nil and tostring(tracked.controller) or (assignment.externalController ~= nil and tostring(assignment.externalController) or nil), + sessionId = tracked.sessionId ~= nil and tostring(tracked.sessionId) or (assignment.externalSessionId ~= nil and tostring(assignment.externalSessionId) or nil), + jobType = tostring(tracked.name or "AI job"), + helperSlot = assignment.helperSlot, + helperIdentityId = assignment.helperIdentityId, + helperName = tostring(assignment.helperName or assignment.workerName or "Worker"), + roleId = tostring(assignment.workerId or assignment.helperRole or "standard"), + roleName = getRoleName(owner, assignment), + payBasis = payBasis, + payRate = payRate, + elapsedHours = elapsedHours, + labourEstimate = labour, + chargeEstimate = estimate, + farmId = tonumber(assignment.farmId) or assignment.farmId, + farmIdSource = tostring(assignment.farmIdSource or "unknown"), + gameDate = assignment.gameDate, + snapshotSource = tostring(assignment.snapshotSource or "runtime"), + vehicleName = tracked.metadata ~= nil and tracked.metadata.vehicleName or nil + }) + end + + for _, tracked in pairs(owner.trackedAIJobs or {}) do + appendTracked(tracked, "giants-ai") + end + + if HelperPayrollExternalSessions ~= nil and HelperPayrollExternalSessions.getTrackedSessions ~= nil then + for _, tracked in ipairs(HelperPayrollExternalSessions.getTrackedSessions()) do + appendTracked(tracked, "external") + end + end + + table.sort(jobs, function(a, b) return (a.sequence or 0) < (b.sequence or 0) end) + return jobs +end + +local function buildPendingPayroll(owner) + local rows = {} + local clock = owner.getGameClockSnapshot ~= nil and owner:getGameClockSnapshot() or {} + local payrollHour = tonumber(owner.settings ~= nil and owner.settings.payrollHour or 18) or 18 + for key, daily in pairs(owner.workerDailyLedger or {}) do + if daily ~= nil and daily.payrollApplied ~= true and (tonumber(daily.jobs) or 0) > 0 then + local charge, minimumApplied = 0, false + if owner.calculateDailyPayrollCharge ~= nil then + charge, minimumApplied = owner:calculateDailyPayrollCharge(daily) + end + local due, dueReason = false, "pending" + if owner.isDailyPayrollRowDue ~= nil then + due, dueReason = owner:isDailyPayrollRowDue(daily, clock, payrollHour) + end + local pendingRoleId = tostring(daily.workerId or daily.helperRole or "standard") + local pendingWorker = owner.getWorkerRateById ~= nil and owner:getWorkerRateById(daily.profileId, pendingRoleId) or nil + table.insert(rows, { + key = tostring(key), + gameDate = tostring(daily.gameDate or "unknown"), + helperSlot = daily.helperSlot, + helperIdentityId = daily.helperIdentityId, + helperName = tostring(daily.helperName or "Worker"), + roleId = pendingRoleId, + roleName = tostring(pendingWorker ~= nil and pendingWorker.name or daily.helperRole or pendingRoleId), + payBasis = tostring(daily.payBasis or "hourly"), + payRate = round(daily.payRate or daily.hourlyRate, 2), + jobs = tonumber(daily.jobs) or 0, + hours = round(daily.hours, 3), + labour = round(daily.labour, 2), + chargeEstimate = round(charge, 2), + minimumApplied = minimumApplied == true, + due = due == true, + dueReason = tostring(dueReason or "pending"), + farmId = tonumber(daily.farmId) or daily.farmId + }) + end + end + table.sort(rows, function(a, b) + if a.gameDate == b.gameDate then return a.helperName < b.helperName end + return a.gameDate < b.gameDate + end) + return rows +end + +local function buildWorkers(owner, options) + options = options or {} + local rows = {} + local profileId = tostring(owner.settings ~= nil and owner.settings.activePayrollProfile or "default") + for _, slot in ipairs(owner.getManagedHelperSlots ~= nil and owner:getManagedHelperSlots() or {}) do + local slotInfo = owner.getHelperProfilesSlotInfo ~= nil and owner:getHelperProfilesSlotInfo(slot) or nil + local roleId = tostring(owner.settings ~= nil and owner.settings.fallbackRole or "standard") + local mappingSource = "fallback" + if owner.getEffectiveHelperProfilesRole ~= nil then + local resolvedRoleId, _, resolvedSource = owner:getEffectiveHelperProfilesRole(slotInfo, slot, profileId) + if resolvedRoleId ~= nil then roleId = tostring(resolvedRoleId) end + if resolvedSource ~= nil then mappingSource = tostring(resolvedSource) end + end + local mapping = owner.getHelperProfilesPayrollMapping ~= nil and select(1, owner:getHelperProfilesPayrollMapping(slotInfo, slot)) or nil + local policy = owner.getEffectiveCompensationPolicy ~= nil and owner:getEffectiveCompensationPolicy(profileId, roleId, mapping) or {} + local enabled = slotInfo == nil or slotInfo.enabled ~= false + if options.includeDisabledWorkers ~= false or enabled then + table.insert(rows, { + slot = tostring(slot), + enabled = enabled, + availabilityKnown = slotInfo ~= nil and slotInfo.availabilityKnown == true, + rosterState = slotInfo ~= nil and tostring(slotInfo.rosterState or "unknown") or "unknown", + enabledIndex = slotInfo ~= nil and tonumber(slotInfo.enabledIndex) or nil, + identityId = slotInfo ~= nil and slotInfo.identityId or ("slot:" .. tostring(slot)), + displayName = tostring(slotInfo ~= nil and slotInfo.displayName or ("Helper " .. tostring(slot))), + selected = slotInfo ~= nil and slotInfo.selected == true, + inUse = slotInfo ~= nil and slotInfo.inUse == true, + roleId = tostring(roleId or "standard"), + mappingSource = tostring(mappingSource or "fallback"), + compensationMode = tostring(mapping ~= nil and mapping.compensationMode or "inherit"), + payBasis = tostring(policy.payBasis or "hourly"), + payRate = round(policy.rate or policy.payRate or policy.hourlyRate, 2), + minimumCallout = round(policy.minimumCallout, 2) + }) + end + end + return rows +end + +function HelperPayrollSnapshot.build(owner, options) + options = options or {} + local clock = owner.getGameClockSnapshot ~= nil and owner:getGameClockSnapshot() or {} + local activeJobs = buildActiveJobs(owner) + local pendingPayroll = buildPendingPayroll(owner) + local ledgerIndex = owner.ledger ~= nil and owner.ledger.index or nil + local totals = ledgerIndex ~= nil and ledgerIndex.totals or nil + local compatibility = copyCompatibility(owner.compatibilityStatus) + local selectedRoleId = tostring(owner.settings ~= nil and owner.settings.selectedRole or "standard") + local activeProfileId = tostring(owner.settings ~= nil and owner.settings.activePayrollProfile or "default") + local selectedRoleWorker = owner.getWorkerRateById ~= nil and owner:getWorkerRateById(activeProfileId, selectedRoleId) or nil + local selectedRolePolicy = owner.getRoleCompensationPolicy ~= nil and owner:getRoleCompensationPolicy(activeProfileId, selectedRoleId) or {} + local roster = owner.getManagedHelperRosterSummary ~= nil and owner:getManagedHelperRosterSummary() or { + supported = false, + source = "managed-slot fallback", + total = owner.getManagedHelperSlotCount ~= nil and owner:getManagedHelperSlotCount() or 0, + enabled = owner.getManagedHelperSlotCount ~= nil and owner:getManagedHelperSlotCount() or 0, + disabled = 0 + } + local activeExternalSessions = HelperPayrollExternalSessions ~= nil and HelperPayrollExternalSessions.getActive ~= nil + and HelperPayrollExternalSessions.getActive(owner) or {} + + local snapshot = { + schemaVersion = HelperPayrollSnapshot.SCHEMA_VERSION, + generatedAt = { + gameDate = tostring(clock.dateKey or "unknown"), + monotonicDay = tonumber(clock.monotonicDay), + dayTimeMs = tonumber(clock.dayTimeMs), + hour = tonumber(clock.hour) + }, + runtime = { + initialized = owner.isInitialized == true, + payrollEnabled = owner.isPayrollRuntimeEnabled ~= nil and owner:isPayrollRuntimeEnabled() or owner.runtimeBillingBlocked ~= true, + billingBlocked = owner.runtimeBillingBlocked == true, + billingBlockReason = owner.runtimeBillingBlockReason, + authority = "singlePlayerMission", + multiplayerSupported = false, + transportReadySchema = true, + externalWorkerSessionsSupported = HelperPayrollExternalSessions ~= nil + }, + compatibility = compatibility, + roster = { + availabilitySupported = roster.supported == true, + source = tostring(roster.source or "managed-slot fallback"), + totalWorkers = tonumber(roster.total) or 0, + enabledWorkers = tonumber(roster.enabled) or 0, + disabledWorkers = tonumber(roster.disabled) or 0 + }, + policy = { + activePayrollProfile = activeProfileId, + payrollMode = tostring(owner.settings ~= nil and owner.settings.payrollMode or "roleType"), + billingMode = tostring(owner.settings ~= nil and owner.settings.billingMode or "onJobFinish"), + payrollHour = tonumber(owner.settings ~= nil and owner.settings.payrollHour or 18) or 18, + selectedRole = selectedRoleId, + selectedRoleName = tostring(selectedRoleWorker ~= nil and selectedRoleWorker.name or selectedRoleId), + selectedRolePayBasis = tostring(selectedRolePolicy.payBasis or "hourly"), + selectedRoleRate = round(selectedRolePolicy.rate or selectedRolePolicy.hourlyRate, 2), + fallbackRole = tostring(owner.settings ~= nil and owner.settings.fallbackRole or "standard"), + calloutFee = round(owner.settings ~= nil and owner.settings.workerCalloutFee, 2), + legacyMinimum = round(owner.settings ~= nil and owner.settings.minimumWorkerCharge, 2), + roundCharges = owner.settings ~= nil and owner.settings.roundWorkerCharges == true + }, + activeJobs = activeJobs, + externalWorkerSessions = activeExternalSessions, + pendingPayroll = pendingPayroll, + ledger = { + currentPeriodId = owner.ledger ~= nil and owner.ledger.currentPeriodId or nil, + totalJobs = tonumber(totals ~= nil and totals.jobs) or 0, + totalHours = round(totals ~= nil and totals.hours, 3), + labour = round(totals ~= nil and totals.labour, 2), + charged = round(totals ~= nil and totals.charged, 2), + sessionEntries = tonumber(owner.workerLedgerCount) or 0, + sessionCharged = round(owner.workerLedgerTotal, 2) + }, + counts = { + activeJobs = #activeJobs, + activeExternalWorkerSessions = #activeExternalSessions, + pendingPayroll = #pendingPayroll, + managedWorkers = tonumber(roster.total) or 0, + enabledWorkers = tonumber(roster.enabled) or 0, + disabledWorkers = tonumber(roster.disabled) or 0 + } + } + + if options.includeWorkers ~= false then + snapshot.workers = buildWorkers(owner, options) + end + + return snapshot +end diff --git a/scripts/gui/HelperPayrollMenu.lua b/scripts/gui/HelperPayrollMenu.lua index 279d327..968f832 100644 --- a/scripts/gui/HelperPayrollMenu.lua +++ b/scripts/gui/HelperPayrollMenu.lua @@ -7,9 +7,9 @@ HelperPayrollMenu = {} local HelperPayrollMenu_mt = Class(HelperPayrollMenu, ScreenElement) -local TAB_TEXTS = {"OVERVIEW", "BILLING", "ROLES", "WORKERS", "LEDGER", "HELP"} -local TAB_TOPICS = {"overview", "billing", "roles", "workers", "ledger", "help"} -local TOPIC_INDEX = {overview=1, billing=2, roles=3, workers=4, ledger=5, help=6} +local TAB_TEXTS = {"DASHBOARD", "BILLING", "ROLES", "WORKERS", "LEDGER", "HELP"} +local TAB_TOPICS = {"dashboard", "billing", "roles", "workers", "ledger", "help"} +local TOPIC_INDEX = {dashboard=1, billing=2, roles=3, workers=4, ledger=5, help=6} local function fmtMoney(v) return string.format("%.2f", tonumber(v or 0) or 0) @@ -34,7 +34,7 @@ end function HelperPayrollMenu.new(target, customMt) local self = ScreenElement.new(target, customMt or HelperPayrollMenu_mt) self.returnScreenName = "" - self.currentTopic = "overview" + self.currentTopic = "dashboard" self.rows = {} self.selectedRowIndex = nil self.selectedRow = nil @@ -47,6 +47,7 @@ function HelperPayrollMenu.new(target, customMt) self.suppressOptionCallback = false self.editControlsInitialised = false self.resetConfirmationArmed = false + self.liveRefreshAccumulatorMs = 0 return self end @@ -103,7 +104,7 @@ function HelperPayrollMenu.show(modDirectory, topic) if controller == nil then return false end - controller.currentTopic = topic or controller.currentTopic or "overview" + controller.currentTopic = topic or controller.currentTopic or "dashboard" controller:refreshDraftFromRuntime() controller:showTopic(controller.currentTopic) @@ -124,7 +125,7 @@ function HelperPayrollMenu:onOpen() HelperPayrollMenu:superClass().onOpen(self) self:setResetConfirmationArmed(false) self:refreshDraftFromRuntime() - self:showTopic(self.currentTopic or "overview") + self:showTopic(self.currentTopic or "dashboard") end function HelperPayrollMenu:onClose() @@ -132,6 +133,29 @@ function HelperPayrollMenu:onClose() HelperPayrollMenu:superClass().onClose(self) end +function HelperPayrollMenu:update(dt) + HelperPayrollMenu:superClass().update(self, dt) + if self.currentTopic ~= "dashboard" then return end + + self.liveRefreshAccumulatorMs = (tonumber(self.liveRefreshAccumulatorMs) or 0) + (tonumber(dt) or 0) + if self.liveRefreshAccumulatorMs < 1000 then return end + self.liveRefreshAccumulatorMs = 0 + + local selectedIndex = tonumber(self.selectedRowIndex) or 1 + self:buildRows() + if self.itemList ~= nil then + self.itemList:reloadData() + if #self.rows > 0 and self.itemList.setSelectedIndex ~= nil then + selectedIndex = math.max(1, math.min(selectedIndex, #self.rows)) + pcall(function() self.itemList:setSelectedIndex(selectedIndex, true) end) + end + end + self.selectedRowIndex = selectedIndex + self.selectedRow = self.rows[selectedIndex] + self:setVisibleSafe(self.itemListSliderBox, #self.rows > 14) + self:updateSelectedDetails() +end + function HelperPayrollMenu:onGuiSetupFinished() HelperPayrollMenu:superClass().onGuiSetupFinished(self) self:setupTabs() @@ -141,7 +165,7 @@ function HelperPayrollMenu:onGuiSetupFinished() end self:initialiseEditControls() self:refreshDraftFromRuntime() - self:showTopic(self.currentTopic or "overview") + self:showTopic(self.currentTopic or "dashboard") end function HelperPayrollMenu:refreshDraftFromRuntime() @@ -199,7 +223,7 @@ function HelperPayrollMenu:setupTabs() end function HelperPayrollMenu:getActiveTabIndex() - return TOPIC_INDEX[self.currentTopic or "overview"] or 1 + return TOPIC_INDEX[self.currentTopic or "dashboard"] or 1 end function HelperPayrollMenu:updateTabSelection() @@ -237,7 +261,7 @@ function HelperPayrollMenu:setDisabledSafe(element, disabled) end function HelperPayrollMenu:showTopic(topic) - self.currentTopic = topic or "overview" + self.currentTopic = topic or "dashboard" self:updateTabSelection() self.selectedRowIndex = 1 self.selectedRow = nil @@ -246,12 +270,59 @@ function HelperPayrollMenu:showTopic(topic) end function HelperPayrollMenu:buildRows() - local topic = self.currentTopic or "overview" + local topic = self.currentTopic or "dashboard" local rows = {} local hp = HelperPayroll local profileId = (hp ~= nil and hp.settings ~= nil and hp.settings.activePayrollProfile) or self.draftSettings.activePayrollProfile or "default" - if topic == "billing" then + if topic == "dashboard" then + local snapshot = hp ~= nil and hp.getPayrollSnapshot ~= nil and hp:getPayrollSnapshot({includeWorkers=false}) or nil + local runtime = snapshot ~= nil and snapshot.runtime or {} + local compatibility = snapshot ~= nil and snapshot.compatibility or {} + local roster = snapshot ~= nil and snapshot.roster or {} + local policy = snapshot ~= nil and snapshot.policy or {} + local counts = snapshot ~= nil and snapshot.counts or {} + local ledger = snapshot ~= nil and snapshot.ledger or {} + local generatedAt = snapshot ~= nil and snapshot.generatedAt or {} + local runtimeStatus = runtime.payrollEnabled == true and "ACTIVE" or "BLOCKED" + local compatibilityValue = compatibility.blocked == true + and tostring(compatibility.primaryConflictName or "Conflict detected") + or "Clear" + + rows = { + {label="Payroll runtime", value=runtimeStatus, status=runtime.payrollEnabled == true and "Processing" or "Safety block", source="Local mission", info=tostring(runtime.billingBlockReason or "HelperPayroll owns wage suppression and custom payroll processing for this save.")}, + {label="Compatibility", value=compatibilityValue, status=compatibility.blocked == true and "Action required" or "Safe", source="Compatibility scan", info=tostring(compatibility.message or "No incompatible worker-cost mod detected.")}, + {label="Authority model", value=tostring(runtime.authority or "singlePlayerMission"), status="Single-player", source="Runtime", info="Payroll state is locally authoritative. Snapshot tables contain scalar values only so the same schema can later be transported from server to clients."}, + {label="Policy", value=string.format("%s / %s", tostring(policy.payrollMode or "-"), tostring(policy.billingMode or "-")), status=tostring(policy.activePayrollProfile or "default"), source="Savegame", info="Current payroll mode, payment schedule and active profile."}, + {label="Selected role", value=string.format("%.2f/%s", tonumber(policy.selectedRoleRate) or 0, policy.selectedRolePayBasis == "daily" and "day" or "hr"), status=tostring(policy.selectedRoleName or policy.selectedRole or "Role"), source="Role policy", info="The role captured for new jobs while roleType payroll mode is active."}, + {label="Payroll clock", value=string.format("%s:00", tostring(generatedAt.hour or "?")), status=policy.billingMode == "dailyPayroll" and ("Pays at " .. tostring(policy.payrollHour or 18) .. ":00") or "On job finish", source=tostring(generatedAt.gameDate or "Game clock"), info="Daily payroll rows become payable at the configured in-game hour; overdue rows remain payable after a day transition."}, + {label="Operational roster", value=string.format("%d ON / %d OFF", tonumber(counts.enabledWorkers) or tonumber(counts.managedWorkers) or 0, tonumber(counts.disabledWorkers) or 0), status=roster.availabilitySupported == true and "HelperProfiles filtered" or "All managed workers", source=tostring(roster.source or "Managed slots"), info="The Workers tab shows operational ON-roster workers only. OFF workers retain their saved role, compensation override and ledger identity and return with the same payroll settings when re-enabled."}, + {label="Active jobs", value=tostring(counts.activeJobs or 0), status="Live", source="Job tracker", info="Each active job retains its worker, role, compensation and farm assignment captured at job start."}, + {label="Pending payroll", value=tostring(counts.pendingPayroll or 0), status=tonumber(counts.pendingPayroll or 0) > 0 and "Awaiting settlement" or "Clear", source="Daily ledger", info="Completed work waiting for daily payroll settlement."}, + {label="Session charged", value=fmtMoney(ledger.sessionCharged), status=string.format("%d entries", tonumber(ledger.sessionEntries) or 0), source="Session ledger", info="Custom payroll deducted during the current game session."}, + {label="Persistent charged", value=fmtMoney(ledger.charged), status=tostring(ledger.currentPeriodId or "No period"), source="Persistent ledger", info="Cumulative custom payroll recorded in the persistent ledger index."} + } + + for _, job in ipairs(snapshot ~= nil and snapshot.activeJobs or {}) do + table.insert(rows, { + label=string.format("Active #%s - %s", tostring(job.sequence or "?"), tostring(job.helperName or "Worker")), + value=string.format("%.3fh | %.2f est.", tonumber(job.elapsedHours) or 0, tonumber(job.chargeEstimate) or 0), + status=string.format("%s / %s", tostring(job.roleName or job.roleId or "Role"), tostring(job.helperSlot or "unassigned")), + source=string.format("%s | F%s", tostring(job.jobType or "AI job"), tostring(job.farmId or "?")), + info=string.format("Job-start snapshot: rate %.2f/%s; current labour estimate %.2f; charge estimate %.2f. Farm ownership source is retained with the assignment for future authority validation.", tonumber(job.payRate) or 0, job.payBasis == "daily" and "day" or "hr", tonumber(job.labourEstimate) or 0, tonumber(job.chargeEstimate) or 0) + }) + end + + for _, pending in ipairs(snapshot ~= nil and snapshot.pendingPayroll or {}) do + table.insert(rows, { + label=string.format("Pending - %s", tostring(pending.helperName or "Worker")), + value=string.format("%.2f | %d job(s)", tonumber(pending.chargeEstimate) or 0, tonumber(pending.jobs) or 0), + status=pending.due == true and "Due now" or tostring(pending.dueReason or "Waiting"), + source=string.format("%s | F%s", tostring(pending.gameDate or "unknown"), tostring(pending.farmId or "?")), + info=string.format("Pending %s payroll for %s. Labour %.2f; estimated settlement %.2f.", tostring(pending.payBasis or "hourly"), tostring(pending.roleName or pending.roleId or "role"), tonumber(pending.labour) or 0, tonumber(pending.chargeEstimate) or 0) + }) + end + elseif 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-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."}, @@ -276,7 +347,18 @@ 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 _, slot in ipairs(hp:getManagedHelperSlots()) do + local rosterSummary = hp ~= nil and hp.getManagedHelperRosterSummary ~= nil and hp:getManagedHelperRosterSummary() or nil + if rosterSummary ~= nil and rosterSummary.supported == true then + table.insert(rows, { + label="HelperProfiles roster filter", + value=string.format("%d ON / %d OFF", tonumber(rosterSummary.enabled) or 0, tonumber(rosterSummary.disabled) or 0), + status="Operational workers only", + source=tostring(rosterSummary.source or "HelperProfiles API"), + info="Only workers marked ON in HelperProfiles are listed below. OFF workers remain stored in HelperPayroll with their role, custom compensation and ledger identity intact." + }) + end + local workerSlots = hp ~= nil and hp.getOperationalHelperSlots ~= nil and hp:getOperationalHelperSlots() or hp:getManagedHelperSlots() + for _, slot in ipairs(workerSlots) 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) @@ -322,42 +404,40 @@ function HelperPayrollMenu:buildRows() end function HelperPayrollMenu:buildBodyText() - local hp = HelperPayroll - local topic = self.currentTopic or "overview" - if topic == "overview" then - local roleName = "-" - local rate = 0 - if hp ~= nil then - local roleId, workerName, hourlyRate = hp:getSelectedRoleInfo() - roleName = workerName or roleId or roleName - rate = tonumber(hourlyRate) or 0 - end - local hpStatus = hp ~= nil and hp.getHelperProfilesStatus ~= nil and hp:getHelperProfilesStatus() or nil - local integration = "Standalone" - if hpStatus ~= nil and hpStatus.available == true then - integration = string.format("Connected (API v%s)", tostring(hpStatus.apiVersion or "?")) - elseif hpStatus ~= nil and hpStatus.modLoaded == true then - integration = "Loaded (API unavailable)" - end - local pendingRows = hp ~= nil and hp.countPendingDailyPayrollRows ~= nil and hp:countPendingDailyPayrollRows() or 0 - local roleBasis = "hourly" - if hp ~= nil and hp.getRoleCompensationPolicy ~= nil then - local roleId = hp.settings ~= nil and hp.settings.selectedRole or "standard" - local policy = hp:getRoleCompensationPolicy(hp.settings.activePayrollProfile, roleId) - if policy ~= nil then roleBasis = policy.payBasis or roleBasis end - 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-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." + local topic = self.currentTopic or "dashboard" + if topic == "help" then + return [=[HelperPayroll Management + +The DASHBOARD is a live, read-only view of runtime safety, active worker jobs, pending payroll and ledger totals. Changes elsewhere 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. + +ROLES 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. When HelperProfiles roster availability is present, the Workers tab lists ON-roster workers only; OFF workers keep their existing mappings and overrides and reappear unchanged when enabled again. + +Hourly 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. + +This build remains single-player. Payroll snapshots and farm-scoped job assignments are structured so server authority and client synchronization can be added later without replacing the public data contract.]=] end return "" end function HelperPayrollMenu:updateContent() - local tableVisible = self.currentTopic == "billing" or self.currentTopic == "roles" or self.currentTopic == "workers" or self.currentTopic == "ledger" + local tableVisible = self.currentTopic == "dashboard" or self.currentTopic == "billing" or self.currentTopic == "roles" or self.currentTopic == "workers" or self.currentTopic == "ledger" + local editableTopic = self.currentTopic == "billing" or self.currentTopic == "roles" or self.currentTopic == "workers" self:setVisibleSafe(self.tableContainer, tableVisible) self:setVisibleSafe(self.bodyTextElement, not tableVisible) + self:setVisibleSafe(self.applyButton, editableTopic) + self:setVisibleSafe(self.discardButton, editableTopic) self:setTextSafe(self.bodyTextElement, self:buildBodyText()) + if self.currentTopic == "dashboard" then + self:setTextSafe(self.hintText, "Live payroll dashboard | Runtime safety, active jobs and pending settlements | Refreshes every second") + elseif self.currentTopic == "workers" then + self:setTextSafe(self.hintText, "Worker payroll management | HelperProfiles OFF-roster workers are hidden but their saved payroll data is retained") + elseif editableTopic then + self:setTextSafe(self.hintText, "HelperPayroll management | Changes are staged until APPLY writes the current save payroll settings") + elseif self.currentTopic == "ledger" then + self:setTextSafe(self.hintText, "Persistent payroll ledger summary | Read-only") + else + self:setTextSafe(self.hintText, "HelperPayroll help and operating notes") + end if self.itemList ~= nil then self.itemList:reloadData() if #self.rows > 0 and self.itemList.setSelectedIndex ~= nil then @@ -459,7 +539,9 @@ end function HelperPayrollMenu:updateSelectedDetails() local row = self.selectedRow - local info = "Select an editable row, then use the left/right controls in the Value column. APPLY writes the current save payroll settings." + local info = self.currentTopic == "dashboard" + and "The dashboard refreshes every second. Select a row for its payroll or compatibility detail." + or "Select an editable row, then use the left/right controls in the Value column. APPLY writes the current save payroll settings." if row ~= nil then if row.editType ~= nil then info = string.format("%s | Draft value: %s | %s", tostring(row.label or "Selected item"), tostring(self:formatDraftValue(row)), tostring(row.info or "Use the Value-column controls to change this setting.")) @@ -634,6 +716,7 @@ function HelperPayrollMenu:onClickReload() if HelperPayroll ~= nil then HelperPayroll:loadConfig() HelperPayroll:loadSavegameSettings() + if HelperPayroll.refreshCompatibilityStatus ~= nil then HelperPayroll:refreshCompatibilityStatus("gui-reload") end end self:refreshDraftFromRuntime() self:showTopic(self.currentTopic) @@ -677,7 +760,7 @@ function HelperPayrollMenu:onPagePrevious() self:showTopic(TAB_TOPICS[idx]) end -function HelperPayrollMenu:onClickOverview() self:cancelResetConfirmation(); self:showTopic("overview") end +function HelperPayrollMenu:onClickDashboard() self:cancelResetConfirmation(); self:showTopic("dashboard") end function HelperPayrollMenu:onClickBilling() self:cancelResetConfirmation(); self:showTopic("billing") end function HelperPayrollMenu:onClickRoles() self:cancelResetConfirmation(); self:showTopic("roles") end function HelperPayrollMenu:onClickWorkers() self:cancelResetConfirmation(); self:showTopic("workers") end