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:
+6
-1
@@ -1,6 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
Latest published Workshop version: **0.0.124**
|
||||
Latest published Workshop version: **0.0.125**
|
||||
|
||||
## 2026-10-30 — Mission results
|
||||
|
||||
- Added an after-mission summary with duration, kills, friendly kills, deaths, times downed and recoveries for each participant.
|
||||
- Results show who escaped and retain the records of players who disconnected.
|
||||
|
||||
## 2026-10-28 — Extraction landing zones
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ SCR_GameOverScreenConfig : "{651583FFFB7FC765}Configs/GameOverScreen/BaseGameOve
|
||||
m_aGameOverScreens {
|
||||
ERR_EscapeGameOverScreenInfo "{6B193DC8DDC63507}" {
|
||||
m_GameOverScreenId VICTORY
|
||||
m_GameOverContentLayout "{C4507D07F3C54831}UI/layouts/HUD/GameOver/EndScreen/EndScreenContent_NoImage.layout"
|
||||
m_GameOverContentLayout "{C63B4D1E09A572F8}UI/layouts/ERR/RoundResults.layout"
|
||||
m_OptionalParams SCR_BaseGameOverScreenInfoOptional "{4984A5C1BFEFC04E}" {
|
||||
m_sTitle "Escape complete"
|
||||
m_sAudioOneShot "SOUND_ONVICTORY"
|
||||
@@ -10,7 +10,7 @@ SCR_GameOverScreenConfig : "{651583FFFB7FC765}Configs/GameOverScreen/BaseGameOve
|
||||
}
|
||||
ERR_FailureGameOverScreenInfo "{E66F29A7A8AD7438}" {
|
||||
m_GameOverScreenId LOOSE
|
||||
m_GameOverContentLayout "{C4507D07F3C54831}UI/layouts/HUD/GameOver/EndScreen/EndScreenContent_NoImage.layout"
|
||||
m_GameOverContentLayout "{C63B4D1E09A572F8}UI/layouts/ERR/RoundResults.layout"
|
||||
m_OptionalParams SCR_BaseGameOverScreenInfoOptional "{3DBF0BDE715945FA}" {
|
||||
m_sTitle "Escape failed"
|
||||
m_sDebriefing "No surviving prisoners can complete the escape, or the extraction helicopter has been lost."
|
||||
|
||||
@@ -33,13 +33,22 @@ class ERR_EscapeGameOverScreenInfo : SCR_BaseGameOverScreenInfo
|
||||
modded class SCR_GameOverScreenContentUIComponent
|
||||
{
|
||||
protected bool m_ERREscapeContent;
|
||||
protected ERR_GameMode m_ERRStatisticsMode;
|
||||
protected ERR_GameMode m_ERRFailureMode;
|
||||
override void InitContent(SCR_GameOverScreenUIContentData endScreenUIContent)
|
||||
{
|
||||
SCR_PlayerController.ERR_OnEscapeResultChanged().Remove(ERR_RefreshEscapeContent);
|
||||
if (m_ERRFailureMode) m_ERRFailureMode.OnEndReasonChanged().Remove(ERR_RefreshFailureContent);
|
||||
m_ERRFailureMode = null;
|
||||
if (m_ERRStatisticsMode) m_ERRStatisticsMode.ERR_OnStatisticsChanged().Remove(ERR_RefreshStatistics);
|
||||
m_ERRStatisticsMode = null;
|
||||
super.InitContent(endScreenUIContent);
|
||||
if (ERR_EscapeGameOverScreenInfo.Cast(endScreenUIContent.m_GameOverInfo) || ERR_FailureGameOverScreenInfo.Cast(endScreenUIContent.m_GameOverInfo))
|
||||
{
|
||||
m_ERRStatisticsMode = ERR_GameMode.Get();
|
||||
if (m_ERRStatisticsMode) m_ERRStatisticsMode.ERR_OnStatisticsChanged().Insert(ERR_RefreshStatistics);
|
||||
ERR_RefreshStatistics();
|
||||
}
|
||||
if (ERR_FailureGameOverScreenInfo.Cast(endScreenUIContent.m_GameOverInfo))
|
||||
{
|
||||
m_ERRFailureMode = ERR_GameMode.Get();
|
||||
@@ -71,10 +80,21 @@ modded class SCR_GameOverScreenContentUIComponent
|
||||
TextWidget debrief = TextWidget.Cast(m_wRoot.FindAnyWidget(m_sDebriefingName));
|
||||
if (debrief) debrief.SetText(info.GetDebriefing(-1, null));
|
||||
}
|
||||
protected void ERR_RefreshStatistics()
|
||||
{
|
||||
if (!m_wRoot || !m_ERRStatisticsMode || m_ERRStatisticsMode != ERR_GameMode.Get()) return;
|
||||
TextWidget text = TextWidget.Cast(m_wRoot.FindAnyWidget("RoundStatistics"));
|
||||
if (!text) return;
|
||||
string summary = m_ERRStatisticsMode.ERR_RoundSummary();
|
||||
if (summary.IsEmpty()) summary = "Waiting for mission statistics...";
|
||||
text.SetText(summary);
|
||||
}
|
||||
override void HandlerDeattached(Widget w)
|
||||
{
|
||||
SCR_PlayerController.ERR_OnEscapeResultChanged().Remove(ERR_RefreshEscapeContent);
|
||||
m_ERREscapeContent = false;
|
||||
if (m_ERRStatisticsMode) m_ERRStatisticsMode.ERR_OnStatisticsChanged().Remove(ERR_RefreshStatistics);
|
||||
m_ERRStatisticsMode = null;
|
||||
if (m_ERRFailureMode) m_ERRFailureMode.OnEndReasonChanged().Remove(ERR_RefreshFailureContent);
|
||||
m_ERRFailureMode = null;
|
||||
super.HandlerDeattached(w);
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// Round-local statistics are always available, independently of telemetry.
|
||||
// Only authoritative participant bodies own records; transient connection IDs
|
||||
// and native winner-ID lists never decide kill credit or escape results.
|
||||
class ERR_RoundPlayerStats
|
||||
{
|
||||
UUID Account;
|
||||
string Name;
|
||||
IEntity Actor;
|
||||
bool Connected;
|
||||
bool Down;
|
||||
int Kills;
|
||||
int FriendlyKills;
|
||||
int Deaths;
|
||||
int Downed;
|
||||
int Recoveries;
|
||||
}
|
||||
class ERR_RoundStatistics
|
||||
{
|
||||
protected ERR_GameMode m_Mode;
|
||||
protected ref array<ref ERR_RoundPlayerStats> m_Players = {};
|
||||
protected bool m_Finished;
|
||||
protected bool m_Truncated;
|
||||
protected int m_StartedAt = -1;
|
||||
void ERR_RoundStatistics(ERR_GameMode mode) { m_Mode = mode; }
|
||||
ERR_RoundPlayerStats Find(UUID account)
|
||||
{
|
||||
foreach (ERR_RoundPlayerStats row : m_Players) if (row.Account == account) return row;
|
||||
return null;
|
||||
}
|
||||
protected ERR_RoundPlayerStats Body(IEntity actor)
|
||||
{
|
||||
if (!actor) return null;
|
||||
foreach (ERR_RoundPlayerStats row : m_Players) if (row.Actor == actor) return row;
|
||||
return null;
|
||||
}
|
||||
static string DisplayName(string name)
|
||||
{
|
||||
name.Replace("\n", " "); name.Replace("\r", " "); name.Replace("\t", " ");
|
||||
if (name.Length() > 48) name = name.Substring(0, 48);
|
||||
if (name.IsEmpty()) return "Player";
|
||||
return name;
|
||||
}
|
||||
void RosterChanged(int playerId) { Sync(); }
|
||||
void Sync()
|
||||
{
|
||||
if (!Replication.IsServer() || m_Finished || !m_Mode || !m_Mode.Players()) return;
|
||||
array<ref ERR_PlayerLife> lives = {}; m_Mode.Players().Participants(lives);
|
||||
foreach (ERR_PlayerLife life : lives)
|
||||
{
|
||||
ERR_RoundPlayerStats row = Find(life.AccountId);
|
||||
if (!row)
|
||||
{
|
||||
if (m_Players.Count() >= 128) { m_Truncated = true; continue; }
|
||||
row = new ERR_RoundPlayerStats(); row.Account = life.AccountId; m_Players.Insert(row);
|
||||
}
|
||||
row.Connected = life.Connected;
|
||||
if (life.Connected) row.Name = DisplayName(GetGame().GetPlayerManager().GetPlayerName(life.ConnectionId));
|
||||
if (row.Actor != life.Actor) { row.Actor = life.Actor; row.Down = false; }
|
||||
}
|
||||
}
|
||||
// A consciousness edge is a down/recovery, not a death. Repeated callbacks
|
||||
// cannot count the same incapacitation twice; corpse deletion is not a kill.
|
||||
static void Consciousness(ERR_RoundPlayerStats row, bool conscious)
|
||||
{
|
||||
if (!row) return;
|
||||
if (!conscious && !row.Down) row.Downed++;
|
||||
if (conscious && row.Down) row.Recoveries++;
|
||||
row.Down = !conscious;
|
||||
}
|
||||
void ConsciousnessChanged(IEntity actor, bool conscious)
|
||||
{
|
||||
if (!Replication.IsServer() || m_Finished || !m_Mode || !m_Mode.Active() || !ERR_Actors.Alive(actor)) return;
|
||||
Sync(); Consciousness(Body(actor), conscious);
|
||||
}
|
||||
static void CountDeath(ERR_RoundPlayerStats victim, ERR_RoundPlayerStats killer, bool friendly, bool credit)
|
||||
{
|
||||
if (victim) { victim.Deaths++; victim.Down = false; }
|
||||
if (!credit || !killer || victim == killer) return;
|
||||
if (friendly) killer.FriendlyKills++;
|
||||
else killer.Kills++;
|
||||
}
|
||||
void Killed(IEntity victim, IEntity killerEntity, Instigator instigator)
|
||||
{
|
||||
if (!Replication.IsServer() || m_Finished || !m_Mode || !m_Mode.Active() || !ChimeraCharacter.Cast(victim)) return;
|
||||
SCR_CharacterDamageManagerComponent damage = SCR_CharacterDamageManagerComponent.Cast(victim.FindComponent(SCR_CharacterDamageManagerComponent));
|
||||
if (!damage || !damage.IsDestroyed()) return;
|
||||
Sync();
|
||||
ERR_RoundPlayerStats killed = Body(victim);
|
||||
// Native damage normally provides the original instigator body, including
|
||||
// delayed explosives. Never recover credit from a possibly recycled player ID.
|
||||
IEntity source = killerEntity;
|
||||
if (!ChimeraCharacter.Cast(source) && instigator) source = instigator.GetInstigatorEntity();
|
||||
ERR_RoundPlayerStats killer = Body(source);
|
||||
if (!killed && !killer) return;
|
||||
if (!damage.ERR_ClaimRoundDeath()) return;
|
||||
bool credit = instigator && instigator.GetInstigatorType() != InstigatorType.INSTIGATOR_GM;
|
||||
bool friendly = killed != null;
|
||||
Faction faction = SCR_FactionManager.SGetFaction(victim);
|
||||
if (faction && faction == m_Mode.PlayerFaction()) friendly = true;
|
||||
CountDeath(killed, killer, friendly, credit);
|
||||
}
|
||||
void PhaseChanged(ERR_Phase previous, ERR_Phase current)
|
||||
{
|
||||
if (!Replication.IsServer() || m_Finished) return;
|
||||
if (m_StartedAt < 0 && current >= ERR_Phase.PRISON && current <= ERR_Phase.EXTRACTION) m_StartedAt = System.GetTickCount();
|
||||
if (ERR_MissionRules.Terminal(current)) Finish();
|
||||
}
|
||||
protected string Outcome(ERR_RoundPlayerStats row)
|
||||
{
|
||||
if (m_Mode.Extraction() && m_Mode.Extraction().DidEscape(row.Account)) return "Escaped";
|
||||
if (row.Deaths > 0 && !ERR_Actors.Alive(row.Actor)) return "Dead";
|
||||
if (!row.Connected) return "Disconnected - did not escape";
|
||||
if (row.Down) return "Down - did not escape";
|
||||
return "Did not escape";
|
||||
}
|
||||
void Finish()
|
||||
{
|
||||
if (!Replication.IsServer() || m_Finished || !m_Mode) return;
|
||||
Sync(); m_Finished = true;
|
||||
int seconds;
|
||||
if (m_StartedAt >= 0) seconds = Math.Max(0, (System.GetTickCount() - m_StartedAt) / 1000);
|
||||
int kills, friendly, deaths, downed, recovered, escaped;
|
||||
string rows;
|
||||
foreach (ERR_RoundPlayerStats row : m_Players)
|
||||
{
|
||||
kills += row.Kills; friendly += row.FriendlyKills; deaths += row.Deaths; downed += row.Downed; recovered += row.Recoveries;
|
||||
if (m_Mode.Extraction() && m_Mode.Extraction().DidEscape(row.Account)) escaped++;
|
||||
if (!rows.IsEmpty()) rows += "\n\n";
|
||||
rows += DisplayName(row.Name) + " - " + Outcome(row) + "\n";
|
||||
rows += string.Format("Kills: %1 Friendly kills: %2 Deaths: %3 Downed: %4 Recoveries: %5", row.Kills, row.FriendlyKills, row.Deaths, row.Downed, row.Recoveries);
|
||||
}
|
||||
string summary = string.Format("Duration: %1m %2s Participants: %3 Escaped: %4\n", seconds / 60, Math.Mod(seconds, 60), m_Players.Count(), escaped);
|
||||
summary += string.Format("Total kills: %1 Friendly kills: %2 Deaths: %3 Downed: %4 Recoveries: %5\n\n", kills, friendly, deaths, downed, recovered);
|
||||
summary += "Downed counts unconscious episodes; recoveries include self-recovery. Deaths count fatal losses.\n\n";
|
||||
if (rows.IsEmpty()) rows = "No players participated in this round.";
|
||||
summary += rows;
|
||||
if (m_Truncated) summary += "\n\nOnly the first 128 participants are shown; totals cover those players.";
|
||||
m_Mode.ERR_PublishRoundStatistics(summary);
|
||||
}
|
||||
}
|
||||
modded class SCR_CharacterDamageManagerComponent
|
||||
{
|
||||
protected bool m_ERRRoundDeathCounted;
|
||||
bool ERR_ClaimRoundDeath()
|
||||
{
|
||||
if (!Replication.IsServer() || m_ERRRoundDeathCounted) return false;
|
||||
m_ERRRoundDeathCounted = true; return true;
|
||||
}
|
||||
}
|
||||
modded class SCR_CharacterControllerComponent
|
||||
{
|
||||
override void OnConsciousnessChanged(bool conscious)
|
||||
{
|
||||
super.OnConsciousnessChanged(conscious);
|
||||
ERR_GameMode mode = ERR_GameMode.Get();
|
||||
if (mode && mode.ERR_Statistics()) mode.ERR_Statistics().ConsciousnessChanged(GetOwner(), conscious);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
modded class ERR_GameMode
|
||||
{
|
||||
protected ref ERR_RoundStatistics m_ERRStatistics;
|
||||
[RplProp(onRplName: "ERR_OnRoundStatisticsReplicated")]
|
||||
protected string m_ERRRoundSummary;
|
||||
protected ref ScriptInvoker m_ERRStatisticsChanged = new ScriptInvoker();
|
||||
ERR_RoundStatistics ERR_Statistics() { return m_ERRStatistics; }
|
||||
string ERR_RoundSummary() { return m_ERRRoundSummary; }
|
||||
ScriptInvoker ERR_OnStatisticsChanged() { return m_ERRStatisticsChanged; }
|
||||
protected void ERR_OnRoundStatisticsReplicated() { m_ERRStatisticsChanged.Invoke(); }
|
||||
void ERR_PublishRoundStatistics(string summary)
|
||||
{
|
||||
if (!Replication.IsServer() || !m_ERRRoundSummary.IsEmpty()) return;
|
||||
m_ERRRoundSummary = summary;
|
||||
Replication.BumpMe(); ERR_OnRoundStatisticsReplicated();
|
||||
}
|
||||
protected override void EOnInit(IEntity owner)
|
||||
{
|
||||
super.EOnInit(owner);
|
||||
if (!Replication.IsServer() || SCR_Global.IsEditMode() || !Players()) return;
|
||||
m_ERRStatistics = new ERR_RoundStatistics(this);
|
||||
OnPhaseChanged().Insert(m_ERRStatistics.PhaseChanged);
|
||||
Players().OnChanged().Insert(m_ERRStatistics.RosterChanged);
|
||||
}
|
||||
protected override void OnControllableDestroyed(IEntity entity, IEntity killerEntity, notnull Instigator instigator)
|
||||
{
|
||||
if (m_ERRStatistics) m_ERRStatistics.Killed(entity, killerEntity, instigator);
|
||||
super.OnControllableDestroyed(entity, killerEntity, instigator);
|
||||
}
|
||||
protected override void OnPlayerKilled(int playerId, IEntity playerEntity, IEntity killerEntity, notnull Instigator killer)
|
||||
{
|
||||
if (m_ERRStatistics) m_ERRStatistics.Killed(playerEntity, killerEntity, killer);
|
||||
super.OnPlayerKilled(playerId, playerEntity, killerEntity, killer);
|
||||
}
|
||||
protected override void OnGameModeEnd(SCR_GameModeEndData endData)
|
||||
{
|
||||
// Freeze before native shutdown can detach bodies or remove roster ownership.
|
||||
if (m_ERRStatistics) m_ERRStatistics.Finish();
|
||||
super.OnGameModeEnd(endData);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,11 @@ class ERR_TelemetryPlayer
|
||||
bool participated;
|
||||
bool escaped;
|
||||
int connectedSeconds;
|
||||
int kills;
|
||||
int friendlyKills;
|
||||
int deaths;
|
||||
int downed;
|
||||
int recoveries;
|
||||
}
|
||||
class ERR_TelemetryMod
|
||||
{
|
||||
@@ -253,6 +258,13 @@ class ERR_Telemetry
|
||||
if (tracked.authenticated && tracked.id == participant.AccountId)
|
||||
{
|
||||
tracked.participated = true;
|
||||
ERR_RoundPlayerStats stats;
|
||||
if (m_Mode.ERR_Statistics()) stats = m_Mode.ERR_Statistics().Find(participant.AccountId);
|
||||
if (stats)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
SizeLayoutWidgetClass {
|
||||
Name "ERR_RoundResults"
|
||||
WidthOverride 1100
|
||||
HeightOverride 620
|
||||
Slot LayoutSlot { HorizontalAlign 1 VerticalAlign 1 }
|
||||
components { SCR_GameOverScreenContentUIComponent "{6E81CD1BC83BE245}" { } }
|
||||
{
|
||||
FrameWidgetClass {
|
||||
Name "ResultFrame"
|
||||
Slot AlignableSlot { HorizontalAlign 3 VerticalAlign 3 }
|
||||
{
|
||||
TextWidgetClass {
|
||||
Name "GameOver_State"
|
||||
Slot FrameWidgetSlot { Anchor 0 0 1 0 OffsetLeft 20 OffsetTop 0 OffsetRight 20 SizeY 52 OffsetBottom -52 }
|
||||
Color 1 1 1 1 "Font Size" 36 "Min Font Size" 36
|
||||
Text "Round ended"
|
||||
}
|
||||
TextWidgetClass {
|
||||
Name "GameOver_Description"
|
||||
Slot FrameWidgetSlot { Anchor 0 0 1 0 OffsetLeft 20 OffsetTop 58 OffsetRight 20 SizeY 64 OffsetBottom -122 }
|
||||
Color 0.9 0.92 0.94 1 "Font Size" 22 "Min Font Size" 22 Wrap 1
|
||||
}
|
||||
TextWidgetClass {
|
||||
Name "StatisticsHeading"
|
||||
Slot FrameWidgetSlot { Anchor 0 0 1 0 OffsetLeft 20 OffsetTop 132 OffsetRight 20 SizeY 36 OffsetBottom -168 }
|
||||
Color 0.9 0.8 0.5 1 "Font Size" 26 "Min Font Size" 26 Text "Mission statistics"
|
||||
}
|
||||
ScrollLayoutWidgetClass {
|
||||
Name "StatisticsScroll"
|
||||
Slot FrameWidgetSlot { Anchor 0 0 1 1 OffsetLeft 20 OffsetTop 178 OffsetRight 20 OffsetBottom 12 }
|
||||
components { SCR_GamepadScrollComponent "{5707100AC31E657E}" { } }
|
||||
{
|
||||
VerticalLayoutWidgetClass {
|
||||
Name "StatisticsContent"
|
||||
Slot AlignableSlot { HorizontalAlign 3 VerticalAlign 0 Padding 0 0 18 0 }
|
||||
{
|
||||
TextWidgetClass {
|
||||
Name "RoundStatistics"
|
||||
Slot LayoutSlot { HorizontalAlign 3 Padding 0 0 0 8 SizeMode Auto }
|
||||
Clipping Ancestor Color 0.9 0.92 0.94 1
|
||||
Text "Waiting for mission statistics..."
|
||||
"Font Size" 20 "Min Font Size" 20 Wrap 1
|
||||
FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
MetaFileClass {
|
||||
Name "{C63B4D1E09A572F8}UI/layouts/ERR/RoundResults.layout"
|
||||
Configurations {
|
||||
LayoutResourceClass PC { }
|
||||
LayoutResourceClass HEADLESS : PC { }
|
||||
LayoutResourceClass XBOX_ONE : PC { }
|
||||
LayoutResourceClass XBOX_SERIES : PC { }
|
||||
LayoutResourceClass PS4 : PC { }
|
||||
LayoutResourceClass PS5 : PC { }
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -11,19 +11,21 @@ def adapt(s):
|
||||
s=s.replace('ERR_GameMode.Get()','Mode::Get()').replace('ERR_GameMode mode','Mode* mode')
|
||||
s=s.replace('ERR_FailureGameOverScreenInfo.Cast(', 'FailureInfo::Cast(').replace('ERR_EscapeGameOverScreenInfo.Cast(', 'EscapeInfo::Cast(')
|
||||
s=s.replace('ERR_FailureGameOverScreenInfo info = new ERR_FailureGameOverScreenInfo();','FailureInfo info;')
|
||||
s=s.replace('TextWidget.Cast(', 'TextWidget::Cast(').replace('TextWidget debrief','TextWidget* debrief')
|
||||
s=s.replace('TextWidget.Cast(', 'TextWidget::Cast(').replace('TextWidget debrief','TextWidget* debrief').replace('TextWidget text','TextWidget* text')
|
||||
s=s.replace('SCR_PlayerController.ERR_OnEscapeResultChanged()','escapeEvent')
|
||||
s=s.replace('SCR_GameOverScreenUIContentData endScreenUIContent','Data* endScreenUIContent').replace('Widget w','int w')
|
||||
s=s.replace('array<int> otherPlayerIds','void* otherPlayerIds').replace('string ','std::string ')
|
||||
s=s.replace('super.InitContent(endScreenUIContent);','nativeInit++;').replace('super.HandlerDeattached(w);','nativeDetach++;')
|
||||
for name in ('m_ERRFailureMode','m_wRoot','debrief','mode','endScreenUIContent'):s=s.replace(name+'.',name+'->')
|
||||
for name in ('m_ERRFailureMode','m_ERRStatisticsMode','m_wRoot','debrief','text','mode','endScreenUIContent'):s=s.replace(name+'.',name+'->')
|
||||
s=s.replace('OnEndReasonChanged().','OnEndReasonChanged()->').replace('EndReason().IsEmpty()','EndReason().empty()')
|
||||
s=s.replace('Insert(ERR_RefreshFailureContent)','Insert(1)').replace('Remove(ERR_RefreshFailureContent)','Remove(1)')
|
||||
s=s.replace('Insert(ERR_RefreshEscapeContent)','Insert(2)').replace('Remove(ERR_RefreshEscapeContent)','Remove(2)')
|
||||
s=s.replace('ERR_OnStatisticsChanged().','ERR_OnStatisticsChanged()->').replace('summary.IsEmpty()','summary.empty()')
|
||||
s=s.replace('Insert(ERR_RefreshStatistics)','Insert(3)').replace('Remove(ERR_RefreshStatistics)','Remove(3)')
|
||||
return s
|
||||
failure=body(SOURCE,'class ERR_FailureGameOverScreenInfo')
|
||||
failure=failure[failure.index(' override string'):failure.rindex('}')]
|
||||
callbacks='\n'.join(body(SOURCE,x) for x in ('override void InitContent(','protected void ERR_RefreshFailureContent(','override void HandlerDeattached('))
|
||||
callbacks='\n'.join(body(SOURCE,x) for x in ('override void InitContent(','protected void ERR_RefreshFailureContent(','protected void ERR_RefreshStatistics(','override void HandlerDeattached('))
|
||||
notify=body(MODE,'protected void ERR_OnEndReasonReplicated(')
|
||||
assert '[RplProp(onRplName: "ERR_OnEndReasonReplicated")]' in MODE.read_text()
|
||||
run(r'''
|
||||
@@ -31,7 +33,7 @@ run(r'''
|
||||
#include <string>
|
||||
#include <functional>
|
||||
struct Event{int count=0;std::function<void()> handler;void Insert(int){count=1;}void Remove(int){count=0;}void Invoke(){if(count&&handler)handler();}};
|
||||
struct Mode{static inline Mode* current=nullptr;Event m_OnEndReasonChanged;std::string reason;static Mode* Get(){return current;}std::string EndReason(){return reason;}Event* OnEndReasonChanged(){return &m_OnEndReasonChanged;}
|
||||
struct Mode{static inline Mode* current=nullptr;Event m_OnEndReasonChanged,statsChanged;std::string reason,summary;Event* ERR_OnStatisticsChanged(){return &statsChanged;}std::string ERR_RoundSummary(){return summary;}static Mode* Get(){return current;}std::string EndReason(){return reason;}Event* OnEndReasonChanged(){return &m_OnEndReasonChanged;}
|
||||
NOTIFY
|
||||
};
|
||||
struct FailureInfo{static bool Cast(int kind){return kind==1;}
|
||||
@@ -39,15 +41,17 @@ FAILURE
|
||||
};
|
||||
struct EscapeInfo{static EscapeInfo* Cast(int kind){static EscapeInfo value;return kind==2?&value:nullptr;}};
|
||||
struct TextWidget{std::string text;int writes=0;static TextWidget* Cast(TextWidget* w){return w;}void SetText(std::string value){text=value;writes++;}};
|
||||
struct Root{TextWidget debrief;bool exists=true;TextWidget* FindAnyWidget(std::string){return exists?&debrief:nullptr;}};
|
||||
struct Root{TextWidget debrief,statistics;bool exists=true;TextWidget* FindAnyWidget(std::string name){return exists?(name=="RoundStatistics"?&statistics:&debrief):nullptr;}};
|
||||
struct Data{int m_GameOverInfo;};
|
||||
struct Content{Event escapeEvent;bool m_ERREscapeContent=false;Mode* m_ERRFailureMode=nullptr;Root* m_wRoot=nullptr;std::string m_sDebriefingName="debrief";int nativeInit=0,nativeDetach=0,escapeRefresh=0;void ERR_RefreshEscapeContent(){escapeRefresh++;}
|
||||
struct Content{Event escapeEvent;bool m_ERREscapeContent=false;Mode* m_ERRFailureMode=nullptr;Mode* m_ERRStatisticsMode=nullptr;Root* m_wRoot=nullptr;std::string m_sDebriefingName="debrief";int nativeInit=0,nativeDetach=0,escapeRefresh=0;void ERR_RefreshEscapeContent(){escapeRefresh++;}
|
||||
CALLBACKS
|
||||
};
|
||||
int main(){Mode mode;Mode::current=&mode;Root root;Content content;content.m_wRoot=&root;mode.m_OnEndReasonChanged.handler=[&](){content.ERR_RefreshFailureContent();};Data failure{1},escape{2},other{3};
|
||||
int main(){Mode mode;Mode::current=&mode;Root root;Content content;content.m_wRoot=&root;mode.m_OnEndReasonChanged.handler=[&](){content.ERR_RefreshFailureContent();};Data failure{1},escape{2},other{3};mode.statsChanged.handler=[&](){content.ERR_RefreshStatistics();};
|
||||
content.InitContent(&failure);assert(mode.m_OnEndReasonChanged.count==1&&root.debrief.text.find("No surviving")!=std::string::npos);
|
||||
assert(mode.statsChanged.count==1&&root.statistics.text=="Waiting for mission statistics...");
|
||||
mode.summary="Player: kills 2, escaped";mode.statsChanged.Invoke();assert(root.statistics.text==mode.summary);
|
||||
mode.reason="An administrator abandoned this round.";mode.ERR_OnEndReasonReplicated();assert(root.debrief.text==mode.reason&&root.debrief.writes==2);
|
||||
content.HandlerDeattached(0);assert(mode.m_OnEndReasonChanged.count==0&&content.m_ERRFailureMode==nullptr);mode.reason="late";mode.ERR_OnEndReasonReplicated();assert(root.debrief.writes==2);
|
||||
content.HandlerDeattached(0);assert(mode.statsChanged.count==0&&content.m_ERRStatisticsMode==nullptr);assert(mode.m_OnEndReasonChanged.count==0&&content.m_ERRFailureMode==nullptr);mode.reason="late";mode.ERR_OnEndReasonReplicated();assert(root.debrief.writes==2);
|
||||
content.InitContent(&failure);assert(root.debrief.writes==3);content.InitContent(&escape);assert(mode.m_OnEndReasonChanged.count==0&&content.escapeEvent.count==1&&content.escapeRefresh==1);mode.ERR_OnEndReasonReplicated();assert(root.debrief.writes==3);
|
||||
content.InitContent(&failure);assert(content.escapeEvent.count==0);Mode replacement;Mode::current=&replacement;mode.ERR_OnEndReasonReplicated();assert(root.debrief.writes==4);
|
||||
Mode::current=&mode;content.m_wRoot=nullptr;mode.ERR_OnEndReasonReplicated();assert(root.debrief.writes==4);content.m_wRoot=&root;root.exists=false;mode.ERR_OnEndReasonReplicated();assert(root.debrief.writes==4);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Execute production counter edges and authoritative death attribution with native doubles."""
|
||||
from policy_harness import ROOT,body,run
|
||||
p=ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_RoundStatistics.c'
|
||||
methods='\n'.join(body(p,s) for s in ['protected ERR_RoundPlayerStats Body(', 'static void Consciousness(', 'static void CountDeath(', 'void Killed(', 'protected string Outcome('])
|
||||
for a,b in [('protected ',''),('ERR_RoundPlayerStats','Row*'),('IEntity','Entity*'),('Instigator instigator','Instigator* instigator'),('foreach (Row* row :','for (Row* row :'),('row.','row->'),('victim.','victim->'),('killer.','killer->'),('m_Mode.','m_Mode->'),('instigator.','instigator->'),('damage.','damage->'),('SCR_CharacterDamageManagerComponent damage = SCR_CharacterDamageManagerComponent.Cast(victim->FindComponent(SCR_CharacterDamageManagerComponent));','Damage* damage = victim->damage;'),('ChimeraCharacter.Cast(victim)','victim && victim->character'),('ChimeraCharacter.Cast(source)','source && source->character'),('Replication.IsServer()','authority'),('ERR_Actors.Alive(row->Actor)','Alive(row->Actor)'),('SCR_FactionManager.SGetFaction(victim)','victim->faction'),('Faction faction','int faction'),('InstigatorType.INSTIGATOR_GM','99'),('string Outcome','std::string Outcome')]:methods=methods.replace(a,b)
|
||||
methods=methods.replace('null','nullptr').replace('!victim && victim->character','!(victim && victim->character)').replace('m_Mode->Extraction().','m_Mode->Extraction()->').replace('!source && source->character','!(source && source->character)')
|
||||
run(r'''
|
||||
#include <cassert>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
bool authority=true;
|
||||
struct Damage {bool dead=true,claimed=false;bool IsDestroyed(){return dead;}bool ERR_ClaimRoundDeath(){if(!authority||claimed)return false;claimed=true;return true;}};
|
||||
struct Entity {bool character=true,alive=false;int faction=2;Damage* damage;};
|
||||
bool Alive(Entity* e){return e&&e->alive;}
|
||||
struct Instigator {Entity* entity=nullptr;int type=1;Entity* GetInstigatorEntity(){return entity;}int GetInstigatorType(){return type;}};
|
||||
struct Row {int Account=1;Entity* Actor=nullptr;bool Down=false,Connected=true;int Kills=0,FriendlyKills=0,Deaths=0,Downed=0,Recoveries=0;};
|
||||
struct Extraction {int escaped=0;bool DidEscape(int account){return account==escaped;}};
|
||||
struct Mode {bool active=true;struct Extraction result;bool Active(){return active;}int PlayerFaction(){return 1;}struct Extraction* Extraction(){return &result;}};
|
||||
struct Stats {Mode mode;Mode* m_Mode=&mode;bool m_Finished=false;std::vector<Row*> m_Players;void Sync(){}
|
||||
'''+methods+r'''
|
||||
};
|
||||
int main(){
|
||||
Stats s;Row player,friendRow;Damage pd,fd,ed;Entity p{true,true,1,&pd},f{true,false,1,&fd},enemy{true,false,2,&ed};player.Actor=&p;friendRow.Actor=&f;friendRow.Account=2;s.m_Players={&player,&friendRow};Instigator cause{&p};
|
||||
s.Consciousness(&player,false);s.Consciousness(&player,false);assert(player.Downed==1&&player.Recoveries==0);
|
||||
s.Consciousness(&player,true);s.Consciousness(&player,true);s.Consciousness(&player,false);assert(player.Downed==2&&player.Recoveries==1);
|
||||
s.Killed(&enemy,&p,&cause);s.Killed(&enemy,&p,&cause);assert(player.Kills==1); // duplicate native events
|
||||
s.Killed(&f,&p,&cause);assert(player.FriendlyKills==1&&friendRow.Deaths==1&&player.Kills==1);
|
||||
s.Killed(&p,&p,&cause);assert(player.Deaths==1&&player.FriendlyKills==1&&player.Kills==1&&!player.Down); // suicide
|
||||
Damage otherDamage;Entity other{true,false,2,&otherDamage};s.m_Finished=true;s.Killed(&other,&p,&cause);assert(!otherDamage.claimed);s.m_Finished=false;
|
||||
authority=false;s.Killed(&other,&p,&cause);assert(!otherDamage.claimed);authority=true;
|
||||
s.mode.active=false;s.Killed(&other,&p,&cause);assert(!otherDamage.claimed);s.mode.active=true;
|
||||
other.character=false;s.Killed(&other,&p,&cause);assert(!otherDamage.claimed);other.character=true;
|
||||
otherDamage.dead=false;s.Killed(&other,&p,&cause);assert(!otherDamage.claimed);otherDamage.dead=true;
|
||||
cause.type=99;s.Killed(&other,&p,&cause);assert(player.Kills==1); // GM kills do not inflate score
|
||||
Damage unknownDamage;Entity unknown{true,false,2,&unknownDamage};Instigator noOwner{&unknown};s.Killed(&unknown,&unknown,&noOwner);assert(!unknownDamage.claimed&&player.Kills==1);
|
||||
player.Connected=false;player.Down=true;assert(s.Outcome(&player)=="Disconnected - did not escape");
|
||||
s.mode.result.escaped=player.Account;assert(s.Outcome(&player)=="Escaped"); // disconnected escapee is still an escapee
|
||||
s.mode.result.escaped=0;player.Connected=true;p.alive=false;assert(s.Outcome(&player)=="Dead");
|
||||
player.Deaths=0;p.alive=true;assert(s.Outcome(&player)=="Down - did not escape");player.Down=false;assert(s.Outcome(&player)=="Did not escape");
|
||||
}
|
||||
''')
|
||||
lifecycle=(p.with_name('ERR_RoundStatisticsLifecycle.c')).read_text()
|
||||
assert lifecycle.index('m_ERRStatistics.Finish();') < lifecycle.index('super.OnGameModeEnd(endData);')
|
||||
assert 'telemetryEnabled' not in p.read_text()+lifecycle
|
||||
assert 'GetInstigatorPlayerID' not in p.read_text()
|
||||
assert 'm_Finished = true;' in body(p,'void Finish(')
|
||||
assert 'm_Players.Count() >= 128' in body(p,'void Sync(')
|
||||
ui=p.with_name('ERR_EscapeGameOverScreenInfo.c').read_text()
|
||||
assert 'ERR_OnStatisticsChanged().Insert(ERR_RefreshStatistics)' in ui
|
||||
assert 'ERR_OnStatisticsChanged().Remove(ERR_RefreshStatistics)' in body(p.with_name('ERR_EscapeGameOverScreenInfo.c'),'override void HandlerDeattached(')
|
||||
print('PASS: authoritative kills/deaths, duplicate-event protection, friendly kills, suicide, GM and vehicle exclusions, down/recovery edges, disconnected escapees and terminal gates')
|
||||
|
||||
# Real roster reconciliation: preserve account-owned counters across disconnects
|
||||
# and reconnects, without inheriting stats when a transient connection ID is reused.
|
||||
sync='\n'.join(body(p,s) for s in ['ERR_RoundPlayerStats Find(', 'void Sync('])
|
||||
sync=sync.replace('new ERR_RoundPlayerStats()', 'new Row()')
|
||||
for a,b in [('ERR_RoundPlayerStats','Row*'),('UUID account','int account'),('array<ref ERR_PlayerLife> lives = {};','std::vector<Life*> lives;'),('foreach (ERR_PlayerLife life :','for (Life* life :'),('foreach (Row* row :','for (Row* row :'),('row.','row->'),('life.','life->'),('m_Mode.','m_Mode->'),('Replication.IsServer()','authority'),('m_Players.Count()','m_Players.size()'),('m_Players.Insert(row)','m_Players.push_back(row)'),('m_Mode->Players().','m_Mode->Players()->'),('null','nullptr')]:sync=sync.replace(a,b)
|
||||
run(r'''
|
||||
#include <cassert>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <map>
|
||||
bool authority=true;
|
||||
struct Life{int AccountId=1,ConnectionId=1,Actor=1;bool Connected=true;};
|
||||
struct Row{int Account=0,Actor=0,Kills=0;bool Connected=false,Down=false;std::string Name;};
|
||||
struct Roster{std::vector<Life*> lives;void Participants(std::vector<Life*>& out){out=lives;}};
|
||||
struct Mode{Roster roster;Roster* Players(){return &roster;}};
|
||||
struct Manager{std::map<int,std::string> names;std::string GetPlayerName(int id){return names[id];}};
|
||||
struct Game{Manager manager;Manager& GetPlayerManager(){return manager;}} game;Game& GetGame(){return game;}
|
||||
struct Stats{Mode mode;Mode* m_Mode=&mode;bool m_Finished=false,m_Truncated=false;std::vector<Row*> m_Players;std::string DisplayName(std::string name){return name;}
|
||||
'''+sync+r'''
|
||||
};
|
||||
int main(){
|
||||
Stats s;Life a;s.mode.roster.lives={&a};game.manager.names[1]="Alice";s.Sync();Row* old=s.Find(1);assert(old&&old->Name=="Alice");old->Kills=3;
|
||||
a.Connected=false;game.manager.names[1]="Bob";Life b;b.AccountId=2;b.Actor=2;s.mode.roster.lives.push_back(&b);s.Sync();
|
||||
assert(old->Name=="Alice"&&!old->Connected&&old->Kills==3);assert(s.Find(2)->Name=="Bob"&&s.Find(2)->Kills==0);
|
||||
a.ConnectionId=3;a.Connected=true;game.manager.names[3]="Alice renamed";s.Sync();assert(s.Find(1)==old&&old->Kills==3&&old->Name=="Alice renamed");
|
||||
s.m_Finished=true;game.manager.names[3]="late";s.Sync();assert(old->Name=="Alice renamed");s.m_Finished=false;
|
||||
std::vector<Life> extra(130);for(int i=0;i<130;i++){extra[i].AccountId=100+i;s.mode.roster.lives.push_back(&extra[i]);}s.Sync();assert(s.m_Players.size()==128&&s.m_Truncated);
|
||||
}
|
||||
''')
|
||||
print('PASS: production roster reconciliation preserves disconnected names and reconnect counters, refuses connection-ID inheritance, freezes records and bounds retained participants')
|
||||
@@ -12,6 +12,7 @@ def check():
|
||||
subprocess.run([sys.executable,'-m','unittest','discover','-s',str(ROOT/'tests'),'-p','test_admin_menu_sources.py'],check=True)
|
||||
subprocess.run([sys.executable,'-m','unittest','discover','-s',str(ROOT/'tests'),'-p','test_workshop_changelog.py'],check=True)
|
||||
subprocess.run([sys.executable,'-m','unittest','discover','-s',str(ROOT/'tests'),'-p','test_discord_changelog_ci.py'],check=True)
|
||||
subprocess.run([sys.executable,str(ROOT/'tests/test_round_statistics.py')],check=True)
|
||||
subprocess.run([sys.executable,str(ROOT/'tests/test_terrain_controller_init.py')],check=True)
|
||||
subprocess.run([sys.executable,str(ROOT/'tests/test_terrain_chat_retention.py')],check=True)
|
||||
subprocess.run([sys.executable,str(ROOT/'tests/test_empty_server_restart.py')],check=True)
|
||||
|
||||
Reference in New Issue
Block a user