--- a/addons/core/Scripts/Game/ERR/00_Core/ERR_PlayerGuide.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_PlayerGuide.c @@ -30,6 +30,14 @@ } protected void ERR_UpdateGuide() { + // A retained controller may outlive its local ownership during replacement. + if (!GetGame()) return; + if (this != GetGame().GetPlayerController()) + { + GetGame().GetCallqueue().Remove(ERR_UpdateGuide); + m_ERRGuideStarted = false; + return; + } ERR_GameMode mode = ERR_GameMode.Get(); if (!mode) return; if (mode.Phase() != ERR_Phase.PREPARING && (ERR_ObserverState() != ERR_ObserverReason.NONE || !ERR_Actors.Alive(GetControlledEntity()))) return; --- a/tests/test_hint_delivery.py +++ b/tests/test_hint_delivery.py @@ -12,12 +12,14 @@ text=text.replace(typename+' ',typename+'* ') for variable in ('mode','hints','resource','respawn','manager'): text=text.replace(variable+'.',variable+'->') + text=text.replace('GetCallqueue().','GetCallqueue()->').replace('Remove(ERR_UpdateGuide)','Remove(this)').replace('CallLater(ERR_UpdateGuide, 1000, true)','CallLater(this, 1000, true)') text=text.replace('GetGame().','GetGame()->').replace('EntitySpawnParams params = new EntitySpawnParams();','EntitySpawnParams params;') return text -methods=cpp(body(GUIDE,'override void OnControlledEntityChanged('))+'\n'+cpp(body(GUIDE,'protected void ERR_UpdateGuide('))+'\n'+cpp(body(OBSERVER,'protected void ERR_UpdateObserverClient(')) +methods=cpp(body(GUIDE,'protected void ERR_StartGuide(')).replace('ERR_StartGuide()', 'StartActual()')+'\n'+cpp(body(GUIDE,'override void OnControlledEntityChanged('))+'\n'+cpp(body(GUIDE,'protected void ERR_UpdateGuide('))+'\n'+cpp(body(OBSERVER,'protected void ERR_UpdateObserverClient(')) guidance=cpp(body(OBSERVER,'static string Message('))+'\n'+cpp(body(OBSERVER,'static bool Refresh(')) run(r'''#include #include +#include struct string:std::string {using std::string::string;using std::string::operator=;string(std::string s):std::string(s){}bool IsEmpty()const{return empty();}}; namespace ERR_Phase {enum {PREPARING,PRISON,RADIO_SEARCH,RADIO_DEFENCE,EXTRACTION};} namespace ERR_ObserverReason {enum {NONE,SPENT_LIFE,LATE_EXTRACTION,SPAWN_UNAVAILABLE,UNCONSCIOUS};} @@ -44,11 +46,13 @@ struct SCR_RespawnSystemComponent {int destroys=0;static SCR_RespawnSystemComponent* current;static SCR_RespawnSystemComponent* GetInstance(){return current;}void DestroyLoadingPlaceholder(){destroys++;}} respawn; SCR_RespawnSystemComponent* SCR_RespawnSystemComponent::current=&respawn; struct RplId {static int Invalid(){return -1;}}; -struct Game {int spawns=0;SCR_ManualCamera* SpawnEntityPrefab(Resource*,int,EntitySpawnParams){spawns++;return &camera;}int GetWorld(){return 1;}CameraManager* GetCameraManager(){return &manager;}} game; -Game* GetGame(){return &game;} +struct Queue {std::set scheduled;int added=0;void CallLater(void* owner,int,bool){added++;scheduled.insert(owner);}void Remove(void* owner){scheduled.erase(owner);}} queue; +struct Game {Queue* GetCallqueue(){return &queue;}void* local=nullptr;void* GetPlayerController(){return local;}int spawns=0;SCR_ManualCamera* SpawnEntityPrefab(Resource*,int,EntitySpawnParams){spawns++;return &camera;}int GetWorld(){return 1;}CameraManager* GetCameraManager(){return &manager;}} game; +bool gameAvailable=true;Game* GetGame(){return gameAvailable?&game:nullptr;} struct Controller { + Controller(){game.local=this;} void ERR_UpdateRecoveryCamera(ERR_GameMode*){} - int nativeChanges=0,starts=0;void ERR_StartGuide(){starts++;} + int nativeChanges=0,starts=0;bool m_ERRGuideStarted=false;void ERR_StartGuide(){starts++;StartActual();} int m_ERRGuidePhase=-1;string m_ERRGuideMessage; Actor* controlled=&actor;int m_ERRObserverReason=ERR_ObserverReason::NONE; SCR_ManualCamera* m_ERRObserverCamera=nullptr;Vec m_ERRObserverAnchor; @@ -58,7 +62,7 @@ void ERR_CloseObserverCamera(){closes++;m_ERRObserverReady=false;m_ERRObserverCamera=nullptr;} '''+methods+r''' }; -void reset(){mode=ERR_GameMode();ERR_GameMode::current=&mode;hints=SCR_HintManagerComponent();SCR_HintManagerComponent::current=&hints;actor=Actor();Resource::current=&resource;manager.current=&camera;} +void reset(){queue=Queue();gameAvailable=true;mode=ERR_GameMode();ERR_GameMode::current=&mode;hints=SCR_HintManagerComponent();SCR_HintManagerComponent::current=&hints;actor=Actor();Resource::current=&resource;manager.current=&camera;} int main(){ reset();Controller g; SCR_HintManagerComponent::current=nullptr;g.ERR_UpdateGuide();assert(g.m_ERRGuidePhase==-1&&hints.attempts==0); @@ -95,6 +99,22 @@ Controller unconscious;actor.unconscious=true;unconscious.ERR_UpdateGuide();assert(unconscious.m_ERRGuidePhase==mode.phase);actor.unconscious=false; Controller preparing;preparing.controlled=nullptr;mode.phase=ERR_Phase::PREPARING;mode.message="preparing";preparing.ERR_UpdateGuide();assert(preparing.m_ERRGuideMessage=="preparing"); ERR_GameMode::current=nullptr;int attempts=hints.attempts;preparing.ERR_UpdateGuide();assert(hints.attempts==attempts); + // A prior local controller can remain alive until native teardown. Its + // recurring callback must not write global hints after ownership changed. + reset();Controller former;former.StartActual();former.ERR_UpdateGuide();Controller current;current.StartActual(); + assert(queue.scheduled.count(&former)==1&&queue.scheduled.count(¤t)==1); + mode.phase=ERR_Phase::PREPARING;mode.message="new round preparing"; + former.controlled=nullptr;int beforeStale=hints.attempts; + former.ERR_UpdateGuide();assert(hints.attempts==beforeStale); + assert(!former.m_ERRGuideStarted&&queue.scheduled.count(&former)==0&&queue.scheduled.count(¤t)==1); + assert(former.m_ERRGuidePhase==ERR_Phase::PRISON&&former.m_ERRGuideMessage=="prison"); + current.ERR_UpdateGuide();assert(hints.attempts==beforeStale+1&¤t.m_ERRGuideMessage==mode.message); + current.ERR_UpdateGuide();assert(hints.attempts==beforeStale+1); + mode.phase=ERR_Phase::RADIO_SEARCH;mode.message="new radio clue";former.controlled=&actor; + former.ERR_UpdateGuide();assert(hints.attempts==beforeStale+1); + game.local=&former;former.StartActual();assert(former.m_ERRGuideStarted&&queue.scheduled.count(&former)==1);int added=queue.added;former.StartActual();assert(queue.added==added);hints.accepts=false;former.ERR_UpdateGuide();assert(former.m_ERRGuideMessage=="prison"); + hints.accepts=true;former.ERR_UpdateGuide();assert(former.m_ERRGuideMessage==mode.message); + int beforeNoGame=hints.attempts;gameAvailable=false;former.ERR_UpdateGuide();assert(hints.attempts==beforeNoGame);gameAvailable=true; reset();Controller s;s.controlled=nullptr;s.m_ERRObserverReason=ERR_ObserverReason::SPENT_LIFE; // Actual camera setup executes; missing manager cannot consume delivery state. SCR_HintManagerComponent::current=nullptr;s.ERR_UpdateObserverClient();assert(s.m_ERRObserverCamera==&camera&&!s.m_ERRObserverReady); @@ -120,4 +140,4 @@ ''') for path,signature in ((GUIDE,'protected void ERR_UpdateGuide('),(OBSERVER,'protected void ERR_UpdateObserverClient(')): assert 'CallLater' not in body(path,signature), 'Delivery retry uses the existing monitor only' -print('PASS: actual guide/observer monitors retry native refusal, cache successful delivery, deduplicate and preserve living/preparing/spectator boundaries; native UI not exercised') +print('PASS: actual guide ownership and scoped timer cancellation, current-controller preservation/reacquisition, hint retry/dedup and observer boundaries; native UI not exercised')