Count occupied restarts as failed missions; sync published Workshop version
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-11-07 01:05:43 +01:00
parent 161100cf08
commit e421dcfc15
14 changed files with 640 additions and 50 deletions
@@ -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
+43
View File
@@ -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.
+96
View File
@@ -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')