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" diff --git a/.github/workflows/validate-game-clock-payroll.yml b/.github/workflows/validate-game-clock-payroll.yml new file mode 100644 index 0000000..3b2f6bc --- /dev/null +++ b/.github/workflows/validate-game-clock-payroll.yml @@ -0,0 +1,259 @@ +name: Validate game-clock payroll + +on: + push: + branches: + - feature/game-clock-payroll + pull_request: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - 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, release metadata and policy wiring + 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') + 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 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 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: + 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", 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 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 + + 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 + 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 + 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 (jump.clockDiscontinuities or 0) ~= 1 then error("sleep jump discontinuity not counted") end + clock.dayTimeMs = clock.dayTimeMs + 1000 + 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 batched = {} + clock = {monotonicDay=100, dayTimeMs=9*3600000, dateKey="Y1-M03-D02"} + scale = 1 + 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 clock update") + + -- Midnight split at the 10x billable cap: 23:50 -> 00:10 next day. + local midnight = {} + clock = {monotonicDay=110, dayTimeMs=23*3600000+50*60000, dateKey="Y1-M03-D03"} + scale = 10 + 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. + owner.settings.maxPayrollTimeScale = 0 + scale = 120 + clock = {monotonicDay=120, dayTimeMs=12*3600000, dateKey="Y1-M03-D05"} + local unlimited = {} + HelperPayrollWorkClock.initialise(owner,unlimited) + clock.dayTimeMs = clock.dayTimeMs + 120000 + 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. + owner.settings.workerTimeBasis = "realTime" + owner.settings.maxPayrollTimeScale = 10 + scale = 120 + clock = {monotonicDay=130, dayTimeMs=12*3600000, dateKey="Y1-M03-D06"} + 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 validation 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*' + sha256sum FS25_HelperPayroll.zip | tee FS25_HelperPayroll.zip.sha256 + unzip -t FS25_HelperPayroll.zip + + - name: Upload validation package + uses: actions/upload-artifact@v4 + with: + name: FS25_HelperPayroll-0.4.4.0-alpha1-validation + path: | + FS25_HelperPayroll.zip + FS25_HelperPayroll.zip.sha256 + if-no-files-found: error + retention-days: 14 diff --git a/README.md b/README.md index 0811176..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. @@ -161,6 +165,33 @@ 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 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 **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 Open the screen with: @@ -174,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, 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. | @@ -194,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: @@ -217,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. @@ -245,6 +298,7 @@ The file contains: - Active payroll profile. - Payroll and billing modes. +- Worker time basis (`gameClock` or legacy `realTime`) and maximum billable payroll time scale. - Role compensation policies. - Worker-role mappings. - Worker compensation overrides. @@ -336,6 +390,8 @@ Role IDs must be unique and non-empty. Save-specific role definitions are preser standard standard dailyPayroll + gameClock + 10 18 0.00 true @@ -490,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. diff --git a/config/defaultPayrollConfig.xml b/config/defaultPayrollConfig.xml index 2ac6620..cbcbbd6 100644 --- a/config/defaultPayrollConfig.xml +++ b/config/defaultPayrollConfig.xml @@ -21,6 +21,8 @@ true onJobFinish + gameClock + 10 18 5.00 0.00 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. diff --git a/modDesc.xml b/modDesc.xml index db55d62..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 @@ -51,6 +51,7 @@ + diff --git a/scripts/HelperPayroll.lua b/scripts/HelperPayroll.lua index 1945afb..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" @@ -33,6 +33,8 @@ HelperPayroll.settings = { defaultHelperSlot = "", chargeCustomWorkerCosts = true, billingMode = "onJobFinish", + workerTimeBasis = "gameClock", + maxPayrollTimeScale = 10, payrollHour = 18, minimumWorkerCharge = 0, workerCalloutFee = 0, @@ -279,6 +281,8 @@ local HPAY_DEFAULT_POLICY_XML = [=[ true onJobFinish + gameClock + 10 18 5.00 0.00 @@ -503,6 +507,8 @@ 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(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)) @@ -585,6 +591,10 @@ 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.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 @@ -692,6 +702,11 @@ 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.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) @@ -708,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 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 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 @@ -3368,6 +3383,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") @@ -3434,6 +3493,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)) @@ -3452,31 +3555,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", @@ -3743,6 +3821,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 @@ -3752,18 +3833,26 @@ 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 billableScale=%.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.lastBillableTimeScale) or 0, + tonumber(tracked.clockDiscontinuities) or 0, tostring(self.settings.enableCustomWorkerCosts and self.settings.chargeCustomWorkerCosts) ) end @@ -3954,6 +4043,11 @@ 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.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) @@ -4171,6 +4265,8 @@ 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) @@ -5503,8 +5599,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 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(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(...) @@ -5619,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) @@ -5631,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, @@ -5640,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 @@ -5715,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) diff --git a/scripts/HelperPayrollExternalSessions.lua b/scripts/HelperPayrollExternalSessions.lua index c7c205c..c7d9104 100644 --- a/scripts/HelperPayrollExternalSessions.lua +++ b/scripts/HelperPayrollExternalSessions.lua @@ -158,7 +158,16 @@ 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, + 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 @@ -201,6 +210,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 +308,25 @@ 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 billableScale=%.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.lastBillableTimeScale) or 0, + tonumber(session.clockDiscontinuities) or 0 ) end end diff --git a/scripts/HelperPayrollPublicAPI.lua b/scripts/HelperPayrollPublicAPI.lua index 006142c..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, @@ -39,6 +45,10 @@ 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"), + 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, diff --git a/scripts/HelperPayrollSnapshot.lua b/scripts/HelperPayrollSnapshot.lua index 8a74a3f..6e8a2b6 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,33 @@ 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), + billableElapsedHours = 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")), @@ -82,11 +109,21 @@ local function buildActiveJobs(owner) payBasis = payBasis, payRate = payRate, 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, + 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, 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 }) @@ -210,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, @@ -227,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 = { @@ -241,6 +286,8 @@ 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"), + 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), diff --git a/scripts/HelperPayrollWorkClock.lua b/scripts/HelperPayrollWorkClock.lua new file mode 100644 index 0000000..46a1bf3 --- /dev/null +++ b/scripts/HelperPayrollWorkClock.lua @@ -0,0 +1,248 @@ +-- 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. +-- Billable acceleration can be capped independently from the actual game speed. + +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.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 + + 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) + 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() + tracked.expectedGameDeltaMs = 0 + tracked.expectedBillableDeltaMs = 0 +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, billableScale, configuredCap = HelperPayrollWorkClock.getCurrentScales(owner) + + 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 + + local dayDiff = current.monotonicDay - previous.monotonicDay + 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 + + local actualDelta = dayDiff * HelperPayrollWorkClock.DAY_MS + + current.dayTimeMs - previous.dayTimeMs + if actualDelta < 0 then + tracked.clockDiscontinuities = (tonumber(tracked.clockDiscontinuities) or 0) + 1 + tracked.expectedGameDeltaMs = 0 + tracked.expectedBillableDeltaMs = 0 + return 0, "clock-rollback" + end + + local expectedDelta = tonumber(tracked.expectedGameDeltaMs) or 0 + 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 + 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, billableDelta) + else + local beforeMidnight = math.max(0, HelperPayrollWorkClock.DAY_MS - previous.dayTimeMs) + local afterMidnight = math.max(0, current.dayTimeMs) + 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.billableGameElapsedMs = (tonumber(tracked.billableGameElapsedMs) or 0) + billableDelta + tracked.elapsedMs = tracked.billableGameElapsedMs + tracked.expectedGameDeltaMs = 0 + tracked.expectedBillableDeltaMs = 0 + return billableDelta, "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, + 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, + billableTimeScale = tonumber(tracked ~= nil and tracked.lastBillableTimeScale or 0) or 0, + maxPayrollTimeScale = tonumber(tracked ~= nil and tracked.maxPayrollTimeScale or 0) or 0 + } +end diff --git a/scripts/gui/HelperPayrollMenu.lua b/scripts/gui/HelperPayrollMenu.lua index 968f832..404fe36 100644 --- a/scripts/gui/HelperPayrollMenu.lua +++ b/scripts/gui/HelperPayrollMenu.lua @@ -176,6 +176,8 @@ 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.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 @@ -326,6 +328,8 @@ 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="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."}, @@ -495,6 +499,8 @@ 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 == "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 @@ -575,6 +581,8 @@ 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 == "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