Files
jakub 6a667d8deb Capture current mission, AI, map and administration development
Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
2026-10-13 22:52:53 +02:00

159 lines
13 KiB
Diff

--- a/addons/core/Scripts/Game/ERR/00_Core/ERR_Observer.c
+++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_Observer.c
@@ -349,6 +349,17 @@
protected void ERR_OnObserverMonitorStarted() {}
protected void ERR_UpdateObserverClient()
{
+ if (!GetGame()) return;
+ if (this != GetGame().GetPlayerController())
+ {
+ // A replaced controller may retain its timer until native destruction.
+ // Release only its presentation; never restore its old body camera.
+ GetGame().GetCallqueue().Remove(ERR_UpdateObserverClient);
+ m_ERRObserverMonitor = false;
+ m_ERRRecoveryBody = null; m_ERRRecoveryDeadline = -1;
+ ERR_CloseObserverCamera();
+ return;
+ }
ERR_GameMode mode = ERR_GameMode.Get();
ERR_UpdateRecoveryCamera(mode);
bool observe = mode && ERR_ObserverRules.View(m_ERRObserverReason, mode.Active(), ERR_Actors.Alive(GetControlledEntity()), ERR_Actors.CanFight(GetControlledEntity()));
--- a/tests/test_hint_delivery.py
+++ b/tests/test_hint_delivery.py
@@ -3,16 +3,16 @@
from pathlib import Path
import os
GUIDE=Path(os.environ.get('ERR_GUIDE_SOURCE', ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_PlayerGuide.c'))
-OBSERVER=ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_Observer.c'
+OBSERVER=Path(os.environ.get('ERR_OBSERVER_SOURCE', ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_Observer.c'))
def cpp(text):
- text=text.replace('protected ', '').replace('override ', '').replace('super.OnControlledEntityChanged(from, to);', 'nativeChanges++;')
+ 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'):
text=text.replace(name+'.',name+'::')
for typename in ('ERR_GameMode','SCR_HintManagerComponent','Resource','SCR_RespawnSystemComponent','CameraManager'):
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('GetCallqueue().','GetCallqueue()->').replace('Remove(ERR_UpdateGuide)','Remove(this)').replace('Remove(ERR_UpdateObserverClient)','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,'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('))
@@ -53,7 +53,7 @@
Controller(){game.local=this;}
void ERR_UpdateRecoveryCamera(ERR_GameMode*){}
int nativeChanges=0,starts=0;bool m_ERRGuideStarted=false;void ERR_StartGuide(){starts++;StartActual();}
- int m_ERRGuidePhase=-1;string m_ERRGuideMessage;
+ int m_ERRGuidePhase=-1;string m_ERRGuideMessage;bool m_ERRObserverMonitor=false;Actor* m_ERRRecoveryBody=nullptr;float m_ERRRecoveryDeadline=-1;
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;
--- /dev/null
+++ b/tests/test_observer_ownership.py
@@ -0,0 +1,104 @@
+"""Actual observer monitor/recovery/cleanup; native camera lifecycle is doubled."""
+import os,re
+from pathlib import Path
+from policy_harness import ROOT,body,run
+p=Path(os.environ.get('ERR_OBSERVER_SOURCE',ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_Observer.c'))
+def cpp(s):
+ s=s.replace('protected ','').replace('null','nullptr')
+ for n in ('ERR_GameMode','ERR_Actors','ERR_ObserverRules','ERR_ObserverGuidance','SCR_HintManagerComponent','SCR_RespawnSystemComponent','SCR_EditorManagerEntity','Resource','SCR_ManualCamera','RplId','ETransformMode','LogLevel','System'):
+ s=s.replace(n+'.',n+'::')
+ for n in ('ERR_GameMode','SCR_HintManagerComponent','SCR_RespawnSystemComponent','SCR_EditorManagerEntity','Resource','CameraManager','CameraBase'):
+ s=re.sub(r'\b'+n+r' (\w+)',n+r'* \1',s)
+ for n in ('mode','hints','respawn','editor','resource','manager','m_ERRObserverCamera'):
+ s=s.replace(n+'.',n+'->')
+ s=s.replace('GetGame().','GetGame()->').replace('GetWorld().','GetWorld()->').replace('GetCallqueue().','GetCallqueue()->')
+ s=s.replace('Remove(ERR_UpdateObserverClient)','Remove(this)').replace('CallLater(ERR_UpdateObserverClient, 250, true)','CallLater(this, 250, true)')
+ s=s.replace('EntitySpawnParams params = new EntitySpawnParams();','EntitySpawnParams params;')
+ return s
+methods='\n'.join(cpp(body(p,n)) for n in ('void ERR_StartObserverMonitor(', 'protected void ERR_UpdateObserverClient(', 'protected void ERR_UpdateRecoveryCamera(', 'protected void ERR_CloseObserverCamera('))
+run(r'''#include <cassert>
+#include <set>
+#include <string>
+using string=std::string;
+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;
+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";}};
+struct Vec{Vec operator+(const char*)const{return {};}};
+struct EntitySpawnParams{int TransformMode=0;Vec Transform[4];};namespace ETransformMode{enum{WORLD};}
+struct Resource{static Resource*Load(const char*){static Resource r;return &r;}bool IsValid(){return true;}};
+struct CameraBase{virtual ~CameraBase()=default;};
+struct CameraManager{CameraBase*current=nullptr;int sets=0;CameraBase*CurrentCamera(){return current;}void SetCamera(CameraBase*c){current=c;sets++;}} manager;
+int terminated=0,deleted=0,explicitSwitch=0;
+struct SCR_ManualCamera:CameraBase{
+ CameraBase*previous=nullptr;bool ended=false;
+ static SCR_ManualCamera*Cast(SCR_ManualCamera*c){return c;}
+ void TrySwitchToControlledEntityCamera(){explicitSwitch++;}
+ void Terminate(){if(!ended){ended=true;terminated++;}}
+ // SDK SCR_ManualCamera destructor invokes SwitchToPreviousCamera, which
+ // changes only its own currently selected camera, never a foreign current one.
+ ~SCR_ManualCamera(){Terminate();if(manager.current==this&&previous)manager.SetCamera(previous);deleted++;}
+};
+struct SCR_HintManagerComponent{int attempts=0;static SCR_HintManagerComponent*GetInstance(){static SCR_HintManagerComponent h;return &h;}bool ShowCustom(string,string,int){attempts++;return true;}};
+struct SCR_RespawnSystemComponent{int clears=0;static SCR_RespawnSystemComponent*GetInstance(){static SCR_RespawnSystemComponent r;return &r;}void DestroyLoadingPlaceholder(){clears++;}};
+struct SCR_EditorManagerEntity{bool opened=false;int closes=0;static SCR_EditorManagerEntity*GetInstance(){static SCR_EditorManagerEntity e;return &e;}bool IsOpened(){return opened;}void Close(bool){opened=false;closes++;}};
+struct RplId{static int Invalid(){return -1;}};namespace LogLevel{enum{WARNING};}void Print(const char*,int){}
+namespace System{bool IsConsoleApp(){return false;}}
+struct Queue{std::set<void*>owners;int added=0;void CallLater(void*owner,int,bool){owners.insert(owner);added++;}void Remove(void*owner){owners.erase(owner);}} queue;
+struct World{float now=1;float GetWorldTime(){return now;}} world;
+struct Game{void*local=nullptr;int spawns=0;void*GetPlayerController(){return local;}Queue*GetCallqueue(){return &queue;}World*GetWorld(){return &world;}CameraManager*GetCameraManager(){return &manager;}
+ SCR_ManualCamera*SpawnEntityPrefab(Resource*,World*,EntitySpawnParams){spawns++;auto*c=new SCR_ManualCamera();c->previous=manager.current;manager.SetCamera(c);return c;}} game;
+bool gameAvailable=true;Game*GetGame(){return gameAvailable?&game:nullptr;}
+struct Controller{
+ IEntity actor=nullptr,m_ERRRecoveryBody=nullptr;float m_ERRRecoveryDeadline=-1;
+ bool m_ERRObserverMonitor=false,m_ERRObserverReady=false,m_ERRShownObserverAvailable=false;
+ 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;}
+ void ERR_OnObserverMonitorStarted(){starts++;}
+'''+methods+r'''
+};
+int main(){
+ Actor body;CameraBase formerBodyCamera,newBodyCamera;
+ Controller former,current;former.actor=&body;former.playerCamera=&formerBodyCamera;
+ game.local=&former;manager.current=&formerBodyCamera;former.ERR_StartObserverMonitor();
+ body.conscious=false;former.m_ERRObserverReason=4;former.ERR_UpdateObserverClient();
+ assert(former.m_ERRRecoveryBody==&body&&former.m_ERRObserverCamera&&queue.owners.count(&former));
+ SCR_ManualCamera*oldOwned=former.m_ERRObserverCamera;
+ // New controller owns a separate spectator camera; old body wakes meanwhile.
+ current.playerCamera=&newBodyCamera;current.m_ERRObserverReason=1;game.local=&current;current.ERR_StartObserverMonitor();current.ERR_UpdateObserverClient();
+ SCR_ManualCamera*newOwned=current.m_ERRObserverCamera;assert(newOwned&&newOwned!=oldOwned&&manager.current==newOwned);
+ auto*editor=SCR_EditorManagerEntity::GetInstance();editor->opened=true;body.conscious=true;
+ int beforeClears=SCR_RespawnSystemComponent::GetInstance()->clears;
+ int beforeSets=manager.sets,beforeHints=SCR_HintManagerComponent::GetInstance()->attempts,beforeSpawns=game.spawns,beforeDeleted=deleted;
+ former.ERR_UpdateObserverClient();
+ assert(manager.current==newOwned&&manager.sets==beforeSets&&editor->opened&&editor->closes==0);
+ assert(!former.m_ERRRecoveryBody&&former.m_ERRRecoveryDeadline==-1&&!former.m_ERRObserverMonitor&&!former.m_ERRObserverCamera);
+ assert(deleted==beforeDeleted+1&&terminated==deleted&&explicitSwitch==0);
+ assert(current.m_ERRObserverCamera==newOwned&&queue.owners.count(&current)==1&&queue.owners.count(&former)==0);
+ assert(SCR_HintManagerComponent::GetInstance()->attempts==beforeHints&&game.spawns==beforeSpawns&&SCR_RespawnSystemComponent::GetInstance()->clears==beforeClears);
+ // Even a queued stale spectator callback cannot create a camera or a hint.
+ former.actor=nullptr;former.m_ERRObserverReason=1;former.ERR_UpdateObserverClient();
+ assert(game.spawns==beforeSpawns&&manager.current==newOwned&&SCR_HintManagerComponent::GetInstance()->attempts==beforeHints);
+ // Start is still local-only; reacquisition restarts exactly one owned timer.
+ int added=queue.added;former.ERR_StartObserverMonitor();assert(queue.added==added);
+ game.local=&former;former.actor=&body;former.m_ERRObserverReason=0;former.ERR_StartObserverMonitor();former.ERR_StartObserverMonitor();
+ assert(queue.added==added+1&&former.starts==2&&queue.owners.count(&current)==1);
+ // Normal recovery still closes the editor and returns this controller's body.
+ body.conscious=false;former.ERR_UpdateObserverClient();assert(former.m_ERRRecoveryBody==&body);
+ body.conscious=true;former.ERR_UpdateObserverClient();assert(manager.current==&formerBodyCamera&&!editor->opened&&editor->closes==1&&!former.m_ERRRecoveryBody);
+ // Foreign current camera survives this controller's retained camera cleanup.
+ game.local=&current;manager.current=&newBodyCamera;current.ERR_CloseObserverCamera();assert(manager.current==&newBodyCamera&&deleted==beforeDeleted+2);
+ // If the old owned camera is still current at replacement, its native
+ // destructor may restore its own previous camera; no new-controller camera is deleted.
+ Controller abandoned;abandoned.m_ERRObserverReason=1;game.local=&abandoned;manager.current=&newBodyCamera;
+ abandoned.ERR_StartObserverMonitor();abandoned.ERR_UpdateObserverClient();assert(manager.current==abandoned.m_ERRObserverCamera);
+ game.local=&current;beforeSets=manager.sets;beforeDeleted=deleted;abandoned.ERR_UpdateObserverClient();
+ assert(!abandoned.m_ERRObserverCamera&&manager.current==&newBodyCamera&&manager.sets==beforeSets+1&&deleted==beforeDeleted+1);
+ assert(queue.owners.count(&abandoned)==0&&queue.owners.count(&current)==1);
+ gameAvailable=false;former.ERR_UpdateObserverClient();gameAvailable=true;
+}
+''')
+print('PASS: actual observer monitor/recovery/cleanup rejects retained old controller, preserves new camera/editor/hints/timer, restarts once and retains normal recovery; native effects modeled from SDK, no runtime claim')