From e421dcfc151c9ca205d203a4069a45c806ad8dd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20=C5=BD=C3=A1=C4=8Dek?= Date: Sat, 7 Nov 2026 01:05:43 +0100 Subject: [PATCH] Count occupied restarts as failed missions; sync published Workshop version --- AGENTS.md | 17 +++ CHANGELOG.md | 6 +- .../Scripts/Game/ERR/00_Core/ERR_Telemetry.c | 84 +++++++++++--- docs/discord-changelog-ci.md | 34 ++++-- docs/telemetry.md | 37 +++++-- services/telemetry-worker/README.md | 3 + services/telemetry-worker/test/worker.test.js | 26 +++++ .../ERR_TelemetryCheckpointTest.c | 63 +++++++++++ tests/native/telemetry_restart/README.md | 43 ++++++++ tests/native/telemetry_restart/run.py | 96 ++++++++++++++++ tests/test_telemetry.py | 45 ++++++-- tests/test_workshop_changelog.py | 45 ++++++++ tools/workshop_changelog.py | 87 ++++++++++++++- tools/workshop_query.py | 104 ++++++++++++++++++ 14 files changed, 640 insertions(+), 50 deletions(-) create mode 100644 tests/native/telemetry_restart/ERR_TelemetryCheckpointTest.c create mode 100644 tests/native/telemetry_restart/README.md create mode 100644 tests/native/telemetry_restart/run.py create mode 100644 tools/workshop_query.py diff --git a/AGENTS.md b/AGENTS.md index a1b92a2b..151d4fe4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,23 @@ server, deployment, Git push status, operational disclaimers or footers. Do not backfill old changes unless requested. Internal workflow-only changes need no announcement. +Before every push to `origin/main`, fetch the latest **published** Workshop version +before preparing the commit. The user should not need to supply a version number. +On this workstation use the live in-game catalogue (the website can be cached): + +``` +python3 tools/workshop_changelog.py --sync --native-compat .local/rhs-19-work/compat +``` + +This runs a bounded, silent query in a disposable game profile and updates only +the changelog version preamble. It does not download or publish an addon. Never +terminate an existing editor to acquire the private prefix. On other machines, +`--sync` without `--native-compat` checks the official Workshop page, verifies the +addon identity and published/approved version, and rejects a downgrade. If a +lookup fails or returns stale data, resolve it through a fresh Workshop query; +do not guess, use a local bundle as publication proof, or ask the user to enter +the version manually. Include the synchronized preamble in the requested push. + After a confirmed successful Workbench Workshop upload, run `python3 tools/workshop_changelog.py --published` before committing the changelog. This reads the local publishing manifest and records the version in the changelog diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a05b045..a8495466 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog -Latest published Workshop version: **0.0.146** +Latest published Workshop version: **0.0.148** + +## 2026-11-07 — Interrupted mission results + +- Restarts with players connected now count as failed missions, including recovery after a forced stop. Empty restarts remain excluded. ## 2026-11-07 — Shore boats diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_Telemetry.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_Telemetry.c index 66ab4d7f..0079a461 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_Telemetry.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_Telemetry.c @@ -55,6 +55,9 @@ class ERR_TelemetryState string endpoint; string previousMissionId; string previousOutcome; + // Unsent, prebuilt failure for the last occupied snapshot. Only promoted to + // the queue if another instance starts before this mission records its end. + string restartFailure; int droppedEvents; ref array pending = {}; } @@ -157,6 +160,16 @@ class ERR_Telemetry JsonLoadContext queued = new JsonLoadContext(); string eventId, missionId, serverId; if (queuedEvent.Length() > 65536 || !queued.LoadFromString(queuedEvent) || !queued.ReadValue("eventId", eventId) || !UUID.IsUUID(eventId) || !queued.ReadValue("missionId", missionId) || !UUID.IsUUID(missionId) || !queued.ReadValue("serverId", serverId) || serverId != state.serverId) return false; } + if (!state.restartFailure.IsEmpty()) + { + JsonLoadContext checkpoint = new JsonLoadContext(); + string checkpointId, checkpointMission, checkpointServer, checkpointType, checkpointOutcome; + int checkpointPhase, checkpointPlayers; + if (state.restartFailure.Length() > 65536 || !checkpoint.LoadFromString(state.restartFailure)) return false; + if (!checkpoint.ReadValue("eventId", checkpointId) || !UUID.IsUUID(checkpointId) || !checkpoint.ReadValue("missionId", checkpointMission) || checkpointMission != state.previousMissionId || !UUID.IsUUID(checkpointMission)) return false; + if (!checkpoint.ReadValue("serverId", checkpointServer) || checkpointServer != state.serverId || !checkpoint.ReadValue("type", checkpointType) || checkpointType != "mission_finished") return false; + if (!checkpoint.ReadValue("outcome", checkpointOutcome) || checkpointOutcome != "defeat" || !checkpoint.ReadValue("phase", checkpointPhase) || checkpointPhase != ERR_Phase.DEFEAT || !checkpoint.ReadValue("connectedPlayers", checkpointPlayers) || checkpointPlayers <= 0) return false; + } return true; } protected bool LoadState(string path) @@ -182,6 +195,7 @@ class ERR_Telemetry protected void Purge() { m_State.pending.Clear(); m_State.previousMissionId = ""; m_State.previousOutcome = ""; + m_State.restartFailure = ""; m_Players.Clear(); m_InFlight = ""; m_Callback = null; // Do not retain personal data in recovery files after opting out. FileIO.DeleteFile(STATE); FileIO.DeleteFile(STATE + ".bak"); FileIO.DeleteFile(STATE + ".tmp"); @@ -216,6 +230,7 @@ class ERR_Telemetry int now = System.GetTickCount(); m_LastSample = now; m_NextHeartbeat = now + 300000; m_Partial = m_Collected || now - m_Started > 1000; m_Peak = 0; m_TruncatedPlayers = false; + if (!m_Collected) RecoverRestart(); string previous = m_State.previousMissionId; string outcome = m_State.previousOutcome; m_State.previousMissionId = m_MissionId; m_State.previousOutcome = "unfinished"; @@ -224,6 +239,27 @@ class ERR_Telemetry if (m_Collected) Event("telemetry_resumed"); else Event("mission_started", "", previous, outcome); m_Collected = true; + Checkpoint(); + } + protected void RecoverRestart() + { + if (m_State.previousOutcome == "unfinished" && !m_State.restartFailure.IsEmpty()) + { + Enqueue(m_State.restartFailure, true); + m_State.previousOutcome = "defeat"; + } + // Saved together with the new start and recovered result, before delivery. + m_State.restartFailure = ""; + } + protected void Checkpoint() + { + if (!Enabled() || m_Ended || ERR_MissionRules.Terminal(m_Mode.Phase())) return; + if (GetGame().GetPlayerManager().GetPlayerCount() > 0) + Event("mission_finished", "defeat", "", "", true); + else if (!m_State.restartFailure.IsEmpty()) + { + m_State.restartFailure = ""; m_Dirty = true; Save(); + } } protected void Sample() { @@ -255,7 +291,7 @@ class ERR_Telemetry player.connected = true; ERR_PlayerLife life = m_Mode.Players().Get(connection); if (life && life.Connected && life.UsedLife) player.participated = true; - if (authenticated && m_Mode.Extraction()) player.escaped = m_Mode.Extraction().DidEscape(account); + if (authenticated && m_Mode.Extraction()) player.escaped = m_Mode.Phase() == ERR_Phase.VICTORY && m_Mode.Extraction().DidEscape(account); } // Include disconnected escapees/participants with their final authoritative result. array lives = {}; m_Mode.Players().Participants(lives); @@ -271,10 +307,10 @@ class ERR_Telemetry tracked.kills = stats.Kills; tracked.friendlyKills = stats.FriendlyKills; tracked.deaths = stats.Deaths; tracked.downed = stats.Downed; tracked.recoveries = stats.Recoveries; } - if (m_Mode.Extraction()) tracked.escaped = m_Mode.Extraction().DidEscape(participant.AccountId); + if (m_Mode.Extraction()) tracked.escaped = m_Mode.Phase() == ERR_Phase.VICTORY && m_Mode.Extraction().DidEscape(participant.AccountId); } } - void RosterChanged(int playerId) { Sample(); } + void RosterChanged(int playerId) { Sample(); Checkpoint(); } void PhaseChanged(ERR_Phase previous, ERR_Phase current) { if (!Enabled() || m_Ended) return; @@ -285,8 +321,10 @@ class ERR_Telemetry { Refresh(); if (!Enabled() || m_Ended) return; + if (outcome == "interrupted" && GetGame().GetPlayerManager().GetPlayerCount() > 0) outcome = "defeat"; Sample(); m_Ended = true; m_State.previousOutcome = outcome; + m_State.restartFailure = ""; Event("mission_finished", outcome); } protected string Terrain() @@ -301,7 +339,7 @@ class ERR_Telemetry } return ERR_TerrainDisplayName.Resolve(world); } - protected void Event(string type, string outcome = "", string previous = "", string previousOutcome = "") + protected void Event(string type, string outcome = "", string previous = "", string previousOutcome = "", bool checkpoint = false) { if (!Enabled()) return; if (!m_Ended) Sample(); @@ -314,7 +352,9 @@ class ERR_Telemetry json.WriteValue("partial", m_Partial); int elapsedSeconds = Math.Max(0, (System.GetTickCount() - m_Started) / 1000); json.WriteValue("elapsedSeconds", elapsedSeconds); - json.WriteValue("phase", m_Mode.Phase()); json.WriteValue("outcome", outcome); + int phase = m_Mode.Phase(); + if (outcome == "defeat") phase = ERR_Phase.DEFEAT; + json.WriteValue("phase", phase); json.WriteValue("outcome", outcome); json.WriteValue("previousMissionId", previous); json.WriteValue("previousOutcome", previousOutcome); string terrain = Terrain(); json.WriteValue("terrain", terrain); @@ -325,12 +365,31 @@ class ERR_Telemetry json.WriteValue("modsTruncated", m_TruncatedMods); json.WriteValue("playersTruncated", m_TruncatedPlayers); json.WriteValue("connectedPlayers", GetGame().GetPlayerManager().GetPlayerCount()); json.WriteValue("peakPlayers", m_Peak); json.WriteValue("players", m_Players); - json.WriteValue("reason", m_Mode.EndReason()); json.WriteValue("preparationError", m_Mode.PreparationError()); + string reason = m_Mode.EndReason(); + if (outcome == "defeat" && !ERR_MissionRules.Terminal(m_Mode.Phase())) reason = "Mission interrupted by a restart while players were connected."; + json.WriteValue("reason", reason); json.WriteValue("preparationError", m_Mode.PreparationError()); map settings = new map(); foreach (string key : ERR_Settings.Keys()) settings.Set(key, m_Mode.Settings().Get(key)); json.WriteValue("settings", settings); json.WriteValue("droppedEvents", m_State.droppedEvents); string payload = json.SaveToString(); if (payload.Length() > 65536) { m_State.droppedEvents++; m_Dirty = true; return; } + // This candidate is private profile state, never sent during a live mission. + if (checkpoint) { m_State.restartFailure = payload; m_Dirty = true; Save(); return; } + Enqueue(payload, type == "mission_finished"); + if (type == "mission_finished") + { + m_FinalEventId = eventId; m_FinalOutcome = outcome; m_FinalTerrain = terrain; + // Finish can arrive while a previous request is in flight. RemoveItem on its + // acknowledgement still removes that exact payload, leaving this final first. + if (outcome == "victory" || outcome == "defeat") + m_Mode.ERR_PublishCommunityStatistics("This map's community record: waiting for the round result to be recorded..."); + GetGame().GetCallqueue().CallLater(Pulse, 1, false); + } + m_Dirty = true; + Save(); + } + protected void Enqueue(string payload, bool finalResult) + { // Bound disk and memory during long outages. Prefer retaining result records. if (m_State.pending.Count() >= 64) { @@ -345,19 +404,9 @@ class ERR_Telemetry if (remove == 0 && !m_InFlight.IsEmpty()) remove = 1; m_State.pending.Remove(remove); m_State.droppedEvents++; } - if (type == "mission_finished") - { - m_FinalEventId = eventId; m_FinalOutcome = outcome; m_FinalTerrain = terrain; - // Finish can arrive while a previous request is in flight. RemoveItem on its - // acknowledgement still removes that exact payload, leaving this final first. - m_State.pending.InsertAt(payload, 0); - if (outcome == "victory" || outcome == "defeat") - m_Mode.ERR_PublishCommunityStatistics("This map's community record: waiting for the round result to be recorded..."); - GetGame().GetCallqueue().CallLater(Pulse, 1, false); - } + if (finalResult) m_State.pending.InsertAt(payload, 0); else m_State.pending.Insert(payload); m_Dirty = true; - Save(); } protected void Pulse() { @@ -368,6 +417,7 @@ class ERR_Telemetry if (!m_Ended) { Sample(); + Checkpoint(); if (now >= m_NextHeartbeat) { m_NextHeartbeat = now + 300000; Event("heartbeat"); } } if (m_Dirty && !Save()) { CommunityUnavailable(); return; } diff --git a/docs/discord-changelog-ci.md b/docs/discord-changelog-ci.md index 2335b6a4..ff647425 100644 --- a/docs/discord-changelog-ci.md +++ b/docs/discord-changelog-ci.md @@ -44,23 +44,33 @@ headings inside code examples within an entry. Keep the formatted newest entry within Discord's 2000-character limit; oversized entries fail without truncation. The dated files in `publishing/` remain separate Workshop release notes. -After compiling/bundling and successfully publishing in Workbench, run: +Before preparing every push, synchronize the last published version automatically: ```sh -python3 tools/workshop_changelog.py --published +python3 tools/workshop_changelog.py --sync --native-compat .local/rhs-19-work/compat ``` -The helper reads `asset.version` from Workbench's local publishing manifest and -updates `Latest published Workshop version: **0.0.109**` above the changelog -entries. It verifies the addon ID against `addons/core/addon.gproj`. Use -`--manifest /path/to/manifest.json` on another installation. Without `--published` -it only displays the bundle version. The flag confirms that you observed a -successful upload; the helper does not upload or query the Workshop. +This workstation uses the game's live public Workshop catalogue because the web +listing can lag behind publication. The helper uses the existing disposable +Proton prefix under `.local`, takes its test-run lock, opens a temporary muted +headless game profile, queries only this addon, and closes its own processes. +It neither downloads addons nor uploads or changes Workshop content. The query +is bounded to 60 seconds and refuses a busy prefix. -Commit that record together with the new player-facing entry and source changes, -then push. Drone reads the version from Git, without needing Workbench files. -Updating only the version does not resend the previous entry. The message labels -it as the last published version, since a Git push can contain newer changes. +On a machine without that native setup, `--sync` alone reads the official +Workshop page. It checks the addon ID and the current version's published and +approved flags. Neither route overwrites a newer recorded publication with an +older response. Lookup/validation failure leaves the changelog unchanged and +returns failure; obtain fresh publication evidence before pushing. + +`--published` remains available immediately after an observed successful +Workbench upload. It records the matching local publishing manifest's version; +without either flag the helper only displays the bundled version. Bundling alone +is never publication confirmation. + +Commit the synchronized preamble with the requested changes. Updating only that +version does not resend the previous entry. Drone labels it as the last published +version because a Git push can contain changes newer than the Workshop upload. The notifier compares `DRONE_COMMIT_BEFORE` with `DRONE_COMMIT_SHA`, covering the whole push, even when a documentation commit follows the changelog commit. diff --git a/docs/telemetry.md b/docs/telemetry.md index 428c5e85..72af1efb 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -124,15 +124,15 @@ existing queued payloads retain their original values and event IDs. | `phase_changed` | An actual nonterminal mission transition. Use elapsed times for milestone durations. | | `heartbeat` | A snapshot every five minutes while unfinished. | | `telemetry_resumed` | Re-enabled during the same mission; the mission ID stays the same and `partial` is true. | -| `mission_finished` | Exactly one local result snapshot, before normal teardown. | +| `mission_finished` | One local result snapshot, at teardown or recovered on the next startup. | | Outcome | Count as a gameplay result? | | --- | --- | | `victory` | Yes, successful mission. | -| `defeat` | Yes, failed mission, including administrator abandonment. | +| `defeat` | Yes, failed mission, including administrator abandonment and a restart with players connected. | | `preparation_failed` | No, startup error; inspect `preparationError`. | | `restarted_empty` | No, automatic last-disconnect or idle-empty restart. | -| `interrupted` | No, session ended without a gameplay result. | +| `interrupted` | No, an empty session ended without a gameplay result (also used by older clients for occupied interruptions). | | `unfinished` in `previousOutcome` | No confirmed ending survived. May be a crash/forced stop. Do not infer a defeat. | Player names and stable game account UUIDs support history across servers. No IP @@ -161,8 +161,8 @@ summary containing matching map/server/mission/event/outcome identifiers plus `server` and `allServers` records. Each record has completed rounds, wins, failures, win rate and partial-round coverage. The mission validates these identifiers and counts before displaying the current map's record on its result -screen. Retries never increase the totals twice. Startup failures, interrupted -sessions and empty restarts are excluded from win/failure totals. If a summary +screen. Retries never increase the totals twice. Startup failures, events marked `interrupted`, and empty restarts are excluded +from win/failure totals. Occupied restarts now produce explicit `defeat` events. If a summary cannot be delivered before the next mission starts, the screen reports it as unavailable and keeps local statistics visible. Once a confirmed record is displayed, failures uploading older queued events @@ -209,8 +209,10 @@ retry without edits; retries still use the original event IDs and count once. Requests have a 15-second timeout, with a 30-second callback watchdog. Retries back off from 10 seconds to five minutes. A five-second dispatch tick sends one -request at a time. Disk writes occur on events and acknowledgement, not every -player sample. Delivery never blocks mission startup or shutdown. +request at a time. Disk writes occur on events and acknowledgement. While occupied, a private +restart checkpoint is also saved every five seconds and on roster changes; it +is not sent as a result while the mission is running. An empty snapshot clears +that candidate, so earlier attendance alone does not turn a restart into a loss. Delivery never blocks mission startup or shutdown. The profile queue holds at most 64 events of 64 KiB each. On overflow it discards queued heartbeats first, then the oldest non-in-flight event; `droppedEvents` @@ -220,9 +222,15 @@ recovery. An unreadable queue suspends telemetry and preserves evidence for repair instead of silently replacing the server identity. Unsent final results survive normal scenario reloads and are retried in the next -enabled instance. Abrupt termination can lose the latest event; the next start's -`previousOutcome` remains unfinished if no result was saved. A permanently -stopped host cannot send further data. +enabled instance. After abrupt termination, the next enabled instance promotes the last saved +occupied checkpoint to a `defeat` for the original mission, with its original +players and statistics. The checkpoint and queue transition are saved together; +retries retain the recovered event ID. Its timestamp and duration reflect the +last sample, not the next startup time. Connection changes in the final five +seconds may be missed if their roster callback or profile write did not complete. +Completed results are never replaced. Empty or legacy states without an occupied +checkpoint remain unknown rather than inventing a result. A permanently stopped +host cannot send further data. Disabling immediately stops collection/new requests and discards queued events and recovery copies. It cannot recall an HTTP request already submitted or erase @@ -232,6 +240,15 @@ discards unsent data instead of forwarding old records to a different recipient. Native API references: [RestContext](https://community.bistudio.com/wikidata/external-data/arma-reforger/EnfusionScriptAPIPublic/interfaceRestContext.html) and [RestCallback](https://community.bistudio.com/wikidata/external-data/arma-reforger/EnfusionScriptAPIPublic/interfaceRestCallback.html). +### Restart verification + +The [native restart fixture](../tests/native/telemetry_restart/README.md) runs +two isolated Workbench processes with a real connected local player. It checks +native shutdown results, JSON/profile persistence, unfinished-state recovery, +completed-state deduplication and receiver validation of the generated events. +Telemetry network dispatch is replaced in the test copy only. This does not +certify a multiplayer AMP deployment or optional asset integrity. + ### Round combat counters Player records additionally include `kills`, `friendlyKills`, `deaths`, `downed` diff --git a/services/telemetry-worker/README.md b/services/telemetry-worker/README.md index c8d3158d..216b386c 100644 --- a/services/telemetry-worker/README.md +++ b/services/telemetry-worker/README.md @@ -287,6 +287,9 @@ snapshots only. Spectators and temporary connection IDs are excluded. A mission victory does not imply a personal escape. Startup failures, empty restarts and interrupted sessions do not count as gameplay wins/losses or player attempts. An interrupted mission can still have raw combat data in its detail snapshot. +New clients report occupied restarts as `defeat`, including recovery from the +last durable occupied checkpoint after a forced stop. Those explicit final +events count as failures and participant attempts; restart links alone do not. Legacy missing counters remain null, with `missionsWithKills`, `missionsWithFriendlyKills`, `missionsWithDeaths`, `missionsWithDowned` and diff --git a/services/telemetry-worker/test/worker.test.js b/services/telemetry-worker/test/worker.test.js index a62d4d45..2e1d48c6 100644 --- a/services/telemetry-worker/test/worker.test.js +++ b/services/telemetry-worker/test/worker.test.js @@ -110,6 +110,32 @@ test('published fractional-duration defeats are accepted, counted and retry idem assert.equal((await ingest(event({connectedPlayers: 0.5}))).status, 400); }); +test('occupied restart recovery counts one failure and retains the old mission participants', async () => { + const terrain = 'Restart recovery fixture'; + const player = {...event().players[0], escaped: false}; + const recovered = event({terrain, phase: 6, outcome: 'defeat', players: [player], + reason: 'Mission interrupted by a restart while players were connected.'}); + const snapshot = {...recovered, eventId: crypto.randomUUID(), type: 'heartbeat', phase: 3, outcome: ''}; + assert.equal((await ingest(snapshot)).status, 201); + const first = await ingest(recovered); + assert.equal(first.status, 201, await first.clone().text()); + assert.equal((await ingest(recovered)).status, 200); + // A delayed pre-restart snapshot must not reopen the completed mission. + assert.equal((await ingest({...snapshot, eventId: crypto.randomUUID(), elapsedSeconds: 150})).status, 201); + const next = event({terrain, type: 'mission_restarted', outcome: '', phase: 0, + previousMissionId: recovered.missionId, previousOutcome: 'defeat', players: [], connectedPlayers: 0}); + assert.equal((await ingest(next)).status, 201); + const summary = (await (await ingest(recovered)).json()).community; + assert.equal(summary.allServers.losses, 1); + assert.equal(summary.allServers.completed, 1); + const old = await detail(recovered); + assert.equal(old.outcome, 'defeat'); + assert.equal(old.players[0].id, player.id); + assert.equal(old.players[0].participated, true); + assert.equal(old.players[0].escaped, false); + assert.equal((await detail(next)).outcome, ''); +}); + test('out-of-order snapshots and second terminal reports cannot reopen or double count a mission', async () => { const e = event({players: []}); const heartbeat = {...e, type: 'heartbeat', phase: 4, outcome: '', elapsedSeconds: 100}; diff --git a/tests/native/telemetry_restart/ERR_TelemetryCheckpointTest.c b/tests/native/telemetry_restart/ERR_TelemetryCheckpointTest.c new file mode 100644 index 00000000..bc0cc578 --- /dev/null +++ b/tests/native/telemetry_restart/ERR_TelemetryCheckpointTest.c @@ -0,0 +1,63 @@ +#ifdef WORKBENCH +// Disposable native fixture only. Keep native serialization, profile storage, +// PlayerManager occupancy and production constructor/Finish; replace transport. +modded class ERR_Telemetry +{ + protected override void Pulse() {} + bool ERR_TestCheck(bool valid, string detail) + { + if (valid) Print("ERR SMOKE PASS: telemetry " + detail); + else Print("ERR SMOKE FAIL: telemetry " + detail, LogLevel.ERROR); + return valid; + } + int ERR_TestFinalCount(string mission) + { + int count; + foreach (string payload : m_State.pending) + { + JsonLoadContext json = new JsonLoadContext(); string id, type, outcome; int phase; + if (!json.LoadFromString(payload)) return -1; + json.ReadValue("missionId", id); json.ReadValue("type", type); + if (id != mission || type != "mission_finished") continue; + json.ReadValue("outcome", outcome); json.ReadValue("phase", phase); + if (outcome != "defeat" || phase != ERR_Phase.DEFEAT) return -1; + count++; + } + return count; + } + void ERR_TestFreeze() + { + m_Closed = true; + GetGame().GetCallqueue().Remove(Pulse); + } + bool ERR_TestOccupiedRecovery() + { + int connected = GetGame().GetPlayerManager().GetPlayerCount(); + PrintFormat("ERR TELEMETRY NATIVE: connected=%1 mission=%2", connected, m_MissionId); + if (!ERR_TestCheck(Enabled() && connected > 0, "uses actual connected native player")) return false; + Checkpoint(); + ERR_TelemetryState disk; + if (!ERR_TestCheck(ReadState(STATE, disk) && !disk.restartFailure.IsEmpty() && disk.previousOutcome == "unfinished", "occupied checkpoint round-trips through native JSON and profile storage")) return false; + if (!ERR_TestCheck(ERR_TestFinalCount(m_MissionId) == 0, "live checkpoint is not queued as a failure")) return false; + // Constructor recovery from persisted unfinished state, as after a forced stop. + ref ERR_Telemetry recovered = new ERR_Telemetry(m_Mode); + bool recoveredOK = recovered.ERR_TestFinalCount(m_MissionId) == 1; + recovered.ERR_TestFreeze(); + if (!ERR_TestCheck(recoveredOK, "fresh service recovers exactly one defeat for the old mission")) return false; + // Restore the live service's state for the real EndGameMode test below. + if (!ERR_TestCheck(Save(), "restore original live checkpoint for native shutdown")) return false; + return true; + } + bool ERR_TestFinished() + { + ERR_TelemetryState disk; + if (!ERR_TestCheck(m_Ended && m_State.previousOutcome == "defeat" && m_State.restartFailure.IsEmpty() && ERR_TestFinalCount(m_MissionId) == 1, "actual native end records exactly one occupied failure")) return false; + if (!ERR_TestCheck(ReadState(STATE, disk) && disk.previousOutcome == "defeat" && disk.restartFailure.IsEmpty(), "completed result persisted and candidate cleared")) return false; + ref ERR_Telemetry recovered = new ERR_Telemetry(m_Mode); + bool recoveredOK = recovered.ERR_TestFinalCount(m_MissionId) == 1; + recovered.ERR_TestFreeze(); + if (!ERR_TestCheck(recoveredOK, "reopening completed state does not duplicate the failure")) return false; + return Save(); + } +} +#endif diff --git a/tests/native/telemetry_restart/README.md b/tests/native/telemetry_restart/README.md new file mode 100644 index 00000000..5801c62d --- /dev/null +++ b/tests/native/telemetry_restart/README.md @@ -0,0 +1,43 @@ +# Native telemetry restart test + +Run from the repository root with a disposable existing Proton test prefix and +the installed authorized dependency tree: + +```sh +python3 tests/native/telemetry_restart/run.py \ + --work .local/restart-failure/verified \ + --compat .local/rhs-19-work/compat \ + --external-addons '/home/jakub/.local/share/Steam/steamapps/compatdata/1874880/pfx/drive_c/users/steamuser/Documents/My Games/ArmaReforger/addons' +``` + +The work directory must be new. The runner copies the current addon and adds the +fixture only to that copy. It reuses the native preparation-stage shutdown probe +so extraction placement is not a prerequisite. It then explicitly stops and +relaunches its owned Workbench process with the same +private profile. Workbench hosting does not automatically reload this fixture. +Two rounds use the actual native PlayerManager connection count and EndGameMode +callback. Production reload behavior is not changed. + +The fixture checks native JSON/profile round-trips, an occupied checkpoint not +being submitted while the round is live, constructor recovery from unfinished +state, a single persisted defeat on shutdown, and completed-state reopening +without another defeat. Forced-stop recovery is simulated by constructing a new +service from the saved unfinished checkpoint; this is not an AMP process-kill +or multiplayer dedicated-server test. Empty occupancy and completion variants +also have production-method tests in `tests/test_telemetry.py`. + +The copied test class replaces only the telemetry dispatch tick, preventing +network submissions. A reserved `.invalid` endpoint is an additional safeguard. +The fixture restores the original service's state after each recovery probe. +No test code or test results are included in the published addon. + +Native evidence is retained under the work directory's `profile/logs`, including +source hashes and cleanup verification. Queued payloads are in +`profile/profile/EscapistsTelemetryState.json` and can be checked with the +receiver's `validateEvent` function. + +The general native report also grades optional asset and backend errors. This +fixture retains that complete report as `telemetry-check.json`, while its result +requires completed telemetry assertions, no script/runtime failures, unchanged +inputs, confirmed process cleanup and valid queued results. It does not certify +Rally/RHS/WCS assets, DLC ownership or the rest of the mission. diff --git a/tests/native/telemetry_restart/run.py b/tests/native/telemetry_restart/run.py new file mode 100644 index 00000000..0ea8088e --- /dev/null +++ b/tests/native/telemetry_restart/run.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Run isolated native occupancy, JSON persistence, recovery and shutdown checks.""" +import argparse +import json +from pathlib import Path +import shutil +import subprocess +import sys + +ROOT = Path(__file__).resolve().parents[3] +p = argparse.ArgumentParser(description=__doc__) +p.add_argument('--work', type=Path, required=True, help='New disposable directory; must not exist') +p.add_argument('--compat', type=Path, required=True, help='Existing private test Proton prefix') +p.add_argument('--external-addons', type=Path, required=True) +a = p.parse_args() +work = a.work.resolve() +if work.exists(): + p.error('--work must not exist; preserve evidence from earlier runs') +work.mkdir(parents=True) +source, profile = work/'source', work/'profile' +for name in ['addons', 'tools']: + shutil.copytree(ROOT/name, source/name) +(source/'docs').mkdir() +shutil.copy2(ROOT/'docs/external-addons.json', source/'docs/external-addons.json') + +def replace(path, before, after): + text = path.read_text() + if text.count(before) != 1: + raise ValueError(f'Fixture insertion point changed in {path.name}') + path.write_text(text.replace(before, after)) + +scripts = source/'addons/core/Scripts/Game/ERR/00_Core' +shutil.copy2(Path(__file__).with_name('ERR_TelemetryCheckpointTest.c'), scripts/'ERR_TelemetryCheckpointTest.c') +# Modded classes can only access fields/methods of earlier layers. Test accessors +# follow telemetry; the existing native-end fixture is moved after that layer. +replace(scripts/'ERR_TelemetryLifecycle.c', ' void ERR_RefreshTelemetry()', + ' bool ERR_TestTelemetryBefore() { return m_ERRTelemetry && m_ERRTelemetry.ERR_TestOccupiedRecovery(); }\n' + ' bool ERR_TestTelemetryAfter() { return m_ERRTelemetry && m_ERRTelemetry.ERR_TestFinished(); }\n' + ' void ERR_RefreshTelemetry()') +probe = scripts/'ERR_NativeEndProbe.c' +replace(probe, 'm_ERREndData = SCR_GameModeEndData.CreateSimple', + 'if (!ERR_EndCheck(ERR_TestTelemetryBefore(), "occupied telemetry snapshot and recovery")) return;\n' + ' m_ERREndData = SCR_GameModeEndData.CreateSimple') +replace(probe, 'EndGameMode(m_ERREndData);', + 'EndGameMode(m_ERREndData);\n' + ' if (!ERR_EndCheck(ERR_TestTelemetryAfter(), "native shutdown records one defeat")) return;') +probe.rename(scripts/'ERR_ZTelemetryNativeEndProbe.c') +runner = source/'tools/headless_workbench.py' +replace(runner, 'if a.roadblock_probe_count is not None: command', + "command += ['-errTelemetryTest', '1']\nif a.roadblock_probe_count is not None: command") +# Use two explicit process launches: Workbench need not auto-reload a hosted game. +replace(runner, "a.project != 'core' or a.rounds != 2 or a.timeout < 120", "a.project != 'core' or a.rounds != 1 or a.timeout < 120") +replace(runner, "if any((profile/name).exists() or (profile/name).is_symlink() for name in fixture_names): p.error('native-end fixture paths must not preexist; preserve prior evidence before retrying')", + '# This isolated fixture intentionally preserves the first instance marker.') +(profile/'profile').mkdir(parents=True) +(profile/'profile/EscapistsTelemetry.json').write_text( + '{"endpoint":"https://telemetry.invalid/api/mission-events",' + '"token":"local-test-only","serverName":"Isolated restart test"}\n') +command = [sys.executable, str(runner), '--compat', str(a.compat.resolve()), + '--profile', str(profile), '--external-addons', str(a.external_addons.resolve()), + '--smoke', 'native-end-preparing', '--project', 'core', '--rounds', '1', '--timeout', '120'] +sys.path.insert(0, str(ROOT/'tools')) +from native_report import analyze +for _ in range(2): + old_logs = set((profile/'logs').glob('logs_*')) + result = subprocess.run(command, check=False) + new_logs = set((profile/'logs').glob('logs_*')) - old_logs + assert len(new_logs) == 1, 'Expected exactly one fresh native session' + session = new_logs.pop() + evidence = json.loads((session/'runner.json').read_text()) + report = analyze((session/'console.log').read_text(errors='replace'), session/'console.log') + assert result.returncode in (0, 1) + assert evidence['result'] == 'smoke-completed' and evidence['cleanup_confirmed'] + assert evidence['source_unchanged_after_run'] and evidence['external_addons_unchanged_after_run'] + assert report['smoke_completions'] == 1 and not report['script_or_runtime_failures'] + assert not report['missing_end_screen'] + # The generic runner also grades all optional asset/backend errors. Preserve + # its complete report; this fixture grades telemetry, not DLC/asset integrity. + report['telemetry_assertions_passed'] = True + (session/'telemetry-check.json').write_text(json.dumps(report, indent=2)+'\n') + if report['engine_errors']: + print(f"Telemetry checks passed; {len(report['engine_errors'])} asset/backend errors retained in {session/'telemetry-check.json'}", flush=True) +state_path = profile/'profile/EscapistsTelemetryState.json' +events = [json.loads(payload) for payload in json.loads(state_path.read_text())['state']['pending']] +finished = [event for event in events if event['type'] == 'mission_finished'] +assert len(finished) == 2 and len({event['missionId'] for event in finished}) == 2 +assert all(event['outcome'] == 'defeat' and event['phase'] == 6 and event['connectedPlayers'] > 0 for event in finished) +restarts = [event for event in events if event['type'] == 'mission_restarted'] +assert len(restarts) == 1 and restarts[0]['previousOutcome'] == 'defeat' +assert restarts[0]['previousMissionId'] in {event['missionId'] for event in finished} +subprocess.run(['node', '--input-type=module', '-e', + "import {readFileSync} from 'node:fs'; import {validateEvent} from " + + json.dumps((ROOT/'services/telemetry-worker/src/validation.js').as_uri()) + ";" + + "const {state}=JSON.parse(readFileSync(process.argv[1],'utf8'));" + + "for(const payload of state.pending) validateEvent(JSON.parse(payload));", str(state_path)], check=True) +print('PASS: two native process instances, real occupied shutdowns, durable recovery, no duplicate results and receiver-valid native payloads') diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 65c708e3..261a5490 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -3,10 +3,10 @@ from policy_harness import ROOT, body, run p=ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_Telemetry.c' s=p.read_text() def cpp(v): - for a,b in [('protected ',''),('m_Config.', 'config.'),('Replication.IsServer()', 'authority'),('m_Mode.Settings()', 'settings'),('FileIO.DeleteFile(', 'DeleteFile('),('ERR_Phase.','Phase::'),('ERR_TelemetryRules.','Rules::'),('ERR_MissionRules.','MissionRules::'),('Math.Min(', 'std::min('),('Math.Pow(', 'power('),('System.GetTickCount()', '::now'),('LogLevel.WARNING','0'),('m_State.', 'm_State->'),('m_Callback = null', 'm_Callback = nullptr'),('m_Context = null', 'm_Context = nullptr'),('m_Mode = null', 'm_Mode = nullptr'),('RestCallback callback', 'RestCallback* callback'),('callback.GetHttpCode()', 'callback->GetHttpCode()'),('GetGame().GetCallqueue().Remove(Pulse)', 'removed++')]: v=v.replace(a,b) + for a,b in [('protected ',''),('JsonLoadContext queued = new JsonLoadContext();', 'JsonLoadContext queued;'),('GetGame().GetPlayerManager().GetPlayerCount()', 'connections'),('m_Mode.Phase()', 'phase'),('m_Config.', 'config.'),('Replication.IsServer()', 'authority'),('m_Mode.Settings()', 'settings'),('FileIO.DeleteFile(', 'DeleteFile('),('ERR_Phase.','Phase::'),('ERR_TelemetryRules.','Rules::'),('ERR_MissionRules.','MissionRules::'),('Math.Min(', 'std::min('),('Math.Pow(', 'power('),('System.GetTickCount()', '::now'),('LogLevel.WARNING','0'),('m_State.', 'm_State->'),('m_Callback = null', 'm_Callback = nullptr'),('m_Context = null', 'm_Context = nullptr'),('m_Mode = null', 'm_Mode = nullptr'),('RestCallback callback', 'RestCallback* callback'),('callback.GetHttpCode()', 'callback->GetHttpCode()'),('GetGame().GetCallqueue().Remove(Pulse)', 'removed++')]: v=v.replace(a,b) return v rules='\n'.join(cpp(body(p,x)) for x in ['static bool Acknowledged(', 'static int RetrySeconds(', 'static string Outcome(']) -methods='\n'.join(cpp(body(p,x)) for x in ['bool Enabled(', 'void Refresh(', 'protected void Purge(', 'void Finish(', 'void PhaseChanged(', 'protected void Delivered(', 'protected void Failed(', 'void Close(']) +methods='\n'.join(cpp(body(p,x)) for x in ['bool Enabled(', 'void Refresh(', 'protected void Purge(', 'void Finish(', 'protected void RecoverRestart(', 'protected void Enqueue(', 'protected void Checkpoint(', 'void RosterChanged(', 'void PhaseChanged(', 'protected void Delivered(', 'protected void Failed(', 'void Close(']) methods=methods.replace('GetGame().GetCallqueue().CallLater(Pulse, 1, false)', 'pulses++') run(r''' #include @@ -21,15 +21,16 @@ namespace MissionRules {bool Terminal(int p){return p==Phase::VICTORY||p==Phase: struct Rules { '''+rules+r''' }; -struct Pending:std::vector{using std::vector::operator=;void Clear(){clear();}bool IsEmpty(){return empty();}void RemoveItem(string x){erase(std::remove(begin(),end(),x),end());}}; -struct State{string previousOutcome,previousMissionId;Pending pending;}; +struct Pending:std::vector{using std::vector::operator=;int Count(){return size();}void Insert(string x){push_back(x);}void InsertAt(string x,int at){insert(begin()+at,x);}void Remove(int at){erase(begin()+at);}void Clear(){clear();}bool IsEmpty(){return empty();}void RemoveItem(string x){erase(std::remove(begin(),end(),x),end());}}; +struct JsonLoadContext{string value;bool LoadFromString(string x){value=x;return true;}bool ReadValue(string key,string& type){type=value;return key=="type";}}; +struct State{int droppedEvents=0;string previousOutcome,previousMissionId,restartFailure;Pending pending;}; struct RestCallback{int code=200;int GetHttpCode(){return code;}}; struct Service { struct Config{string Endpoint="https://fixture.invalid/";}config; struct Settings{int enabled=1;int Get(string){return enabled;}explicit operator bool()const{return true;}}settings; Pending m_Players;bool authority=true,m_Collected=false,m_Partial=false,m_TruncatedPlayers=false,current=true; string m_MissionId="same-round";string STATE="queue"; - int m_Started=now,m_LastSample=0,m_NextHeartbeat=0,m_Peak=0,deletes=0; + int connections=0,phase=Phase::PRISON;int m_Started=now,m_LastSample=0,m_NextHeartbeat=0,m_Peak=0,deletes=0; void DeleteFile(std::string){deletes++;} bool m_Enabled=true,m_Closed=false,m_Ended=false,m_Dirty=false,desired=true,saveOK=true; State state;State* m_State=&state;RestCallback* m_Callback=nullptr;void* m_Context=nullptr;void* m_Mode=nullptr; @@ -38,7 +39,10 @@ struct Service { int pulses=0;void UpdateCommunity(RestCallback*){}void CommunityUnavailable(){} bool Current(){return current;} void Sample(){samples++;} - void Event(string t,string o="",string p="",string prior=""){type=t;result=o;events++;} + void Event(string t,string o="",string p="",string prior="",bool checkpoint=false){ + if(checkpoint){state.restartFailure="snapshot";Save();return;}type=t;result=o;events++; + } + bool Save(){saves++;if(saveOK)m_Dirty=false;return saveOK;} '''+methods+r''' }; @@ -59,6 +63,33 @@ int main(){ Service loss;loss.Finish("preparation_failed");loss.Close();loss.Close(); assert(loss.events==1&&loss.result=="preparation_failed"&&loss.removed==1); Service abandoned;abandoned.Close();assert(abandoned.result=="interrupted"); + Service occupied;occupied.connections=2;occupied.Close(); + assert(occupied.events==1&&occupied.result=="defeat"&&occupied.state.previousOutcome=="defeat"); + occupied.Close();assert(occupied.events==1); + Service emptyRestart;emptyRestart.Finish("restarted_empty");emptyRestart.Close();assert(emptyRestart.result=="restarted_empty"); + Service checkpoint;checkpoint.connections=1;checkpoint.RosterChanged(1); + assert(checkpoint.state.restartFailure=="snapshot"&&checkpoint.events==0&&checkpoint.saves==1); + checkpoint.connections=0;checkpoint.RosterChanged(1); + assert(checkpoint.state.restartFailure.IsEmpty()&&checkpoint.events==0&&checkpoint.saves==2); + checkpoint.connections=1;checkpoint.Checkpoint();checkpoint.Finish("victory");checkpoint.Checkpoint(); + assert(checkpoint.state.restartFailure.IsEmpty()&&checkpoint.result=="victory"); + Service crash;crash.m_Enabled=false;crash.state.previousMissionId="crashed-round"; + crash.state.previousOutcome="unfinished";crash.state.restartFailure="persisted-event-with-stable-id"; + crash.Refresh();assert(crash.state.pending.size()==1&&crash.state.pending[0]=="persisted-event-with-stable-id"); + assert(crash.state.restartFailure.IsEmpty()&&crash.state.previousMissionId=="same-round"); + crash.RecoverRestart();assert(crash.state.pending.size()==1); + Service full;full.m_InFlight="in-flight";full.state.pending.push_back("in-flight"); + for(int i=1;i<64;i++)full.state.pending.push_back(i==10?"heartbeat":"old"); + full.state.previousOutcome="unfinished";full.state.restartFailure="recovered";full.RecoverRestart(); + assert(full.state.pending.size()==64&&full.state.pending[0]=="recovered"&&full.state.pending[1]=="in-flight"); + assert(std::count(full.state.pending.begin(),full.state.pending.end(),"heartbeat")==0&&full.state.droppedEvents==1); + Service legacy;legacy.state.previousOutcome="unfinished";legacy.RecoverRestart();assert(legacy.state.pending.empty()); + for(string result:{"victory","defeat","preparation_failed","restarted_empty","interrupted"}){ + Service completed;completed.state.previousOutcome=result;completed.state.restartFailure="stale";completed.RecoverRestart(); + assert(completed.state.pending.empty()&&completed.state.previousOutcome==result&&completed.state.restartFailure.IsEmpty()); + } + Service optout;optout.state.restartFailure="private-data";optout.state.previousMissionId="old"; + optout.settings.enabled=0;optout.Refresh();assert(optout.state.restartFailure.IsEmpty()&&optout.state.pending.empty()); Service disabled;disabled.settings.enabled=0;disabled.Finish("victory");assert(disabled.events==0); RestCallback old,current;Service delivery; delivery.state.pending={"first","second"};delivery.m_InFlight="first";delivery.m_Callback=¤t; @@ -94,4 +125,4 @@ assert 'ReadValue("token"' in s and 'WriteValue("token", m_Config.Token)' in s event=body(p,'protected void Event(') assert 'Token' not in event and 'Endpoint' not in event assert 'DidEscape' in body(p,'protected void Sample(') -print('PASS: telemetry outcomes, exactly-once finish, opt-out gate, stale callbacks, HTTP status handling and bounded retry') +print('PASS: telemetry outcomes, occupied/empty restarts, durable recovery, bounded queue, opt-out, exactly-once finish and HTTP retries') diff --git a/tests/test_workshop_changelog.py b/tests/test_workshop_changelog.py index 06523ed5..c11f68be 100644 --- a/tests/test_workshop_changelog.py +++ b/tests/test_workshop_changelog.py @@ -2,6 +2,9 @@ import importlib.util from pathlib import Path import unittest +import json +import io +from unittest.mock import patch ROOT = Path(__file__).resolve().parents[1] spec = importlib.util.spec_from_file_location('workshop_changelog', ROOT / 'tools/workshop_changelog.py') @@ -40,5 +43,47 @@ class WorkshopTests(unittest.TestCase): tool.update_changelog(text, '0.0.109') +class PublishedLookupTests(unittest.TestCase): + def page(self, **overrides): + asset = dict(id='2528466DE220D91C', currentVersionNumber='0.0.147', + gameVersion='1.9.0.37', versions=[dict(version='0.0.147', + assetId='2528466DE220D91C', published=True, approved=True)]) + asset.update(overrides) + return '' + + def test_public_version_is_not_game_or_bundle_version(self): + self.assertEqual(tool.online_version(self.page(), '2528466DE220D91C'), '0.0.147') + + def test_wrong_asset_unpublished_and_ambiguous_pages_are_rejected(self): + cases = [self.page(id='1111111111111111'), self.page(versions=[]), + self.page(private=True), self.page(blocked=True), + self.page(currentVersionNumber='1.9.0.37'), + self.page(versions=[dict(version='0.0.147', assetId='2528466DE220D91C', published=False, approved=True)]), + self.page(versions=[dict(version='0.0.147', assetId='2528466DE220D91C', published=True, approved=False)]), + self.page() + self.page(), 'unavailable'] + for page in cases: + with self.subTest(page=page), self.assertRaises(ValueError): + tool.online_version(page, '2528466DE220D91C') + + def test_stale_response_is_not_a_downgrade(self): + text = tool.update_changelog(TEXT, '0.0.146') + with self.assertRaises(ValueError): + tool.prevent_downgrade(text, '0.0.142') + tool.prevent_downgrade(text, '0.0.146') + tool.prevent_downgrade(text, '0.0.147') + tool.prevent_downgrade(tool.update_changelog(TEXT, '0.0.9'), '0.0.10') + + def test_live_sync_validates_response_and_fails_closed(self): + with patch.object(tool.urllib.request, 'urlopen', return_value=io.BytesIO(self.page().encode())): + self.assertEqual(tool.sync_version(PROJECT, TEXT), '0.0.147') + with patch.object(tool.urllib.request, 'urlopen', side_effect=OSError('offline')): + with self.assertRaises(OSError): + tool.sync_version(PROJECT, TEXT) + with patch.object(tool.urllib.request, 'urlopen', return_value=io.BytesIO(self.page().encode())): + with self.assertRaises(ValueError): + tool.sync_version(PROJECT, tool.update_changelog(TEXT, '0.0.148')) + + if __name__ == '__main__': unittest.main() diff --git a/tools/workshop_changelog.py b/tools/workshop_changelog.py index d22ccfc2..e5d2201b 100644 --- a/tools/workshop_changelog.py +++ b/tools/workshop_changelog.py @@ -1,10 +1,12 @@ #!/usr/bin/env python3 -"""Read Workbench's bundled version; record it after a confirmed Workshop upload.""" +"""Synchronize the published Workshop version, or inspect a local Workbench bundle.""" import argparse import json from pathlib import Path import re import sys +import urllib.request +from html.parser import HTMLParser ROOT = Path(__file__).resolve().parents[1] VERSION_LINE = 'Latest published Workshop version:' @@ -30,6 +32,71 @@ def bundled_version(manifest, project): return version +class WorkshopPage(HTMLParser): + def __init__(self): + super().__init__() + self.active = False + self.payloads = [] + + def handle_starttag(self, tag, attrs): + if tag == 'script' and dict(attrs).get('id') == '__NEXT_DATA__': + self.active = True + self.payloads.append('') + + def handle_endtag(self, tag): + if tag == 'script': + self.active = False + + def handle_data(self, data): + if self.active: + self.payloads[-1] += data + + +def online_version(page, guid): + parser = WorkshopPage() + parser.feed(page) + if len(parser.payloads) != 1: + raise ValueError('Workshop page has no unique version metadata.') + data = json.loads(parser.payloads[0]) + asset = data['props']['pageProps']['asset'] + if asset.get('id') != guid or asset.get('private') is True or asset.get('blocked') is True: + raise ValueError('Workshop page does not identify the public addon.') + version = asset.get('currentVersionNumber') + if not valid_version(version) or not any(v.get('version') == version and v.get('assetId') == guid + and v.get('published') is True and v.get('approved') is True + for v in asset.get('versions', [])): + raise ValueError('Workshop current version is not confirmed as published and approved.') + return version + + +def prevent_downgrade(text, version): + existing = re.search(r'^Latest published Workshop version: \*\*(\d+\.\d+\.\d+)\*\*$', text, re.MULTILINE) + if existing and tuple(map(int, version.split('.'))) < tuple(map(int, existing[1].split('.'))): + raise ValueError('Workshop response is older than the recorded publication; refusing stale metadata.') + + +def sync_version(project, text, native_compat=None): + match = re.search(r'\bGUID\s+"([A-Fa-f0-9]{16})"', project) + if not match: + raise ValueError('Addon GUID is missing.') + guid = match[1].upper() + if native_compat: + from workshop_query import native_version + version = native_version(ROOT, native_compat, guid) + else: + request = urllib.request.Request('https://reforger.armaplatform.com/workshop/' + guid, + headers={'Cache-Control': 'no-cache'}) + with urllib.request.urlopen(request, timeout=20) as response: + page = response.read(2 * 1024 * 1024 + 1) + if len(page) > 2 * 1024 * 1024: + raise ValueError('Workshop response exceeds the expected size.') + version = online_version(page.decode('utf-8'), guid) + if not valid_version(version): + raise ValueError('Workshop returned an invalid version.') + prevent_downgrade(text, version) + return version + + def update_changelog(text, version): if not valid_version(version): raise ValueError('Invalid Workshop version.') @@ -50,10 +117,24 @@ def update_changelog(text, version): def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--manifest', type=Path, default=DEFAULT_MANIFEST) - parser.add_argument('--published', action='store_true', + action = parser.add_mutually_exclusive_group() + action.add_argument('--sync', action='store_true', help='Fetch the live published version and update CHANGELOG.md') + parser.add_argument('--native-compat', type=Path, help='Use the live game catalogue with this disposable prefix instead of the website') + action.add_argument('--published', action='store_true', help='Confirm the bundle was successfully uploaded; update CHANGELOG.md') args = parser.parse_args() + if args.native_compat and not args.sync: + parser.error('--native-compat requires --sync') try: + if args.sync: + path = ROOT / 'CHANGELOG.md' + before = path.read_text() + version = sync_version((ROOT / 'addons/core/addon.gproj').read_text(), before, args.native_compat) + after = update_changelog(before, version) + if before != after: + path.write_text(after) + print('Verified published Workshop version ' + version + '; CHANGELOG.md synchronized.') + return 0 manifest = json.loads(args.manifest.read_text(encoding='utf-8-sig')) version = bundled_version(manifest, (ROOT / 'addons/core/addon.gproj').read_text()) if args.published: @@ -66,7 +147,7 @@ def main(): else: print('Workbench bundle version: ' + version) print('Bundling alone does not confirm publication. Use --published after a successful upload.') - except (OSError, ValueError, AttributeError) as error: + except (OSError, ValueError, AttributeError, KeyError, TypeError) as error: print('Cannot read/record Workshop version: ' + str(error), file=sys.stderr) return 1 return 0 diff --git a/tools/workshop_query.py b/tools/workshop_query.py new file mode 100644 index 00000000..132e5348 --- /dev/null +++ b/tools/workshop_query.py @@ -0,0 +1,104 @@ +"""Query the public Workshop catalogue using a disposable, silent game session.""" +import fcntl +import os +from pathlib import Path +import re +import subprocess +import tempfile +import time + +from headless_game import assert_prefix_idle, cleanup_owned_run + +SCRIPT = '''class ERR_VersionQuery : PageParams +{ + override void OnPack() { StoreString("search", "Co10 escape Reintroduced"); } +} +modded class SCR_AddonManager +{ + protected ref ERR_VersionQuery m_Query; + protected ref BackendCallback m_Callback; + override void EOnInit(IEntity owner) + { + super.EOnInit(owner);GetGame().GetCallqueue().CallLater(QueryVersion,10000,false); + } + void QueryVersion() + { + m_Query=new ERR_VersionQuery();m_Query.limit=100;m_Query.offset=0; + m_Callback=new BackendCallback();m_Callback.SetOnSuccess(VersionReady); + GetGame().GetBackendApi().GetWorkshop().RequestPage(m_Callback,m_Query,true); + } + void VersionReady(BackendCallback callback) + { + array items={};GetGame().GetBackendApi().GetWorkshop().GetPageItems(items); + foreach(WorkshopItem item:items) + { + if(item.Id()!="2528466DE220D91C")continue; + Revision revision=item.GetLatestRevision(); + if(revision)PrintFormat("ERR_WORKSHOP_PUBLISHED %1 %2",item.Id(),revision.GetVersion()); + } + } +} +''' + + +def published_from_log(text, guid): + versions = set(re.findall(r'\bERR_WORKSHOP_PUBLISHED ' + re.escape(guid) + + r' (\d+\.\d+\.\d+)\s*$', text, re.MULTILINE)) + if len(versions) > 1: + raise ValueError('Workshop returned conflicting published versions.') + return next(iter(versions), None) + + +def native_version(root, compat, guid): + if guid != '2528466DE220D91C': + raise ValueError('Native query is restricted to the Escape addon.') + compat = Path(compat).resolve() + steam = Path.home() / '.local/share/Steam' + if not compat.is_relative_to(root / '.local') or not (compat / 'pfx').is_dir(): + raise ValueError('Use an existing disposable compat prefix under this checkout/.local.') + proton = steam / 'steamapps/common/Proton - Experimental/proton' + game = steam / 'steamapps/common/Arma Reforger/ArmaReforgerSteam.exe' + if not proton.is_file() or not game.is_file(): + raise ValueError('Installed Reforger and Proton Experimental are required for the native query.') + with (compat / '.erri-workbench.lock').open('a') as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + assert_prefix_idle(compat) + scratch = root / '.local/workshop-version' + scratch.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix='query-', dir=scratch) as temp: + work = Path(temp) + addon = work / 'addons/query' + (addon / 'Scripts/Game').mkdir(parents=True) + (addon / 'addon.gproj').write_text('GameProject { ID "ERR_VersionQuery" GUID "8F729DB72D635EF1" Dependencies { "58D0FB3206B6F859" } }\n') + (addon / 'Scripts/Game/VersionQuery.c').write_text(SCRIPT) + profile = work / 'profile' + profile.mkdir() + env = os.environ.copy() + env.update(STEAM_COMPAT_DATA_PATH=str(compat), STEAM_COMPAT_CLIENT_INSTALL_PATH=str(steam), + WINEPREFIX=str(compat / 'pfx'), SteamAppId='1874880', SteamGameId='1874880') + command = ['gamescope', '--backend', 'headless', '-W', '1280', '-H', '720', '-r', '30', '--', + str(proton), 'run', str(game), '-profile', 'Z:' + str(profile), + '-addonDownloadDir', 'Z:' + str(profile), '-addonsDir', 'Z:' + str(work / 'addons'), + '-addons', '8F729DB72D635EF1', '-noSound', '-noSplash', '-noThrow', + '-disableCrashReporter', '-logconsole'] + version = None + with (work / 'launch.log').open('w') as output: + child = subprocess.Popen(command, cwd=game.parent, env=env, stdout=output, + stderr=subprocess.STDOUT, start_new_session=True) + try: + deadline = time.monotonic() + 60 + while child.poll() is None and time.monotonic() < deadline: + for log in (profile / 'logs').glob('*/console.log'): + version = published_from_log(log.read_text(errors='replace'), guid) + if version: + break + if version: + break + time.sleep(.5) + finally: + cleanup = cleanup_owned_run(child, proton, env, compat) + if not cleanup.get('cleanup_confirmed'): + raise ValueError('Workshop query process cleanup could not be confirmed.') + if not version: + raise ValueError('Live Workshop query did not return a published version within 60 seconds.') + return version