Stop successor services on native game-over and preserve escape results

This commit is contained in:
2026-09-19 13:56:15 +02:00
parent a880e0485c
commit 6f0e4c5978
70 changed files with 11457 additions and 23 deletions
+105
View File
@@ -0,0 +1,105 @@
"""Run actual personal-result title/debrief and refresh methods with UI adapters.
No native replication, end-screen rendering or multiplayer claim.
"""
import re
from policy_harness import ROOT, body, run
SOURCE=ROOT/'addons/core/Scripts/Game/ERR/ERR_EscapeGameOverScreenInfo.c'
methods='\n'.join(body(SOURCE, signature) for signature in (
'protected ERR_PersonalEscapeResult PersonalResult(',
'override string GetTitle(',
'override string GetDebriefing('))
refresh=body(SOURCE,'protected void ERR_RefreshEscapeContent(')
def adapt(text):
text=re.sub(r' #ifdef WORKBENCH\n.*? #endif\n','',text,flags=re.S)
text=text.replace('protected ','').replace('override ','')
text=text.replace('ERR_PersonalEscapeResult.','Result::').replace('ERR_PersonalEscapeResult','Result::Value')
text=text.replace('array<int> otherPlayerIds','std::vector<int>* otherPlayerIds')
text=re.sub(r'\bstring\b','std::string',text)
text=text.replace('GetGame().GetPlayerController()', 'GetGame()->GetPlayerController()')
text=text.replace('SCR_PlayerController.Cast(', 'Controller::Cast(').replace('SCR_PlayerController controller', 'Controller* controller')
text=text.replace('TextWidget.Cast(', 'TextWidget::Cast(')
text=text.replace('TextWidget title', 'TextWidget* title').replace('TextWidget debrief', 'TextWidget* debrief')
# Enforce manages this local object; the C++ adapter uses scoped ownership.
text=text.replace('ERR_EscapeGameOverScreenInfo info = new ERR_EscapeGameOverScreenInfo();', 'auto info = std::make_unique<Info>();')
for variable in ('controller','info','title','debrief','m_wRoot'):
text=text.replace(variable+'.',variable+'->')
text=re.sub(r'\bnull\b','nullptr',text)
return text
run(r'''
#include <cassert>
#include <memory>
#include <string>
#include <vector>
namespace Result {enum Value {UNKNOWN,ESCAPED,MISSED};}
struct Controller {
int id=7;Result::Value result=Result::UNKNOWN;
static Controller* Cast(Controller* value){return value;}
int GetPlayerId(){return id;}Result::Value ERR_EscapeResult(){return result;}
};
struct Game {Controller* local=nullptr;Controller* GetPlayerController(){return local;}} game;
Game* GetGame(){return &game;}
struct Info {
'''+adapt(methods)+r'''
};
struct TextWidget {std::string value;int writes=0;
static TextWidget* Cast(TextWidget* widget){return widget;}
void SetText(std::string text){value=text;writes++;}
};
struct Root {TextWidget title,debrief;bool hasTitle=true,hasDebrief=true;
TextWidget* FindAnyWidget(std::string name){if(name=="title")return hasTitle?&title:nullptr;return hasDebrief?&debrief:nullptr;}
};
struct Content {
bool m_ERREscapeContent=true;Root* m_wRoot=nullptr;
std::string m_sTileName="title",m_sDebriefingName="debrief";
'''+adapt(refresh)+r'''
};
int main(){
Controller controller;game.local=&controller;Info info;
std::vector<int> staleWinners={7};std::vector<int> noWinners;
const char* titles[]={"Round ended","You escaped Everon","The helicopter escaped"};
const char* descriptions[]={"Your personal escape result is not available.","You crossed the extraction boundary aboard the helicopter.","You did not escape with the helicopter."};
for(int state=0;state<3;state++){
controller.result=static_cast<Result::Value>(state);
for(auto* winners:{&staleWinners,&noWinners,static_cast<std::vector<int>*>(nullptr)}){
assert(info.GetTitle(7,winners)==titles[state]);
assert(info.GetDebriefing(7,winners)==descriptions[state]);
// A different connection ID cannot use this local controller's result.
assert(info.GetTitle(8,winners)==titles[0]);
assert(info.GetDebriefing(8,winners)==descriptions[0]);
}
}
game.local=nullptr;
assert(info.GetTitle(7,&staleWinners)==titles[0]);
assert(info.GetDebriefing(7,&staleWinners)==descriptions[0]);
// The unchanged actual refresh method pulls only the current personal result;
// late arrival, account reset, and missing local controller refresh both texts.
game.local=&controller;Root root;Content content;content.m_wRoot=&root;
for(int state:{0,1,2,0}){
controller.result=static_cast<Result::Value>(state);content.ERR_RefreshEscapeContent();
assert(root.title.value==titles[state]&&root.debrief.value==descriptions[state]);
}
game.local=nullptr;content.ERR_RefreshEscapeContent();
assert(root.title.value==titles[0]&&root.debrief.value==descriptions[0]);
assert(root.title.writes==5&&root.debrief.writes==5);
content.m_ERREscapeContent=false;content.ERR_RefreshEscapeContent();
assert(root.title.writes==5&&root.debrief.writes==5);
content.m_ERREscapeContent=true;content.m_wRoot=nullptr;content.ERR_RefreshEscapeContent();
assert(root.title.writes==5&&root.debrief.writes==5);
content.m_wRoot=&root;root.hasTitle=false;content.ERR_RefreshEscapeContent();
assert(root.title.writes==5&&root.debrief.writes==6);
root.hasTitle=true;root.hasDebrief=false;content.ERR_RefreshEscapeContent();
assert(root.title.writes==6&&root.debrief.writes==6);
}
''')
# Keep native subscription/detach wiring intact; actual text refresh is executed above.
init=body(SOURCE,'override void InitContent(')
detach=body(SOURCE,'override void HandlerDeattached(')
assert 'SCR_PlayerController.ERR_OnEscapeResultChanged().Remove(ERR_RefreshEscapeContent);' in init
assert 'SCR_PlayerController.ERR_OnEscapeResultChanged().Insert(ERR_RefreshEscapeContent);' in init
assert 'ERR_RefreshEscapeContent();' in init
assert 'SCR_PlayerController.ERR_OnEscapeResultChanged().Remove(ERR_RefreshEscapeContent);' in detach
assert 'super.HandlerDeattached(w);' in detach
print('PASS: actual three-state result wording and UI refresh; stale winner IDs never manufacture escape')
+101
View File
@@ -0,0 +1,101 @@
"""Actual native-end hook/shutdown/active bodies with engine boundaries substituted."""
from policy_harness import ROOT, body, run
SOURCE=ROOT/'addons/core/Scripts/Game/ERR/ERR_GameMode.c'
def cpp(s):
s=s.replace('protected override ', '').replace('protected ', '')
for a,b in [('Replication.','Replication::'),('ERR_MissionRules.','Rules::'),('ERR_Phase.','Phase::'),('ERR_ObserverReason.','Observer::'),('SCR_AIGroup.','SCR_AIGroup::'),('ERR_Actors.','ERR_Actors::'),('super.OnGameModeEnd(', 'NativeEnd(')]:s=s.replace(a,b)
s=s.replace('array<int> connected = {};','std::vector<int> connected;').replace('foreach (','for (')
s=s.replace('SCR_PlayerController controller = SCR_PlayerController.Cast(', 'SCR_PlayerController* controller = SCR_PlayerController::Cast(').replace('controller.ERR_SetObserver(', 'controller->ERR_SetObserver(')
s=s.replace('GetGame().','GetGame()->').replace('GetCallqueue().','GetCallqueue()->').replace('GetPlayerManager().','GetPlayerManager()->')
return s
methods='\n'.join(cpp(body(SOURCE, signature)) for signature in ('bool Active(', 'protected override void OnGameModeEnd(', 'protected void StopServices('))
startGuard=body(SOURCE,'protected void StartScenario(').split('if (!m_Factions',1)[0].replace('protected ','')
run(r'''#include <cassert>
#include <string>
#include <vector>
#include <functional>
using ERR_Phase=int;using IEntity=int;using SCR_GameModeEndData=int;
struct string:std::string {using std::string::string;using std::string::operator=;bool IsEmpty(){return empty();}};
namespace Phase {enum {PREPARING,PRISON,RADIO_SEARCH,RADIO_DEFENCE,EXTRACTION,VICTORY,DEFEAT};}
namespace Observer {enum {NONE};}
struct Replication {static bool server;static int bumps;static bool IsServer(){return server;}static void BumpMe(){bumps++;}};
bool Replication::server=true;int Replication::bumps=0;
struct Rules {static bool Terminal(int phase){return phase==Phase::VICTORY||phase==Phase::DEFEAT;}};
struct Queue {std::vector<int> removed;void Remove(int method){removed.push_back(method);}};
struct SCR_PlayerController {int releases=0;static SCR_PlayerController* Cast(SCR_PlayerController* p){return p;}void ERR_SetObserver(int){releases++;}};
struct Manager {SCR_PlayerController player;void GetPlayers(std::vector<int>& ids){ids={1};}SCR_PlayerController* GetPlayerController(int){return &player;}};
struct Game {Queue queue;Manager manager;Queue* GetCallqueue(){return &queue;}Manager* GetPlayerManager(){return &manager;}} game;
Game* GetGame(){return &game;}
struct SCR_AIGroup {static bool Cast(int e){return e==10;}};
struct ERR_Actors {static int deleted;static void DeleteSpawned(int e){assert(e==10);deleted++;}};int ERR_Actors::deleted=0;
struct Command {int stops=0;explicit operator bool()const{return true;}void Stop(){stops++;}};
struct PhaseEvent {int count=0,oldPhase=-1,newPhase=-1;std::function<void()> callback;void Invoke(int old,int next){count++;oldPhase=old;newPhase=next;if(callback)callback();}};
struct StopEvent {int count=0;std::function<void()> callback;void Invoke(){count++;if(callback)callback();}};
struct Mode {
bool m_Stopped=false,m_NativeEndHandled=false;
int m_Phase=Phase::PRISON,StartScenario=1,ServerTick=2,BindLocalObserver=3;
string m_EndReason;string m_PreparationError;
Command m_Command;PhaseEvent m_OnPhaseChanged;StopEvent m_OnStop;
std::vector<int> m_OwnedEntities={10,11};
int published=0,feedback=0,nativeEnds=0,receivedData=0,nativeWork=0,escapeSnapshot=77;
bool RefreshRadioFeedback(){feedback++;return true;}void PublishEscapeResults(){published++;}
void NativeEnd(int data){assert(!Replication::server||m_Stopped);nativeEnds++;receivedData=data;}
'''+methods+r'''
};
struct StartGuard {bool m_Stopped=false,m_NativeEndHandled=false;int m_Phase=Phase::PREPARING;string m_PreparationError;int nativeWork=0;
'''+cpp(startGuard)+r'''nativeWork++;}
};
int main(){
// Every unfinished phase closes before native listeners/restart, preserving data.
for(int phase=Phase::PREPARING;phase<=Phase::EXTRACTION;phase++) {
Mode m;m.m_Phase=phase;auto removed=game.queue.removed.size();int deleted=ERR_Actors::deleted;
m.OnGameModeEnd(314);
assert(m.m_Phase==Phase::DEFEAT&&m.m_Stopped&&m.m_NativeEndHandled&&!m.Active());
assert(m.m_OnPhaseChanged.count==1&&m.m_OnPhaseChanged.oldPhase==phase);
assert(m.m_OnStop.count==1&&m.m_Command.stops==1&&ERR_Actors::deleted==deleted+1);
assert(game.queue.removed.size()==removed+4&&game.queue.removed[removed]==m.BindLocalObserver&&game.queue.removed[removed+1]==m.StartScenario&&game.queue.removed[removed+2]==m.ServerTick&&game.queue.removed[removed+3]==m.BindLocalObserver);
assert(m.nativeEnds==1&&m.receivedData==314&&m.escapeSnapshot==77&&m.published==1);
m.OnGameModeEnd(999);m.StopServices();
assert(m.nativeEnds==1&&m.receivedData==314&&m.m_OnStop.count==1&&m.m_OnPhaseChanged.count==1&&m.published==1);
}
// Existing successor terminal snapshots/reason are not replaced or republished.
for(int terminal:{Phase::VICTORY,Phase::DEFEAT}) {
Mode m;m.m_Phase=terminal;m.m_EndReason="original result";m.StopServices();
m.OnGameModeEnd(451);assert(m.m_Phase==terminal&&m.escapeSnapshot==77&&m.m_EndReason=="original result");
assert(m.m_OnPhaseChanged.count==0&&m.published==0&&m.m_OnStop.count==1&&m.nativeEnds==1&&m.receivedData==451);
}
// Reentrant end notification from successor listeners cannot schedule twice.
Mode reentrant;reentrant.m_OnPhaseChanged.callback=[&](){reentrant.OnGameModeEnd(2);};
reentrant.m_OnStop.callback=[&](){reentrant.OnGameModeEnd(3);reentrant.StopServices();};
reentrant.OnGameModeEnd(1);assert(reentrant.nativeEnds==1&&reentrant.receivedData==1&&reentrant.m_OnStop.count==1);
// Client closes local Active immediately, without mutating replicated phase/result.
Replication::server=false;Mode client;client.OnGameModeEnd(44);
assert(!client.Active()&&client.m_Phase==Phase::PRISON&&!client.m_Stopped&&client.published==0&&client.m_OnPhaseChanged.count==0&&client.nativeEnds==1);
Replication::server=true;
// An already dequeued preparation callback is independently inert after stop/end.
for(int fault=0;fault<4;fault++){
StartGuard g;if(fault==0)g.m_Stopped=true;if(fault==1)g.m_NativeEndHandled=true;if(fault==2)g.m_Phase=Phase::DEFEAT;if(fault==3)g.m_PreparationError="failed";
g.StartScenario();assert(g.nativeWork==0);
}
StartGuard normal;normal.StartScenario();assert(normal.nativeWork==1);
}
''')
source=SOURCE.read_text();hook=body(SOURCE,'protected override void OnGameModeEnd(')
assert hook.count('super.OnGameModeEnd(endData)')==1
assert hook.index('StopServices()') < hook.index('super.OnGameModeEnd(endData)')
assert 'Transition(' not in hook and 'EndGameMode(' not in hook
assert 'm_Extraction.' not in hook and 'EndGameData.Create' not in hook
assert 'if (!m_NativeEndHandled) EndGameMode(' in body(SOURCE,'bool Transition(')
probe=(ROOT/'addons/core/Scripts/Game/ERR/ERR_NativeEndProbe.c').read_text()
assert probe.startswith('#ifdef WORKBENCH')
assert probe.count('ERR SMOKE COMPLETE:')==1 and probe.count('EndGameMode(m_ERREndData)')==1
assert 'super.OnGameModeEnd' not in probe
assert 'm_Phase = ERR_Phase.' not in probe
assert 'GetEndGameData() == m_ERREndData' in probe
assert 'StartScenario(); ServerTick(); StopServices();' in probe
assert 'erri-native-end-round.json' in probe
print('PASS: actual native-end shutdown, existing terminal snapshots, reentrancy, unchanged native end data and stale preparation guards')
bind=body(SOURCE,'protected void BindLocalObserver(')
assert bind.index('m_Stopped || m_NativeEndHandled') < bind.index('controller.ERR_StartObserverMonitor()')
assert hook.index('Remove(BindLocalObserver)') < hook.index('if (Replication.IsServer())')
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Runner rejects unsafe fixture reuse before any native/audio initialization."""
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
ROOT=Path(__file__).resolve().parents[1]
CASES=('native-end-active','native-end-preparing')
FILES=('erri-native-end-round.json','erri-native-end-round.json.tmp','erri-native-end-round.json.bak')
class NativeEndRunnerTests(unittest.TestCase):
def invoke(self,profile,case,*extra):
return subprocess.run([sys.executable,str(ROOT/'tools/headless_workbench.py'),'--compat',str(profile/'absent-prefix'),'--profile',str(profile),'--smoke',case,'--project','core','--rounds','2','--timeout','120',*extra],text=True,capture_output=True)
def test_scope_rejected_before_launch(self):
with tempfile.TemporaryDirectory() as tmp:
for case in CASES:
for args in [('--project','chat'),('--rounds','1'),('--rounds','3'),('--timeout','119')]:
with self.subTest(case=case,args=args):
r=self.invoke(Path(tmp),case,*args)
self.assertNotEqual(r.returncode,0)
self.assertIn('native-end fixtures require Core',r.stderr)
self.assertEqual(list(Path(tmp).iterdir()),[])
def test_existing_files_and_broken_links_are_preserved(self):
for case in CASES:
for name in FILES:
for linked in (False,True):
with self.subTest(case=case,name=name,linked=linked),tempfile.TemporaryDirectory() as tmp:
root=Path(tmp);p=root/name
if linked:p.symlink_to(root/'missing')
else:p.write_bytes(b'prior evidence')
r=self.invoke(root,case)
self.assertNotEqual(r.returncode,0)
self.assertIn('native-end fixture paths must not preexist',r.stderr)
if linked:self.assertTrue(p.is_symlink())
else:self.assertEqual(p.read_bytes(),b'prior evidence')
self.assertEqual(list(root.iterdir()),[p])
def test_valid_scope_reaches_prefix_guard_without_writes(self):
with tempfile.TemporaryDirectory() as tmp:
for case in CASES:
r=self.invoke(Path(tmp),case)
self.assertIn('must be a disposable cloned prefix',r.stderr)
self.assertEqual(list(Path(tmp).iterdir()),[])
def test_unrelated_outcome_keeps_prior_marker_untouched(self):
with tempfile.TemporaryDirectory() as tmp:
p=Path(tmp)/FILES[0];p.write_text('existing')
r=self.invoke(Path(tmp),'victory')
self.assertIn('must be a disposable cloned prefix',r.stderr)
self.assertEqual(p.read_text(),'existing')
if __name__=='__main__':unittest.main()