--- a/addons/core/Scripts/Game/ERR/00_Core/ERR_Observer.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_Observer.c @@ -228,6 +228,7 @@ protected bool m_ERRObserverReady; protected ERR_ObserverReason m_ERRShownObserverReason; protected bool m_ERRShownObserverAvailable; + protected string m_ERRShownRecoveryStatus; protected float m_ERRLastCycle = -1000; protected IEntity m_ERRRecoveryBody; protected float m_ERRRecoveryDeadline = -1; @@ -385,14 +386,18 @@ // The server-selected replicated ID distinguishes no target from a target // whose local entity is still streaming. Never imply no survivors from delay. bool survivorAvailable = m_ERRObservedId != RplId.Invalid(); - if (ERR_ObserverGuidance.Refresh(m_ERRObserverReady, m_ERRShownObserverReason, m_ERRObserverReason, m_ERRShownObserverAvailable, survivorAvailable)) + string recovery = mode.RecoveryStatus(); + if (ERR_ObserverGuidance.Refresh(m_ERRObserverReady, m_ERRShownObserverReason, m_ERRObserverReason, m_ERRShownObserverAvailable, survivorAvailable) || m_ERRShownRecoveryStatus != recovery) { SCR_HintManagerComponent hints = SCR_HintManagerComponent.GetInstance(); if (!hints) return; // retry on the existing monitor; do not lose the first hint - if (!hints.ShowCustom(ERR_ObserverGuidance.Message(m_ERRObserverReason, survivorAvailable), "Spectating", 12)) return; + string message = ERR_ObserverGuidance.Message(m_ERRObserverReason, survivorAvailable); + if (!recovery.IsEmpty()) message += "\n" + recovery; + if (!hints.ShowCustom(message, "Spectating", 12)) return; m_ERRObserverReady = true; m_ERRShownObserverReason = m_ERRObserverReason; m_ERRShownObserverAvailable = survivorAvailable; + m_ERRShownRecoveryStatus = recovery; } } // Track the actual local body even when an editor/other camera owns the view. @@ -423,6 +428,7 @@ protected void ERR_CloseObserverCamera() { m_ERRObserverReady = false; + m_ERRShownRecoveryStatus = ""; if (!m_ERRObserverCamera) return; if (GetGame().GetPlayerController() == this) m_ERRObserverCamera.TrySwitchToControlledEntityCamera(); m_ERRObserverCamera.Terminate(); --- a/tests/test_hint_delivery.py +++ b/tests/test_hint_delivery.py @@ -4,6 +4,7 @@ import os GUIDE=Path(os.environ.get('ERR_GUIDE_SOURCE', ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_PlayerGuide.c')) OBSERVER=Path(os.environ.get('ERR_OBSERVER_SOURCE', ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_Observer.c')) +PROBE=Path(os.environ.get('ERR_OBSERVER_PROBE_SOURCE', ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_UnconsciousCameraProbe.c')) def cpp(text): text=text.replace(' = null;', ' = nullptr;').replace('protected ', '').replace('override ', '').replace('super.OnControlledEntityChanged(from, to);', 'nativeChanges++;') for name in ('ERR_GameMode','ERR_Phase','ERR_ObserverReason','ERR_Actors','ERR_ObjectiveText','SCR_HintManagerComponent','ERR_ObserverGuidance','ERR_ObserverRules','Resource','ETransformMode','SCR_ManualCamera','SCR_RespawnSystemComponent','RplId'): @@ -16,6 +17,7 @@ text=text.replace('GetGame().','GetGame()->').replace('EntitySpawnParams params = new EntitySpawnParams();','EntitySpawnParams params;') return text 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(')) +methods+='\n'+cpp(body(PROBE,'string ERR_ProbeAcceptedRecoveryStatus(')) guidance=cpp(body(OBSERVER,'static string Message('))+'\n'+cpp(body(OBSERVER,'static bool Refresh(')) run(r'''#include #include @@ -25,7 +27,7 @@ namespace ERR_ObserverReason {enum {NONE,SPENT_LIFE,LATE_EXTRACTION,SPAWN_UNAVAILABLE,UNCONSCIOUS};} struct Actor {bool alive=true;bool unconscious=false;} actor;using IEntity=Actor*; struct ERR_Actors {static bool Alive(Actor* a){return a&&a->alive;}static bool CanFight(Actor* a){return Alive(a)&&!a->unconscious;}}; -struct ERR_GameMode {int phase=ERR_Phase::PRISON;bool active=true;string message="prison";static ERR_GameMode* current;static ERR_GameMode* Get(){return current;}int Phase(){return phase;}bool Active(){return active;}} mode; +struct ERR_GameMode {string recovery;string RecoveryStatus(){return recovery;}int phase=ERR_Phase::PRISON;bool active=true;string message="prison";static ERR_GameMode* current;static ERR_GameMode* Get(){return current;}int Phase(){return phase;}bool Active(){return active;}} mode; ERR_GameMode* ERR_GameMode::current=&mode; struct ERR_ObjectiveText {static string Description(ERR_GameMode* m,bool){return m->message;}static string Title(int){return "objective";}}; struct SCR_HintManagerComponent {static SCR_HintManagerComponent* current;bool accepts=true;int attempts=0,shown=0;string last;static SCR_HintManagerComponent* GetInstance(){return current;}bool ShowCustom(string text,string,int){attempts++;if(!accepts)return false;shown++;last=text;return true;}} hints; @@ -57,9 +59,10 @@ Actor* controlled=&actor;int m_ERRObserverReason=ERR_ObserverReason::NONE; SCR_ManualCamera* m_ERRObserverCamera=nullptr;Vec m_ERRObserverAnchor; int m_ERRObservedId=1;bool m_ERRObserverReady=false; + string m_ERRShownRecoveryStatus; int m_ERRShownObserverReason=0;bool m_ERRShownObserverAvailable=false;int closes=0; Actor* GetControlledEntity(){return controlled;}int ERR_ObserverState(){return m_ERRObserverReason;} - void ERR_CloseObserverCamera(){closes++;m_ERRObserverReady=false;m_ERRObserverCamera=nullptr;} + void ERR_CloseObserverCamera(){closes++;m_ERRObserverReady=false;m_ERRShownRecoveryStatus="";m_ERRObserverCamera=nullptr;} '''+methods+r''' }; 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;} @@ -135,7 +138,30 @@ reset();Controller casualty;actor.unconscious=true;casualty.m_ERRObserverReason=ERR_ObserverReason::UNCONSCIOUS; casualty.ERR_UpdateObserverClient();assert(casualty.m_ERRObserverCamera==&camera&&casualty.controlled==&actor&&hints.last.find("You are unconscious")!=string::npos); casualty.ERR_UpdateObserverClient();assert(hints.shown==1); - actor.unconscious=false;casualty.ERR_UpdateObserverClient();assert(!casualty.m_ERRObserverCamera&&casualty.closes==1&&hints.shown==1); + // The phase-guide explicitly excludes this server-designated unconscious + // observer; the existing observer monitor must deliver recovery/defeat facts. + int attemptsBeforeGuide=hints.attempts;casualty.ERR_UpdateGuide();assert(hints.attempts==attemptsBeforeGuide); + mode.recovery="Everyone is unconscious with no treatment or recovery progressing. The mission fails after 30 uninterrupted seconds in this state."; + casualty.ERR_UpdateObserverClient();assert(hints.shown==2&&hints.last.find(mode.recovery)!=string::npos&&casualty.ERR_ProbeAcceptedRecoveryStatus()==mode.recovery); + casualty.ERR_UpdateObserverClient();assert(hints.shown==2); // stable status never repeats + mode.recovery="Treatment or natural recovery is possible; the round continues.";hints.accepts=false; + casualty.ERR_UpdateObserverClient();assert(hints.shown==2&&casualty.m_ERRShownRecoveryStatus.find("30 uninterrupted")!=string::npos); + hints.accepts=true;casualty.ERR_UpdateObserverClient();assert(hints.shown==3&&hints.last.find(mode.recovery)!=string::npos); + mode.recovery="A conscious prisoner's reconnection may provide help.";SCR_HintManagerComponent::current=nullptr; + casualty.ERR_UpdateObserverClient();assert(hints.shown==3&&casualty.m_ERRShownRecoveryStatus.find("natural recovery")!=string::npos); + SCR_HintManagerComponent::current=&hints;casualty.ERR_UpdateObserverClient();assert(hints.shown==4&&hints.last.find(mode.recovery)!=string::npos); + mode.recovery="";casualty.ERR_UpdateObserverClient();assert(hints.shown==5&&casualty.m_ERRShownRecoveryStatus.empty()&&hints.last.find("reconnection")==string::npos); + casualty.ERR_UpdateObserverClient();assert(hints.shown==5); + // Waking closes the view; a new status cannot keep a recovered observer hint alive. + mode.recovery="remaining casualty warning";actor.unconscious=false;casualty.ERR_UpdateObserverClient();assert(!casualty.m_ERRObserverCamera&&casualty.closes==1&&hints.shown==5&&casualty.ERR_ProbeAcceptedRecoveryStatus().empty()); + // An old controller cannot consume the replacement's recovery-status update. + reset();Controller oldCasualty;actor.unconscious=true;oldCasualty.m_ERRObserverReason=ERR_ObserverReason::UNCONSCIOUS;oldCasualty.ERR_UpdateObserverClient(); + Controller newCasualty;newCasualty.m_ERRObserverReason=ERR_ObserverReason::UNCONSCIOUS;mode.recovery="recovery warning after reconnect";int beforeOld=hints.shown; + oldCasualty.ERR_UpdateObserverClient();assert(hints.shown==beforeOld&&oldCasualty.m_ERRShownRecoveryStatus.empty()); + newCasualty.ERR_UpdateObserverClient();assert(hints.shown==beforeOld+1&&hints.last.find(mode.recovery)!=string::npos); + mode.active=false;mode.recovery="old round recovery";newCasualty.ERR_UpdateObserverClient();assert(hints.shown==beforeOld+1&&!newCasualty.m_ERRObserverReady&&newCasualty.m_ERRShownRecoveryStatus.empty()); + // A later active round's first observer hint contains only its current fact. + mode.active=true;mode.recovery="new round recovery";newCasualty.ERR_UpdateObserverClient();assert(hints.shown==beforeOld+2&&hints.last.find("new round recovery")!=string::npos&&hints.last.find("old round recovery")==string::npos); } ''') for path,signature in ((GUIDE,'protected void ERR_UpdateGuide('),(OBSERVER,'protected void ERR_UpdateObserverClient(')): --- a/tests/test_observer_ownership.py +++ b/tests/test_observer_ownership.py @@ -23,10 +23,10 @@ run(r'''#include #include #include -using string=std::string; +struct string:std::string {using std::string::string;using std::string::operator=;string(std::string s):std::string(s){}bool IsEmpty()const{return empty();}}; struct Actor {bool alive=true,conscious=true;};using IEntity=Actor*; struct ERR_Actors {static bool Alive(IEntity a){return a&&a->alive;}static bool CanFight(IEntity a){return Alive(a)&&a->conscious;}}; -struct ERR_GameMode {bool active=true;static ERR_GameMode*current;static ERR_GameMode*Get(){return current;}bool Active(){return active;}} mode; +struct ERR_GameMode {string RecoveryStatus(){return "";}bool active=true;static ERR_GameMode*current;static ERR_GameMode*Get(){return current;}bool Active(){return active;}} mode; ERR_GameMode*ERR_GameMode::current=&mode; struct ERR_ObserverRules {static bool View(int reason,bool active,bool alive,bool conscious){return active&&((reason==4&&alive&&!conscious)||(reason!=0&&!alive));}}; struct ERR_ObserverGuidance {static bool Refresh(bool ready,int oldReason,int reason,bool oldAvailable,bool available){return !ready||oldReason!=reason||oldAvailable!=available;}static string Message(int,bool){return "spectating";}}; @@ -58,6 +58,7 @@ struct Controller{ IEntity actor=nullptr,m_ERRRecoveryBody=nullptr;float m_ERRRecoveryDeadline=-1; bool m_ERRObserverMonitor=false,m_ERRObserverReady=false,m_ERRShownObserverAvailable=false; + string m_ERRShownRecoveryStatus; int m_ERRObserverReason=0,m_ERRShownObserverReason=0,m_ERRObservedId=1,starts=0; Vec m_ERRObserverAnchor;SCR_ManualCamera*m_ERRObserverCamera=nullptr;CameraBase*playerCamera=nullptr; IEntity GetControlledEntity(){return actor;}CameraBase*GetPlayerCamera(){return playerCamera;} --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_UnconsciousCameraProbe.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_UnconsciousCameraProbe.c @@ -4,6 +4,7 @@ modded class SCR_PlayerController { protected IEntity m_ERRCameraProbeTarget; + string ERR_ProbeAcceptedRecoveryStatus() { return m_ERRShownRecoveryStatus; } void ERR_ProbeCameraTarget(IEntity target) { m_ERRCameraProbeTarget = target; } override IEntity ERR_ObservedEntity() { @@ -91,6 +92,7 @@ if (!ERR_CameraCheck(pc.GetControlledEntity() == m_ERRCameraBody && ERR_Actors.Alive(m_ERRCameraBody) && !ERR_Actors.CanFight(m_ERRCameraBody), "possession and living unconscious body unchanged")) return; if (!ERR_CameraCheck(!pc.ERR_ObservedEntity() && vector.DistanceXZ(pc.ERR_ObserverAnchor(), m_ERRCameraBody.GetOrigin()) < 1, "no teammate falls back to the casualty body")) return; if (!ERR_CameraCheck(playerInventory.Contains(m_ERRCameraWeapon), "enabled retention keeps the exact rifle with the unconscious player")) return; + if (!ERR_CameraCheck(!RecoveryStatus().IsEmpty() && RecoveryStatus().Contains("Treatment or natural recovery") && pc.ERR_ProbeAcceptedRecoveryStatus() == RecoveryStatus(), "accepted observer hint contains the current ongoing recovery status")) return; vector point = ERR_Actors.Ground(m_ERRCameraBody.GetOrigin() + "25 0 25"); m_ERRCameraTarget = ERR_Actors.Spawn(Factions().PrisonerPrefab, point); Own(m_ERRCameraTarget); if (!ERR_CameraCheck(m_ERRCameraTarget != null, "physical follow-target stand-in created")) return; @@ -165,6 +167,7 @@ { if (!ERR_CameraCheck(ERR_Actors.CanFight(m_ERRCameraBody) && pc.GetControlledEntity() == m_ERRCameraBody && pc.ERR_ObserverState() == ERR_ObserverReason.NONE && !pc.ERR_HasObserverCamera(), "native healing closes spectator and retains the original conscious player")) return; if (!ERR_CameraCheck(GetGame().GetCameraManager().CurrentCamera() == pc.GetPlayerCamera(), "normal player camera restored")) return; + if (!ERR_CameraCheck(pc.ERR_ProbeAcceptedRecoveryStatus().IsEmpty(), "awakening clears the accepted observer recovery-status cache")) return; if (m_ERRCameraOther) { m_ERRCameraOther.Terminate(); delete m_ERRCameraOther; m_ERRCameraOther = null; } if (!ERR_CameraCheck(character.GetWeaponManagerComponent().GetCurrentWeapon() && character.GetWeaponManagerComponent().GetCurrentWeapon().GetOwner() == m_ERRCameraWeapon, "revival equips the same rifle without ground pickup")) return; if (!ERR_CameraCheck(Settings().Set("keepWeaponOnUnconscious", 0), "retention disabled through persistent setting")) return;