Add after-mission statistics; record Workshop 0.0.125
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-10-30 12:59:32 +01:00
parent 970dd338f5
commit c6ee764c6c
15 changed files with 572 additions and 11 deletions
+21
View File
@@ -381,3 +381,24 @@ Open **Items → Starting backpack** in the admin menu. Select a loaded mod, cho
Select **Custom loadout** to use that list for the next prison or crash start. The shared field map is always included. **Default loadout** restores the existing pistol and magazine count settings without erasing your custom list. Existing backpacks are never refilled or changed by these edits. Enemy item permissions and probability weights do not change this explicit starting loadout.
The list permits up to 32 item/destination entries, 48 per entry and 64 total items across both destinations, plus the map. Ground items bypass inventory insertion and remain available nearby for pickup. Items that do not fit inside the backpack also stay nearby instead of blocking startup or being deleted. Unavailable custom items are skipped and logged. Add matching magazines explicitly; selecting a weapon does not automatically add spare ammunition. Settings persist in the `startBackpack` section of `Escapists.json`. Existing version 1 loadouts remain valid and their items keep the backpack destination. Saving writes version 2 records as `prefab|quantity|backpack` or `prefab|quantity|ground`; the enclosing configuration version does not change.
## After-mission statistics
Victory and defeat screens include a scrollable mission summary: duration,
participant and escape counts, squad totals, and each participant's kills,
friendly kills, deaths, times downed, recoveries and final escape status.
Disconnected participants retain their records. Only participants who actually
escaped according to the extraction service are marked **Escaped**.
Kills count non-friendly characters credited by native damage events to a
participant's body; friendly kills are separate. Vehicles, self-kills, editor
kills and unattributed damage do not add to the kill score. A fatal loss counts
as a death. Because the mission normally keeps wounded players alive, unconscious
episodes are counted separately as **Downed**. **Recoveries** include both medical
treatment and natural/self-recovery; they are not a medic's revive score.
The report freezes when the round ends. Counts belong to the current round and
start fresh with the next mission. They work with statistics telemetry disabled
and require no web service. At most 128 distinct participants are retained; the
report explicitly marks truncation if that limit is reached. No account IDs are
included in the displayed or replicated summary.
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""Frozen local Workbench round-statistics fixture; no live server or installed asset writes."""
from pathlib import Path
import hashlib,json,shutil,subprocess,sys,os,time
ROOT=Path(__file__).resolve().parents[3]
sys.path.insert(0,str(ROOT/'tools'))
from source_manifest import addon_manifest
from headless_game import assert_prefix_idle
name=sys.argv[1]
if not name.isalnum(): raise ValueError('Use an alphanumeric run suffix')
source=Path('/tmp/erri-round-statistics-source-'+name)
profile=Path('/tmp/erri-round-statistics-run-'+name)
compat=ROOT/'.local/playtest-compat-20260920'
assert_prefix_idle(compat)
source.mkdir();profile.mkdir()
for rel in addon_manifest(ROOT):
target=source/rel;target.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(ROOT/rel,target)
shutil.copytree(ROOT/'tools',source/'tools',ignore=shutil.ignore_patterns('__pycache__'))
(source/'docs').mkdir();shutil.copy2(ROOT/'docs/external-addons.json',source/'docs/external-addons.json')
fixture=Path(__file__).with_name('native-fixture.c')
shutil.copy2(fixture,source/'addons/core/Scripts/Game/ERR/00_Core/ERR_ZRoundStatisticsFixture.c')
# Opt-in fixture is independent of the historical generic Core smoke.
p=source/'addons/core/Scripts/Game/ERR/00_Core/ERR_NativeBaseSmoke.c'
p.write_text(p.read_text().replace('array<string> independentFixtures = {','array<string> independentFixtures = {"round-statistics", '))
p=source/'tools/headless_workbench.py'
p.write_text(p.read_text().replace("choices=['coordinated-ai',", "choices=['round-statistics', 'coordinated-ai',").replace("a.smoke not in {'coordinated-ai',", "a.smoke not in {'round-statistics', 'coordinated-ai',"))
# The existing fatal-loss diagnostic is guarded by errSmoke; this Core-only
# runner uses errBaseSmoke. Adapt only that test-helper flag in the frozen copy.
p=source/'addons/core/Scripts/Game/ERR/00_Core/ERR_PlayerMedical.c'
p.write_text(p.read_text().replace('System.GetCLIParam("errSmoke", enabled)', 'System.GetCLIParam("errBaseSmoke", enabled)'))
installed=Path.home()/'.local/share/Steam/steamapps/compatdata/1874880/pfx/drive_c/users/steamuser/Documents/My Games/ArmaReforger/addons'
(profile/'profile').mkdir()
(profile/'profile/EscapistsFactions.json').write_text(json.dumps({'escapists':'ussr','enemy':'us','independent':'fia'})+'\n')
(profiles:=profile/'fixture-inputs.json').write_text(json.dumps({'fixture_sha256':hashlib.sha256(fixture.read_bytes()).hexdigest(),'production_sources':addon_manifest(ROOT),'source':str(source),'profile':str(profile)},indent=2)+'\n')
command=[sys.executable,str(source/'tools/headless_workbench.py'),'--compat',str(compat),'--profile',str(profile),'--external-addons',str(installed),'--project','core','--smoke','round-statistics','--rounds','1','--timeout','300']
with (profile/'runner-output.log').open('w') as output:
result=subprocess.Popen(command,stdout=output,stderr=subprocess.STDOUT)
captured=False
while result.poll() is None:
if not captured and any('ERR STATS UI READY' in log.read_text(errors='replace') for log in (profile/'logs').glob('*/console.log')):
for proc in Path('/proc').iterdir():
if not proc.name.isdigit(): continue
try:
args=(proc/'cmdline').read_bytes().decode(errors='replace')
if 'ArmaReforgerWorkbenchSteamDiag.exe' not in args or str(source/'addons/core/addon.gproj') not in args: continue
values=dict(x.split('=',1) for x in (proc/'environ').read_bytes().decode(errors='replace').split('\0') if '=' in x)
if 'DISPLAY' not in values: continue
env=os.environ.copy()
for key in ['DISPLAY','XAUTHORITY']:
if key in values: env[key]=values[key]
capture=subprocess.run(['import','-display',values['DISPLAY'],'-window','root',str(profile/'results-desktop.png')],env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=10)
if capture.returncode==0: captured=True;break
except (OSError,ValueError,subprocess.TimeoutExpired): pass
time.sleep(1)
print(json.dumps({'exit':result.returncode,'source':str(source),'profile':str(profile)}))
print('\n'.join((profile/'runner-output.log').read_text().splitlines()[-45:]))
raise SystemExit(result.returncode)
@@ -0,0 +1,85 @@
modded class ERR_GameMode
{
protected int m_ERRStatsStage;
protected IEntity m_ERRStatsActor;
protected IEntity m_ERRStatsFriend;
protected UUID m_ERRStatsAccount;
protected SCR_CharacterDamageManagerComponent m_ERRStatsDamage;
protected float m_ERRStatsWait;
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
if (Replication.IsServer() && !SCR_Global.IsEditMode()) GetGame().GetCallqueue().CallLater(ERR_StatsPoll, 500, true);
}
protected bool ERR_StatsCheck(bool ok, string detail)
{
if (ok) { Print("ERR SMOKE PASS: statistics " + detail); return true; }
Print("ERR SMOKE FAIL: statistics " + detail, LogLevel.ERROR);
GetGame().GetCallqueue().Remove(ERR_StatsPoll); return false;
}
protected void ERR_StatsPoll()
{
if (!PreparationError().IsEmpty()) { ERR_StatsCheck(false, PreparationError()); return; }
if (GetWorld().GetWorldTime() < m_ERRStatsWait) return;
if (m_ERRStatsStage == 0)
{
if (!Active()) return;
SCR_PlayerController pc = SCR_PlayerController.Cast(GetGame().GetPlayerController());
if (!pc || !pc.GetControlledEntity() || !Players().CurrentActor(pc.GetControlledEntity())) return;
m_ERRStatsActor = pc.GetControlledEntity(); m_ERRStatsAccount = Players().Get(pc.GetPlayerId()).AccountId;
m_ERRStatsDamage = SCR_CharacterDamageManagerComponent.Cast(m_ERRStatsActor.FindComponent(SCR_CharacterDamageManagerComponent));
ERR_SiteRuntime site = Sites().Find("everon-prison-1");
array<AIAgent> agents = {}; site.Group.GetAgents(agents);
IEntity enemy = agents[0].GetControlledEntity();
SCR_CharacterDamageManagerComponent damage = SCR_CharacterDamageManagerComponent.Cast(enemy.FindComponent(SCR_CharacterDamageManagerComponent));
damage.EnableDamageHandling(true); damage.Kill(Instigator.CreateInstigator(m_ERRStatsActor));
m_ERRStatsDamage.EnableDamageHandling(true); m_ERRStatsDamage.ForceUnconsciousness();
m_ERRStatsStage = 1; m_ERRStatsWait = GetWorld().GetWorldTime() + 2000; return;
}
ERR_RoundPlayerStats row = ERR_Statistics().Find(m_ERRStatsAccount);
if (!ERR_StatsCheck(row != null, "participant record exists")) return;
if (m_ERRStatsStage == 1)
{
PrintFormat("ERR STATS VALUES: kills=%1 friendly=%2 deaths=%3 downed=%4 recoveries=%5", row.Kills, row.FriendlyKills, row.Deaths, row.Downed, row.Recoveries);
if (!ERR_StatsCheck(row.Kills == 1 && row.Deaths == 0 && row.Downed == 1, "native enemy kill and unconscious callback counted separately")) return;
m_ERRStatsDamage.FullHeal(); m_ERRStatsStage = 2; m_ERRStatsWait = GetWorld().GetWorldTime() + 2000; return;
}
if (m_ERRStatsStage == 2)
{
if (row.Recoveries < 1) return;
if (!ERR_StatsCheck(row.Recoveries == 1, "native recovery counted once")) return;
m_ERRStatsFriend = ERR_Actors.Spawn("{5436629450D8387A}Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_SL.et", ERR_Actors.Ground(m_ERRStatsActor.GetOrigin() + "10 0 10"));
if (!ERR_StatsCheck(m_ERRStatsFriend != null, "friendly NPC created")) return;
SCR_CharacterDamageManagerComponent damage = SCR_CharacterDamageManagerComponent.Cast(m_ERRStatsFriend.FindComponent(SCR_CharacterDamageManagerComponent));
damage.EnableDamageHandling(true); damage.Kill(Instigator.CreateInstigator(m_ERRStatsActor));
m_ERRStatsStage = 3; m_ERRStatsWait = GetWorld().GetWorldTime() + 1000; return;
}
if (m_ERRStatsStage == 3)
{
if (!ERR_StatsCheck(row.FriendlyKills == 1 && row.Kills == 1, "friendly kill separated from normal kills")) return;
ERR_Statistics().Killed(m_ERRStatsFriend, m_ERRStatsActor, Instigator.CreateInstigator(m_ERRStatsActor));
if (!ERR_StatsCheck(row.FriendlyKills == 1, "duplicate native callback does not double count")) return;
if (!ERR_StatsCheck(m_ERRStatsDamage.ERR_ForceFatalLossForTest(Instigator.CreateInstigator(m_ERRStatsActor), m_ERRStatsActor), "fatal player fixture requested")) return;
m_ERRStatsStage = 4; m_ERRStatsWait = GetWorld().GetWorldTime() + 1000; return;
}
if (m_ERRStatsStage == 4)
{
if (!ERR_StatsCheck(row.Deaths == 1 && row.Kills == 1 && row.FriendlyKills == 1, "actual fatal player loss counted once without self-kill credit")) return;
if (!ERR_StatsCheck(Transition(ERR_Phase.DEFEAT), "normal mission end requested")) return;
m_ERRStatsStage = 5; m_ERRStatsWait = GetWorld().GetWorldTime() + 4000; return;
}
if (m_ERRStatsStage == 5)
{
if (!ERR_StatsCheck(!ERR_RoundSummary().IsEmpty() && ERR_RoundSummary().Contains("Death") && ERR_RoundSummary().Contains("Dead"), "frozen end summary contains participant death outcome")) return;
MenuBase menu = GetGame().GetMenuManager().GetTopMenu(); if (!menu) return;
TextWidget text = TextWidget.Cast(menu.GetRootWidget().FindAnyWidget("RoundStatistics"));
if (!ERR_StatsCheck(text && text.GetText() == ERR_RoundSummary(), "real native game-over screen displays replicated summary")) return;
float x, y, width, height; text.GetScreenPos(x, y); text.GetScreenSize(width, height);
PrintFormat("ERR STATS UI READY: text x=%1 y=%2 width=%3 height=%4 visible=%5", x, y, width, height, text.IsVisible());
System.MakeScreenshot("$logs:err-round-statistics");
m_ERRStatsStage = 6; m_ERRStatsWait = GetWorld().GetWorldTime() + 15000; return;
}
Print("ERR SMOKE COMPLETE: round statistics native death/knockdown/recovery/friendly-kill and end-screen integration");
GetGame().GetCallqueue().Remove(ERR_StatsPoll);
}
}
+12
View File
@@ -184,3 +184,15 @@ disabled interval, and marks subsequent events partial. Changing the receiver UR
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).
### Round combat counters
Player records additionally include `kills`, `friendlyKills`, `deaths`, `downed`
and `recoveries`, using the same server-owned counters as the after-mission
screen. These fields are additive to schema version 1. Older queued records may
not contain them; treat absent values as unavailable rather than inventing a
historical count. Kills exclude friendly kills, suicides, vehicles and editor
kills. Downed means an unconscious episode, while a death is a fatal loss.
Recoveries include self-recovery and are not healer-attributed revives.
Local result tracking continues when telemetry is disabled; sending still follows
the existing telemetry setting and endpoint configuration.