Count occupied restarts as failed missions; sync published Workshop version
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
This commit is contained in:
@@ -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
|
||||
|
||||
+5
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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<string> 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<ref ERR_PlayerLife> 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<string, int> settings = new map<string, int>();
|
||||
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; }
|
||||
|
||||
@@ -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.
|
||||
|
||||
+27
-10
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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')
|
||||
+38
-7
@@ -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 <cassert>
|
||||
@@ -21,15 +21,16 @@ namespace MissionRules {bool Terminal(int p){return p==Phase::VICTORY||p==Phase:
|
||||
struct Rules {
|
||||
'''+rules+r'''
|
||||
};
|
||||
struct Pending:std::vector<string>{using std::vector<string>::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<string>{using std::vector<string>::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')
|
||||
|
||||
@@ -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 '<script id="__NEXT_DATA__" type="application/json">' + json.dumps(
|
||||
{'props': {'pageProps': {'asset': asset}}}) + '</script>'
|
||||
|
||||
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(), '<html>unavailable</html>']
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<WorkshopItem> 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
|
||||
Reference in New Issue
Block a user