Add after-mission statistics; record Workshop 0.0.125
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user