From 48d377db317b5594da6597b6ddef1ae514286f5f Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Thu, 29 Oct 2026 16:08:21 +0000 Subject: [PATCH 01/38] Add game-clock worker time accounting --- scripts/HelperPayrollWorkClock.lua | 203 +++++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 scripts/HelperPayrollWorkClock.lua diff --git a/scripts/HelperPayrollWorkClock.lua b/scripts/HelperPayrollWorkClock.lua new file mode 100644 index 0000000..3783e46 --- /dev/null +++ b/scripts/HelperPayrollWorkClock.lua @@ -0,0 +1,203 @@ +-- FS25_HelperPayroll +-- Work-time accounting shared by GIANTS AI jobs and externally managed worker sessions. +-- +-- Billing can follow either the in-game clock (default) or real frame time +-- (legacy compatibility). Game-clock mode validates observed environment-clock +-- movement against the mission's effective time scale so normal fast-forward +-- counts while discontinuous jumps such as sleep/period skips only re-anchor. + +HelperPayrollWorkClock = HelperPayrollWorkClock or {} + +HelperPayrollWorkClock.DAY_MS = 24 * 60 * 60 * 1000 +HelperPayrollWorkClock.MIN_VALID_STEP_MS = 15000 +HelperPayrollWorkClock.EXPECTED_STEP_FACTOR = 4 +HelperPayrollWorkClock.EXPECTED_STEP_TOLERANCE_MS = 5000 + +local function normaliseBasis(value) + return string.lower(tostring(value or "gameClock")) == "realtime" and "realTime" or "gameClock" +end + +local function copyAnchor(clock) + if type(clock) ~= "table" then return nil end + if tonumber(clock.monotonicDay) == nil or tonumber(clock.dayTimeMs) == nil then return nil end + return { + monotonicDay = tonumber(clock.monotonicDay), + dayTimeMs = tonumber(clock.dayTimeMs), + dateKey = tostring(clock.dateKey or "unknown") + } +end + +local function getEffectiveTimeScale() + if g_currentMission ~= nil and g_currentMission.getEffectiveTimeScale ~= nil then + local ok, value = pcall(g_currentMission.getEffectiveTimeScale, g_currentMission) + if ok and tonumber(value) ~= nil then + return math.max(0, tonumber(value)) + end + end + + if g_currentMission ~= nil and tonumber(g_currentMission.timeScale) ~= nil then + return math.max(0, tonumber(g_currentMission.timeScale)) + end + + return 1 +end + +local function getClock(owner) + if owner ~= nil and owner.getGameClockSnapshot ~= nil then + local ok, clock = pcall(owner.getGameClockSnapshot, owner) + if ok and type(clock) == "table" then return clock end + end + return nil +end + +local function getSegment(tracked, dateKey, monotonicDay) + tracked.workSegments = tracked.workSegments or {} + for _, segment in ipairs(tracked.workSegments) do + if tostring(segment.gameDate) == tostring(dateKey) + and tonumber(segment.monotonicDay) == tonumber(monotonicDay) then + return segment + end + end + + local segment = { + gameDate = tostring(dateKey or "unknown"), + monotonicDay = tonumber(monotonicDay), + elapsedMs = 0 + } + table.insert(tracked.workSegments, segment) + return segment +end + +local function addSegment(tracked, dateKey, monotonicDay, delta) + delta = tonumber(delta) or 0 + if delta <= 0 then return end + local segment = getSegment(tracked, dateKey, monotonicDay) + segment.elapsedMs = (tonumber(segment.elapsedMs) or 0) + delta +end + +function HelperPayrollWorkClock.normaliseTimeBasis(value) + return normaliseBasis(value) +end + +function HelperPayrollWorkClock.initialise(owner, tracked) + if tracked == nil then return end + + tracked.elapsedMs = tonumber(tracked.elapsedMs) or 0 + tracked.realElapsedMs = tonumber(tracked.realElapsedMs) or 0 + tracked.gameElapsedMs = tonumber(tracked.gameElapsedMs) or 0 + tracked.clockDiscontinuities = tonumber(tracked.clockDiscontinuities) or 0 + tracked.workSegments = tracked.workSegments or {} + + local clock = getClock(owner) + tracked.workClockAnchor = copyAnchor(clock) + tracked.lastEffectiveTimeScale = getEffectiveTimeScale() +end + +function HelperPayrollWorkClock.reanchor(owner, tracked) + if tracked == nil then return end + tracked.workClockAnchor = copyAnchor(getClock(owner)) + tracked.lastEffectiveTimeScale = getEffectiveTimeScale() +end + +function HelperPayrollWorkClock.accrue(owner, tracked, dt) + if tracked == nil then return 0, "missing-tracker" end + + local realDelta = math.max(0, tonumber(dt) or 0) + tracked.realElapsedMs = (tonumber(tracked.realElapsedMs) or 0) + realDelta + + local basis = normaliseBasis(owner ~= nil and owner.settings ~= nil and owner.settings.workerTimeBasis or "gameClock") + tracked.timeBasis = basis + + if basis == "realTime" then + tracked.elapsedMs = (tonumber(tracked.elapsedMs) or 0) + realDelta + return realDelta, "realTime" + end + + local current = copyAnchor(getClock(owner)) + if current == nil then + tracked.workClockAnchor = nil + return 0, "clock-unavailable" + end + + local previous = tracked.workClockAnchor + tracked.workClockAnchor = current + local effectiveScale = getEffectiveTimeScale() + tracked.lastEffectiveTimeScale = effectiveScale + + if previous == nil then + return 0, "anchored" + end + + local dayDiff = current.monotonicDay - previous.monotonicDay + if dayDiff < 0 or dayDiff > 1 then + tracked.clockDiscontinuities = (tonumber(tracked.clockDiscontinuities) or 0) + 1 + return 0, "day-discontinuity" + end + + local actualDelta = dayDiff * HelperPayrollWorkClock.DAY_MS + + current.dayTimeMs - previous.dayTimeMs + if actualDelta < 0 then + tracked.clockDiscontinuities = (tonumber(tracked.clockDiscontinuities) or 0) + 1 + return 0, "clock-rollback" + end + + local expectedDelta = realDelta * effectiveScale + local maximumExpected = math.max( + HelperPayrollWorkClock.MIN_VALID_STEP_MS, + expectedDelta * HelperPayrollWorkClock.EXPECTED_STEP_FACTOR + + HelperPayrollWorkClock.EXPECTED_STEP_TOLERANCE_MS + ) + + if actualDelta > maximumExpected then + tracked.clockDiscontinuities = (tonumber(tracked.clockDiscontinuities) or 0) + 1 + return 0, "clock-jump" + end + + if actualDelta <= 0 then + return 0, "paused" + end + + if dayDiff == 0 then + addSegment(tracked, previous.dateKey, previous.monotonicDay, actualDelta) + else + local beforeMidnight = math.max(0, HelperPayrollWorkClock.DAY_MS - previous.dayTimeMs) + local afterMidnight = math.max(0, current.dayTimeMs) + addSegment(tracked, previous.dateKey, previous.monotonicDay, beforeMidnight) + addSegment(tracked, current.dateKey, current.monotonicDay, afterMidnight) + end + + tracked.gameElapsedMs = (tonumber(tracked.gameElapsedMs) or 0) + actualDelta + tracked.elapsedMs = tracked.gameElapsedMs + return actualDelta, "gameClock" +end + +function HelperPayrollWorkClock.getSegments(tracked) + local rows = {} + for _, segment in ipairs(tracked ~= nil and tracked.workSegments or {}) do + if (tonumber(segment.elapsedMs) or 0) > 0 then + table.insert(rows, { + gameDate = tostring(segment.gameDate or "unknown"), + monotonicDay = tonumber(segment.monotonicDay), + elapsedMs = tonumber(segment.elapsedMs) or 0 + }) + end + end + table.sort(rows, function(a, b) + if tonumber(a.monotonicDay) ~= tonumber(b.monotonicDay) then + return (tonumber(a.monotonicDay) or 0) < (tonumber(b.monotonicDay) or 0) + end + return tostring(a.gameDate) < tostring(b.gameDate) + end) + return rows +end + +function HelperPayrollWorkClock.getDiagnostics(tracked) + return { + timeBasis = tostring(tracked ~= nil and tracked.timeBasis or "unknown"), + elapsedMs = tonumber(tracked ~= nil and tracked.elapsedMs or 0) or 0, + realElapsedMs = tonumber(tracked ~= nil and tracked.realElapsedMs or 0) or 0, + gameElapsedMs = tonumber(tracked ~= nil and tracked.gameElapsedMs or 0) or 0, + discontinuities = tonumber(tracked ~= nil and tracked.clockDiscontinuities or 0) or 0, + effectiveTimeScale = tonumber(tracked ~= nil and tracked.lastEffectiveTimeScale or 0) or 0 + } +end From 4358f96fd8f5b2e9bbe0b93a944afeb74b616fd4 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Thu, 29 Oct 2026 16:08:33 +0000 Subject: [PATCH 02/38] Load worker game-clock accounting module --- modDesc.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/modDesc.xml b/modDesc.xml index db55d62..51bd4e4 100644 --- a/modDesc.xml +++ b/modDesc.xml @@ -51,6 +51,7 @@ + From 39920d78258211ca122b52e0cf04c4896e8f6b0a Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Thu, 29 Oct 2026 16:08:36 +0000 Subject: [PATCH 03/38] Default worker time accounting to game clock --- config/defaultPayrollConfig.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/config/defaultPayrollConfig.xml b/config/defaultPayrollConfig.xml index 2ac6620..5208724 100644 --- a/config/defaultPayrollConfig.xml +++ b/config/defaultPayrollConfig.xml @@ -21,6 +21,7 @@ true onJobFinish + gameClock 18 5.00 0.00 From 8c5bbf65947773e7ba77d8954068e76768b3fe45 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Thu, 29 Oct 2026 16:08:55 +0000 Subject: [PATCH 04/38] Persist configurable worker time basis --- scripts/HelperPayroll.lua | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/scripts/HelperPayroll.lua b/scripts/HelperPayroll.lua index 1945afb..a10e560 100644 --- a/scripts/HelperPayroll.lua +++ b/scripts/HelperPayroll.lua @@ -33,6 +33,7 @@ HelperPayroll.settings = { defaultHelperSlot = "", chargeCustomWorkerCosts = true, billingMode = "onJobFinish", + workerTimeBasis = "gameClock", payrollHour = 18, minimumWorkerCharge = 0, workerCalloutFee = 0, @@ -585,6 +586,9 @@ function HelperPayroll:applyManagementDraft(draftSettings, draftRolePolicies, dr draftMappings = draftMappings or {} draftWorkerOverrides = draftWorkerOverrides or {} self.settings.billingMode = tostring(draftSettings.billingMode or self.settings.billingMode or 'onJobFinish') + self.settings.workerTimeBasis = HelperPayrollWorkClock ~= nil + and HelperPayrollWorkClock.normaliseTimeBasis(draftSettings.workerTimeBasis or self.settings.workerTimeBasis) + or tostring(draftSettings.workerTimeBasis or self.settings.workerTimeBasis or 'gameClock') self.settings.payrollHour = math.floor(tonumber(draftSettings.payrollHour or self.settings.payrollHour) or 18) self.settings.minimumWorkerCharge = tonumber(draftSettings.minimumWorkerCharge or self.settings.minimumWorkerCharge) or 0 self.settings.workerCalloutFee = tonumber(draftSettings.workerCalloutFee or self.settings.workerCalloutFee) or 0 @@ -692,6 +696,10 @@ function HelperPayroll:loadConfig() self.settings.defaultHelperSlot = getXmlStringOrDefault(xmlFile, "helperPayroll.settings.defaultHelperSlot", self.settings.defaultHelperSlot) self.settings.chargeCustomWorkerCosts = getXmlBoolOrDefault(xmlFile, "helperPayroll.settings.chargeCustomWorkerCosts", self.settings.chargeCustomWorkerCosts) self.settings.billingMode = getXmlStringOrDefault(xmlFile, "helperPayroll.settings.billingMode", self.settings.billingMode) + self.settings.workerTimeBasis = getXmlStringOrDefault(xmlFile, "helperPayroll.settings.workerTimeBasis", self.settings.workerTimeBasis) + if HelperPayrollWorkClock ~= nil and HelperPayrollWorkClock.normaliseTimeBasis ~= nil then + self.settings.workerTimeBasis = HelperPayrollWorkClock.normaliseTimeBasis(self.settings.workerTimeBasis) + end self.settings.payrollHour = getXmlFloatOrDefault(xmlFile, "helperPayroll.settings.payrollHour", self.settings.payrollHour) self.settings.minimumWorkerCharge = getXmlFloatOrDefault(xmlFile, "helperPayroll.settings.minimumWorkerCharge", self.settings.minimumWorkerCharge) self.settings.workerCalloutFee = getXmlFloatOrDefault(xmlFile, "helperPayroll.settings.workerCalloutFee", self.settings.workerCalloutFee) @@ -708,7 +716,7 @@ function HelperPayroll:loadConfig() delete(xmlFile) rcLog("Loaded config. source=%s file=%s Active payroll profile: %s", tostring(self.policyConfig ~= nil and self.policyConfig.source or "unknown"), tostring(self.CONFIG_FILE), tostring(self.settings.activePayrollProfile)) - rcLog("Worker billing settings: enabled=%s payrollMode=%s billingMode=%s payrollHour=%s selectedRole=%s fallbackRole=%s defaultHelperSlot=%s minimum=%.2f callout=%.2f round=%s logLevel=%s helperProfilesDiagnostics=%s", tostring(self.settings.chargeCustomWorkerCosts), tostring(self.settings.payrollMode), tostring(self.settings.billingMode), tostring(self.settings.payrollHour), tostring(self.settings.selectedRole), tostring(self.settings.fallbackRole), tostring(self.settings.defaultHelperSlot ~= "" and self.settings.defaultHelperSlot or "none"), tonumber(self.settings.minimumWorkerCharge) or 0, tonumber(self.settings.workerCalloutFee) or 0, tostring(self.settings.roundWorkerCharges), tostring(self.settings.logLevel), tostring(self.settings.helperProfilesDiagnostics)) + rcLog("Worker billing settings: enabled=%s payrollMode=%s billingMode=%s timeBasis=%s payrollHour=%s selectedRole=%s fallbackRole=%s defaultHelperSlot=%s minimum=%.2f callout=%.2f round=%s logLevel=%s helperProfilesDiagnostics=%s", tostring(self.settings.chargeCustomWorkerCosts), tostring(self.settings.payrollMode), tostring(self.settings.billingMode), tostring(self.settings.workerTimeBasis), tostring(self.settings.payrollHour), tostring(self.settings.selectedRole), tostring(self.settings.fallbackRole), tostring(self.settings.defaultHelperSlot ~= "" and self.settings.defaultHelperSlot or "none"), tonumber(self.settings.minimumWorkerCharge) or 0, tonumber(self.settings.workerCalloutFee) or 0, tostring(self.settings.roundWorkerCharges), tostring(self.settings.logLevel), tostring(self.settings.helperProfilesDiagnostics)) self:logHelperSlotConfig() end @@ -3954,6 +3962,10 @@ function HelperPayroll:loadSavegameSettings() -- Save-specific gameplay policy overrides. The global defaultPayrollConfig.xml remains -- the template/default policy; the management UI writes active gameplay changes here. self.settings.billingMode = getXmlStringOrDefault(xmlFile, "helperPayrollSave.policy#billingMode", self.settings.billingMode) + self.settings.workerTimeBasis = getXmlStringOrDefault(xmlFile, "helperPayrollSave.policy#workerTimeBasis", self.settings.workerTimeBasis or "gameClock") + if HelperPayrollWorkClock ~= nil and HelperPayrollWorkClock.normaliseTimeBasis ~= nil then + self.settings.workerTimeBasis = HelperPayrollWorkClock.normaliseTimeBasis(self.settings.workerTimeBasis) + end self.settings.payrollHour = getXmlFloatOrDefault(xmlFile, "helperPayrollSave.policy#payrollHour", self.settings.payrollHour or 18) self.settings.minimumWorkerCharge = getXmlFloatOrDefault(xmlFile, "helperPayrollSave.policy#minimumWorkerCharge", self.settings.minimumWorkerCharge or 0) self.settings.workerCalloutFee = getXmlFloatOrDefault(xmlFile, "helperPayrollSave.policy#workerCalloutFee", self.settings.workerCalloutFee or 0) @@ -4171,6 +4183,7 @@ function HelperPayroll:saveSavegameSettings(reason) -- Save-specific gameplay policy overrides written by the management UI. setXMLString(xmlFile, "helperPayrollSave.policy#billingMode", tostring(self.settings.billingMode or "onJobFinish")) + setXMLString(xmlFile, "helperPayrollSave.policy#workerTimeBasis", tostring(self.settings.workerTimeBasis or "gameClock")) setXMLInt(xmlFile, "helperPayrollSave.policy#payrollHour", math.floor(tonumber(self.settings.payrollHour) or 18)) setXMLFloat(xmlFile, "helperPayrollSave.policy#minimumWorkerCharge", tonumber(self.settings.minimumWorkerCharge) or 0) setXMLFloat(xmlFile, "helperPayrollSave.policy#workerCalloutFee", tonumber(self.settings.workerCalloutFee) or 0) From 26cf1a8b4d4632290e200ee95f7a0d8835f6f776 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Thu, 29 Oct 2026 16:09:31 +0000 Subject: [PATCH 05/38] Accrue GIANTS AI worker time from game clock --- scripts/HelperPayroll.lua | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/scripts/HelperPayroll.lua b/scripts/HelperPayroll.lua index a10e560..aef074f 100644 --- a/scripts/HelperPayroll.lua +++ b/scripts/HelperPayroll.lua @@ -3751,6 +3751,9 @@ function HelperPayroll:scanActiveAIJobs(dt) local assignment = self:captureWorkerAssignment(self.trackedAIJobs[jobId]) self.trackedAIJobs[jobId].assignmentSnapshot = assignment + if HelperPayrollWorkClock ~= nil and HelperPayrollWorkClock.initialise ~= nil then + HelperPayrollWorkClock.initialise(self, self.trackedAIJobs[jobId]) + end self.trackedAIJobs[jobId].helperSlot = assignment.helperSlot self.trackedAIJobs[jobId].helperSlotSource = assignment.helperSlotSource self.trackedAIJobs[jobId].helperSlotUsedForPayroll = assignment.helperSlotUsedForPayroll @@ -3760,18 +3763,25 @@ function HelperPayroll:scanActiveAIJobs(dt) end local tracked = self.trackedAIJobs[jobId] - tracked.elapsedMs = (tracked.elapsedMs or 0) + (dt or 0) + if HelperPayrollWorkClock ~= nil and HelperPayrollWorkClock.accrue ~= nil then + HelperPayrollWorkClock.accrue(self, tracked, dt) + else + tracked.elapsedMs = (tracked.elapsedMs or 0) + (dt or 0) + end -- Duration tracking and active summary. Billing is applied when the job finishes. if tracked.elapsedMs - (tracked.lastSummaryMs or 0) >= 60000 then tracked.lastSummaryMs = tracked.elapsedMs rcLog( - "AI job active summary: seq=%s id=%s type=%s helperSlot=%s elapsedMinutes=%.1f customBilling=%s", + "AI job active summary: seq=%s id=%s type=%s helperSlot=%s elapsedMinutes=%.1f timeBasis=%s effectiveScale=%.1f discontinuities=%d customBilling=%s", tostring(tracked.sequence), tostring(jobId), tostring(tracked.name), tostring(tracked.helperSlot or "unassigned"), (tracked.elapsedMs or 0) / 60000, + tostring(tracked.timeBasis or self.settings.workerTimeBasis or "gameClock"), + tonumber(tracked.lastEffectiveTimeScale) or 0, + tonumber(tracked.clockDiscontinuities) or 0, tostring(self.settings.enableCustomWorkerCosts and self.settings.chargeCustomWorkerCosts) ) end From db2b6b1367622e77a390ab36dbeaab8a4cf1f457 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Thu, 29 Oct 2026 16:09:35 +0000 Subject: [PATCH 06/38] Accrue external worker sessions from game clock --- scripts/HelperPayrollExternalSessions.lua | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/scripts/HelperPayrollExternalSessions.lua b/scripts/HelperPayrollExternalSessions.lua index c7c205c..16208cd 100644 --- a/scripts/HelperPayrollExternalSessions.lua +++ b/scripts/HelperPayrollExternalSessions.lua @@ -201,6 +201,9 @@ function HelperPayrollExternalSessions.begin(owner, request) label = request.label } } + if HelperPayrollWorkClock ~= nil and HelperPayrollWorkClock.initialise ~= nil then + HelperPayrollWorkClock.initialise(owner, session) + end HelperPayrollExternalSessions.sessions[sessionId] = session log( @@ -296,17 +299,24 @@ function HelperPayrollExternalSessions:update(dt) if delta <= 0 then return end for _, session in pairs(self.sessions or {}) do - session.elapsedMs = (tonumber(session.elapsedMs) or 0) + delta + if HelperPayrollWorkClock ~= nil and HelperPayrollWorkClock.accrue ~= nil and HelperPayroll ~= nil then + HelperPayrollWorkClock.accrue(HelperPayroll, session, delta) + else + session.elapsedMs = (tonumber(session.elapsedMs) or 0) + delta + end 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", + "Active: id=%s controller=%s helperSlot=%s helper=%s elapsedMinutes=%.1f timeBasis=%s effectiveScale=%.1f discontinuities=%d", tostring(session.sessionId), tostring(session.controller), tostring(assignment.helperSlot or "unassigned"), tostring(assignment.helperName or "Worker"), - session.elapsedMs / 60000 + session.elapsedMs / 60000, + tostring(session.timeBasis or (HelperPayroll ~= nil and HelperPayroll.settings ~= nil and HelperPayroll.settings.workerTimeBasis) or "gameClock"), + tonumber(session.lastEffectiveTimeScale) or 0, + tonumber(session.clockDiscontinuities) or 0 ) end end From 5f17c19fb6fe6315cbb40532cd8e7b629c1282f2 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Thu, 29 Oct 2026 16:09:48 +0000 Subject: [PATCH 07/38] Expose worker time-basis diagnostics in payroll snapshots --- scripts/HelperPayrollSnapshot.lua | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/HelperPayrollSnapshot.lua b/scripts/HelperPayrollSnapshot.lua index 8a74a3f..18cd2b9 100644 --- a/scripts/HelperPayrollSnapshot.lua +++ b/scripts/HelperPayrollSnapshot.lua @@ -82,6 +82,11 @@ local function buildActiveJobs(owner) payBasis = payBasis, payRate = payRate, elapsedHours = elapsedHours, + realElapsedHours = (tonumber(tracked.realElapsedMs) or 0) / 3600000, + gameElapsedHours = (tonumber(tracked.gameElapsedMs) or 0) / 3600000, + timeBasis = tostring(tracked.timeBasis or (owner.settings ~= nil and owner.settings.workerTimeBasis) or "gameClock"), + effectiveTimeScale = tonumber(tracked.lastEffectiveTimeScale) or 0, + clockDiscontinuities = tonumber(tracked.clockDiscontinuities) or 0, labourEstimate = labour, chargeEstimate = estimate, farmId = tonumber(assignment.farmId) or assignment.farmId, @@ -241,6 +246,7 @@ function HelperPayrollSnapshot.build(owner, options) activePayrollProfile = activeProfileId, payrollMode = tostring(owner.settings ~= nil and owner.settings.payrollMode or "roleType"), billingMode = tostring(owner.settings ~= nil and owner.settings.billingMode or "onJobFinish"), + workerTimeBasis = tostring(owner.settings ~= nil and owner.settings.workerTimeBasis or "gameClock"), payrollHour = tonumber(owner.settings ~= nil and owner.settings.payrollHour or 18) or 18, selectedRole = selectedRoleId, selectedRoleName = tostring(selectedRoleWorker ~= nil and selectedRoleWorker.name or selectedRoleId), From 748e341b5f90420af1edecbf55f15a1f99617ee4 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Thu, 29 Oct 2026 16:09:52 +0000 Subject: [PATCH 08/38] Expose worker time basis through public API --- scripts/HelperPayrollPublicAPI.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/HelperPayrollPublicAPI.lua b/scripts/HelperPayrollPublicAPI.lua index 006142c..966126e 100644 --- a/scripts/HelperPayrollPublicAPI.lua +++ b/scripts/HelperPayrollPublicAPI.lua @@ -39,6 +39,7 @@ function HelperPayrollPublicAPI.build(owner) activePayrollProfile = tostring(owner.settings.activePayrollProfile or "default"), payrollMode = tostring(owner.settings.payrollMode or "roleType"), billingMode = tostring(owner.settings.billingMode or "onJobFinish"), + workerTimeBasis = tostring(owner.settings.workerTimeBasis or "gameClock"), managedSlotCount = owner:getManagedHelperSlotCount(), enabledSlotCount = tonumber(roster.enabled) or owner:getManagedHelperSlotCount(), disabledSlotCount = tonumber(roster.disabled) or 0, From f6468088397c2fc5ac330ede93eb7a96933f0b1a Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Thu, 29 Oct 2026 16:10:12 +0000 Subject: [PATCH 09/38] Expose worker time basis in payroll management UI --- scripts/gui/HelperPayrollMenu.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/gui/HelperPayrollMenu.lua b/scripts/gui/HelperPayrollMenu.lua index 968f832..cfe8038 100644 --- a/scripts/gui/HelperPayrollMenu.lua +++ b/scripts/gui/HelperPayrollMenu.lua @@ -176,6 +176,7 @@ function HelperPayrollMenu:refreshDraftFromRuntime() self.draftWorkerOverrides = {} if hp ~= nil and hp.settings ~= nil then self.draftSettings.billingMode = hp.settings.billingMode or "onJobFinish" + self.draftSettings.workerTimeBasis = hp.settings.workerTimeBasis or "gameClock" self.draftSettings.payrollHour = tonumber(hp.settings.payrollHour) or 18 self.draftSettings.minimumWorkerCharge = tonumber(hp.settings.minimumWorkerCharge) or 0 self.draftSettings.workerCalloutFee = tonumber(hp.settings.workerCalloutFee) or 0 @@ -326,6 +327,7 @@ function HelperPayrollMenu:buildRows() 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."}, + {id="workerTimeBasis", label="Worker time basis", value=tostring(self.draftSettings.workerTimeBasis or "gameClock"), status="Editable", source="Savegame", editType="option", options={"gameClock", "realTime"}, info="gameClock pays workers for in-game hours, so deliberate time acceleration increases payroll. realTime preserves legacy frame-time accounting. Sleep and discontinuous clock jumps are ignored in gameClock mode."}, {id="payrollHour", label="Payroll hour", value=fmtHour(self.draftSettings.payrollHour), status="Editable", source="Savegame", editType="number", step=1, min=0, max=23, info="In dailyPayroll mode, pending rows settle at this in-game hour. Any unpaid row from an earlier game day is treated as overdue and settles automatically."}, {id="minimumWorkerCharge", label="Legacy minimum", value=fmtMoney(self.draftSettings.minimumWorkerCharge), status="Editable", source="Savegame", editType="number", step=0.5, min=0, max=999, info="Compatibility fallback for old save data that has no per-role minimum. New payroll policies use the minimum call-out configured on each role or worker."}, {id="workerCalloutFee", label="Callout fee", value=fmtMoney(self.draftSettings.workerCalloutFee), status="Editable", source="Savegame", editType="number", step=0.5, min=0, max=999, info="Flat callout fee: per job in onJobFinish mode, or once per worker and workday in dailyPayroll mode."}, @@ -495,6 +497,7 @@ function HelperPayrollMenu:getDraftValue(row) if row == nil then return nil end if row.id == "payrollMode" then return self.draftSettings.payrollMode end if row.id == "billingMode" then return self.draftSettings.billingMode end + if row.id == "workerTimeBasis" then return self.draftSettings.workerTimeBasis end if row.id == "payrollHour" then return self.draftSettings.payrollHour end if row.id == "minimumWorkerCharge" then return self.draftSettings.minimumWorkerCharge end if row.id == "workerCalloutFee" then return self.draftSettings.workerCalloutFee end @@ -575,6 +578,7 @@ function HelperPayrollMenu:setDraftValue(row, value) self:cancelResetConfirmation() if row.id == "payrollMode" then self.draftSettings.payrollMode = tostring(value) elseif row.id == "billingMode" then self.draftSettings.billingMode = tostring(value) + elseif row.id == "workerTimeBasis" then self.draftSettings.workerTimeBasis = tostring(value) elseif row.id == "payrollHour" then self.draftSettings.payrollHour = tonumber(value) or 0 elseif row.id == "minimumWorkerCharge" then self.draftSettings.minimumWorkerCharge = tonumber(value) or 0 elseif row.id == "workerCalloutFee" then self.draftSettings.workerCalloutFee = tonumber(value) or 0 From 19097b66a0d844252695623b51164311c32e59a1 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Thu, 29 Oct 2026 16:10:17 +0000 Subject: [PATCH 10/38] Write worker time basis to generated policy config --- scripts/HelperPayroll.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/HelperPayroll.lua b/scripts/HelperPayroll.lua index aef074f..42927e6 100644 --- a/scripts/HelperPayroll.lua +++ b/scripts/HelperPayroll.lua @@ -504,6 +504,7 @@ function HelperPayroll:buildPolicyConfigXmlFromRuntime() add(string.format(' %s', hpayXmlEscape(settings.defaultHelperSlot or ''))) add(string.format(' %s', tostring(settings.chargeCustomWorkerCosts == true))) add(string.format(' %s', hpayXmlEscape(settings.billingMode or 'onJobFinish'))) + add(string.format(' %s', hpayXmlEscape(settings.workerTimeBasis or 'gameClock'))) add(string.format(' %s', tostring(math.floor(tonumber(settings.payrollHour) or 18)))) add(string.format(' %.2f', tonumber(settings.minimumWorkerCharge) or 0)) add(string.format(' %.2f', tonumber(settings.workerCalloutFee) or 0)) From 345af175401cd279f1ddbcdc40d00d6e257854cf Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Thu, 29 Oct 2026 16:10:46 +0000 Subject: [PATCH 11/38] Split daily payroll work across game-day segments --- scripts/HelperPayroll.lua | 113 +++++++++++++++++++++++++++++--------- 1 file changed, 88 insertions(+), 25 deletions(-) diff --git a/scripts/HelperPayroll.lua b/scripts/HelperPayroll.lua index 42927e6..0e34808 100644 --- a/scripts/HelperPayroll.lua +++ b/scripts/HelperPayroll.lua @@ -3377,6 +3377,50 @@ function HelperPayroll:processDailyPayroll(dt, force, triggerReason) end +function HelperPayroll:buildDailyPayrollEntries(tracked, baseEntry) + local segments = HelperPayrollWorkClock ~= nil and HelperPayrollWorkClock.getSegments ~= nil + and HelperPayrollWorkClock.getSegments(tracked) or {} + + if #segments == 0 then + return {baseEntry} + end + + local rows = {} + local payBasis = self:normalisePayBasis(baseEntry.payBasis) + local payRate = tonumber(baseEntry.payRate or baseEntry.hourlyRate) or 0 + local calloutFee = payBasis == "daily" and 0 or (tonumber(self.settings.workerCalloutFee) or 0) + local minimumCharge = payBasis == "daily" and 0 + or (tonumber(baseEntry.minimumCallout) or tonumber(self.settings.minimumWorkerCharge) or 0) + + for _, segment in ipairs(segments) do + local row = {} + for key, value in pairs(baseEntry) do row[key] = value end + + local elapsedHours = (tonumber(segment.elapsedMs) or 0) / 3600000 + local labour = payBasis == "daily" and payRate or elapsedHours * payRate + local calculated = labour + calloutFee + local minimumApplied = false + if calculated > 0 and minimumCharge > 0 and calculated < minimumCharge then + calculated = minimumCharge + minimumApplied = true + end + + row.gameDate = tostring(segment.gameDate or baseEntry.gameDate or self:getGameDateKey()) + row.workMonotonicDay = tonumber(segment.monotonicDay) or baseEntry.workMonotonicDay + row.workDayTime = nil + row.elapsedHours = elapsedHours + row.labourCharge = self:roundCurrency(labour) + row.calloutFee = self:roundCurrency(calloutFee) + row.minimumCharge = self:roundCurrency(minimumCharge) + row.appliedMinimum = minimumApplied + row.calculatedJobCharge = self:roundCurrency(calculated) + row.charge = 0 + table.insert(rows, row) + end + + return rows +end + function HelperPayroll:applyWorkerCharge(tracked) if not self:isPayrollRuntimeEnabled() then rcWarn("Worker billing skipped because the runtime compatibility safety block is active") @@ -3443,6 +3487,50 @@ function HelperPayroll:applyWorkerCharge(tracked) entry.appliedMinimum = breakdown.appliedMinimum self.workerLedger[self.workerLedgerCount] = entry + if self:isDailyPayrollMode() then + local dailyEntries = self:buildDailyPayrollEntries(tracked, entry) + local recordedRows = 0 + + for _, dailyEntry in ipairs(dailyEntries) do + local skipDailyRate = self:hasDailyRateBeenPaid(dailyEntry) + if skipDailyRate then + dailyEntry.calculatedJobCharge = 0 + dailyEntry.labourCharge = 0 + self:recordPersistentLedgerEntry( + self:buildPersistentLedgerEntryFromWorkerEntry(dailyEntry, "daily-rate-already-paid"), + "job-daily-rate-already-paid" + ) + else + local daily = self:updateDailyLedger(dailyEntry) + recordedRows = recordedRows + 1 + rcLog( + "Worker billing deferred to daily payroll: seq=%s type=%s helperSlot=%s helper=%s elapsedHours=%.3f rate=%.2f labour=%.2f dailyJobs=%s gameDate=%s payrollHour=%s timeBasis=%s", + tostring(dailyEntry.sequence), + tostring(dailyEntry.jobType), + tostring(dailyEntry.helperSlot), + tostring(dailyEntry.helperName), + tonumber(dailyEntry.elapsedHours) or 0, + tonumber(dailyEntry.hourlyRate) or 0, + tonumber(dailyEntry.labourCharge) or 0, + tostring(daily ~= nil and daily.jobs or "unknown"), + tostring(dailyEntry.gameDate), + tostring(self.settings.payrollHour), + tostring(self.settings.workerTimeBasis or "gameClock") + ) + self:recordPersistentLedgerEntry( + self:buildPersistentLedgerEntryFromWorkerEntry(dailyEntry, "deferred"), + "job-deferred" + ) + end + end + + if recordedRows > 0 then + self:saveSavegameSettings("daily-payroll-pending") + self:processDailyPayroll(0, true, "job-deferred") + end + return true + end + if self:hasDailyRateBeenPaid(entry) then entry.calculatedJobCharge = 0 rcLog("Day-rate worker already paid for workDate=%s helper=%s workerRate=%s; additional job recorded with no extra charge", tostring(entry.gameDate), tostring(entry.helperName), tostring(entry.workerId)) @@ -3461,31 +3549,6 @@ function HelperPayroll:applyWorkerCharge(tracked) local daily = self:updateDailyLedger(entry) - if self:isDailyPayrollMode() then - rcLog( - "Worker billing deferred to daily payroll: seq=%s type=%s payrollMode=%s helperSlot=%s helperSlotUsedForPayroll=%s helper=%s role=%s workerRate=%s profile=%s elapsedHours=%.3f rate=%.2f labour=%.2f dailyJobs=%s gameDate=%s payrollHour=%s", - tostring(entry.sequence), - tostring(entry.jobType), - tostring(entry.payrollMode or self.settings.payrollMode), - tostring(entry.helperSlot), - tostring(entry.helperSlotUsedForPayroll), - tostring(entry.helperName), - tostring(entry.helperRole), - tostring(entry.workerId), - tostring(entry.profileId), - tonumber(entry.elapsedHours) or 0, - tonumber(entry.hourlyRate) or 0, - tonumber(entry.labourCharge) or 0, - tostring(daily ~= nil and daily.jobs or "unknown"), - tostring(entry.gameDate), - tostring(self.settings.payrollHour) - ) - self:recordPersistentLedgerEntry(self:buildPersistentLedgerEntryFromWorkerEntry(entry, "deferred"), "job-deferred") - self:saveSavegameSettings("daily-payroll-pending") - self:processDailyPayroll(0, true, "job-deferred") - return true - end - if chargeToApply <= 0 then rcLog( "Worker billing skipped: seq=%s type=%s worker=%s elapsedHours=%.3f rate=%.2f charge=%.2f", From c0686879f68f7c148a91305911f7605b3204dee6 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Thu, 29 Oct 2026 16:11:15 +0000 Subject: [PATCH 12/38] Expose active worker day segments in payroll snapshot --- scripts/HelperPayrollSnapshot.lua | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/scripts/HelperPayrollSnapshot.lua b/scripts/HelperPayrollSnapshot.lua index 18cd2b9..7e49264 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 = 3 +HelperPayrollSnapshot.SCHEMA_VERSION = 4 local function round(value, places) local n = tonumber(value) or 0 @@ -68,6 +68,32 @@ local function buildActiveJobs(owner) local function appendTracked(tracked, source) if tracked == nil then return end local estimate, labour, elapsedHours, assignment, payBasis, payRate = estimateTrackedCharge(owner, tracked) + local workSegments = {} + if HelperPayrollWorkClock ~= nil and HelperPayrollWorkClock.getSegments ~= nil then + for _, segment in ipairs(HelperPayrollWorkClock.getSegments(tracked)) do + local segmentHours = (tonumber(segment.elapsedMs) or 0) / 3600000 + local segmentLabour = payBasis == "daily" and payRate or segmentHours * payRate + local segmentCallout = payBasis == "daily" and 0 or tonumber(owner.settings ~= nil and owner.settings.workerCalloutFee or 0) or 0 + local segmentMinimum = payBasis == "daily" and 0 or tonumber(assignment.minimumCallout or (owner.settings ~= nil and owner.settings.minimumWorkerCharge)) or 0 + local segmentCharge = segmentLabour + segmentCallout + if segmentCharge > 0 and segmentMinimum > 0 and segmentCharge < segmentMinimum then + segmentCharge = segmentMinimum + end + table.insert(workSegments, { + gameDate = tostring(segment.gameDate or assignment.gameDate or "unknown"), + monotonicDay = tonumber(segment.monotonicDay), + elapsedHours = round(segmentHours, 3), + labourEstimate = round(segmentLabour, 2), + chargeEstimate = round(segmentCharge, 2) + }) + end + end + + local currentGameDate = assignment.gameDate + if #workSegments > 0 then + currentGameDate = workSegments[#workSegments].gameDate + end + table.insert(jobs, { sequence = tonumber(tracked.sequence) or 0, source = tostring(source or (tracked.external == true and "external" or "giants-ai")), @@ -91,7 +117,8 @@ local function buildActiveJobs(owner) chargeEstimate = estimate, farmId = tonumber(assignment.farmId) or assignment.farmId, farmIdSource = tostring(assignment.farmIdSource or "unknown"), - gameDate = assignment.gameDate, + gameDate = currentGameDate, + workSegments = workSegments, snapshotSource = tostring(assignment.snapshotSource or "runtime"), vehicleName = tracked.metadata ~= nil and tracked.metadata.vehicleName or nil }) From 831171236b8ef06588b1827ee8491ba02d85963f Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Thu, 29 Oct 2026 16:11:45 +0000 Subject: [PATCH 13/38] Include worker time basis in policy template and diagnostics --- scripts/HelperPayroll.lua | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/HelperPayroll.lua b/scripts/HelperPayroll.lua index 0e34808..a34e421 100644 --- a/scripts/HelperPayroll.lua +++ b/scripts/HelperPayroll.lua @@ -280,6 +280,7 @@ local HPAY_DEFAULT_POLICY_XML = [=[ true onJobFinish + gameClock 18 5.00 0.00 @@ -5590,8 +5591,8 @@ function HelperPayroll:printSaveSettingsStatus() tostring(self.persistence ~= nil and self.persistence.savegameName or "unknown"), tostring(self.persistence ~= nil and self.persistence.filePath or "nil"), tostring(self.persistence ~= nil and self.persistence.loaded == true)) - hpayPrintf("Effective active values: payrollMode=%s billingMode=%s profile=%s selectedRole=%s name=%s rate=%.2f fallbackRole=%s minimum=%.2f callout=%.2f payrollHour=%s round=%s helperProfilesMappings=%d", - tostring(self.settings.payrollMode), tostring(self.settings.billingMode), tostring(profileId), tostring(roleId), tostring(roleName), tonumber(rate) or 0, tostring(self.settings.fallbackRole), tonumber(self.settings.minimumWorkerCharge) or 0, tonumber(self.settings.workerCalloutFee) or 0, tostring(self.settings.payrollHour), tostring(self.settings.roundWorkerCharges), #(self.helperProfilesMappings or {})) + hpayPrintf("Effective active values: payrollMode=%s billingMode=%s timeBasis=%s profile=%s selectedRole=%s name=%s rate=%.2f fallbackRole=%s minimum=%.2f callout=%.2f payrollHour=%s round=%s helperProfilesMappings=%d", + tostring(self.settings.payrollMode), tostring(self.settings.billingMode), tostring(self.settings.workerTimeBasis), tostring(profileId), tostring(roleId), tostring(roleName), tonumber(rate) or 0, tostring(self.settings.fallbackRole), tonumber(self.settings.minimumWorkerCharge) or 0, tonumber(self.settings.workerCalloutFee) or 0, tostring(self.settings.payrollHour), tostring(self.settings.roundWorkerCharges), #(self.helperProfilesMappings or {})) end function HelperPayroll:hpayConfig(...) From 78636460dc46a2b771c6f2ff7750fb0cd49d4ba5 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Thu, 29 Oct 2026 16:11:49 +0000 Subject: [PATCH 14/38] Expose external-session time diagnostics --- scripts/HelperPayrollExternalSessions.lua | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/HelperPayrollExternalSessions.lua b/scripts/HelperPayrollExternalSessions.lua index 16208cd..115df5c 100644 --- a/scripts/HelperPayrollExternalSessions.lua +++ b/scripts/HelperPayrollExternalSessions.lua @@ -158,7 +158,12 @@ local function makeResult(session, status) roleId = assignment.workerId, payBasis = assignment.payBasis, payRate = assignment.payRate, - elapsedHours = session ~= nil and ((tonumber(session.elapsedMs) or 0) / 3600000) or 0 + elapsedHours = session ~= nil and ((tonumber(session.elapsedMs) or 0) / 3600000) or 0, + realElapsedHours = session ~= nil and ((tonumber(session.realElapsedMs) or 0) / 3600000) or 0, + gameElapsedHours = session ~= nil and ((tonumber(session.gameElapsedMs) or 0) / 3600000) or 0, + timeBasis = session ~= nil and tostring(session.timeBasis or "unknown") or "unknown", + effectiveTimeScale = session ~= nil and (tonumber(session.lastEffectiveTimeScale) or 0) or 0, + clockDiscontinuities = session ~= nil and (tonumber(session.clockDiscontinuities) or 0) or 0 } end From 3069e5663104bae145d36c67aa411b1889d43306 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Thu, 29 Oct 2026 16:12:29 +0000 Subject: [PATCH 15/38] Tolerate batched game-clock updates at high time scale --- scripts/HelperPayrollWorkClock.lua | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/scripts/HelperPayrollWorkClock.lua b/scripts/HelperPayrollWorkClock.lua index 3783e46..f02f094 100644 --- a/scripts/HelperPayrollWorkClock.lua +++ b/scripts/HelperPayrollWorkClock.lua @@ -86,6 +86,7 @@ function HelperPayrollWorkClock.initialise(owner, tracked) tracked.realElapsedMs = tonumber(tracked.realElapsedMs) or 0 tracked.gameElapsedMs = tonumber(tracked.gameElapsedMs) or 0 tracked.clockDiscontinuities = tonumber(tracked.clockDiscontinuities) or 0 + tracked.expectedGameDeltaMs = tonumber(tracked.expectedGameDeltaMs) or 0 tracked.workSegments = tracked.workSegments or {} local clock = getClock(owner) @@ -97,6 +98,7 @@ function HelperPayrollWorkClock.reanchor(owner, tracked) if tracked == nil then return end tracked.workClockAnchor = copyAnchor(getClock(owner)) tracked.lastEffectiveTimeScale = getEffectiveTimeScale() + tracked.expectedGameDeltaMs = 0 end function HelperPayrollWorkClock.accrue(owner, tracked, dt) @@ -123,14 +125,17 @@ function HelperPayrollWorkClock.accrue(owner, tracked, dt) tracked.workClockAnchor = current local effectiveScale = getEffectiveTimeScale() tracked.lastEffectiveTimeScale = effectiveScale + tracked.expectedGameDeltaMs = (tonumber(tracked.expectedGameDeltaMs) or 0) + realDelta * effectiveScale if previous == nil then + tracked.expectedGameDeltaMs = 0 return 0, "anchored" end local dayDiff = current.monotonicDay - previous.monotonicDay if dayDiff < 0 or dayDiff > 1 then tracked.clockDiscontinuities = (tonumber(tracked.clockDiscontinuities) or 0) + 1 + tracked.expectedGameDeltaMs = 0 return 0, "day-discontinuity" end @@ -138,10 +143,11 @@ function HelperPayrollWorkClock.accrue(owner, tracked, dt) + current.dayTimeMs - previous.dayTimeMs if actualDelta < 0 then tracked.clockDiscontinuities = (tonumber(tracked.clockDiscontinuities) or 0) + 1 + tracked.expectedGameDeltaMs = 0 return 0, "clock-rollback" end - local expectedDelta = realDelta * effectiveScale + local expectedDelta = tonumber(tracked.expectedGameDeltaMs) or 0 local maximumExpected = math.max( HelperPayrollWorkClock.MIN_VALID_STEP_MS, expectedDelta * HelperPayrollWorkClock.EXPECTED_STEP_FACTOR @@ -150,11 +156,16 @@ function HelperPayrollWorkClock.accrue(owner, tracked, dt) if actualDelta > maximumExpected then tracked.clockDiscontinuities = (tonumber(tracked.clockDiscontinuities) or 0) + 1 + tracked.expectedGameDeltaMs = 0 return 0, "clock-jump" end if actualDelta <= 0 then - return 0, "paused" + if effectiveScale <= 0 then + tracked.expectedGameDeltaMs = 0 + return 0, "paused" + end + return 0, "clock-wait" end if dayDiff == 0 then @@ -168,6 +179,7 @@ function HelperPayrollWorkClock.accrue(owner, tracked, dt) tracked.gameElapsedMs = (tonumber(tracked.gameElapsedMs) or 0) + actualDelta tracked.elapsedMs = tracked.gameElapsedMs + tracked.expectedGameDeltaMs = 0 return actualDelta, "gameClock" end From 814fa55e2ffa955a6abc5d1c12126d3bf8ad0a68 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Thu, 29 Oct 2026 16:12:55 +0000 Subject: [PATCH 16/38] Add game-clock payroll validation workflow --- .../workflows/validate-game-clock-payroll.yml | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 .github/workflows/validate-game-clock-payroll.yml diff --git a/.github/workflows/validate-game-clock-payroll.yml b/.github/workflows/validate-game-clock-payroll.yml new file mode 100644 index 0000000..055ee2b --- /dev/null +++ b/.github/workflows/validate-game-clock-payroll.yml @@ -0,0 +1,176 @@ +name: Validate game-clock payroll + +on: + push: + branches: + - feature/game-clock-payroll + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Check out feature branch + uses: actions/checkout@v4 + + - name: Install Lua + run: sudo apt-get update && sudo apt-get install -y lua5.4 + + - name: Validate XML and policy wiring + shell: bash + run: | + set -euo pipefail + python - <<'PY' + from pathlib import Path + import xml.etree.ElementTree as ET + + ET.parse('modDesc.xml') + ET.parse('config/defaultPayrollConfig.xml') + ET.parse('gui/HelperPayrollMenu.xml') + ET.parse('gui/guiProfiles.xml') + + mod_desc = Path('modDesc.xml').read_text(encoding='utf-8') + main = Path('scripts/HelperPayroll.lua').read_text(encoding='utf-8') + clock = Path('scripts/HelperPayrollWorkClock.lua').read_text(encoding='utf-8') + menu = Path('scripts/gui/HelperPayrollMenu.lua').read_text(encoding='utf-8') + snapshot = Path('scripts/HelperPayrollSnapshot.lua').read_text(encoding='utf-8') + config = Path('config/defaultPayrollConfig.xml').read_text(encoding='utf-8') + + checks = { + 'work clock source loaded': 'scripts/HelperPayrollWorkClock.lua' in mod_desc, + 'default time basis': 'gameClock' in config, + 'runtime setting': 'workerTimeBasis = "gameClock"' in main, + 'savegame persistence': 'helperPayrollSave.policy#workerTimeBasis' in main, + 'management UI': 'id="workerTimeBasis"' in menu, + 'effective time scale': 'getEffectiveTimeScale' in clock, + 'midnight segments': 'workSegments' in clock and 'buildDailyPayrollEntries' in main, + 'snapshot schema v4': 'SCHEMA_VERSION = 4' in snapshot, + 'snapshot diagnostics': 'clockDiscontinuities' in snapshot, + } + failed = [name for name, ok in checks.items() if not ok] + if failed: + raise SystemExit('Validation failed: ' + ', '.join(failed)) + PY + + - name: Validate Lua syntax + shell: bash + run: | + set -euo pipefail + find scripts -name '*.lua' -print0 | while IFS= read -r -d '' file; do + luac -p "$file" + done + + - name: Test work-clock accounting + shell: bash + run: | + set -euo pipefail + lua <<'LUA' + dofile("scripts/HelperPayrollWorkClock.lua") + + local DAY = 24 * 60 * 60 * 1000 + local scale = 1 + local clock = {monotonicDay=10, dayTimeMs=8*3600000, dateKey="Y1-M01-D10"} + g_currentMission = { + timeScale = 1, + getEffectiveTimeScale = function() return scale end + } + + local owner = { + settings = {workerTimeBasis="gameClock"}, + getGameClockSnapshot = function() return { + monotonicDay=clock.monotonicDay, + dayTimeMs=clock.dayTimeMs, + dateKey=clock.dateKey + } end + } + + local function near(a,b,label) + if math.abs((a or 0)-(b or 0)) > 0.001 then + error(string.format("%s: expected %.3f got %.3f", label, b, a or -1)) + end + end + + local t = {} + HelperPayrollWorkClock.initialise(owner,t) + + clock.dayTimeMs = clock.dayTimeMs + 1000 + HelperPayrollWorkClock.accrue(owner,t,1000) + near(t.elapsedMs,1000,"1x second") + + scale = 10 + clock.dayTimeMs = clock.dayTimeMs + 10000 + HelperPayrollWorkClock.accrue(owner,t,1000) + near(t.elapsedMs,11000,"10x second") + + scale = 120 + clock.dayTimeMs = clock.dayTimeMs + 120000 + HelperPayrollWorkClock.accrue(owner,t,1000) + near(t.elapsedMs,131000,"120x second") + + scale = 0 + HelperPayrollWorkClock.accrue(owner,t,1000) + near(t.elapsedMs,131000,"pause") + + scale = 1 + clock.dayTimeMs = clock.dayTimeMs + 8*3600000 + local delta, reason = HelperPayrollWorkClock.accrue(owner,t,1000) + near(delta,0,"sleep jump ignored") + if reason ~= "clock-jump" then error("sleep jump reason=" .. tostring(reason)) end + if (t.clockDiscontinuities or 0) ~= 1 then error("sleep jump discontinuity not counted") end + + clock.dayTimeMs = clock.dayTimeMs + 1000 + HelperPayrollWorkClock.accrue(owner,t,1000) + near(t.elapsedMs,132000,"post jump resumes") + + -- Batched clock updates: two frames with no visible clock motion, then a 3-second step. + local b = {} + clock = {monotonicDay=20, dayTimeMs=9*3600000, dateKey="Y1-M01-D20"} + scale = 1 + HelperPayrollWorkClock.initialise(owner,b) + HelperPayrollWorkClock.accrue(owner,b,1000) + HelperPayrollWorkClock.accrue(owner,b,1000) + clock.dayTimeMs = clock.dayTimeMs + 3000 + HelperPayrollWorkClock.accrue(owner,b,1000) + near(b.elapsedMs,3000,"batched clock update") + + -- Midnight split: 23:50 -> 00:10 next day at 120x over ten real seconds. + local m = {} + clock = {monotonicDay=30, dayTimeMs=23*3600000+50*60000, dateKey="Y1-M01-D30"} + scale = 120 + HelperPayrollWorkClock.initialise(owner,m) + clock = {monotonicDay=31, dayTimeMs=10*60000, dateKey="Y1-M01-D31"} + HelperPayrollWorkClock.accrue(owner,m,10000) + near(m.elapsedMs,20*60000,"midnight elapsed") + local segments = HelperPayrollWorkClock.getSegments(m) + if #segments ~= 2 then error("expected 2 midnight segments, got " .. tostring(#segments)) end + near(segments[1].elapsedMs,10*60000,"pre-midnight segment") + near(segments[2].elapsedMs,10*60000,"post-midnight segment") + + -- Legacy real-time mode ignores accelerated game clock. + local r = {} + owner.settings.workerTimeBasis = "realTime" + clock = {monotonicDay=40, dayTimeMs=12*3600000, dateKey="Y1-M01-D40"} + scale = 120 + HelperPayrollWorkClock.initialise(owner,r) + clock.dayTimeMs = clock.dayTimeMs + 120000 + HelperPayrollWorkClock.accrue(owner,r,1000) + near(r.elapsedMs,1000,"legacy real time") + LUA + + - name: Build test package + shell: bash + run: | + set -euo pipefail + rm -f FS25_HelperPayroll.zip + zip -r FS25_HelperPayroll.zip modDesc.xml HelperPayroll_icon.dds config gui scripts -x '*.DS_Store' '*__MACOSX*' + + - name: Upload test package + uses: actions/upload-artifact@v4 + with: + name: FS25_HelperPayroll-game-clock-test + path: FS25_HelperPayroll.zip + if-no-files-found: error + retention-days: 7 From 49f6ae9df7e0711f57d8c2e1213609aa180c1b99 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Thu, 29 Oct 2026 16:13:52 +0000 Subject: [PATCH 17/38] Document in-game worker time accounting --- README.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0811176..4cfb272 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,18 @@ Completed work is accumulated and settled at the configured in-game payroll hour `dailyPayroll` controls settlement timing; it does not force every role to use daily compensation. +## Worker Time Basis + +HelperPayroll now defaults worker-hour accounting to the **in-game clock**. + +- `gameClock` (default): wages accrue from Farming Simulator's in-game time. Deliberate time acceleration therefore increases payroll proportionally while workers remain active. +- `realTime`: legacy behaviour; wages accrue from real frame time and ignore game-speed acceleration. +- Paused game time does not accrue `gameClock` wages. +- Discontinuous jumps such as sleep, period skips, clock rollbacks, or other time resets re-anchor the worker timer instead of creating windfall charges. +- Jobs that cross midnight are split across the relevant game days for `dailyPayroll`. + +Normal accelerated clock movement is validated against the mission's effective time scale, so 10×/60×/120× fieldwork remains billable while unrelated instantaneous clock jumps are rejected. + ## Management Screen Open the screen with: @@ -174,7 +186,7 @@ The screen contains six tabs: | Tab | Purpose | |---|---| | **Overview** | Shows active profile, modes, selected role, integration status, pending payroll, and save path. | -| **Billing** | Edits payroll mode, billing mode, payroll hour, legacy minimum, global call-out fee, and rounding. | +| **Billing** | Edits payroll mode, billing mode, worker time basis, payroll hour, legacy minimum, global call-out fee, and rounding. | | **Roles** | Edits each role's pay basis, rate, and minimum call-out. | | **Workers** | Assigns roles and configures inherited or custom worker compensation for the managed roster. | | **Ledger** | Displays persistent payroll totals. | @@ -245,6 +257,7 @@ The file contains: - Active payroll profile. - Payroll and billing modes. +- Worker time basis (`gameClock` or legacy `realTime`). - Role compensation policies. - Worker-role mappings. - Worker compensation overrides. @@ -336,6 +349,7 @@ Role IDs must be unique and non-empty. Save-specific role definitions are preser standard standard dailyPayroll + gameClock 18 0.00 true From cec2d563365ae3145e8c14adeb56aca8333666d4 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Thu, 29 Oct 2026 22:22:58 +0000 Subject: [PATCH 18/38] Cap billable game-clock acceleration independently of clock validation --- scripts/HelperPayrollWorkClock.lua | 38 +++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/scripts/HelperPayrollWorkClock.lua b/scripts/HelperPayrollWorkClock.lua index f02f094..281774f 100644 --- a/scripts/HelperPayrollWorkClock.lua +++ b/scripts/HelperPayrollWorkClock.lua @@ -85,8 +85,10 @@ function HelperPayrollWorkClock.initialise(owner, tracked) tracked.elapsedMs = tonumber(tracked.elapsedMs) or 0 tracked.realElapsedMs = tonumber(tracked.realElapsedMs) or 0 tracked.gameElapsedMs = tonumber(tracked.gameElapsedMs) or 0 + tracked.billableGameElapsedMs = tonumber(tracked.billableGameElapsedMs) or 0 tracked.clockDiscontinuities = tonumber(tracked.clockDiscontinuities) or 0 tracked.expectedGameDeltaMs = tonumber(tracked.expectedGameDeltaMs) or 0 + tracked.expectedBillableDeltaMs = tonumber(tracked.expectedBillableDeltaMs) or 0 tracked.workSegments = tracked.workSegments or {} local clock = getClock(owner) @@ -99,6 +101,7 @@ function HelperPayrollWorkClock.reanchor(owner, tracked) tracked.workClockAnchor = copyAnchor(getClock(owner)) tracked.lastEffectiveTimeScale = getEffectiveTimeScale() tracked.expectedGameDeltaMs = 0 + tracked.expectedBillableDeltaMs = 0 end function HelperPayrollWorkClock.accrue(owner, tracked, dt) @@ -124,11 +127,18 @@ function HelperPayrollWorkClock.accrue(owner, tracked, dt) local previous = tracked.workClockAnchor tracked.workClockAnchor = current local effectiveScale = getEffectiveTimeScale() + local configuredCap = tonumber(owner ~= nil and owner.settings ~= nil and owner.settings.maxPayrollTimeScale or 10) or 10 + local billableScale = configuredCap > 0 and math.min(effectiveScale, configuredCap) or effectiveScale + tracked.lastEffectiveTimeScale = effectiveScale + tracked.lastBillableTimeScale = billableScale + tracked.maxPayrollTimeScale = configuredCap tracked.expectedGameDeltaMs = (tonumber(tracked.expectedGameDeltaMs) or 0) + realDelta * effectiveScale + tracked.expectedBillableDeltaMs = (tonumber(tracked.expectedBillableDeltaMs) or 0) + realDelta * billableScale if previous == nil then tracked.expectedGameDeltaMs = 0 + tracked.expectedBillableDeltaMs = 0 return 0, "anchored" end @@ -136,6 +146,7 @@ function HelperPayrollWorkClock.accrue(owner, tracked, dt) if dayDiff < 0 or dayDiff > 1 then tracked.clockDiscontinuities = (tonumber(tracked.clockDiscontinuities) or 0) + 1 tracked.expectedGameDeltaMs = 0 + tracked.expectedBillableDeltaMs = 0 return 0, "day-discontinuity" end @@ -144,6 +155,7 @@ function HelperPayrollWorkClock.accrue(owner, tracked, dt) if actualDelta < 0 then tracked.clockDiscontinuities = (tonumber(tracked.clockDiscontinuities) or 0) + 1 tracked.expectedGameDeltaMs = 0 + tracked.expectedBillableDeltaMs = 0 return 0, "clock-rollback" end @@ -157,30 +169,41 @@ function HelperPayrollWorkClock.accrue(owner, tracked, dt) if actualDelta > maximumExpected then tracked.clockDiscontinuities = (tonumber(tracked.clockDiscontinuities) or 0) + 1 tracked.expectedGameDeltaMs = 0 + tracked.expectedBillableDeltaMs = 0 return 0, "clock-jump" end if actualDelta <= 0 then if effectiveScale <= 0 then tracked.expectedGameDeltaMs = 0 + tracked.expectedBillableDeltaMs = 0 return 0, "paused" end return 0, "clock-wait" end + local expectedGameDelta = tonumber(tracked.expectedGameDeltaMs) or 0 + local expectedBillableDelta = tonumber(tracked.expectedBillableDeltaMs) or 0 + local billableRatio = expectedGameDelta > 0 and math.min(1, expectedBillableDelta / expectedGameDelta) or 1 + local billableDelta = actualDelta * billableRatio + if dayDiff == 0 then - addSegment(tracked, previous.dateKey, previous.monotonicDay, actualDelta) + addSegment(tracked, previous.dateKey, previous.monotonicDay, billableDelta) else local beforeMidnight = math.max(0, HelperPayrollWorkClock.DAY_MS - previous.dayTimeMs) local afterMidnight = math.max(0, current.dayTimeMs) - addSegment(tracked, previous.dateKey, previous.monotonicDay, beforeMidnight) - addSegment(tracked, current.dateKey, current.monotonicDay, afterMidnight) + local beforeRatio = actualDelta > 0 and beforeMidnight / actualDelta or 0 + local afterRatio = actualDelta > 0 and afterMidnight / actualDelta or 0 + addSegment(tracked, previous.dateKey, previous.monotonicDay, billableDelta * beforeRatio) + addSegment(tracked, current.dateKey, current.monotonicDay, billableDelta * afterRatio) end tracked.gameElapsedMs = (tonumber(tracked.gameElapsedMs) or 0) + actualDelta - tracked.elapsedMs = tracked.gameElapsedMs + tracked.billableGameElapsedMs = (tonumber(tracked.billableGameElapsedMs) or 0) + billableDelta + tracked.elapsedMs = tracked.billableGameElapsedMs tracked.expectedGameDeltaMs = 0 - return actualDelta, "gameClock" + tracked.expectedBillableDeltaMs = 0 + return billableDelta, "gameClock" end function HelperPayrollWorkClock.getSegments(tracked) @@ -209,7 +232,10 @@ function HelperPayrollWorkClock.getDiagnostics(tracked) elapsedMs = tonumber(tracked ~= nil and tracked.elapsedMs or 0) or 0, realElapsedMs = tonumber(tracked ~= nil and tracked.realElapsedMs or 0) or 0, gameElapsedMs = tonumber(tracked ~= nil and tracked.gameElapsedMs or 0) or 0, + billableGameElapsedMs = tonumber(tracked ~= nil and tracked.billableGameElapsedMs or 0) or 0, discontinuities = tonumber(tracked ~= nil and tracked.clockDiscontinuities or 0) or 0, - effectiveTimeScale = tonumber(tracked ~= nil and tracked.lastEffectiveTimeScale or 0) or 0 + effectiveTimeScale = tonumber(tracked ~= nil and tracked.lastEffectiveTimeScale or 0) or 0, + billableTimeScale = tonumber(tracked ~= nil and tracked.lastBillableTimeScale or 0) or 0, + maxPayrollTimeScale = tonumber(tracked ~= nil and tracked.maxPayrollTimeScale or 0) or 0 } end From 7cce2eaa0a6bfda1bd8b39d3f36d9ea81e7fcb36 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Thu, 29 Oct 2026 22:23:31 +0000 Subject: [PATCH 19/38] Add configurable maximum billable payroll time scale --- scripts/HelperPayroll.lua | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/scripts/HelperPayroll.lua b/scripts/HelperPayroll.lua index a34e421..42b0e6c 100644 --- a/scripts/HelperPayroll.lua +++ b/scripts/HelperPayroll.lua @@ -34,6 +34,7 @@ HelperPayroll.settings = { chargeCustomWorkerCosts = true, billingMode = "onJobFinish", workerTimeBasis = "gameClock", + maxPayrollTimeScale = 10, payrollHour = 18, minimumWorkerCharge = 0, workerCalloutFee = 0, @@ -281,6 +282,7 @@ local HPAY_DEFAULT_POLICY_XML = [=[ true onJobFinish gameClock + 10 18 5.00 0.00 @@ -506,6 +508,7 @@ function HelperPayroll:buildPolicyConfigXmlFromRuntime() add(string.format(' %s', tostring(settings.chargeCustomWorkerCosts == true))) add(string.format(' %s', hpayXmlEscape(settings.billingMode or 'onJobFinish'))) add(string.format(' %s', hpayXmlEscape(settings.workerTimeBasis or 'gameClock'))) + add(string.format(' %s', tostring(tonumber(settings.maxPayrollTimeScale) or 10))) add(string.format(' %s', tostring(math.floor(tonumber(settings.payrollHour) or 18)))) add(string.format(' %.2f', tonumber(settings.minimumWorkerCharge) or 0)) add(string.format(' %.2f', tonumber(settings.workerCalloutFee) or 0)) @@ -591,6 +594,7 @@ function HelperPayroll:applyManagementDraft(draftSettings, draftRolePolicies, dr self.settings.workerTimeBasis = HelperPayrollWorkClock ~= nil and HelperPayrollWorkClock.normaliseTimeBasis(draftSettings.workerTimeBasis or self.settings.workerTimeBasis) or tostring(draftSettings.workerTimeBasis or self.settings.workerTimeBasis or 'gameClock') + self.settings.maxPayrollTimeScale = math.max(0, tonumber(draftSettings.maxPayrollTimeScale or self.settings.maxPayrollTimeScale) or 10) self.settings.payrollHour = math.floor(tonumber(draftSettings.payrollHour or self.settings.payrollHour) or 18) self.settings.minimumWorkerCharge = tonumber(draftSettings.minimumWorkerCharge or self.settings.minimumWorkerCharge) or 0 self.settings.workerCalloutFee = tonumber(draftSettings.workerCalloutFee or self.settings.workerCalloutFee) or 0 @@ -702,6 +706,7 @@ function HelperPayroll:loadConfig() if HelperPayrollWorkClock ~= nil and HelperPayrollWorkClock.normaliseTimeBasis ~= nil then self.settings.workerTimeBasis = HelperPayrollWorkClock.normaliseTimeBasis(self.settings.workerTimeBasis) end + self.settings.maxPayrollTimeScale = math.max(0, getXmlFloatOrDefault(xmlFile, "helperPayroll.settings.maxPayrollTimeScale", self.settings.maxPayrollTimeScale or 10)) self.settings.payrollHour = getXmlFloatOrDefault(xmlFile, "helperPayroll.settings.payrollHour", self.settings.payrollHour) self.settings.minimumWorkerCharge = getXmlFloatOrDefault(xmlFile, "helperPayroll.settings.minimumWorkerCharge", self.settings.minimumWorkerCharge) self.settings.workerCalloutFee = getXmlFloatOrDefault(xmlFile, "helperPayroll.settings.workerCalloutFee", self.settings.workerCalloutFee) @@ -718,7 +723,7 @@ function HelperPayroll:loadConfig() delete(xmlFile) rcLog("Loaded config. source=%s file=%s Active payroll profile: %s", tostring(self.policyConfig ~= nil and self.policyConfig.source or "unknown"), tostring(self.CONFIG_FILE), tostring(self.settings.activePayrollProfile)) - rcLog("Worker billing settings: enabled=%s payrollMode=%s billingMode=%s timeBasis=%s payrollHour=%s selectedRole=%s fallbackRole=%s defaultHelperSlot=%s minimum=%.2f callout=%.2f round=%s logLevel=%s helperProfilesDiagnostics=%s", tostring(self.settings.chargeCustomWorkerCosts), tostring(self.settings.payrollMode), tostring(self.settings.billingMode), tostring(self.settings.workerTimeBasis), tostring(self.settings.payrollHour), tostring(self.settings.selectedRole), tostring(self.settings.fallbackRole), tostring(self.settings.defaultHelperSlot ~= "" and self.settings.defaultHelperSlot or "none"), tonumber(self.settings.minimumWorkerCharge) or 0, tonumber(self.settings.workerCalloutFee) or 0, tostring(self.settings.roundWorkerCharges), tostring(self.settings.logLevel), tostring(self.settings.helperProfilesDiagnostics)) + rcLog("Worker billing settings: enabled=%s payrollMode=%s billingMode=%s timeBasis=%s maxPayrollTimeScale=%s payrollHour=%s selectedRole=%s fallbackRole=%s defaultHelperSlot=%s minimum=%.2f callout=%.2f round=%s logLevel=%s helperProfilesDiagnostics=%s", tostring(self.settings.chargeCustomWorkerCosts), tostring(self.settings.payrollMode), tostring(self.settings.billingMode), tostring(self.settings.workerTimeBasis), tostring(self.settings.maxPayrollTimeScale), tostring(self.settings.payrollHour), tostring(self.settings.selectedRole), tostring(self.settings.fallbackRole), tostring(self.settings.defaultHelperSlot ~= "" and self.settings.defaultHelperSlot or "none"), tonumber(self.settings.minimumWorkerCharge) or 0, tonumber(self.settings.workerCalloutFee) or 0, tostring(self.settings.roundWorkerCharges), tostring(self.settings.logLevel), tostring(self.settings.helperProfilesDiagnostics)) self:logHelperSlotConfig() end @@ -4041,6 +4046,7 @@ function HelperPayroll:loadSavegameSettings() if HelperPayrollWorkClock ~= nil and HelperPayrollWorkClock.normaliseTimeBasis ~= nil then self.settings.workerTimeBasis = HelperPayrollWorkClock.normaliseTimeBasis(self.settings.workerTimeBasis) end + self.settings.maxPayrollTimeScale = math.max(0, getXmlFloatOrDefault(xmlFile, "helperPayrollSave.policy#maxPayrollTimeScale", self.settings.maxPayrollTimeScale or 10)) self.settings.payrollHour = getXmlFloatOrDefault(xmlFile, "helperPayrollSave.policy#payrollHour", self.settings.payrollHour or 18) self.settings.minimumWorkerCharge = getXmlFloatOrDefault(xmlFile, "helperPayrollSave.policy#minimumWorkerCharge", self.settings.minimumWorkerCharge or 0) self.settings.workerCalloutFee = getXmlFloatOrDefault(xmlFile, "helperPayrollSave.policy#workerCalloutFee", self.settings.workerCalloutFee or 0) @@ -4259,6 +4265,7 @@ function HelperPayroll:saveSavegameSettings(reason) -- Save-specific gameplay policy overrides written by the management UI. setXMLString(xmlFile, "helperPayrollSave.policy#billingMode", tostring(self.settings.billingMode or "onJobFinish")) setXMLString(xmlFile, "helperPayrollSave.policy#workerTimeBasis", tostring(self.settings.workerTimeBasis or "gameClock")) + setXMLFloat(xmlFile, "helperPayrollSave.policy#maxPayrollTimeScale", tonumber(self.settings.maxPayrollTimeScale) or 10) setXMLInt(xmlFile, "helperPayrollSave.policy#payrollHour", math.floor(tonumber(self.settings.payrollHour) or 18)) setXMLFloat(xmlFile, "helperPayrollSave.policy#minimumWorkerCharge", tonumber(self.settings.minimumWorkerCharge) or 0) setXMLFloat(xmlFile, "helperPayrollSave.policy#workerCalloutFee", tonumber(self.settings.workerCalloutFee) or 0) @@ -5591,7 +5598,7 @@ function HelperPayroll:printSaveSettingsStatus() tostring(self.persistence ~= nil and self.persistence.savegameName or "unknown"), tostring(self.persistence ~= nil and self.persistence.filePath or "nil"), tostring(self.persistence ~= nil and self.persistence.loaded == true)) - hpayPrintf("Effective active values: payrollMode=%s billingMode=%s timeBasis=%s profile=%s selectedRole=%s name=%s rate=%.2f fallbackRole=%s minimum=%.2f callout=%.2f payrollHour=%s round=%s helperProfilesMappings=%d", + hpayPrintf("Effective active values: payrollMode=%s billingMode=%s timeBasis=%s maxPayrollTimeScale=%s profile=%s selectedRole=%s name=%s rate=%.2f fallbackRole=%s minimum=%.2f callout=%.2f payrollHour=%s round=%s helperProfilesMappings=%d", tostring(self.settings.payrollMode), tostring(self.settings.billingMode), tostring(self.settings.workerTimeBasis), tostring(profileId), tostring(roleId), tostring(roleName), tonumber(rate) or 0, tostring(self.settings.fallbackRole), tonumber(self.settings.minimumWorkerCharge) or 0, tonumber(self.settings.workerCalloutFee) or 0, tostring(self.settings.payrollHour), tostring(self.settings.roundWorkerCharges), #(self.helperProfilesMappings or {})) end From 8d02dfe1f1b643aca1ee61b51650d2fd2c1dfa20 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Thu, 29 Oct 2026 22:23:44 +0000 Subject: [PATCH 20/38] Set default payroll time-scale cap to 10x --- config/defaultPayrollConfig.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/config/defaultPayrollConfig.xml b/config/defaultPayrollConfig.xml index 5208724..cbcbbd6 100644 --- a/config/defaultPayrollConfig.xml +++ b/config/defaultPayrollConfig.xml @@ -22,6 +22,7 @@ true onJobFinish gameClock + 10 18 5.00 0.00 From 8a103d4878434ca0a1588b9f92ebebd6b36f479f Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Thu, 29 Oct 2026 22:23:47 +0000 Subject: [PATCH 21/38] Expose maximum payroll time-scale cap in Billing UI --- scripts/gui/HelperPayrollMenu.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/gui/HelperPayrollMenu.lua b/scripts/gui/HelperPayrollMenu.lua index cfe8038..404fe36 100644 --- a/scripts/gui/HelperPayrollMenu.lua +++ b/scripts/gui/HelperPayrollMenu.lua @@ -177,6 +177,7 @@ function HelperPayrollMenu:refreshDraftFromRuntime() if hp ~= nil and hp.settings ~= nil then self.draftSettings.billingMode = hp.settings.billingMode or "onJobFinish" self.draftSettings.workerTimeBasis = hp.settings.workerTimeBasis or "gameClock" + self.draftSettings.maxPayrollTimeScale = tonumber(hp.settings.maxPayrollTimeScale) or 10 self.draftSettings.payrollHour = tonumber(hp.settings.payrollHour) or 18 self.draftSettings.minimumWorkerCharge = tonumber(hp.settings.minimumWorkerCharge) or 0 self.draftSettings.workerCalloutFee = tonumber(hp.settings.workerCalloutFee) or 0 @@ -328,6 +329,7 @@ function HelperPayrollMenu:buildRows() {id="payrollMode", label="Payroll mode", value=tostring(self.draftSettings.payrollMode or "roleType"), status="Editable", source="Savegame", editType="option", options={"roleType", "helperSlot"}, info="roleType assigns every new job to the selected payroll role. helperSlot uses the deployed A-T worker, with live HelperProfiles identity data when available."}, {id="billingMode", label="Billing mode", value=tostring(self.draftSettings.billingMode or "onJobFinish"), status="Editable", source="Savegame", editType="option", options={"onJobFinish", "dailyPayroll"}, info="onJobFinish charges each completed job immediately. dailyPayroll aggregates each worker's completed work for the game day and settles it through payroll."}, {id="workerTimeBasis", label="Worker time basis", value=tostring(self.draftSettings.workerTimeBasis or "gameClock"), status="Editable", source="Savegame", editType="option", options={"gameClock", "realTime"}, info="gameClock pays workers for in-game hours, so deliberate time acceleration increases payroll. realTime preserves legacy frame-time accounting. Sleep and discontinuous clock jumps are ignored in gameClock mode."}, + {id="maxPayrollTimeScale", label="Max payroll time scale", value=tostring(self.draftSettings.maxPayrollTimeScale or 10), status="Editable", source="Savegame", editType="number", step=1, min=0, max=360, info="Maximum game-speed multiplier used for wage accrual. Default 10 means 60x or 360x gameplay still bills at 10x. Set 0 for unlimited game-clock billing."}, {id="payrollHour", label="Payroll hour", value=fmtHour(self.draftSettings.payrollHour), status="Editable", source="Savegame", editType="number", step=1, min=0, max=23, info="In dailyPayroll mode, pending rows settle at this in-game hour. Any unpaid row from an earlier game day is treated as overdue and settles automatically."}, {id="minimumWorkerCharge", label="Legacy minimum", value=fmtMoney(self.draftSettings.minimumWorkerCharge), status="Editable", source="Savegame", editType="number", step=0.5, min=0, max=999, info="Compatibility fallback for old save data that has no per-role minimum. New payroll policies use the minimum call-out configured on each role or worker."}, {id="workerCalloutFee", label="Callout fee", value=fmtMoney(self.draftSettings.workerCalloutFee), status="Editable", source="Savegame", editType="number", step=0.5, min=0, max=999, info="Flat callout fee: per job in onJobFinish mode, or once per worker and workday in dailyPayroll mode."}, @@ -498,6 +500,7 @@ function HelperPayrollMenu:getDraftValue(row) if row.id == "payrollMode" then return self.draftSettings.payrollMode end if row.id == "billingMode" then return self.draftSettings.billingMode end if row.id == "workerTimeBasis" then return self.draftSettings.workerTimeBasis end + if row.id == "maxPayrollTimeScale" then return self.draftSettings.maxPayrollTimeScale end if row.id == "payrollHour" then return self.draftSettings.payrollHour end if row.id == "minimumWorkerCharge" then return self.draftSettings.minimumWorkerCharge end if row.id == "workerCalloutFee" then return self.draftSettings.workerCalloutFee end @@ -579,6 +582,7 @@ function HelperPayrollMenu:setDraftValue(row, value) if row.id == "payrollMode" then self.draftSettings.payrollMode = tostring(value) elseif row.id == "billingMode" then self.draftSettings.billingMode = tostring(value) elseif row.id == "workerTimeBasis" then self.draftSettings.workerTimeBasis = tostring(value) + elseif row.id == "maxPayrollTimeScale" then self.draftSettings.maxPayrollTimeScale = math.max(0, tonumber(value) or 0) elseif row.id == "payrollHour" then self.draftSettings.payrollHour = tonumber(value) or 0 elseif row.id == "minimumWorkerCharge" then self.draftSettings.minimumWorkerCharge = tonumber(value) or 0 elseif row.id == "workerCalloutFee" then self.draftSettings.workerCalloutFee = tonumber(value) or 0 From 0da4395c39f826a3f7f9dbad7adb8c76401000ba Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Thu, 29 Oct 2026 22:23:59 +0000 Subject: [PATCH 22/38] Expose payroll time-scale cap in snapshots --- scripts/HelperPayrollSnapshot.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/HelperPayrollSnapshot.lua b/scripts/HelperPayrollSnapshot.lua index 7e49264..8e9f275 100644 --- a/scripts/HelperPayrollSnapshot.lua +++ b/scripts/HelperPayrollSnapshot.lua @@ -112,6 +112,8 @@ local function buildActiveJobs(owner) gameElapsedHours = (tonumber(tracked.gameElapsedMs) or 0) / 3600000, timeBasis = tostring(tracked.timeBasis or (owner.settings ~= nil and owner.settings.workerTimeBasis) or "gameClock"), effectiveTimeScale = tonumber(tracked.lastEffectiveTimeScale) or 0, + billableTimeScale = tonumber(tracked.lastBillableTimeScale) or 0, + maxPayrollTimeScale = tonumber(tracked.maxPayrollTimeScale) or tonumber(owner.settings ~= nil and owner.settings.maxPayrollTimeScale) or 10, clockDiscontinuities = tonumber(tracked.clockDiscontinuities) or 0, labourEstimate = labour, chargeEstimate = estimate, @@ -274,6 +276,7 @@ function HelperPayrollSnapshot.build(owner, options) payrollMode = tostring(owner.settings ~= nil and owner.settings.payrollMode or "roleType"), billingMode = tostring(owner.settings ~= nil and owner.settings.billingMode or "onJobFinish"), workerTimeBasis = tostring(owner.settings ~= nil and owner.settings.workerTimeBasis or "gameClock"), + maxPayrollTimeScale = tonumber(owner.settings ~= nil and owner.settings.maxPayrollTimeScale or 10) or 10, payrollHour = tonumber(owner.settings ~= nil and owner.settings.payrollHour or 18) or 18, selectedRole = selectedRoleId, selectedRoleName = tostring(selectedRoleWorker ~= nil and selectedRoleWorker.name or selectedRoleId), From 3db241add89965eda51ca6b8801bfd5ebe435de4 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Thu, 29 Oct 2026 22:24:03 +0000 Subject: [PATCH 23/38] Expose payroll time-scale cap through public API --- scripts/HelperPayrollPublicAPI.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/HelperPayrollPublicAPI.lua b/scripts/HelperPayrollPublicAPI.lua index 966126e..b84addf 100644 --- a/scripts/HelperPayrollPublicAPI.lua +++ b/scripts/HelperPayrollPublicAPI.lua @@ -40,6 +40,7 @@ function HelperPayrollPublicAPI.build(owner) payrollMode = tostring(owner.settings.payrollMode or "roleType"), billingMode = tostring(owner.settings.billingMode or "onJobFinish"), workerTimeBasis = tostring(owner.settings.workerTimeBasis or "gameClock"), + maxPayrollTimeScale = tonumber(owner.settings.maxPayrollTimeScale) or 10, managedSlotCount = owner:getManagedHelperSlotCount(), enabledSlotCount = tonumber(roster.enabled) or owner:getManagedHelperSlotCount(), disabledSlotCount = tonumber(roster.disabled) or 0, From 9e8cc142d77c0189d1e000d9db5bb09799cf561a Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Thu, 29 Oct 2026 22:24:07 +0000 Subject: [PATCH 24/38] Log effective and billable payroll time scales --- scripts/HelperPayroll.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/HelperPayroll.lua b/scripts/HelperPayroll.lua index 42b0e6c..162404e 100644 --- a/scripts/HelperPayroll.lua +++ b/scripts/HelperPayroll.lua @@ -3843,7 +3843,7 @@ function HelperPayroll:scanActiveAIJobs(dt) if tracked.elapsedMs - (tracked.lastSummaryMs or 0) >= 60000 then tracked.lastSummaryMs = tracked.elapsedMs rcLog( - "AI job active summary: seq=%s id=%s type=%s helperSlot=%s elapsedMinutes=%.1f timeBasis=%s effectiveScale=%.1f discontinuities=%d customBilling=%s", + "AI job active summary: seq=%s id=%s type=%s helperSlot=%s elapsedMinutes=%.1f timeBasis=%s effectiveScale=%.1f billableScale=%.1f discontinuities=%d customBilling=%s", tostring(tracked.sequence), tostring(jobId), tostring(tracked.name), @@ -3851,6 +3851,7 @@ function HelperPayroll:scanActiveAIJobs(dt) (tracked.elapsedMs or 0) / 60000, tostring(tracked.timeBasis or self.settings.workerTimeBasis or "gameClock"), tonumber(tracked.lastEffectiveTimeScale) or 0, + tonumber(tracked.lastBillableTimeScale) or 0, tonumber(tracked.clockDiscontinuities) or 0, tostring(self.settings.enableCustomWorkerCosts and self.settings.chargeCustomWorkerCosts) ) From c90311752f66285dd01d9721b177a4108a82d480 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Thu, 29 Oct 2026 22:24:10 +0000 Subject: [PATCH 25/38] Log effective and billable payroll time scales --- scripts/HelperPayrollExternalSessions.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/HelperPayrollExternalSessions.lua b/scripts/HelperPayrollExternalSessions.lua index 115df5c..27f3cdc 100644 --- a/scripts/HelperPayrollExternalSessions.lua +++ b/scripts/HelperPayrollExternalSessions.lua @@ -313,7 +313,7 @@ function HelperPayrollExternalSessions:update(dt) session.lastSummaryMs = session.elapsedMs local assignment = session.assignmentSnapshot or {} log( - "Active: id=%s controller=%s helperSlot=%s helper=%s elapsedMinutes=%.1f timeBasis=%s effectiveScale=%.1f discontinuities=%d", + "Active: id=%s controller=%s helperSlot=%s helper=%s elapsedMinutes=%.1f timeBasis=%s effectiveScale=%.1f billableScale=%.1f discontinuities=%d", tostring(session.sessionId), tostring(session.controller), tostring(assignment.helperSlot or "unassigned"), @@ -321,6 +321,7 @@ function HelperPayrollExternalSessions:update(dt) session.elapsedMs / 60000, tostring(session.timeBasis or (HelperPayroll ~= nil and HelperPayroll.settings ~= nil and HelperPayroll.settings.workerTimeBasis) or "gameClock"), tonumber(session.lastEffectiveTimeScale) or 0, + tonumber(session.lastBillableTimeScale) or 0, tonumber(session.clockDiscontinuities) or 0 ) end From 63b95f2c770abb23a7e91cd6b1d7200eee0ffecc Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Thu, 29 Oct 2026 22:24:28 +0000 Subject: [PATCH 26/38] Document 10x default payroll time-scale cap --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4cfb272..f8beff7 100644 --- a/README.md +++ b/README.md @@ -165,13 +165,14 @@ Completed work is accumulated and settled at the configured in-game payroll hour HelperPayroll now defaults worker-hour accounting to the **in-game clock**. -- `gameClock` (default): wages accrue from Farming Simulator's in-game time. Deliberate time acceleration therefore increases payroll proportionally while workers remain active. +- `gameClock` (default): wages accrue from Farming Simulator's in-game time. Deliberate time acceleration therefore increases payroll while workers remain active. +- `maxPayrollTimeScale` caps the **billable** acceleration. The default is `10`, so 30×, 60× or 360× gameplay is still recognised as valid accelerated time but wages accrue no faster than 10×. Set `0` for unlimited game-clock billing. - `realTime`: legacy behaviour; wages accrue from real frame time and ignore game-speed acceleration. - Paused game time does not accrue `gameClock` wages. - Discontinuous jumps such as sleep, period skips, clock rollbacks, or other time resets re-anchor the worker timer instead of creating windfall charges. - Jobs that cross midnight are split across the relevant game days for `dailyPayroll`. -Normal accelerated clock movement is validated against the mission's effective time scale, so 10×/60×/120× fieldwork remains billable while unrelated instantaneous clock jumps are rejected. +Normal accelerated clock movement is validated against the mission's **actual** effective time scale, while payroll uses the configured billable cap. This keeps fast-forward economically meaningful without allowing extreme 360× speeds to create disproportionate wages. ## Management Screen @@ -257,7 +258,7 @@ The file contains: - Active payroll profile. - Payroll and billing modes. -- Worker time basis (`gameClock` or legacy `realTime`). +- Worker time basis (`gameClock` or legacy `realTime`) and maximum billable payroll time scale. - Role compensation policies. - Worker-role mappings. - Worker compensation overrides. @@ -350,6 +351,7 @@ Role IDs must be unique and non-empty. Save-specific role definitions are preser standard dailyPayroll gameClock + 10 18 0.00 true From ffc970bbc7e065897f319ad3103b6ef68ec8c1e4 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Thu, 29 Oct 2026 22:24:31 +0000 Subject: [PATCH 27/38] Clarify capped game-clock payroll accounting --- scripts/HelperPayrollWorkClock.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/HelperPayrollWorkClock.lua b/scripts/HelperPayrollWorkClock.lua index 281774f..e2a8f98 100644 --- a/scripts/HelperPayrollWorkClock.lua +++ b/scripts/HelperPayrollWorkClock.lua @@ -5,6 +5,7 @@ -- (legacy compatibility). Game-clock mode validates observed environment-clock -- movement against the mission's effective time scale so normal fast-forward -- counts while discontinuous jumps such as sleep/period skips only re-anchor. +-- Billable acceleration can be capped independently from the actual game speed. HelperPayrollWorkClock = HelperPayrollWorkClock or {} @@ -176,7 +177,7 @@ function HelperPayrollWorkClock.accrue(owner, tracked, dt) if actualDelta <= 0 then if effectiveScale <= 0 then tracked.expectedGameDeltaMs = 0 - tracked.expectedBillableDeltaMs = 0 + tracked.expectedBillableDeltaMs = 0 return 0, "paused" end return 0, "clock-wait" From 6c45a30aab2331e00599dd7e3c25592ce8863374 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Thu, 29 Oct 2026 22:24:45 +0000 Subject: [PATCH 28/38] Test capped and unlimited payroll time scales --- .../workflows/validate-game-clock-payroll.yml | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/.github/workflows/validate-game-clock-payroll.yml b/.github/workflows/validate-game-clock-payroll.yml index 055ee2b..4c1f4b2 100644 --- a/.github/workflows/validate-game-clock-payroll.yml +++ b/.github/workflows/validate-game-clock-payroll.yml @@ -42,7 +42,9 @@ jobs: checks = { 'work clock source loaded': 'scripts/HelperPayrollWorkClock.lua' in mod_desc, 'default time basis': 'gameClock' in config, + 'default payroll cap': '10' in config, 'runtime setting': 'workerTimeBasis = "gameClock"' in main, + 'runtime payroll cap': 'maxPayrollTimeScale = 10' in main, 'savegame persistence': 'helperPayrollSave.policy#workerTimeBasis' in main, 'management UI': 'id="workerTimeBasis"' in menu, 'effective time scale': 'getEffectiveTimeScale' in clock, @@ -79,7 +81,7 @@ jobs: } local owner = { - settings = {workerTimeBasis="gameClock"}, + settings = {workerTimeBasis="gameClock", maxPayrollTimeScale=10}, getGameClockSnapshot = function() return { monotonicDay=clock.monotonicDay, dayTimeMs=clock.dayTimeMs, @@ -108,11 +110,16 @@ jobs: scale = 120 clock.dayTimeMs = clock.dayTimeMs + 120000 HelperPayrollWorkClock.accrue(owner,t,1000) - near(t.elapsedMs,131000,"120x second") + near(t.elapsedMs,21000,"120x capped second") + + scale = 360 + clock.dayTimeMs = clock.dayTimeMs + 360000 + HelperPayrollWorkClock.accrue(owner,t,1000) + near(t.elapsedMs,31000,"360x capped second") scale = 0 HelperPayrollWorkClock.accrue(owner,t,1000) - near(t.elapsedMs,131000,"pause") + near(t.elapsedMs,31000,"pause") scale = 1 clock.dayTimeMs = clock.dayTimeMs + 8*3600000 @@ -123,7 +130,7 @@ jobs: clock.dayTimeMs = clock.dayTimeMs + 1000 HelperPayrollWorkClock.accrue(owner,t,1000) - near(t.elapsedMs,132000,"post jump resumes") + near(t.elapsedMs,32000,"post jump resumes") -- Batched clock updates: two frames with no visible clock motion, then a 3-second step. local b = {} @@ -136,22 +143,33 @@ jobs: HelperPayrollWorkClock.accrue(owner,b,1000) near(b.elapsedMs,3000,"batched clock update") - -- Midnight split: 23:50 -> 00:10 next day at 120x over ten real seconds. + -- Midnight split at the 10x billable cap: 23:50 -> 00:10 next day. local m = {} clock = {monotonicDay=30, dayTimeMs=23*3600000+50*60000, dateKey="Y1-M01-D30"} - scale = 120 + scale = 10 HelperPayrollWorkClock.initialise(owner,m) clock = {monotonicDay=31, dayTimeMs=10*60000, dateKey="Y1-M01-D31"} - HelperPayrollWorkClock.accrue(owner,m,10000) + HelperPayrollWorkClock.accrue(owner,m,120000) near(m.elapsedMs,20*60000,"midnight elapsed") local segments = HelperPayrollWorkClock.getSegments(m) if #segments ~= 2 then error("expected 2 midnight segments, got " .. tostring(#segments)) end near(segments[1].elapsedMs,10*60000,"pre-midnight segment") near(segments[2].elapsedMs,10*60000,"post-midnight segment") + -- Unlimited cap restores full accelerated game-clock billing. + local u = {} + owner.settings.maxPayrollTimeScale = 0 + clock = {monotonicDay=35, dayTimeMs=12*3600000, dateKey="Y1-M01-D35"} + scale = 120 + HelperPayrollWorkClock.initialise(owner,u) + clock.dayTimeMs = clock.dayTimeMs + 120000 + HelperPayrollWorkClock.accrue(owner,u,1000) + near(u.elapsedMs,120000,"unlimited 120x") + -- Legacy real-time mode ignores accelerated game clock. local r = {} owner.settings.workerTimeBasis = "realTime" + owner.settings.maxPayrollTimeScale = 10 clock = {monotonicDay=40, dayTimeMs=12*3600000, dateKey="Y1-M01-D40"} scale = 120 HelperPayrollWorkClock.initialise(owner,r) From 1ce80e06c823863744fb0bccb31ed6fd1fdc06fa Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Fri, 30 Oct 2026 16:29:07 +0000 Subject: [PATCH 29/38] Expose current payroll time-scale diagnostics --- scripts/HelperPayrollWorkClock.lua | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/scripts/HelperPayrollWorkClock.lua b/scripts/HelperPayrollWorkClock.lua index e2a8f98..46a1bf3 100644 --- a/scripts/HelperPayrollWorkClock.lua +++ b/scripts/HelperPayrollWorkClock.lua @@ -80,6 +80,14 @@ function HelperPayrollWorkClock.normaliseTimeBasis(value) return normaliseBasis(value) end +function HelperPayrollWorkClock.getCurrentScales(owner) + local effectiveScale = getEffectiveTimeScale() + local configuredCap = tonumber(owner ~= nil and owner.settings ~= nil and owner.settings.maxPayrollTimeScale or 10) or 10 + configuredCap = math.max(0, configuredCap) + local billableScale = configuredCap > 0 and math.min(effectiveScale, configuredCap) or effectiveScale + return effectiveScale, billableScale, configuredCap +end + function HelperPayrollWorkClock.initialise(owner, tracked) if tracked == nil then return end @@ -127,9 +135,7 @@ function HelperPayrollWorkClock.accrue(owner, tracked, dt) local previous = tracked.workClockAnchor tracked.workClockAnchor = current - local effectiveScale = getEffectiveTimeScale() - local configuredCap = tonumber(owner ~= nil and owner.settings ~= nil and owner.settings.maxPayrollTimeScale or 10) or 10 - local billableScale = configuredCap > 0 and math.min(effectiveScale, configuredCap) or effectiveScale + local effectiveScale, billableScale, configuredCap = HelperPayrollWorkClock.getCurrentScales(owner) tracked.lastEffectiveTimeScale = effectiveScale tracked.lastBillableTimeScale = billableScale From de82df24034ebe12ac84a4f67005ef1d3c2a8457 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Fri, 30 Oct 2026 16:29:22 +0000 Subject: [PATCH 30/38] Prepare 0.4.4.0 alpha1 runtime diagnostics --- scripts/HelperPayroll.lua | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/scripts/HelperPayroll.lua b/scripts/HelperPayroll.lua index 162404e..7d30aca 100644 --- a/scripts/HelperPayroll.lua +++ b/scripts/HelperPayroll.lua @@ -14,8 +14,8 @@ local hpGetTimeMs HelperPayroll = {} HelperPayroll.MOD_NAME = g_currentModName or "FS25_HelperPayroll" -HelperPayroll.VERSION = "0.4.3.0" -HelperPayroll.RELEASE_CHANNEL = "alpha2" +HelperPayroll.VERSION = "0.4.4.0" +HelperPayroll.RELEASE_CHANNEL = "alpha1" HelperPayroll.TARGET_HELPER_SLOTS = 20 HelperPayroll.MOD_DIRECTORY = g_currentModDirectory or "" HelperPayroll.BUNDLED_CONFIG_FILE = HelperPayroll.MOD_DIRECTORY .. "config/defaultPayrollConfig.xml" @@ -5600,7 +5600,7 @@ function HelperPayroll:printSaveSettingsStatus() tostring(self.persistence ~= nil and self.persistence.filePath or "nil"), tostring(self.persistence ~= nil and self.persistence.loaded == true)) hpayPrintf("Effective active values: payrollMode=%s billingMode=%s timeBasis=%s maxPayrollTimeScale=%s profile=%s selectedRole=%s name=%s rate=%.2f fallbackRole=%s minimum=%.2f callout=%.2f payrollHour=%s round=%s helperProfilesMappings=%d", - tostring(self.settings.payrollMode), tostring(self.settings.billingMode), tostring(self.settings.workerTimeBasis), tostring(profileId), tostring(roleId), tostring(roleName), tonumber(rate) or 0, tostring(self.settings.fallbackRole), tonumber(self.settings.minimumWorkerCharge) or 0, tonumber(self.settings.workerCalloutFee) or 0, tostring(self.settings.payrollHour), tostring(self.settings.roundWorkerCharges), #(self.helperProfilesMappings or {})) + tostring(self.settings.payrollMode), tostring(self.settings.billingMode), tostring(self.settings.workerTimeBasis), tostring(self.settings.maxPayrollTimeScale or 10), tostring(profileId), tostring(roleId), tostring(roleName), tonumber(rate) or 0, tostring(self.settings.fallbackRole), tonumber(self.settings.minimumWorkerCharge) or 0, tonumber(self.settings.workerCalloutFee) or 0, tostring(self.settings.payrollHour), tostring(self.settings.roundWorkerCharges), #(self.helperProfilesMappings or {})) end function HelperPayroll:hpayConfig(...) @@ -5715,6 +5715,12 @@ function HelperPayroll:hpayDump(...) local payrollHour = tonumber(self.settings.payrollHour) or 18 hpayPrintf("Payroll clock: date=%s monotonicDay=%s dayTimeMs=%s hour=%s payrollHour=%s pendingRows=%d", tostring(clock.dateKey), tostring(clock.monotonicDay), tostring(clock.dayTimeMs), tostring(clock.hour), tostring(payrollHour), self:countPendingDailyPayrollRows()) + local effectiveScale, billableScale, configuredCap = 0, 0, tonumber(self.settings.maxPayrollTimeScale) or 10 + if HelperPayrollWorkClock ~= nil and HelperPayrollWorkClock.getCurrentScales ~= nil then + effectiveScale, billableScale, configuredCap = HelperPayrollWorkClock.getCurrentScales(self) + end + hpayPrintf("Worker time accounting: basis=%s effectiveTimeScale=%.1f billableTimeScale=%.1f maxPayrollTimeScale=%s", + tostring(self.settings.workerTimeBasis or "gameClock"), tonumber(effectiveScale) or 0, tonumber(billableScale) or 0, tostring(configuredCap)) for key, daily in pairs(self.workerDailyLedger or {}) do if daily ~= nil and daily.payrollApplied ~= true and (tonumber(daily.jobs) or 0) > 0 then local due, reason = self:isDailyPayrollRowDue(daily, clock, payrollHour) @@ -5727,6 +5733,10 @@ function HelperPayroll:hpayDump(...) local roleId, roleName, rate, profileId = self:getSelectedRoleInfo() local hpStatus = self:getHelperProfilesStatus() local compatibility = self:getCompatibilityStatus() + local effectiveScale, billableScale, configuredCap = 0, 0, tonumber(self.settings.maxPayrollTimeScale) or 10 + if HelperPayrollWorkClock ~= nil and HelperPayrollWorkClock.getCurrentScales ~= nil then + effectiveScale, billableScale, configuredCap = HelperPayrollWorkClock.getCurrentScales(self) + end 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, @@ -5736,6 +5746,8 @@ function HelperPayroll:hpayDump(...) 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)) + hpayPrintf("Worker time accounting: basis=%s effectiveTimeScale=%.1f billableTimeScale=%.1f maxPayrollTimeScale=%s", + tostring(self.settings.workerTimeBasis or "gameClock"), tonumber(effectiveScale) or 0, tonumber(billableScale) or 0, tostring(configuredCap)) return end @@ -5811,6 +5823,12 @@ function HelperPayroll:logRuntimeStartupStatus() tostring(compatibility.blocked == true), tostring(compatibility.primaryConflictName or "none"), tostring(HelperPayrollSnapshot ~= nil and HelperPayrollSnapshot.SCHEMA_VERSION or 1)) + local effectiveScale, billableScale, configuredCap = 0, 0, tonumber(self.settings.maxPayrollTimeScale) or 10 + if HelperPayrollWorkClock ~= nil and HelperPayrollWorkClock.getCurrentScales ~= nil then + effectiveScale, billableScale, configuredCap = HelperPayrollWorkClock.getCurrentScales(self) + end + rcLog("Worker time accounting: basis=%s effectiveTimeScale=%.1f billableTimeScale=%.1f maxPayrollTimeScale=%s", + tostring(self.settings.workerTimeBasis or "gameClock"), tonumber(effectiveScale) or 0, tonumber(billableScale) or 0, tostring(configuredCap)) end function HelperPayroll:processStartupStatus(dt) From dfc9675e457c8e03a899d93c2cf689909b3a91db Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Fri, 30 Oct 2026 16:29:29 +0000 Subject: [PATCH 31/38] Expose game-clock scale status through public API --- scripts/HelperPayrollPublicAPI.lua | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/scripts/HelperPayrollPublicAPI.lua b/scripts/HelperPayrollPublicAPI.lua index b84addf..cc56a31 100644 --- a/scripts/HelperPayrollPublicAPI.lua +++ b/scripts/HelperPayrollPublicAPI.lua @@ -10,7 +10,7 @@ function HelperPayrollPublicAPI.build(owner) 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"), + modVersion = tostring(owner.VERSION or "0.4.4.0"), readOnly = false, capabilities = { roleMappings = true, @@ -21,7 +21,9 @@ function HelperPayrollPublicAPI.build(owner) transportReadySnapshot = true, rosterAvailability = true, enabledWorkerFiltering = true, - externalWorkerSessions = HelperPayrollExternalSessions ~= nil + externalWorkerSessions = HelperPayrollExternalSessions ~= nil, + gameClockPayroll = HelperPayrollWorkClock ~= nil, + activeWorkSegments = HelperPayrollSnapshot ~= nil and (HelperPayrollSnapshot.SCHEMA_VERSION or 0) >= 4 } } @@ -30,6 +32,10 @@ function HelperPayrollPublicAPI.build(owner) local roster = owner.getManagedHelperRosterSummary ~= nil and owner:getManagedHelperRosterSummary() or {} local externalSessions = HelperPayrollExternalSessions ~= nil and HelperPayrollExternalSessions.getActive ~= nil and HelperPayrollExternalSessions.getActive(owner) or {} + local effectiveScale, billableScale, configuredCap = 0, 0, tonumber(owner.settings.maxPayrollTimeScale) or 10 + if HelperPayrollWorkClock ~= nil and HelperPayrollWorkClock.getCurrentScales ~= nil then + effectiveScale, billableScale, configuredCap = HelperPayrollWorkClock.getCurrentScales(owner) + end return { available = owner.isInitialized == true, apiVersion = self.apiVersion, @@ -40,7 +46,9 @@ function HelperPayrollPublicAPI.build(owner) payrollMode = tostring(owner.settings.payrollMode or "roleType"), billingMode = tostring(owner.settings.billingMode or "onJobFinish"), workerTimeBasis = tostring(owner.settings.workerTimeBasis or "gameClock"), - maxPayrollTimeScale = tonumber(owner.settings.maxPayrollTimeScale) or 10, + effectiveTimeScale = tonumber(effectiveScale) or 0, + billableTimeScale = tonumber(billableScale) or 0, + maxPayrollTimeScale = tonumber(configuredCap) or 10, managedSlotCount = owner:getManagedHelperSlotCount(), enabledSlotCount = tonumber(roster.enabled) or owner:getManagedHelperSlotCount(), disabledSlotCount = tonumber(roster.disabled) or 0, From 894af4981344c7aa4b15c4f611a7648fae7a3960 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Fri, 30 Oct 2026 16:29:37 +0000 Subject: [PATCH 32/38] Clarify snapshot v4 game-clock accounting fields --- scripts/HelperPayrollSnapshot.lua | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/scripts/HelperPayrollSnapshot.lua b/scripts/HelperPayrollSnapshot.lua index 8e9f275..6e8a2b6 100644 --- a/scripts/HelperPayrollSnapshot.lua +++ b/scripts/HelperPayrollSnapshot.lua @@ -83,6 +83,7 @@ local function buildActiveJobs(owner) gameDate = tostring(segment.gameDate or assignment.gameDate or "unknown"), monotonicDay = tonumber(segment.monotonicDay), elapsedHours = round(segmentHours, 3), + billableElapsedHours = round(segmentHours, 3), labourEstimate = round(segmentLabour, 2), chargeEstimate = round(segmentCharge, 2) }) @@ -110,6 +111,8 @@ local function buildActiveJobs(owner) elapsedHours = elapsedHours, realElapsedHours = (tonumber(tracked.realElapsedMs) or 0) / 3600000, gameElapsedHours = (tonumber(tracked.gameElapsedMs) or 0) / 3600000, + validatedGameElapsedHours = (tonumber(tracked.gameElapsedMs) or 0) / 3600000, + billableGameElapsedHours = (tonumber(tracked.billableGameElapsedMs) or tonumber(tracked.elapsedMs) or 0) / 3600000, timeBasis = tostring(tracked.timeBasis or (owner.settings ~= nil and owner.settings.workerTimeBasis) or "gameClock"), effectiveTimeScale = tonumber(tracked.lastEffectiveTimeScale) or 0, billableTimeScale = tonumber(tracked.lastBillableTimeScale) or 0, @@ -244,6 +247,10 @@ function HelperPayrollSnapshot.build(owner, options) } local activeExternalSessions = HelperPayrollExternalSessions ~= nil and HelperPayrollExternalSessions.getActive ~= nil and HelperPayrollExternalSessions.getActive(owner) or {} + local effectiveScale, billableScale, configuredCap = 0, 0, tonumber(owner.settings ~= nil and owner.settings.maxPayrollTimeScale or 10) or 10 + if HelperPayrollWorkClock ~= nil and HelperPayrollWorkClock.getCurrentScales ~= nil then + effectiveScale, billableScale, configuredCap = HelperPayrollWorkClock.getCurrentScales(owner) + end local snapshot = { schemaVersion = HelperPayrollSnapshot.SCHEMA_VERSION, @@ -261,7 +268,11 @@ function HelperPayrollSnapshot.build(owner, options) authority = "singlePlayerMission", multiplayerSupported = false, transportReadySchema = true, - externalWorkerSessionsSupported = HelperPayrollExternalSessions ~= nil + externalWorkerSessionsSupported = HelperPayrollExternalSessions ~= nil, + workerTimeBasis = tostring(owner.settings ~= nil and owner.settings.workerTimeBasis or "gameClock"), + effectiveTimeScale = tonumber(effectiveScale) or 0, + billableTimeScale = tonumber(billableScale) or 0, + maxPayrollTimeScale = tonumber(configuredCap) or 10 }, compatibility = compatibility, roster = { From 9c1ad6828a0e688daad049d44f96eb20fd625a5e Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Fri, 30 Oct 2026 16:29:45 +0000 Subject: [PATCH 33/38] Expose external-session billable clock diagnostics --- scripts/HelperPayrollExternalSessions.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/HelperPayrollExternalSessions.lua b/scripts/HelperPayrollExternalSessions.lua index 27f3cdc..c7d9104 100644 --- a/scripts/HelperPayrollExternalSessions.lua +++ b/scripts/HelperPayrollExternalSessions.lua @@ -161,8 +161,12 @@ local function makeResult(session, status) elapsedHours = session ~= nil and ((tonumber(session.elapsedMs) or 0) / 3600000) or 0, realElapsedHours = session ~= nil and ((tonumber(session.realElapsedMs) or 0) / 3600000) or 0, gameElapsedHours = session ~= nil and ((tonumber(session.gameElapsedMs) or 0) / 3600000) or 0, + validatedGameElapsedHours = session ~= nil and ((tonumber(session.gameElapsedMs) or 0) / 3600000) or 0, + billableGameElapsedHours = session ~= nil and ((tonumber(session.billableGameElapsedMs) or tonumber(session.elapsedMs) or 0) / 3600000) or 0, timeBasis = session ~= nil and tostring(session.timeBasis or "unknown") or "unknown", effectiveTimeScale = session ~= nil and (tonumber(session.lastEffectiveTimeScale) or 0) or 0, + billableTimeScale = session ~= nil and (tonumber(session.lastBillableTimeScale) or 0) or 0, + maxPayrollTimeScale = session ~= nil and (tonumber(session.maxPayrollTimeScale) or 10) or 10, clockDiscontinuities = session ~= nil and (tonumber(session.clockDiscontinuities) or 0) or 0 } end From 50a59d4cc594a1c900a0e6b1c04d780f5d6e072b Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Fri, 30 Oct 2026 16:29:58 +0000 Subject: [PATCH 34/38] Bump mod metadata to 0.4.4.0 alpha1 --- modDesc.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modDesc.xml b/modDesc.xml index 51bd4e4..2516f1f 100644 --- a/modDesc.xml +++ b/modDesc.xml @@ -1,12 +1,12 @@ SimGamerJen - 0.4.3.0 + 0.4.4.0 <en>Helper Payroll Alpha</en> - 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. + 0.4.4.0 Alpha 1: Changes worker-time accounting to use Farming Simulator's in-game clock by default, with legacy realTime mode retained. Game-clock payroll follows normal time acceleration but caps billable acceleration at 10x by default; set maxPayrollTimeScale to 0 for uncapped billing. Paused time does not accrue wages, discontinuous clock jumps are re-anchored instead of billed, and daily payroll can split active work across midnight. Snapshot schema v4 exposes validated and billable game-clock time, scale diagnostics and active work segments. HelperPayroll remains optional for HelperProfiles and multiplayer remains disabled. HelperPayroll_icon.dds From 5084845ff6d60a9961a17ab068724dfbd78bf560 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Fri, 30 Oct 2026 16:30:38 +0000 Subject: [PATCH 35/38] Document 0.4.4.0 alpha1 game-clock payroll --- README.md | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 65 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f8beff7..ab6d92d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ It supports role-based and named-worker compensation, hourly or daily pay, per-role minimum call-outs, per-worker overrides, immediate or scheduled settlement, persistent ledger reporting, and optional integration with HelperProfiles. -> **Current version:** `0.4.2.0` Alpha 2 +> **Current version:** `0.4.4.0` Alpha 1 > **Game:** Farming Simulator 25 > **Multiplayer:** Not supported @@ -19,6 +19,10 @@ It supports role-based and named-worker compensation, hourly or daily pay, per-r - Configures pay basis, rate, and minimum call-out for each role. - Allows each worker to inherit role terms or use a custom override. - Supports immediate `onJobFinish` settlement or scheduled `dailyPayroll`. +- Uses Farming Simulator's in-game clock for worker-hour accounting by default, with legacy `realTime` mode retained. +- Caps billable game-clock acceleration at **10× by default**, independently from clock validation; set `maxPayrollTimeScale=0` for uncapped billing. +- Pauses game-clock wage accrual when the game clock is paused and rejects implausible sleep/period/rollback jumps instead of billing them as labour. +- Splits `dailyPayroll` work across midnight using validated active-work segments. - Supports a global call-out fee and optional charge rounding. - Stores settings, role policies, worker mappings, and pending payroll per savegame. - Captures worker identity, role, and compensation terms when a job starts. @@ -172,7 +176,21 @@ HelperPayroll now defaults worker-hour accounting to the **in-game clock**. - Discontinuous jumps such as sleep, period skips, clock rollbacks, or other time resets re-anchor the worker timer instead of creating windfall charges. - Jobs that cross midnight are split across the relevant game days for `dailyPayroll`. -Normal accelerated clock movement is validated against the mission's **actual** effective time scale, while payroll uses the configured billable cap. This keeps fast-forward economically meaningful without allowing extreme 360× speeds to create disproportionate wages. +Normal accelerated clock movement is validated against the mission's **actual** effective time scale, while payroll uses the configured billable cap. The cap never weakens clock validation. + +With the default `maxPayrollTimeScale=10`: + +| FS time scale | Billable payroll scale | +|---:|---:| +| 1× | 1× | +| 5× | 5× | +| 10× | 10× | +| 30× | 10× | +| 60× | 10× | +| 120× | 10× | +| 360× | 10× | + +Setting `maxPayrollTimeScale=0` removes the billing cap and restores full game-clock payroll at the actual validated Farming Simulator time scale. ## Management Screen @@ -187,7 +205,7 @@ The screen contains six tabs: | Tab | Purpose | |---|---| | **Overview** | Shows active profile, modes, selected role, integration status, pending payroll, and save path. | -| **Billing** | Edits payroll mode, billing mode, worker time basis, payroll hour, legacy minimum, global call-out fee, and rounding. | +| **Billing** | Edits payroll mode, billing mode, worker time basis, maximum billable payroll time scale, payroll hour, legacy minimum, global call-out fee, and rounding. | | **Roles** | Edits each role's pay basis, rate, and minimum call-out. | | **Workers** | Assigns roles and configures inherited or custom worker compensation for the managed roster. | | **Ledger** | Displays persistent payroll totals. | @@ -207,7 +225,9 @@ Resetting a save does not overwrite the global `defaultPayrollConfig.xml`. HelperProfiles is optional and is not a hard dependency. -HelperPayroll `0.4.2.0` supports the twenty-slot HelperProfiles API and publishes its own role-assignment API globally and on the active mission. +HelperPayroll owns **roles, rates, wage accounting, settlement and payroll history**. HelperProfiles remains the roster/identity layer and only consumes HelperPayroll's public payroll snapshot/API to display read-only role and live **COST** information. Neither mod requires the other to operate. + +HelperPayroll `0.4.4.0` supports the twenty-slot HelperProfiles API and publishes its role-assignment and payroll-snapshot API globally and on the active mission. With HelperProfiles `2.1.0.0` or a compatible later build: @@ -230,6 +250,26 @@ hpayProfiles slots HelperPayroll remains usable in standalone `roleType` mode when HelperProfiles is absent. +## Public API and Snapshot + +HelperPayroll keeps **public API version 5** for compatibility. No existing public API methods are removed by 0.4.4.0 Alpha 1. + +The payroll snapshot advances to **schema v4** because active work now exposes game-clock accounting and per-day work segments. Existing fields remain available; `elapsedHours` continues to represent the billable duration used for wage estimates. + +Active job/session snapshot data can include: + +- `timeBasis` — `gameClock` or legacy `realTime`; +- `realElapsedHours` — real frame time observed while the worker is active; +- `gameElapsedHours` / `validatedGameElapsedHours` — accepted Farming Simulator clock movement; +- `billableGameElapsedHours` — validated game-clock time after the payroll acceleration cap; +- `effectiveTimeScale` — the actual Farming Simulator effective time scale used for clock validation; +- `billableTimeScale` — the scale currently used for payroll accrual; +- `maxPayrollTimeScale` — configured billable cap, where `0` means unlimited; +- `clockDiscontinuities` — rejected/re-anchored clock jumps; +- `workSegments` — active billable work split by game date for midnight-aware daily payroll. + +`getStatus()` also exposes the current worker time basis, effective scale, billable scale and configured cap. Consumers should prefer capability/schema checks over assuming every field is present in older builds. + ### Hired Helper Tool note HelperProfiles and Hired Helper Tool are explicitly incompatible because both own the helper roster. When HelperProfiles disables itself due to that conflict, its shared identity API is intentionally unavailable to HelperPayroll. HelperPayroll remains responsible only for its own payroll behaviour and does not treat Hired Helper Tool's roster as a HelperProfiles A–T roster. @@ -506,6 +546,27 @@ Include the HelperPayroll version, HelperProfiles version when installed, releva ## Version History +### Version 0.4.4.0 Alpha 1 + +- Changed the default worker-time basis from real frame time to validated Farming Simulator **game-clock** time. +- Retained legacy `realTime` accounting as a selectable Billing option. +- Added independent validation and billing scales: the actual effective FS time scale validates clock movement, while `maxPayrollTimeScale` caps wage acceleration. +- Defaulted `maxPayrollTimeScale` to **10×**; `0` means unlimited/full game-clock payroll. +- Added pause-safe accrual, coarse/batched clock handling, discontinuity rejection/re-anchoring and clock-rollback protection. +- Added midnight work segmentation so `dailyPayroll` can attribute work to the correct game day. +- Persisted worker time basis and payroll time-scale cap per save and exposed both in the Billing UI. +- Advanced payroll snapshot schema to **v4** with validated/billable elapsed time, scale diagnostics and active work segments. +- Kept public API version **5** and preserved existing API methods while adding time-accounting status fields. +- Retained HelperProfiles live role/COST integration and external-worker session accounting. + +### Version 0.4.3.0 Alpha 2 + +- Added HelperProfiles roster-aware payroll management: OFF-roster workers are hidden operationally without losing roles, overrides or history. +- Added a generic external-worker session API for controllers outside GIANTS `activeJobs`, including AutoDrive continuity integration. +- Added the live payroll Dashboard and serializable payroll snapshots. +- Added WorkerCosts conflict protection and farm/job state boundaries designed for later multiplayer work while multiplayer remains disabled. +- Extended public API/roster reporting and retained stable A–T identities. + ### Version 0.4.2.0 Alpha 2 - Extended helper-slot handling from A–J to A–T. From 35f8bceb509b4504fbefec74b5c4d6629ca53b53 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Fri, 30 Oct 2026 16:30:57 +0000 Subject: [PATCH 36/38] Add 0.4.4.0 alpha1 release notes --- docs/releases/0.4.4.0-alpha1.md | 121 ++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 docs/releases/0.4.4.0-alpha1.md diff --git a/docs/releases/0.4.4.0-alpha1.md b/docs/releases/0.4.4.0-alpha1.md new file mode 100644 index 0000000..e8c5c1a --- /dev/null +++ b/docs/releases/0.4.4.0-alpha1.md @@ -0,0 +1,121 @@ +# HelperPayroll 0.4.4.0 Alpha 1 + +This pre-release changes the default way HelperPayroll measures worker time. + +Previous builds primarily accounted worker hours from real frame time. **0.4.4.0 Alpha 1 uses Farming Simulator's in-game clock as the default authoritative time basis**, so deliberate in-game time acceleration now affects worker payroll. + +This is an alpha pre-release for wider testing, not a stable release. + +## Game-clock payroll + +The default setting is now: + +```xml +gameClock +10 +``` + +With the default 10× billable cap: + +| FS effective time scale | Billable payroll scale | +|---:|---:| +| 1× | 1× | +| 5× | 5× | +| 10× | 10× | +| 30× | 10× | +| 60× | 10× | +| 120× | 10× | +| 360× | 10× | + +The **actual Farming Simulator effective time scale is still used for clock validation**. The payroll cap only limits billable wage acceleration; it does not make high-speed clock movement look invalid. + +Set `maxPayrollTimeScale=0` to remove the cap and bill the full validated game-clock acceleration. + +## Legacy real-time mode + +Players who prefer the previous accounting model can select: + +```text +workerTimeBasis = realTime +``` + +In `realTime` mode, worker wages accrue from real frame time rather than accelerated in-game time. + +## Clock safety + +Game-clock accounting now: + +- stops accruing wages while the game clock is paused; +- supports coarse/batched environment clock updates; +- rejects and re-anchors implausible time jumps rather than charging them as labour; +- rejects clock rollback; +- rejects large sleep/period discontinuities; +- tracks both validated game-clock elapsed time and billable game-clock elapsed time; +- splits active `dailyPayroll` work across midnight so work is attributed to the correct game day. + +## Billing UI and save data + +The Billing tab now exposes: + +- **Worker time basis** — `gameClock` or `realTime`; +- **Max payroll time scale** — default `10`, or `0` for unlimited. + +Both values persist per savegame and are included in the generated/default policy configuration. + +## API and diagnostics + +The public API remains **version 5**. Existing public API methods are retained. + +Payroll snapshot schema advances to **v4**. Active work can now expose: + +- real elapsed hours; +- validated game-clock elapsed hours; +- billable game-clock elapsed hours; +- effective FS time scale; +- billable payroll time scale; +- configured payroll cap; +- rejected clock discontinuity count; +- active per-day work segments. + +The runtime diagnostics and `getStatus()` also expose the current time basis and effective/billable scale information. + +## HelperProfiles compatibility + +HelperPayroll remains **optional** for HelperProfiles. + +- **HelperPayroll owns:** payroll roles, pay rates, worker compensation overrides, wage accounting, settlement and payroll history. +- **HelperProfiles owns:** worker identity, roster availability and helper selection. +- HelperProfiles only consumes HelperPayroll's public payroll snapshot/API to display read-only role and live **COST** information. + +Neither mod requires the other to run. + +## Existing 0.4.3.0 Alpha 2 functionality retained + +This build retains the current main-line features, including: + +- A–T / twenty-worker HelperProfiles integration; +- roster-aware Workers filtering while preserving OFF-worker payroll data; +- external-worker session accounting, including AutoDrive continuity integration; +- live payroll Dashboard; +- WorkerCosts conflict protection; +- persistent ledger and daily payroll settlement; +- single-player authority boundaries prepared for later multiplayer work. + +Multiplayer remains disabled in this release. + +## Validation + +Automated validation covers: + +- XML parsing and Lua syntax; +- 1×, 5× and 10× game-clock accrual; +- 30×, 60×, 120× and 360× gameplay capped to 10× billing; +- unlimited cap mode; +- paused-clock behaviour; +- rejected large clock jumps and rollback; +- coarse/batched clock updates; +- midnight work splitting; +- legacy `realTime` mode; +- package construction. + +This alpha is being published specifically to widen in-game testing of economic balance and clock edge cases before any stable promotion. From 22333de8b68ac71789171b187f916db27af15a46 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Fri, 30 Oct 2026 16:31:33 +0000 Subject: [PATCH 37/38] Harden 0.4.4.0 alpha1 validation workflow --- .../workflows/validate-game-clock-payroll.yml | 197 ++++++++++++------ 1 file changed, 131 insertions(+), 66 deletions(-) diff --git a/.github/workflows/validate-game-clock-payroll.yml b/.github/workflows/validate-game-clock-payroll.yml index 4c1f4b2..3b2f6bc 100644 --- a/.github/workflows/validate-game-clock-payroll.yml +++ b/.github/workflows/validate-game-clock-payroll.yml @@ -4,6 +4,9 @@ on: push: branches: - feature/game-clock-payroll + pull_request: + branches: + - main workflow_dispatch: permissions: @@ -13,13 +16,13 @@ jobs: validate: runs-on: ubuntu-latest steps: - - name: Check out feature branch + - name: Check out source uses: actions/checkout@v4 - name: Install Lua run: sudo apt-get update && sudo apt-get install -y lua5.4 - - name: Validate XML and policy wiring + - name: Validate XML, release metadata and policy wiring shell: bash run: | set -euo pipefail @@ -27,30 +30,53 @@ jobs: from pathlib import Path import xml.etree.ElementTree as ET - ET.parse('modDesc.xml') - ET.parse('config/defaultPayrollConfig.xml') - ET.parse('gui/HelperPayrollMenu.xml') - ET.parse('gui/guiProfiles.xml') + for path in ( + 'modDesc.xml', + 'config/defaultPayrollConfig.xml', + 'gui/HelperPayrollMenu.xml', + 'gui/guiProfiles.xml', + ): + ET.parse(path) mod_desc = Path('modDesc.xml').read_text(encoding='utf-8') main = Path('scripts/HelperPayroll.lua').read_text(encoding='utf-8') clock = Path('scripts/HelperPayrollWorkClock.lua').read_text(encoding='utf-8') menu = Path('scripts/gui/HelperPayrollMenu.lua').read_text(encoding='utf-8') snapshot = Path('scripts/HelperPayrollSnapshot.lua').read_text(encoding='utf-8') + api = Path('scripts/HelperPayrollPublicAPI.lua').read_text(encoding='utf-8') + external = Path('scripts/HelperPayrollExternalSessions.lua').read_text(encoding='utf-8') config = Path('config/defaultPayrollConfig.xml').read_text(encoding='utf-8') + readme = Path('README.md').read_text(encoding='utf-8') + notes = Path('docs/releases/0.4.4.0-alpha1.md').read_text(encoding='utf-8') checks = { + 'mod version': '0.4.4.0' in mod_desc, + 'runtime version': 'HelperPayroll.VERSION = "0.4.4.0"' in main, + 'release channel': 'HelperPayroll.RELEASE_CHANNEL = "alpha1"' in main, + 'README current version': '**Current version:** `0.4.4.0` Alpha 1' in readme, + 'release notes alpha warning': 'alpha pre-release' in notes.lower(), 'work clock source loaded': 'scripts/HelperPayrollWorkClock.lua' in mod_desc, 'default time basis': 'gameClock' in config, 'default payroll cap': '10' in config, 'runtime setting': 'workerTimeBasis = "gameClock"' in main, 'runtime payroll cap': 'maxPayrollTimeScale = 10' in main, - 'savegame persistence': 'helperPayrollSave.policy#workerTimeBasis' in main, - 'management UI': 'id="workerTimeBasis"' in menu, + 'savegame time-basis persistence': 'helperPayrollSave.policy#workerTimeBasis' in main, + 'savegame cap persistence': 'helperPayrollSave.policy#maxPayrollTimeScale' in main, + 'management time-basis UI': 'id="workerTimeBasis"' in menu, + 'management payroll-cap UI': 'id="maxPayrollTimeScale"' in menu, 'effective time scale': 'getEffectiveTimeScale' in clock, + 'current scale diagnostics': 'getCurrentScales' in clock, 'midnight segments': 'workSegments' in clock and 'buildDailyPayrollEntries' in main, 'snapshot schema v4': 'SCHEMA_VERSION = 4' in snapshot, - 'snapshot diagnostics': 'clockDiscontinuities' in snapshot, + 'snapshot validated elapsed': 'validatedGameElapsedHours' in snapshot, + 'snapshot billable elapsed': 'billableGameElapsedHours' in snapshot, + 'snapshot active segments': 'workSegments = workSegments' in snapshot, + 'snapshot billable scale': 'billableTimeScale' in snapshot, + 'public API v5 retained': 'API_VERSION = 5' in api, + 'public API scale diagnostics': 'effectiveTimeScale' in api and 'billableTimeScale' in api, + 'external session billable diagnostics': 'billableGameElapsedHours' in external and 'billableTimeScale' in external, + 'HelperProfiles ownership note': 'HelperPayroll owns **roles, rates, wage accounting, settlement and payroll history**' in readme, + 'save status format fix': 'tostring(self.settings.workerTimeBasis), tostring(self.settings.maxPayrollTimeScale or 10), tostring(profileId)' in main, } failed = [name for name, ok in checks.items() if not ok] if failed: @@ -95,100 +121,139 @@ jobs: end end - local t = {} - HelperPayrollWorkClock.initialise(owner,t) - - clock.dayTimeMs = clock.dayTimeMs + 1000 - HelperPayrollWorkClock.accrue(owner,t,1000) - near(t.elapsedMs,1000,"1x second") - - scale = 10 - clock.dayTimeMs = clock.dayTimeMs + 10000 - HelperPayrollWorkClock.accrue(owner,t,1000) - near(t.elapsedMs,11000,"10x second") - - scale = 120 - clock.dayTimeMs = clock.dayTimeMs + 120000 - HelperPayrollWorkClock.accrue(owner,t,1000) - near(t.elapsedMs,21000,"120x capped second") + local function testScale(actualScale, cap, expectedBillableScale) + owner.settings.workerTimeBasis = "gameClock" + owner.settings.maxPayrollTimeScale = cap + scale = actualScale + clock = {monotonicDay=50, dayTimeMs=8*3600000, dateKey="Y1-M02-D01"} + local t = {} + HelperPayrollWorkClock.initialise(owner,t) + clock.dayTimeMs = clock.dayTimeMs + actualScale * 1000 + local delta, reason = HelperPayrollWorkClock.accrue(owner,t,1000) + near(delta, expectedBillableScale * 1000, tostring(actualScale) .. "x billable delta") + near(t.gameElapsedMs, actualScale * 1000, tostring(actualScale) .. "x validated game delta") + near(t.billableGameElapsedMs, expectedBillableScale * 1000, tostring(actualScale) .. "x tracked billable delta") + if reason ~= "gameClock" then error("unexpected scale reason " .. tostring(reason)) end + local effective, billable, configured = HelperPayrollWorkClock.getCurrentScales(owner) + near(effective, actualScale, tostring(actualScale) .. "x effective scale") + near(billable, expectedBillableScale, tostring(actualScale) .. "x billable scale") + near(configured, cap, tostring(actualScale) .. "x configured cap") + end - scale = 360 - clock.dayTimeMs = clock.dayTimeMs + 360000 - HelperPayrollWorkClock.accrue(owner,t,1000) - near(t.elapsedMs,31000,"360x capped second") + testScale(1, 10, 1) + testScale(5, 10, 5) + testScale(10, 10, 10) + testScale(30, 10, 10) + testScale(60, 10, 10) + testScale(120, 10, 10) + testScale(360, 10, 10) + -- Pause: no visible clock movement and effective scale zero. + owner.settings.workerTimeBasis = "gameClock" + owner.settings.maxPayrollTimeScale = 10 scale = 0 - HelperPayrollWorkClock.accrue(owner,t,1000) - near(t.elapsedMs,31000,"pause") + clock = {monotonicDay=60, dayTimeMs=9*3600000, dateKey="Y1-M02-D02"} + local paused = {} + HelperPayrollWorkClock.initialise(owner,paused) + local delta, reason = HelperPayrollWorkClock.accrue(owner,paused,1000) + near(delta,0,"pause") + if reason ~= "paused" then error("pause reason=" .. tostring(reason)) end + -- Implausible sleep/time jump is rejected and re-anchored. scale = 1 + clock = {monotonicDay=70, dayTimeMs=10*3600000, dateKey="Y1-M02-D03"} + local jump = {} + HelperPayrollWorkClock.initialise(owner,jump) clock.dayTimeMs = clock.dayTimeMs + 8*3600000 - local delta, reason = HelperPayrollWorkClock.accrue(owner,t,1000) + delta, reason = HelperPayrollWorkClock.accrue(owner,jump,1000) near(delta,0,"sleep jump ignored") if reason ~= "clock-jump" then error("sleep jump reason=" .. tostring(reason)) end - if (t.clockDiscontinuities or 0) ~= 1 then error("sleep jump discontinuity not counted") end - + if (jump.clockDiscontinuities or 0) ~= 1 then error("sleep jump discontinuity not counted") end clock.dayTimeMs = clock.dayTimeMs + 1000 - HelperPayrollWorkClock.accrue(owner,t,1000) - near(t.elapsedMs,32000,"post jump resumes") + HelperPayrollWorkClock.accrue(owner,jump,1000) + near(jump.elapsedMs,1000,"post jump resumes") + + -- Clock rollback is rejected. + clock = {monotonicDay=80, dayTimeMs=12*3600000, dateKey="Y1-M02-D04"} + local rollback = {} + HelperPayrollWorkClock.initialise(owner,rollback) + clock.dayTimeMs = clock.dayTimeMs - 60000 + delta, reason = HelperPayrollWorkClock.accrue(owner,rollback,1000) + near(delta,0,"rollback ignored") + if reason ~= "clock-rollback" then error("rollback reason=" .. tostring(reason)) end + + -- Multi-day/period skip is rejected. + clock = {monotonicDay=90, dayTimeMs=12*3600000, dateKey="Y1-M02-D05"} + local period = {} + HelperPayrollWorkClock.initialise(owner,period) + clock = {monotonicDay=92, dayTimeMs=12*3600000, dateKey="Y1-M03-D01"} + delta, reason = HelperPayrollWorkClock.accrue(owner,period,1000) + near(delta,0,"period skip ignored") + if reason ~= "day-discontinuity" then error("period skip reason=" .. tostring(reason)) end -- Batched clock updates: two frames with no visible clock motion, then a 3-second step. - local b = {} - clock = {monotonicDay=20, dayTimeMs=9*3600000, dateKey="Y1-M01-D20"} + local batched = {} + clock = {monotonicDay=100, dayTimeMs=9*3600000, dateKey="Y1-M03-D02"} scale = 1 - HelperPayrollWorkClock.initialise(owner,b) - HelperPayrollWorkClock.accrue(owner,b,1000) - HelperPayrollWorkClock.accrue(owner,b,1000) + HelperPayrollWorkClock.initialise(owner,batched) + HelperPayrollWorkClock.accrue(owner,batched,1000) + HelperPayrollWorkClock.accrue(owner,batched,1000) clock.dayTimeMs = clock.dayTimeMs + 3000 - HelperPayrollWorkClock.accrue(owner,b,1000) - near(b.elapsedMs,3000,"batched clock update") + HelperPayrollWorkClock.accrue(owner,batched,1000) + near(batched.elapsedMs,3000,"batched clock update") -- Midnight split at the 10x billable cap: 23:50 -> 00:10 next day. - local m = {} - clock = {monotonicDay=30, dayTimeMs=23*3600000+50*60000, dateKey="Y1-M01-D30"} + local midnight = {} + clock = {monotonicDay=110, dayTimeMs=23*3600000+50*60000, dateKey="Y1-M03-D03"} scale = 10 - HelperPayrollWorkClock.initialise(owner,m) - clock = {monotonicDay=31, dayTimeMs=10*60000, dateKey="Y1-M01-D31"} - HelperPayrollWorkClock.accrue(owner,m,120000) - near(m.elapsedMs,20*60000,"midnight elapsed") - local segments = HelperPayrollWorkClock.getSegments(m) + HelperPayrollWorkClock.initialise(owner,midnight) + clock = {monotonicDay=111, dayTimeMs=10*60000, dateKey="Y1-M03-D04"} + HelperPayrollWorkClock.accrue(owner,midnight,120000) + near(midnight.elapsedMs,20*60000,"midnight elapsed") + local segments = HelperPayrollWorkClock.getSegments(midnight) if #segments ~= 2 then error("expected 2 midnight segments, got " .. tostring(#segments)) end near(segments[1].elapsedMs,10*60000,"pre-midnight segment") near(segments[2].elapsedMs,10*60000,"post-midnight segment") -- Unlimited cap restores full accelerated game-clock billing. - local u = {} owner.settings.maxPayrollTimeScale = 0 - clock = {monotonicDay=35, dayTimeMs=12*3600000, dateKey="Y1-M01-D35"} scale = 120 - HelperPayrollWorkClock.initialise(owner,u) + clock = {monotonicDay=120, dayTimeMs=12*3600000, dateKey="Y1-M03-D05"} + local unlimited = {} + HelperPayrollWorkClock.initialise(owner,unlimited) clock.dayTimeMs = clock.dayTimeMs + 120000 - HelperPayrollWorkClock.accrue(owner,u,1000) - near(u.elapsedMs,120000,"unlimited 120x") + HelperPayrollWorkClock.accrue(owner,unlimited,1000) + near(unlimited.elapsedMs,120000,"unlimited 120x") + near(unlimited.gameElapsedMs,120000,"unlimited validated time") + near(unlimited.billableGameElapsedMs,120000,"unlimited billable time") -- Legacy real-time mode ignores accelerated game clock. - local r = {} owner.settings.workerTimeBasis = "realTime" owner.settings.maxPayrollTimeScale = 10 - clock = {monotonicDay=40, dayTimeMs=12*3600000, dateKey="Y1-M01-D40"} scale = 120 - HelperPayrollWorkClock.initialise(owner,r) + clock = {monotonicDay=130, dayTimeMs=12*3600000, dateKey="Y1-M03-D06"} + local realtime = {} + HelperPayrollWorkClock.initialise(owner,realtime) clock.dayTimeMs = clock.dayTimeMs + 120000 - HelperPayrollWorkClock.accrue(owner,r,1000) - near(r.elapsedMs,1000,"legacy real time") + HelperPayrollWorkClock.accrue(owner,realtime,1000) + near(realtime.elapsedMs,1000,"legacy real time") LUA - - name: Build test package + - name: Build validation package shell: bash run: | set -euo pipefail - rm -f FS25_HelperPayroll.zip + rm -f FS25_HelperPayroll.zip FS25_HelperPayroll.zip.sha256 zip -r FS25_HelperPayroll.zip modDesc.xml HelperPayroll_icon.dds config gui scripts -x '*.DS_Store' '*__MACOSX*' + sha256sum FS25_HelperPayroll.zip | tee FS25_HelperPayroll.zip.sha256 + unzip -t FS25_HelperPayroll.zip - - name: Upload test package + - name: Upload validation package uses: actions/upload-artifact@v4 with: - name: FS25_HelperPayroll-game-clock-test - path: FS25_HelperPayroll.zip + name: FS25_HelperPayroll-0.4.4.0-alpha1-validation + path: | + FS25_HelperPayroll.zip + FS25_HelperPayroll.zip.sha256 if-no-files-found: error - retention-days: 7 + retention-days: 14 From ca9e719e82de1fa703ac31a5ec81532121cfc114 Mon Sep 17 00:00:00 2001 From: SimGamerJen Date: Fri, 30 Oct 2026 16:32:01 +0000 Subject: [PATCH 38/38] Add guarded 0.4.4.0 alpha1 release workflow --- .github/workflows/release-0.4.4.0-alpha1.yml | 240 +++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 .github/workflows/release-0.4.4.0-alpha1.yml diff --git a/.github/workflows/release-0.4.4.0-alpha1.yml b/.github/workflows/release-0.4.4.0-alpha1.yml new file mode 100644 index 0000000..9152b8d --- /dev/null +++ b/.github/workflows/release-0.4.4.0-alpha1.yml @@ -0,0 +1,240 @@ +name: Publish HelperPayroll 0.4.4.0 Alpha 1 + +on: + push: + branches: + - main + +permissions: + contents: write + +jobs: + release: + if: contains(github.event.head_commit.message, '[release 0.4.4.0-alpha1]') + runs-on: ubuntu-latest + steps: + - name: Check out merged main commit + uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + + - name: Install Lua and ZIP tools + run: sudo apt-get update && sudo apt-get install -y lua5.4 zip unzip + + - name: Validate release metadata and XML + shell: bash + run: | + set -euo pipefail + python - <<'PY' + from pathlib import Path + import xml.etree.ElementTree as ET + + for path in ( + 'modDesc.xml', + 'config/defaultPayrollConfig.xml', + 'gui/HelperPayrollMenu.xml', + 'gui/guiProfiles.xml', + ): + ET.parse(path) + + mod_desc = Path('modDesc.xml').read_text(encoding='utf-8') + main = Path('scripts/HelperPayroll.lua').read_text(encoding='utf-8') + clock = Path('scripts/HelperPayrollWorkClock.lua').read_text(encoding='utf-8') + snapshot = Path('scripts/HelperPayrollSnapshot.lua').read_text(encoding='utf-8') + api = Path('scripts/HelperPayrollPublicAPI.lua').read_text(encoding='utf-8') + config = Path('config/defaultPayrollConfig.xml').read_text(encoding='utf-8') + readme = Path('README.md').read_text(encoding='utf-8') + notes = Path('docs/releases/0.4.4.0-alpha1.md').read_text(encoding='utf-8') + + checks = { + 'mod version': '0.4.4.0' in mod_desc, + 'runtime version': 'HelperPayroll.VERSION = "0.4.4.0"' in main, + 'release channel': 'HelperPayroll.RELEASE_CHANNEL = "alpha1"' in main, + 'README version': '**Current version:** `0.4.4.0` Alpha 1' in readme, + 'release notes': '# HelperPayroll 0.4.4.0 Alpha 1' in notes, + 'default game clock': 'gameClock' in config, + 'default 10x cap': '10' in config, + 'work clock loaded': 'scripts/HelperPayrollWorkClock.lua' in mod_desc, + 'clock scale separation': 'getCurrentScales' in clock and 'billableScale' in clock, + 'snapshot v4': 'SCHEMA_VERSION = 4' in snapshot, + 'snapshot billable elapsed': 'billableGameElapsedHours' in snapshot, + 'snapshot active segments': 'workSegments = workSegments' in snapshot, + 'API v5 retained': 'API_VERSION = 5' in api, + } + failed = [name for name, ok in checks.items() if not ok] + if failed: + raise SystemExit('Release validation failed: ' + ', '.join(failed)) + PY + + - name: Validate Lua syntax + shell: bash + run: | + set -euo pipefail + find scripts -name '*.lua' -print0 | while IFS= read -r -d '' file; do + luac -p "$file" + done + + - name: Validate game-clock accounting + shell: bash + run: | + set -euo pipefail + lua <<'LUA' + dofile("scripts/HelperPayrollWorkClock.lua") + + local scale = 1 + local clock = {monotonicDay=1, dayTimeMs=8*3600000, dateKey="Y1-M01-D01"} + g_currentMission = { + timeScale = 1, + getEffectiveTimeScale = function() return scale end + } + local owner = { + settings = {workerTimeBasis="gameClock", maxPayrollTimeScale=10}, + getGameClockSnapshot = function() return { + monotonicDay=clock.monotonicDay, + dayTimeMs=clock.dayTimeMs, + dateKey=clock.dateKey + } end + } + + local function near(a,b,label) + if math.abs((a or 0)-(b or 0)) > 0.001 then + error(string.format("%s: expected %.3f got %.3f", label, b, a or -1)) + end + end + + local function oneSecond(actualScale, cap, billableScale) + owner.settings.workerTimeBasis = "gameClock" + owner.settings.maxPayrollTimeScale = cap + scale = actualScale + clock = {monotonicDay=10, dayTimeMs=8*3600000, dateKey="Y1-M01-D10"} + local tracked = {} + HelperPayrollWorkClock.initialise(owner, tracked) + clock.dayTimeMs = clock.dayTimeMs + actualScale * 1000 + local delta, reason = HelperPayrollWorkClock.accrue(owner, tracked, 1000) + near(delta, billableScale * 1000, "billable delta") + near(tracked.gameElapsedMs, actualScale * 1000, "validated game delta") + near(tracked.billableGameElapsedMs, billableScale * 1000, "billable game delta") + if reason ~= "gameClock" then error("unexpected reason " .. tostring(reason)) end + end + + oneSecond(1,10,1) + oneSecond(5,10,5) + oneSecond(10,10,10) + oneSecond(30,10,10) + oneSecond(60,10,10) + oneSecond(120,10,10) + oneSecond(360,10,10) + oneSecond(120,0,120) + + -- Pause. + owner.settings.maxPayrollTimeScale = 10 + scale = 0 + clock = {monotonicDay=20, dayTimeMs=9*3600000, dateKey="Y1-M01-D20"} + local paused = {} + HelperPayrollWorkClock.initialise(owner, paused) + local delta, reason = HelperPayrollWorkClock.accrue(owner, paused, 1000) + near(delta, 0, "paused delta") + if reason ~= "paused" then error("pause reason=" .. tostring(reason)) end + + -- Large jump rejection. + scale = 1 + clock = {monotonicDay=30, dayTimeMs=10*3600000, dateKey="Y1-M01-D30"} + local jump = {} + HelperPayrollWorkClock.initialise(owner, jump) + clock.dayTimeMs = clock.dayTimeMs + 8*3600000 + delta, reason = HelperPayrollWorkClock.accrue(owner, jump, 1000) + near(delta, 0, "jump delta") + if reason ~= "clock-jump" then error("jump reason=" .. tostring(reason)) end + + -- Batched movement. + clock = {monotonicDay=40, dayTimeMs=9*3600000, dateKey="Y1-M02-D01"} + local batched = {} + HelperPayrollWorkClock.initialise(owner, batched) + HelperPayrollWorkClock.accrue(owner, batched, 1000) + HelperPayrollWorkClock.accrue(owner, batched, 1000) + clock.dayTimeMs = clock.dayTimeMs + 3000 + HelperPayrollWorkClock.accrue(owner, batched, 1000) + near(batched.elapsedMs, 3000, "batched movement") + + -- Midnight segmentation. + scale = 10 + clock = {monotonicDay=50, dayTimeMs=23*3600000+50*60000, dateKey="Y1-M02-D10"} + local midnight = {} + HelperPayrollWorkClock.initialise(owner, midnight) + clock = {monotonicDay=51, dayTimeMs=10*60000, dateKey="Y1-M02-D11"} + HelperPayrollWorkClock.accrue(owner, midnight, 120000) + local segments = HelperPayrollWorkClock.getSegments(midnight) + if #segments ~= 2 then error("expected two midnight segments") end + near(segments[1].elapsedMs, 10*60000, "pre-midnight") + near(segments[2].elapsedMs, 10*60000, "post-midnight") + + -- Legacy real time. + owner.settings.workerTimeBasis = "realTime" + scale = 120 + clock = {monotonicDay=60, dayTimeMs=12*3600000, dateKey="Y1-M02-D20"} + local realtime = {} + HelperPayrollWorkClock.initialise(owner, realtime) + clock.dayTimeMs = clock.dayTimeMs + 120000 + HelperPayrollWorkClock.accrue(owner, realtime, 1000) + near(realtime.elapsedMs, 1000, "legacy real time") + LUA + + - name: Build exact release package + id: package + shell: bash + run: | + set -euo pipefail + rm -f FS25_HelperPayroll.zip FS25_HelperPayroll.zip.sha256 + zip -r FS25_HelperPayroll.zip modDesc.xml HelperPayroll_icon.dds config gui scripts -x '*.DS_Store' '*__MACOSX*' + unzip -t FS25_HelperPayroll.zip + ZIP_SHA256="$(sha256sum FS25_HelperPayroll.zip | awk '{print $1}')" + printf '%s FS25_HelperPayroll.zip\n' "$ZIP_SHA256" | tee FS25_HelperPayroll.zip.sha256 + echo "zip_sha256=$ZIP_SHA256" >> "$GITHUB_OUTPUT" + + - name: Upload merged-main package artifact + uses: actions/upload-artifact@v4 + with: + name: FS25_HelperPayroll-0.4.4.0-alpha1 + path: | + FS25_HelperPayroll.zip + FS25_HelperPayroll.zip.sha256 + if-no-files-found: error + retention-days: 30 + + - name: Publish GitHub pre-release + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + TAG="0.4.4.0-alpha1" + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "Release $TAG already exists; refusing to replace an existing release." + exit 1 + fi + gh release create "$TAG" FS25_HelperPayroll.zip FS25_HelperPayroll.zip.sha256 --repo "$GITHUB_REPOSITORY" --target "$GITHUB_SHA" --title "HelperPayroll 0.4.4.0 Alpha 1" --notes-file docs/releases/0.4.4.0-alpha1.md --prerelease + + - name: Verify published asset and tag + env: + GH_TOKEN: ${{ github.token }} + LOCAL_SHA: ${{ steps.package.outputs.zip_sha256 }} + shell: bash + run: | + set -euo pipefail + TAG="0.4.4.0-alpha1" + RELEASE_JSON="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$TAG")" + REMOTE_DIGEST="$(printf '%s' "$RELEASE_JSON" | jq -r '.assets[] | select(.name=="FS25_HelperPayroll.zip") | .digest')" + RELEASE_URL="$(printf '%s' "$RELEASE_JSON" | jq -r '.html_url')" + TAG_SHA="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --jq '.object.sha')" + + test "$REMOTE_DIGEST" = "sha256:$LOCAL_SHA" + test "$TAG_SHA" = "$GITHUB_SHA" + + { + echo "## HelperPayroll 0.4.4.0 Alpha 1" + echo "- Commit: `$GITHUB_SHA`" + echo "- Tag: `$TAG`" + echo "- ZIP SHA-256: `$LOCAL_SHA`" + echo "- Release: $RELEASE_URL" + } >> "$GITHUB_STEP_SUMMARY"