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
+12 -8
View File
@@ -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);
+82
View File
@@ -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')