From 7810c32bdb99a6beb58cdb937941875dff381beb Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Wed, 12 Aug 2026 17:46:12 +0100 Subject: [PATCH 01/13] Stage HelperPayroll 0.4.3.0 Alpha 2 compatibility module --- scripts/HelperPayrollCompatibility.lua | 172 +++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 scripts/HelperPayrollCompatibility.lua 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 From db1bb0b1420dd0d529e9a5eb9dce613509805051 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Wed, 12 Aug 2026 17:46:28 +0100 Subject: [PATCH 02/13] Stage HelperPayroll 0.4.3.0 Alpha 2 farm scope --- scripts/HelperPayrollFarmScope.lua | 82 ++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 scripts/HelperPayrollFarmScope.lua 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 From 9f23833f8adfd7be0d5680f91b8d55cbc493f5bf Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Wed, 12 Aug 2026 17:46:47 +0100 Subject: [PATCH 03/13] Stage HelperPayroll 0.4.3.0 Alpha 2 public API --- scripts/HelperPayrollPublicAPI.lua | 109 +++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 scripts/HelperPayrollPublicAPI.lua diff --git a/scripts/HelperPayrollPublicAPI.lua b/scripts/HelperPayrollPublicAPI.lua new file mode 100644 index 0000000..c55111e --- /dev/null +++ b/scripts/HelperPayrollPublicAPI.lua @@ -0,0 +1,109 @@ +-- 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 = 4 + +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 + } + } + + function api:getStatus() + local compatibility = owner.getCompatibilityStatus ~= nil and owner:getCompatibilityStatus() or {} + local roster = owner.getManagedHelperRosterSummary ~= nil and owner:getManagedHelperRosterSummary() 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, + 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:applyRoleMappings(roleMappings, reason) + return owner:applyIntegrationRoleMappings(roleMappings, reason) + end + + return api +end From 094e67580e0216c9448aa4ac0947047016bc6264 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Wed, 12 Aug 2026 17:47:19 +0100 Subject: [PATCH 04/13] Stage HelperPayroll 0.4.3.0 Alpha 2 snapshot module --- scripts/HelperPayrollSnapshot.lua | 259 ++++++++++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 scripts/HelperPayrollSnapshot.lua diff --git a/scripts/HelperPayrollSnapshot.lua b/scripts/HelperPayrollSnapshot.lua new file mode 100644 index 0000000..566ed9e --- /dev/null +++ b/scripts/HelperPayrollSnapshot.lua @@ -0,0 +1,259 @@ +-- 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 = 2 + +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 = {} + for _, tracked in pairs(owner.trackedAIJobs or {}) do + local estimate, labour, elapsedHours, assignment, payBasis, payRate = estimateTrackedCharge(owner, tracked) + table.insert(jobs, { + sequence = tonumber(tracked.sequence) or 0, + 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") + }) + 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 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 + }, + 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, + 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, + 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 From 12426bea66e5b04e91948443285254d9c015e78b Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Wed, 12 Aug 2026 17:48:42 +0100 Subject: [PATCH 05/13] Stage exact HelperPayroll 0.4.3.0 Alpha 2 patch payload --- .publish/hpay_alpha2_existing.patch.b64 | 1 + 1 file changed, 1 insertion(+) create mode 100644 .publish/hpay_alpha2_existing.patch.b64 diff --git a/.publish/hpay_alpha2_existing.patch.b64 b/.publish/hpay_alpha2_existing.patch.b64 new file mode 100644 index 0000000..b2a4a78 --- /dev/null +++ b/.publish/hpay_alpha2_existing.patch.b64 @@ -0,0 +1 @@ +H4sIAAAAAAAC/+19/XPbOJLo7/krWJyaGulZUiT5K3bi7Dq2k/GuE7vszMxdZVIpWqQsbihSR1JxfJnc3/76AwABEKRkJ/P27dVu1WYssNEAGo3uRqPR6Pf7XvD4Zhk//jlKFlF+EdzlWZK8jtLl4PM8ebSxseFdt3z/61+9/mjc2/U26N+//vWRJ//37PGrZXySRPMoLZ8/8rQPL7LPZ8Fdtiy9RZ5N4yQ68KfFePtDsbw+CsroJsvvrqIkmpRZ/ja4vo5CqOB7cXjgaxBY9vxR39P+9+zFsiyzdF2s8G8NK5QV70bvfa+MPpcH/vmvJ5e/np785ntZepTEk48Hvvjj/FOUf4qjW//5s7ezPIougrx8EZfzYHGP5l/c+F4azAH0Oph8vMmzZRr6j58/e8wDef5o488d3vHh1c8vzg8vj+vjOw6K2XUW5OGfO0DvzxjgWA3wxenZ2embV/XhvYiTJE5v/gUHt6kGd3l+dnJVH9pllkTFv+DAttTAfju//PvJpWNov2X5xyj/kwfXJ5E4z8LjqJhoQlAvIbHXG41B7OG/KPae/QU+eCAVijhLD/zRYOh7UTrJQmCzA39ZTvtPfK8ogzQMkiyF5tPM9/4CtHwmEHsh/POrqj8a+oLQz4JlOcvy51fx/BV0PP9blD57LMpYAD4TzT4fDrYG48Hw2WNZwBLE+L5pfOcWyrhMIm1en0Xpcxb4npD43mGymAXPHkeqzmO90jPsfB4vSkLa1/GIPjECb7zvnXwuozQsPEOjAEuAnojLu54HPyMvKIr4JkXd0fNuadq9DDqdA1jhARG9f2TX/SLJSiBbCTMM7XrTPJt7h/2/eWUG/3nrTbPcK2eRV94Cmrv+jAckmmWGKaC1oozyAfQqLkqYK0JQBJ8iD/hqAQUAEs2DOPUm2XwRlPF1Eg3kYODTJMthLNgMfk+gMyE1HqfAgHlAHSujouRe51EJqArufQn86xVpsChmGXy/nUUpDb7woOPQeBTkQTqJvOs4Dakjk1mQ3kReuMyxowGMGrqJ1BnwxGzYdN/U6X4YVkQ3R98PboM88hZiKuZBGtyQ3h54b2FgYtl5ZXDtpdmtB929LbzzN32uLeYHep0mdzAKQAzrMSpQQUI3z1++lBA/8fB6RKooLZg65rwmUXgDOAU7xFExUCxYkQoBkeSL5TXIBe/w4pQ6Fn1eZEUkBuUFn4I4Ca7jBLiKakRpAJMXPg7jgv6AbizTssDZLDKYsEmyxF4gYibayEuQwqHUhT1BiqMM51PyAzcAEkjyIbY1XZZLIOl8mZTxIgnugMh5FIR3KAJKmFIUO0EOw3uqwwhWKzzZxYGx4oxFxmXxJEtfAsVRpD03VtQH/DSAOX/22AASFfVWi+VikeXAuSA/g6SIfO/xc5JxW3u9HW8D/h0NlW0H6NLFEgQvMaWy7J6B6M6Dq2yZTyJsq9DZsVDF3lT0AxQAjaUwLcsjnaiDZBlQXx6C6WWQz68m2SL6JixXgue+CckFsSlwqcLirYfFbXjfE4mBwKwM3FWbNdZ/jZWFNmz+zrpxG1XjNjEN4DN1CTUhNN2+J/RDP8AlN360YX/brL4hqotljot8n3543mjgXQH3grgpvE9BCmZd4B2eSpUBEg3FWDEQ0GMQaDmofSk7ARIkMXymTm/RPmZL7GMeWQrqwPvy1SobvD4//vDm8PUJfLz5MFnmOTT2OgvfAP1Rhvsvr8AaMar4j/omCthhXJ2evwEMvqCE/2ijFWQTQayOXJ6cnRxenXw4+vnwzZuTMwRlmtUg3x5evjp5++Hnk7OLk8sPV2fnb68Aejx0De349PLk6O355X9a4zuOczKq7miQPlFvexNFBfwrRIWJbWaonddCs764u0IN7qKspj5Ruh94MLXtMLTIihmI9QOPpJhNRkNeX4EYXhYCrwWYg1aI55HYI7xIssnHZqwO4MsoKEANOHEbnfgtyFOodAX6NFX4rUFmeXwTpwGJM9Dgr8F2vHOTI4hZO/2cZR+L0xQUTZJo/a5BX+TxJDqOrpc3R6gHAXDI62B7jFM52t7p7W4rsb8Am6PsFCWaHgOwrOZB2fHfGTjfv/vt8PLNe8/3BgOwwRi2M4elCeZEtwelg273EajhEFbXxnSZsro0cOzn0RQW8+yoPlmdnOjaZREMhA4S0qbVNGJ5PPWaFYr3PwRJGrpF7QB7JgoWzIG0kvqqwVXVO0WUTEVfI+KbGoov5v6+mMxA+wvZB59HPet7MI2guMyXkfXl2mRR6ysbyFGoZtn6LiYIZYZJLNiYLEGjLFNhSSXRUzCzwO5Jp6DOSu82KBTygW9hlVAFL2/1TfsTKdSwKplGFuiKdbkK1lyWNC/AiRuKbbjNgSImk9qa/3yCi7ZjUQqnBv5DVYVu2feYXQ9+LBQt8G9BbfjTpphaMVyRZGsxCVK/2wQoegxLcx7kd0eiFaV9lulHsIpX15cr1Mmt+eQsu6mNF7rlTRLYoGjD9HstQ+hqFCe0EdjHqZpnMRMNMuEmKl3yoFtNXSMnOdcwQLfJGZ97BQb7fy1h5+bbff8eIgYMU9lHp5wR9FkLQ6dp+E1EbwBfNQ1xIf665MV1wvspex4alqm2nGRHaOF+X7rGhdm5h1LXxmOIc4ueOK41WNjaUXQy2sgVXffgJVjjuNXO5HoZJ/cYp1mPxtXzzL7YA9QUVZuSEhNf12xxCjv5IIn/m8Q1MUkMFopW6FRrwh8hZ5JrNnOhrdaqDe6V3N82aEh2piGTgQ4sgGmT6IIqvo4LHKdfU19fV803yJYs+RT9LbtGw+007MA+o4eNJ+h25LKGmVcb18apr7a2djPrs0Ezjpau1jg/S5fz6yjvWNAo+GmygO0PSR0K1GAF+kl0E0zuXooavqSk1yh1jtFpBlN8HAc3KWiYeFJUoserjMEk+hQlZDyQkZpkt9CzStkh2xVRWZIrTSOt8QFq3ZwRHtRdKdq5CWovnY6iHeCVEK1nH0GrMthPTyKxJ0IT2tvYGe9pZ2P2dOOWvnGmab+P/jZjYj3T1MmS6Cwuyl/jAp2TlcFvQkW4BPDYCjjbBu23cYrqA5PQ2Bj2vJ8ycRL2U7ed4VagUU62nwSxaZugmVw/vVZuSe/VL6deXIAxWnrKNv3JnCQeGO1n9kbkxhrt7Y17o82xmgmpDmEPWqhtSb9PPk/o9zxIsS32hobKQQ3khMJlUUq/cEiOY7CHyf+M3kNYVbwtDRJC2OByvlD+Vt4ON3i6Z4AfpNNEIFP9AAsmgg35BMHySLiWoaPo9gZuhN257Dr6o8lXDsWp8NAGhEw0CLREN63s2Dk6crVBeEBusVKEi7NY5tMAW4YJXYL2WaLJCPhgvZ9X9XjcuMcHY23QphsPgT+4s0admtmCsG7AdnE8CwpzEi5pqIeai7hjbCyDRSwVDjRp1j28OO2guqrkN0FXC9Rh3+i4PyntqeQnIIBtYyz0KsnPocIu4Z8feDs0D53ybhFRHeibEIVEiC5JIEkDEkwKNC4QREBbgF23wmDDxtop8Riyj71qI0tkgtKkTicfesjWpV+RK/vIHnzsGiPh/pTYN9/qS7UzE/57gZ63s0Isor5hIOk5b4diTseOsdfUqTadlDDUYI0s0zgvSuHUYmslDaPPb7OKWTsjQYd7URAqHgdlAFss1YK5OFaQlLjGJGMrUcTH7uotQW01KoO56iPThpUp2ns6gURpEWkk0qrC7Nv1GtYZWBZx+gkAQzoX9K3dPeoKanAtUdC8HqAZ/WCpr3lH/AamiNNp1ihMcEynANAxh1kNn2urucKfA70Hf8c9Ps6Xw1sBEvllgPUWQCyUU3xoleJEolsV5H4ZzcEwCPIYRD5oMXGGWSmnENjOQEiqD6l5HQF0BJWCCWgCNiigLABd4GXYFlhCaRGjDk2yIOxneUiqplhGgzVJy/6LGllFHSKF5Ga5hfB89jWLiICiD7LPX2N31qS2DKUAuycs1SazWWt9J56TbTbwlhj+VVRqfrZKROd4UrtSvOgqpEFMI6IWIY02xwdqDmbFixcBSClRJ8xMiSp0YJAsyZspkNekFZQNyGYBzPC3C8f6AoVa61qb0ppk0ZSARtd3JtR74YKtlEC7RoCpP9HnKI0+l50Ke1e0akycZnlVkypITDSpaCzZw6SzwRuAQxtNwWM4sFSa4Fazt3V1eB+BpknxikyyR/eTahLCWuwO+ouxSECaS42e737QibvhjXA62TR2zGDddL9ZYkxGGaHNDWItCkAWZmnknb8RpvTAuyQ7HMTfFEbTR8mrkMldMoli6FeCAjHDY8ocXSEkgSunCApo3rXhTieVQhPqGoMAWgxpxfygxNNzKFlXmOg2hL5tWSkwDcnH8uxqOUdH9DcITXGqo/lr1pefpthVKJyEsI9eVjiI+HwdvUNiS8WhR3I+6578kpaumpFe0zJoglAhKgdyH6A5n0wOvaB9oMWoP3nBzU0e3aApJ4zMIqO9qRnRAlIjon20Qkf6WYRM4S6YR4q8iPqjEDtL4T33JneTRDLm/+uNAbcnaa22UupQRLP/KxtXzB/SnD7RVusHc124JKiN3rEX6dq1tUm0q7t2KV2nBKusdIWucbNSdXcelDMY6+fOsMd/T5Msy6XeQWwdJlzf6yi8uOnsdrvdRlZswKojUGhFUzV07gVoL0KHN9hch5ZcBo7yHfCSORo6Tp+7XUfFmup0wGiEkX+aQF/XsQ80WSDFY7sp6vDJNxNuXYK1Saw2WjQwR6WKKkawHOdN7l623k9520zGm1ZiuHuLFUZfvZ7m8CPP4Hi4u9fb8zbG490nvfHQuL4gYCnOQrkjDb1Ws1YOaLPk2F9TcX137WYDG4d2ug1ioQGVUzTqYE1ecRtf15RCBgrgomw69dkiN8slH/iG9Sy1ac08q6FNnVgFmhpS0/4W/GSsjUpiMJOY45X7CkESWD7GQQHarSgVtPMr4CFdihMWKiX9EYefe0Z8B/Tz1FlH+0Y118MoIq/cKPWP3fqydVfSP+ojFTHSxFNa8SQAAxQ90KeKQ/Ui7fTCJLQGRMMysUpPdoVUK2nEWcFUKHkx741pMW8O0c1vLOYqmPosuKYzIkJlFWtdo/jlsuqY/G3QhG8YVATh32tKTpf4qJXph6vGYq7XrhNKr4GaWfYGIeWKowXd7RrhIgbj/1JEanL4h9Qx+vEShQBJuOp3I6hkSgOeCvUR47nkElXEldRiPC67vJFVbEgHDyoFaUkILqb4lVmQg8GPbiShRr7yYREx3Wi0g2dL483NYe+J4jmhoMTAKCDnAA8JzIgP7LAqHBjQ3E8XMkk6Jzb9PMFCW8kcC7FhKK+PV6+mn1cY6uziYbhdxrXs+Ubdvn5gK04bXBFIHdZKAurOZzFzh9Lzax3Lav63qFgm5Ro7Ia0RYDNmre0Rs9bOLsg1XZwppuU/DLllkEL/6RC8bjBL5Oib7Hvtx+uC0JooR6nDtLQquYpbJIwlXGpwledQ/XRAiRWs/xRKZ3O0S5O0vbkjb4KtMEVMd6JTWxTChaY5yGRRzTJcoVIUKj3gQeJyVKjtICwj0oHOMJwIr6V5qkBES8802SqtnTZgpTzfcCijVix1JbVhK73W+k3K0LSUWlHooO22UXtPKkjU8j5+2KcQbOtEyYH9MImDIirWbUKCQztfGtv5KlcG7Km2nsDS2Blu9sasG3kT1bT1o4C0UyOov9MQI6Vu1DRGzlR3bu4ZH2dVrAX+iV24iAO+qG6gucKlF3wpAe/BgRGxnNClMz2qEffhxq4WVgYZ4NisXs4BCV90mzauwvDG2uTOMymvXPdPelWwDwD+qoUi6HFS8soJrld5LaXb0+OEgpCiRGrR1/dAuslINxxIybsvjK0+/6GYBga+r5yHMgLMLXI1qUjRTkTcpthDs5ZOXep9VWKBVvQmOPGzDmThq0p6ZhoDjh+X3MhqTqclD0IFrDnBkcRhNA3A+DDmTQurfJ2FbWh1KMSGUWZv7xZRDd01x/muQKdD0b4jxXjDOIXFUcNo+2jlxDmPDgiiY6Mog1yETUgMygDkjjmuQZF2Gg+dMahWULHs0PeIRX2k+QuVp6nPw7F5nu72A8vb8XqKPJr0RNjL7FaBr0D7MtNjL9ZtwFGrpR3px+zbfqpbcUukr58nkzekduDZzAmAmU5BDUZA3O8I03udjVYPQw3FogQibB8oXf9BrPKaWyfXfvQ8eY3JTWOqbXVvHUTcG+OwbxFLjUsKeWu8i9Go453NsRmOygHZ1oW7xnDT2s082hG4dbdJRUf1wX11EK+rb8REikca6nSnlLS0d5Nk13QMGxTebZQk+F+8vj+LvDlHgg8o9DQE3ZHhffwg9a4jbwITAQtaoZP38wFuSiGapazuJfE0ojMzbwF7qKiHp3LiW5+uz/ONdhoUz9r23pBmbXdvW5u1Kj3C4AaUzTFb5xO8azEI4cffo7saIB5Qv87SrESr8zi4UxXmWqGzFpS/5cB+2QL9fi0WggY95Sj0g4bob569GnyvXqRcPuKyjhWijkHWHyPTS8hFg39k19Kc5hA2e0QyjYBqwlfZGHxzAak6msNnZ7hN8/FkNPz3fPxJ8zElu6CvjteNOHCfb7lqGFRiCLqRqNnfGKMhDA8P1fNTT3Q8pPAMEYyhIQJ47O5U2CVN3MCM8GTcG4+QE57sSn9z+/6mTv6+FjkqZwq9T30hmatL10J+aPS1v2EssOMSSKVSZQN2xX1VsfMdbqSgZma8bTdSrDGviatjHk5Naw1UAaH0RV0h4Hnpm/UOmmnZSmeRNGM1yGD6kOkwK9dMjVr/G9CONPtC3VnFUH2MjhHLQKZBEJXQ6BIXAiYzNKCf0iLB1UMLpcwE5MHIrxt7BunNTfL3bVg/BR+tWnPAZpNlAhJYZG4h9FJcCUfr3i758DdHu5u90WiNhUw2Gt3EF/iCObkhPSk+J1mKqawckaCNO4bGe8aSdHwp/cfiqcrTI+/bxYW6AX5915iWRr+eK/pHBpFBeL9bv8WjbfC1o1aXdKrclTWmDlTqApdh2TbaZlT2DaC1R+g5R4i8sDnkPBab421pbrWzAtAXg5+PoQt3oqwTIidk6JgHJRjf3ET5pZ65YH12kP1TcsysqjeKG+s1alUbcnajHi2LMpvrWY1QVdeAmXR14FpzTMTRDi+ozZ09uaBWrSb38vzm5SNsBWkEFB/jxQKXiQhTR8terqJJ841+YDQWWP49Voc0idwMr1dWKoqINx5R+o3NrfHQyqjJs8AZHDSHsxBwIVhjRxKCQd9mh0hbDVqpByGptAhu08y7hj3NxxC2pIb9Vz9pImQVsCrVIDWDuAJUhdpN0VeiDIzlju6tdhjKFSL7I3ufNzf36M7f5s7wyVrG2SCIgX6vopKyolxE+euC78EWSwB6CXU4f4nBkmtej5drymxQLa5C5C/6ldMXHZ7aK8y8CyX7s/LOkoLkgVS9rwVMiwrDGhuzmQbVhYOzbs+eAtUohwxCYENmiFYZlLVkKTL1DDpwixd36FR8J5p4LyXI1hYvgp3tJ+up5Jjz3UjiUQ6czncQIFouKeyzN0PENTnyQI1ckyYuOcLOZ3duH1uLUqoOd5eDhPPPxbKyX9ODgvR7rAF3VSasdspjfg/e1hAzFKD+vouiu5d1EV/dFWU0bxO2lpjdHtM+fnN3a9cSs8rBJCQ4DwtZ9DR8LzJZobdQyUptL2t/vT/WX4oofJlpWcec2E2odVsxPf8aYu2D5T5VLIX7Ypnsx/vhx3Afk7j+WFCsNv5XQ4E/q66av5gsZpk5GPxWnWnqv6q64sa0jQz/Qhcp/peXwCWoE+qdyA+LAFQ0GE81AsicGJzFpkZADjKojEsiZ7dneRmdJMdUfDqkm+gNEBWJVgKIAKdVYCapW8FPjXNlv++vBX1VBU5V6Yda6r3WZ3L9am+ayToTF1KSJgDmDHMC9WmRJgx9l1FDGn7oanKHjMVhT24splOrFgT/v3lZia06lOmmI/7+94L794L7sxac2NnggP7SNBRjj7PG0GuLuKZn90OVk+YK7SBkRZhuMzhObCSaWFZDW+2ht3EPPRpiRs0nT+xwZjOJG3aD03DITKhXKs04zsoppuvFw9EKgfbnDFbFBeZ2nHZM1e+4MbKvhUWQvFBHbpowKA5+DI14OvymR84RbDz5KGSafW/nm9oVwWFYLAMFWXrZ4Yl6p9brpNeWQk8LF5Frt72CmRB1Vd3mq21iSbQn+HOFX6qmWms6gzDXq+qKHuVQm1W0MWIz1yOnHTy9RhU5uxKcl914uMnLbmv8RAZ01i9Ai/1Vdaz2m1KLL0B+dyo3iKYwuy5MOTtGBEJXwLK4V2xLQReyeSC6NVvYsXxcUoU90iUA7//I377I5Ou47G0GgNYxK7qKJtqDP6sXEHxzBI1yiGSC58F6tO644hK1TJq6BWMZLJWZUhc87Q3xsL5Dm17tUqMZc1zj19mClW+9txqp2xBoN2bawIxxtEJqPM3utu2tIR2dbGzvbA57oy21bDCXKOUdwetcvPCI1bjAd8bNo6mJKwf/S5F0RMCe4XGU607GzbOem2Z6OKDIv7C4Mm4j1/MVyDjCvoMVff66rx72+JFfYUijxGGda0FulnVcaP3E36lQMZXVLCwDtAtQkS0iSq+PAVz401QXZ1kQsmIzyw8XsbNQU5ZWsjgqcmSGq8ovOFktPYnxUoyG42QusiSeaLsHvVRA6kuMp8P0hVVz0pLf9X/7lJhpLjlfsEYMkUDVziOsclYeOBJWOgMOD/gupjJqKZPnP4Unmg1ZK1RrU8QIW0B2tnsEvo7KoJ9PWtS+caLVsBc0gTTm0YHMDYvMeJy5StS2SSjzvNrFNNl4zr2w3PnwNSIsuWBu1M8AOdqzafxSEGKgHHNrZY85oIzrTSsAa+brRvNOpdER3DVOb/VcvFZG7mbApkTYmD2luVsPTLt7dfTzyevDDxrDjtp5r5ansz6clkyd6wDjulyjIwucLrAcUvPupP1tgEx+EZQzpmGc1BfighY20ju+qaPSPuoXKl2bbqpwdP7m5emrDy9Pz0667sOJ7a1temxwG4Ov7nMuRAED8lCoL+I0zygRrVdt4ascmhSLGRR6mBgb1wMP71TIe9Ly6A7bpWiEWy/EdEDlLM+WNzOPFjCGMVWninwcM1D5Vr57J+7VgW85GsOYrCoYA7HKc7GBd9T0NlISTUv1UIsYwC2wLcbtldkSdFI4aDsX092nbR34paC3vBzD18mFgUdWZyZ47Ipd0J0xW7vb9LrJ7nDP3hTWBeKKvejD1iKqWjwzryf3fyQ8OhvfZGQJkoqpr6yLfXS2RPe1MR5m0tQsEzNDyZpaxFFlhTpZWWNtvfJn6JTumiFIVxjAvBRMSIewzLp7Y2bdzbF8mudRdUxY6JWAA25Ufo0KaD/JbgSZzTbulfdfcXXzCzjuHJYrKtmZ4KQ79JYuTvDLFh3ffMpJerX2RcZLftVPbNv7KAGQ69W5jP4AxEp1Iy+WqddqOBH4cKf3BGdhe6i/pUZjS4JCvg1whESrorvVTf36XJ0kwDJR+LqgDGGrJlQLUlpJTi0mScx+ELISl5trVXwlhNKVMJg72vWM1gc2KjL5Ns4zev+PbvfK9iSDiacBbTHJxYNZUFAtyudrPN5ieuyxEdAEcSaa6kjpyD/502nYcUYlwWo030drfKHNeietGQ75Ay/4b8hb/o+EFK+Bf5jjRbYj4JeiU/vY864meRSl4sVlvN7SZzxvD198eHvyH/TO15fqSeOep57HhT/5MVn4Qz6+Cn+enRy/OrnEv/CanP/VQHh+cXrEGGUCeAQUOyb8k556xD/E448+pf5HEvsiSauGEbF9OH1zfPIfiFJiPBj15Ab+YMwOoeJgU/pUi4Otnngu8mC7R/r7YOfrow3XqKuXjh8w7A3nsFUOvYeMe8M5boXyQQNXjKNk03Rekm3U+WQlwDJf8UJnh69tUj9xJjj+VwQFUayMDAhyCz/i6DS67fDly543oXjR12XXymMyhbEazOqshc27loAmMAY8HsYl7zz72mUzESb0NluAXY15piSr6rLQgqnmVW9IXVHURK1yIN3KxAWWwK4AxDd6U5MSRhhRRwwt7NJzeorlSFr8NYE8iMIYLLm0BJqoi8yFfZ9UkKdAow9kN0403pPL5059gEkhL1leH04myzkGlGa5rly0HPy6ZTwabtKjicMt80rhhPuX4GnE+vGvffFwmKhqT01J/83yRhC6WGxO8Tdis5mhgpX67TgPpuXLPJsLE0kqLQ0SrRFC2WloqiupOeYnKMf6Vb/aGtinwE5WA91Blp4volRkKdAsoKi8dM5+h2iugzYPpG8YVDyE+poxqK5bAKsqVcRdZePSsPER8KzAfhGtNvmVws3N3nhvfWIxCkGt1pcIqfZygbf3qpDGFfgFOL+ppMdB1gnwP4aoqUVBbqy3Ojum37AJUshyb0OrEJZSwhudbGzsmTcaDof1jq4nRIycl3aSHnMQtlylbo40vqK7xuJqu9H3uIzm6GFreLpRBwGuR0MQH1WwbpX9UAn85yKzslET3cNXxgAaA7HtcaoUmSORhnQe08rVMhRpzdtBYwtMH9WRzNrpWgOyu2Ujpr0uTlm3KS1pm2YzkLlBZWILunJvwL/XhUIkHaBXwZSXihrDVRKHUf4iM8gAszDa0sUKLzI5Vn4cSnsCpl2EvFrGsFtZLjj1RPWC1ErBUavokLjLxdvgupACamtEwnxHF+bKhSUeB5Ha+0TT6Z1/ReHc3E9+gmmPLk2PN9cJKSeE6rIskFTuBvV7hprp/G7FsN9rAmTt2tX4RfU1+sycCR2+kg8lifGPN0XqhdHa46/mp2QTodGqLZtsn3Zgl53rHkGryTtaafCOtyg12sZ4d9jb3ltz+LqM7+vZvnkYK2ZcVzbtVWwq2BlJjEgC+yaLI40iRSd0ANSIvXG+O6cVO5P4qFf0BiGuqqu1E/5oV53F4A+qHaqmpozvli1in9urK/YYAmSNov62pQngevzyS5xOkmUoruUW7Df+amZ51OZDvSxZOFqQZdIDSfnJvq6MPGjDZAI78XHwzcouMZQTAx+QrcIgoBrGBCMuVg+GoJwYhE9tBQYB5cRwE6WYrjwKD8tVaHRQN02Vm5kjdsTvgf0SqOZT9g+P3p7+esIBbC/Ozo/+fnLst879r+LpmdZTAeuBUD3Obb2zAVlQ8yLL/xEQPqHsS+NY5iuqP6D6JcH8xAf+hXnPzO/xOzoHBt3k+wsH61Dvgg8PSDJgj660e68Ymc+RHP4ZxxeK51B79BrLQRXpIBq6rr+2jThN53t2mxZ05GecxGFn2POjLtMtVNfonK6cxRxxMvC7X3tu+hjOZkWd+uwrErXygOAvVlH4GHScy0hJJJNGnvrr2DUamS3JN9cR15tsvWOIlnEfqgds8bQuUSOvzVD10i0FYzqeuu0q2vhX9LnPR3zaaC8V99EQFVcWFPMJs0SLDs+uRWsBKqyBp7QDJUGnsz96KgfolQQ5d7ngU2/xWEmBq4mfHKZkS3jPOceUS/S0mMijRs9uFqD5I8yn7k0SfHKsGPhNxOJoJEUj2xlaeI898zFzlsC1jHjmBQ0BZOe5A6CKojbwGqn7FNHlWYui+hGbMmq54Mz38BenxMGTdExHSamKOduGMFGaKSP3VORwbiTQYDx9LAgktu1iOHpEnX5dw/EZxvwiKOJCmB5xcsevfsHfd7zCZrnfTDgdlZS4jk+ECP/QCUkfFpIHmJb44iuOGZhsAfsD8YhqGt3i/axCvJgqMyAaFK9yA7QwnICfCKHawHf7w6HOdZqypOBvebWlooqD5RQ5ZbpPfvkRu0AvVlG+1gbG/lk0MnrSRSgf+8NB2+epnhZJkdLZVf1iv49nZ2LYXUlrilirbkyjvhMvCUKZzNqEi39Ch440G3HaR7we0uEpPY8bLiOuKx7hVXWnKCECD/hIvD+I0rt5brS3VoRF1zhBIb7yBbIhxLdydfZn+0reshAWrfm8gwARmS11kGEdkTyeNsCqaRev9OrJm7Wkkrrqsm7SwL9QUaqwQ3p5mB8Iro6q7KkVjWnhWyLvIj+bXE0rLiHRYZTv+DAwzI7+UNn5m76whkVzJOoH9PKwLOHYJ5z+OCdlz7KoR8ZalBaES72PTKMUtql6FpkeD+Rd/m1czio9IllO7cVu2ZXZFxPXzCTsiCBZUFetYtJYCGCssDVd/hkmDqnkD4CIyOJcyZ+TYDKTUhpXGpOh8OJS3rjsueiAQ8U7bXoKMyXBRC4zCghokU0cRiqJ0zg8EfysjDhjiDYDu4CVM9U/vA3ikrKxwETwAaCvWcMVpVhSqLNTofbEC6XMtJ7ENaVndnTJUmFvU3hsfXK6lFCNXh2citiCguE4vUpYDbwuIqC5PI4M7WjiOGGAalFL/S66Yg/XtIlDTCKNgxc5JklOCkOAJKRopW3CRVBZuXLUE3u4iikkALcsQyakNbug37rO1RqtjY/OCZDxLfM/B6WQhyT8aZgLGwcniB34X40H8mpvkuIiqNLHtu5PzTX85Wv98VKyXAewNKO8pPdNe66XzJjUFnMIIfLDj/golmlgQheBPf5rSVGA9Rus+Lm6hUsALGl959tlDVbb5nTm/eHRxYaogDWhcSg2EHFAERoBdcWEAMwNJwWY/sq2czXfsDTqZjUizTUDTv4WjCTtNgcZ1MXAZcqSD3fX3cb34hx9+cN7We8M/J/sO9KOdDW/1r55y9jVJLG11eDfZH5RxWr7fPFP2NJP1RoGzkErLBJUJoCnMvGTUTrwMPyVkwYXs3ghHwWJiyqAWOk/TT/gqpguUUVUQZ4ePZsdsKVkTTssR92WF0WrjXcLDQ/M5J51GMx6Uc8+u6qtdqF61l3xdU31nVe91K/2khcNf4d1Dev5DzRKgYSdomtsy0QbLsI6wNC8WbmsJTDa34ateQx/pNmtz2avNUqA1vxBv7HO9r9txUrcxobDdbdAAj5s7coJrC5qEav9WAy8M16svEbl4gw1w4MXqqvTagnRaqGrvc4+6/JRLxPjyJWMtOaSl1vjVD9kqWnXOZtOFbyaF9Mg75c4PPA1LwoQxtohz7nUMkEdZyAtrxMouxvPVEVaSIcTBaOosMaBny1Eekz+ozj4UqFTz8nTw0xfpdWiXAEsWOWruMJhgF4o2nVId0q12QUxomVo8Zb4mgqChtEiye7w0ef+W2Xxk/im93qtjZza6+BbZbyNUUkwyfKrk11zElRkF6Ha65O9/RWH70F5HWPP8mUo8mtAQjfCDOAWaqI2B2T1zefQFjA47jJ1TNXjyKIeU/ynQkNAuwv2O0dsWqNLgaNBcH3j5kxdsxETTLSnI8/dHQ6Y3O2NnhjXRejxyyQ6z8MqNA5fIDYKh9Zy4iWlALwv2qWTex40yptEyj3GF/LxmP+rmYekb2lW6x0GPtZrfITB/RQpvw/ecIbY/KB4/Tix7fHx2hli9eBoHZ3xYVDU/Cj1oJ77GAO2B0b4P9gR46+t4xs9T2bv5Uu7ttQ3oaR3aaWqN3xjusvGb9bblt9I0bVyHzneIW5UxT69BiSbprtvRIo4tSVikOPL5kVJORmT7Nb2KpGPELaX7Ew0T/GVmRzn0snCO9Gar8V2NcVpCVvGgb+G5rRzh/DryO6F4H7/ub4Imt6JRjo3L8+ayWwtbK1/tJIdeUaq9+mc/bfyKzheFuPeueH4ERbjocras+p4JCXfXGp7uEwHpcfRuEl2g/NjL44Gvsfba4ycE/qOe1tjb2NruNXbGt4n/OVFFt69jT6XZgiMIwzlAQEy9QAR9c3KKF8l5ZAR9n2/9pWTwwyrckBdsYaJsJ7pg1mOc31UeW6IuxqSfPRt1Sj6VmHi96SlpSxBrIrcihKUtRw77peAaolF1lgEtQdJnUtA3KuTzG+1qD14g9NwpaXSMenuak3e1rfv9Dsmx2zHUkZHWZqyVdvB538+/Sj2nCvSAuCOSyMn7yNae1pLU7Cyp76Ap45pz4B0/daJ1PJ6uOayLduCNaHtiRkslqqWgHCmqI1gwyqqeAsXwpGmnPhEe73VRkNcaSbWTlGVmejCrAKZmKnd3eusinfriR5aqxtosKiNz6BbdYyp9tTVZ2PCXQ962ft97sw+7PB/T/V9qSoxzte51Dg4xyKvI1x73d/TV5QDBRO1J9my9KYRQsDX31Nz9YsGzAMZ2krDl/D3VBz49/FYTB1icaXf6TVSca+N9LqMA4AdPoD1PLroxqckHFtgWDc9T9x/43NvEJRhX6Qkk2dtYOYik/DlOAILZJA/jHsWo3F1NzBFQW1Tsk7YoyO2Yg1M7cEZayBwBG6sn8tFZ8f1fKJr9Ign4IjZ5mUUabfytJ5pItDhCELJU6tSta1nH6gXmQlA+kqC17xAdKnRkjtidVnBX2yQImci0x7NgvRGWO8gYPBUGUOLEtgVXJz9J7qyKT4M8zlwyW0e454dt+No11VHucLjIt3otERwq/IUd+lhFvFLIcjLhII39bwu5XoQUsQ7Pr06Orw8xqdqcL9Q6IjxOJJ1DAcrDXAUvLQATZwqb45ACV28Rq6QKcyIxiTkPViW8Xw5J7HQhwkeqCWIgU5xOotycjBkRbXYcR6qs+8ERWEOfEGLkVet5j+ivv3MjWFH4oKaLmTehpiT03PHiuX1P0CCSTrWO6eiNhCPOBgECk/oXE7lm4f/K1cJuaXKWVCqp32Eb4WPtAeeLUoRcxEtAuzRvqf7eJTHpXLo9ERQjuHWkXD1ABIpTZECg4eFh9dDpjW2t59SeHfwronx8dACgyXUlWQKISI/Hz3pGPJDgGiKe9lUhT7zgyQ9GSUgaIo+++rQQ45S26uWWRkkxcCTSw3XLswMnv78yy46pOD/r4vO+w3Z3u320SN2JJ0jynNtxc6gJ6NoiJd5ang2PkbRQrguos9Qi7y54oFQGrhS3yIuBr5FQQ4zPiGGCHmZykflg5sgTom+/xYcDsHBSxcf96AnxdmtBG3rcbLUPUYqDhkLFafTL/BZO3ZOV0eyUv/lywlH7hSZDGetTmgpPpoiW73iDiYvz9L4v3njI14hDUJcIhwhix4ttDVzXLETGSjCb4vyoQHdREbn1fuD99bVNKm5/fWvPOHVtSi1HBXkKFX5y1y3kfQDJHnXpQbB+RSav8vYtWYIEXJiSP11+qZdiWnE/U/qfyROWN42aq/v2znznmPtAif15YjDufHkSidvd0Xda+HlErkgeqQ0TATtt0fp/bAXy7LEfYxBmFU1w7iYwPQ21DW6jT1s6XN1L7ly2rXd/m69ceVucwaWPpZghhKK5pYhYRKN94d36TYWKIgZJYg0FKojajxYF5e2I3mMWUQgHaqsR2ztr2Dee45AvMumIqmVdQTdsZQnqDtbD6K8nMUg70DwLUsjWrSiCgg5LRbGGo25fu7ZedOyM/q+YkcjTKma1WSHpq5FeyEW7tt7LRZPNitsxUIcjyFHCDNU78iDKYR2MhsjfGABDAhrPCqMvFor7/F7a9zj977bPX7K1LJNt5a3N/dUZqaVmrB+Ld28VipltXZVVteZMZ+qiJsY0GnFqVi5x74v+XIh5lJ8nMc3s1JmGylksCTfcZtkyXKeDu7JfOb+SPRolQirmIO8HrjBqQRT7hQwA0+OkgiDBjXGOy+qWCzzFmZIJNVP2+gm1j+XUtUB862bW/nbQAY9uIHkiWfd3U6RT3Srnnc/5Fz8wwomwwbowFmjCB50AqfXsqzuM2ZCSWPH2t2uhY06g8h+ERQk0D6TqaIhXrciiSdu5zFdBugpwgW0s7mFOVp2RztmdiBTPDgpYqbVq+egc8M0JaRztTtoTlBneJrNVtqy2t0s4z6LI79rvT29Rj4Hb53UDIKuu5ROYHdnaCWR0qpW2crexeHn9yqJQ39Fhp148vFcnASKFCP7sMGYREktnVCn+9RuVEs1wUffqxs7llLiAa1pOSp4dKubEyFPD2hMGtNrN4XHIMUDGmKbfO1mhOPgAQ1J442b+r/WNIDXC8oAAA== From acc8f39fc2fff2c81469f18b8d6cc6618cea06f6 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Wed, 12 Aug 2026 17:48:56 +0100 Subject: [PATCH 06/13] Run exact-source finalizer for HelperPayroll 0.4.3.0 Alpha 2 --- .../finalize-helperpayroll-alpha2.yml | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 .github/workflows/finalize-helperpayroll-alpha2.yml diff --git a/.github/workflows/finalize-helperpayroll-alpha2.yml b/.github/workflows/finalize-helperpayroll-alpha2.yml new file mode 100644 index 0000000..fdbe92e --- /dev/null +++ b/.github/workflows/finalize-helperpayroll-alpha2.yml @@ -0,0 +1,61 @@ +name: Finalize HelperPayroll 0.4.3.0 Alpha 2 + +on: + push: + branches: + - agent/helperpayroll-0.4.3.0-alpha2 + +permissions: + contents: write + +jobs: + finalize: + if: ${{ !contains(github.event.head_commit.message, '[alpha2 finalized]') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: agent/helperpayroll-0.4.3.0-alpha2 + fetch-depth: 0 + + - name: Apply exact tested source patch + shell: bash + run: | + set -euo pipefail + base64 -d .publish/hpay_alpha2_existing.patch.b64 | gzip -dc > /tmp/hpay-alpha2.patch + git apply --check /tmp/hpay-alpha2.patch + git apply /tmp/hpay-alpha2.patch + + - name: Verify tested source hashes + shell: bash + run: | + set -euo pipefail + check() { + local expected="$1" path="$2" + actual="$(git hash-object "$path")" + printf '%s %s\n' "$actual" "$path" + test "$actual" = "$expected" + } + check 90deb4670c99a6a5b59662b1ba9022b4d16d0440 gui/HelperPayrollMenu.xml + check 1d3c799464782135ea5cb1196e205596e94a2731 modDesc.xml + check 1945afbd57bfc9180c3d44380ee9d49925166535 scripts/HelperPayroll.lua + check 968f8324f8dc641e0ea2123c11a0b65e14a1276f scripts/gui/HelperPayrollMenu.lua + check 20cd6e1312ca0f1e448017473cdb8af96ccd2d3c scripts/HelperPayrollCompatibility.lua + check d49fec5ad66ecc9c70c0a72e48a8e7e27d1b84fd scripts/HelperPayrollFarmScope.lua + check c55111e59811e7172a101b64854ffcfd13787a87 scripts/HelperPayrollPublicAPI.lua + check 566ed9ebf0392fd9252db09e4605cb69a54100bb scripts/HelperPayrollSnapshot.lua + check 37074e83c1b5ce73a61ebce248b80141b7d1204c HelperPayroll_icon.dds + check 2ac6620442c760d14405e120c5b7649d3e3f509f config/defaultPayrollConfig.xml + check 8f25ef05cce47a339692df347159cd2c46f13127 gui/guiProfiles.xml + + - name: Finalize branch + shell: bash + run: | + set -euo pipefail + rm -rf .publish + rm -f .github/workflows/finalize-helperpayroll-alpha2.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "Publish HelperPayroll 0.4.3.0 Alpha 2 [alpha2 finalized]" + git push origin HEAD:agent/helperpayroll-0.4.3.0-alpha2 From 978ff7ef3d3ce7b6dab47cb0a44a54d1fde58ad1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:49:04 +0000 Subject: [PATCH 07/13] Publish HelperPayroll 0.4.3.0 Alpha 2 [alpha2 finalized] --- .../finalize-helperpayroll-alpha2.yml | 61 --- .publish/hpay_alpha2_existing.patch.b64 | 1 - gui/HelperPayrollMenu.xml | 2 +- modDesc.xml | 8 +- scripts/HelperPayroll.lua | 352 +++++++++++++++--- scripts/gui/HelperPayrollMenu.lua | 167 ++++++--- 6 files changed, 431 insertions(+), 160 deletions(-) delete mode 100644 .github/workflows/finalize-helperpayroll-alpha2.yml delete mode 100644 .publish/hpay_alpha2_existing.patch.b64 diff --git a/.github/workflows/finalize-helperpayroll-alpha2.yml b/.github/workflows/finalize-helperpayroll-alpha2.yml deleted file mode 100644 index fdbe92e..0000000 --- a/.github/workflows/finalize-helperpayroll-alpha2.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: Finalize HelperPayroll 0.4.3.0 Alpha 2 - -on: - push: - branches: - - agent/helperpayroll-0.4.3.0-alpha2 - -permissions: - contents: write - -jobs: - finalize: - if: ${{ !contains(github.event.head_commit.message, '[alpha2 finalized]') }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: agent/helperpayroll-0.4.3.0-alpha2 - fetch-depth: 0 - - - name: Apply exact tested source patch - shell: bash - run: | - set -euo pipefail - base64 -d .publish/hpay_alpha2_existing.patch.b64 | gzip -dc > /tmp/hpay-alpha2.patch - git apply --check /tmp/hpay-alpha2.patch - git apply /tmp/hpay-alpha2.patch - - - name: Verify tested source hashes - shell: bash - run: | - set -euo pipefail - check() { - local expected="$1" path="$2" - actual="$(git hash-object "$path")" - printf '%s %s\n' "$actual" "$path" - test "$actual" = "$expected" - } - check 90deb4670c99a6a5b59662b1ba9022b4d16d0440 gui/HelperPayrollMenu.xml - check 1d3c799464782135ea5cb1196e205596e94a2731 modDesc.xml - check 1945afbd57bfc9180c3d44380ee9d49925166535 scripts/HelperPayroll.lua - check 968f8324f8dc641e0ea2123c11a0b65e14a1276f scripts/gui/HelperPayrollMenu.lua - check 20cd6e1312ca0f1e448017473cdb8af96ccd2d3c scripts/HelperPayrollCompatibility.lua - check d49fec5ad66ecc9c70c0a72e48a8e7e27d1b84fd scripts/HelperPayrollFarmScope.lua - check c55111e59811e7172a101b64854ffcfd13787a87 scripts/HelperPayrollPublicAPI.lua - check 566ed9ebf0392fd9252db09e4605cb69a54100bb scripts/HelperPayrollSnapshot.lua - check 37074e83c1b5ce73a61ebce248b80141b7d1204c HelperPayroll_icon.dds - check 2ac6620442c760d14405e120c5b7649d3e3f509f config/defaultPayrollConfig.xml - check 8f25ef05cce47a339692df347159cd2c46f13127 gui/guiProfiles.xml - - - name: Finalize branch - shell: bash - run: | - set -euo pipefail - rm -rf .publish - rm -f .github/workflows/finalize-helperpayroll-alpha2.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "Publish HelperPayroll 0.4.3.0 Alpha 2 [alpha2 finalized]" - git push origin HEAD:agent/helperpayroll-0.4.3.0-alpha2 diff --git a/.publish/hpay_alpha2_existing.patch.b64 b/.publish/hpay_alpha2_existing.patch.b64 deleted file mode 100644 index b2a4a78..0000000 --- a/.publish/hpay_alpha2_existing.patch.b64 +++ /dev/null @@ -1 +0,0 @@ -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 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..1d3c799 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. The Workers tab now shows ON-roster workers only while preserving OFF workers' roles, compensation overrides and ledger identities. Payroll snapshots and the public API now 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,10 @@ + + + + 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/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 From 30a445a4fd7aa04a48bd89c6efee2180a1eebc5a Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Wed, 12 Aug 2026 17:49:44 +0100 Subject: [PATCH 08/13] TEMP --- docs/ALPHA2_TEMP_PR_BODY.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/ALPHA2_TEMP_PR_BODY.md diff --git a/docs/ALPHA2_TEMP_PR_BODY.md b/docs/ALPHA2_TEMP_PR_BODY.md new file mode 100644 index 0000000..e3fbbe0 --- /dev/null +++ b/docs/ALPHA2_TEMP_PR_BODY.md @@ -0,0 +1 @@ +TEMP \ No newline at end of file From be0c6fea83930ddd923b5e493c042cde63a200a9 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Wed, 12 Aug 2026 17:49:54 +0100 Subject: [PATCH 09/13] Remove temporary publish marker --- docs/ALPHA2_TEMP_PR_BODY.md | 1 - 1 file changed, 1 deletion(-) delete mode 100644 docs/ALPHA2_TEMP_PR_BODY.md diff --git a/docs/ALPHA2_TEMP_PR_BODY.md b/docs/ALPHA2_TEMP_PR_BODY.md deleted file mode 100644 index e3fbbe0..0000000 --- a/docs/ALPHA2_TEMP_PR_BODY.md +++ /dev/null @@ -1 +0,0 @@ -TEMP \ No newline at end of file From 793e4b7b6916e6e3e8013e5a04f23e14caf0d8b8 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Fri, 14 Aug 2026 11:47:17 +0100 Subject: [PATCH 10/13] Add generic external worker session billing --- scripts/HelperPayrollExternalSessions.lua | 327 ++++++++++++++++++++++ 1 file changed, 327 insertions(+) create mode 100644 scripts/HelperPayrollExternalSessions.lua 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) From e91bb2b425951a0f91566e31c76cb8fbdf8b0873 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Fri, 14 Aug 2026 11:47:31 +0100 Subject: [PATCH 11/13] Expose external worker session API --- scripts/HelperPayrollPublicAPI.lua | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/scripts/HelperPayrollPublicAPI.lua b/scripts/HelperPayrollPublicAPI.lua index c55111e..006142c 100644 --- a/scripts/HelperPayrollPublicAPI.lua +++ b/scripts/HelperPayrollPublicAPI.lua @@ -3,7 +3,7 @@ -- so consumers and future network adapters depend on a stable boundary. HelperPayrollPublicAPI = HelperPayrollPublicAPI or {} -HelperPayrollPublicAPI.API_VERSION = 4 +HelperPayrollPublicAPI.API_VERSION = 5 function HelperPayrollPublicAPI.build(owner) local api = { @@ -20,13 +20,16 @@ function HelperPayrollPublicAPI.build(owner) multiplayer = false, transportReadySnapshot = true, rosterAvailability = true, - enabledWorkerFiltering = 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, @@ -44,6 +47,8 @@ function HelperPayrollPublicAPI.build(owner) 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" } @@ -101,6 +106,27 @@ function HelperPayrollPublicAPI.build(owner) } 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 From 03a543a730adc782bbfd1fc14f70f1458736622c Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Fri, 14 Aug 2026 11:47:46 +0100 Subject: [PATCH 12/13] Load external worker session module --- modDesc.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modDesc.xml b/modDesc.xml index 1d3c799..db55d62 100644 --- a/modDesc.xml +++ b/modDesc.xml @@ -6,7 +6,7 @@ Helper Payroll Alpha - 0.4.3.0 Alpha 2: Adds HelperProfiles roster-aware payroll management. The Workers tab now shows ON-roster workers only while preserving OFF workers' roles, compensation overrides and ledger identities. Payroll snapshots and the public API now 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. + 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 @@ -51,6 +51,7 @@ + From 2f8077bd2188c085a17e3b1cdf58d06f5208069c Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Fri, 14 Aug 2026 11:48:17 +0100 Subject: [PATCH 13/13] Include external sessions in payroll snapshots --- scripts/HelperPayrollSnapshot.lua | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/scripts/HelperPayrollSnapshot.lua b/scripts/HelperPayrollSnapshot.lua index 566ed9e..8a74a3f 100644 --- a/scripts/HelperPayrollSnapshot.lua +++ b/scripts/HelperPayrollSnapshot.lua @@ -5,7 +5,7 @@ -- server-to-client transport without changing consumers. HelperPayrollSnapshot = HelperPayrollSnapshot or {} -HelperPayrollSnapshot.SCHEMA_VERSION = 2 +HelperPayrollSnapshot.SCHEMA_VERSION = 3 local function round(value, places) local n = tonumber(value) or 0 @@ -64,10 +64,15 @@ end local function buildActiveJobs(owner) local jobs = {} - for _, tracked in pairs(owner.trackedAIJobs or {}) do + + 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, @@ -82,9 +87,21 @@ local function buildActiveJobs(owner) farmId = tonumber(assignment.farmId) or assignment.farmId, farmIdSource = tostring(assignment.farmIdSource or "unknown"), gameDate = assignment.gameDate, - snapshotSource = tostring(assignment.snapshotSource or "runtime") + 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 @@ -191,6 +208,8 @@ function HelperPayrollSnapshot.build(owner, options) 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, @@ -207,7 +226,8 @@ function HelperPayrollSnapshot.build(owner, options) billingBlockReason = owner.runtimeBillingBlockReason, authority = "singlePlayerMission", multiplayerSupported = false, - transportReadySchema = true + transportReadySchema = true, + externalWorkerSessionsSupported = HelperPayrollExternalSessions ~= nil }, compatibility = compatibility, roster = { @@ -232,6 +252,7 @@ function HelperPayrollSnapshot.build(owner, options) 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, @@ -244,6 +265,7 @@ function HelperPayrollSnapshot.build(owner, options) }, counts = { activeJobs = #activeJobs, + activeExternalWorkerSessions = #activeExternalSessions, pendingPayroll = #pendingPayroll, managedWorkers = tonumber(roster.total) or 0, enabledWorkers = tonumber(roster.enabled) or 0,