From 01994ed344dfb0d77bca92633ef6efea532b091c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20=C5=BD=C3=A1=C4=8Dek?= Date: Wed, 14 Oct 2026 14:39:55 +0200 Subject: [PATCH] Preserve native chat during terrain takeover with fallback for missing chat --- addons/core/Prefabs/ERR/TerrainTakeover.et | 4 - .../Prefabs/ERR/TerrainTakeoverWithChat.et | 9 ++ .../ERR/TerrainTakeoverWithChat.et.meta | 4 + .../Game/ERR/00_Core/ERR_TerrainTakeover.c | 24 +++-- .../ERR/00_Core/ERR_TerrainTakeoverClient.c | 8 +- .../ERR/00_Core/ERR_TerrainTakeoverManagers.c | 26 +++++- docs/terrain-takeover.md | 20 ++++- tests/test_terrain_chat_retention.py | 89 +++++++++++++++++++ tools/check.py | 1 + 9 files changed, 170 insertions(+), 15 deletions(-) create mode 100644 addons/core/Prefabs/ERR/TerrainTakeoverWithChat.et create mode 100644 addons/core/Prefabs/ERR/TerrainTakeoverWithChat.et.meta create mode 100644 tests/test_terrain_chat_retention.py diff --git a/addons/core/Prefabs/ERR/TerrainTakeover.et b/addons/core/Prefabs/ERR/TerrainTakeover.et index 822303f..6e521bc 100644 --- a/addons/core/Prefabs/ERR/TerrainTakeover.et +++ b/addons/core/Prefabs/ERR/TerrainTakeover.et @@ -16,9 +16,5 @@ ERR_TerrainTakeoverMode : "{978E045E53EF36C8}Prefabs/ERR/GameMode.et" { SCR_LoadoutManager Loadouts : "{97EF4C1A41F63DDB}Prefabs/MP/Managers/Loadouts/LoadoutManager_USxUSSR.et" { ID "588AD55F7655278B" } - ScriptedChatEntity Chat : "{F69BC912AC8236F9}Prefabs/MP/ScriptedChatEntity.et" { - ID "85ED17B0836DB4A2" - components { RplComponent "{CA456708D69D73BA}" { Streamable Disabled } } - } } } diff --git a/addons/core/Prefabs/ERR/TerrainTakeoverWithChat.et b/addons/core/Prefabs/ERR/TerrainTakeoverWithChat.et new file mode 100644 index 0000000..c1a2bf9 --- /dev/null +++ b/addons/core/Prefabs/ERR/TerrainTakeoverWithChat.et @@ -0,0 +1,9 @@ +ERR_TerrainTakeoverMode : "{9D182739ABEF405C}Prefabs/ERR/TerrainTakeover.et" { + ID "CE902374A658BDF1" + { + ScriptedChatEntity Chat : "{F69BC912AC8236F9}Prefabs/MP/ScriptedChatEntity.et" { + ID "85ED17B0836DB4A2" + components { RplComponent "{CA456708D69D73BA}" { Streamable Disabled } } + } + } +} diff --git a/addons/core/Prefabs/ERR/TerrainTakeoverWithChat.et.meta b/addons/core/Prefabs/ERR/TerrainTakeoverWithChat.et.meta new file mode 100644 index 0000000..aa3dec5 --- /dev/null +++ b/addons/core/Prefabs/ERR/TerrainTakeoverWithChat.et.meta @@ -0,0 +1,4 @@ +MetaFileClass { + Name "{DA8E364BC095712F}Prefabs/ERR/TerrainTakeoverWithChat.et" + Configurations { ResourceClass PC {} ResourceClass HEADLESS : PC {} } +} diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_TerrainTakeover.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_TerrainTakeover.c index 1a436d5..48cc53f 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_TerrainTakeover.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_TerrainTakeover.c @@ -11,6 +11,11 @@ enum ERR_TerrainTakeoverStage class ERR_TerrainTakeoverRules { + static ResourceName Bootstrap(bool hasNativeChat) + { + if (hasNativeChat) return "{9D182739ABEF405C}Prefabs/ERR/TerrainTakeover.et"; + return "{DA8E364BC095712F}Prefabs/ERR/TerrainTakeoverWithChat.et"; + } static bool TimedOut(float elapsed) { return elapsed >= 10; } static bool MayDrain(float elapsed, bool sameMode) { return elapsed >= 0.5 && sameMode; } static bool MayCreate(float elapsed, bool empty) { return empty && !TimedOut(elapsed); } @@ -68,6 +73,7 @@ modded class ArmaReforgerScripted protected ERR_TerrainTakeoverStage m_ERRTerrainStage; protected float m_ERRTerrainElapsed; protected RadioManagerEntity m_ERRTerrainRadio; + protected BaseChatEntity m_ERRTerrainChat; protected EntityID m_ERRTerrainOldLoadoutId; protected BaseWorld m_ERRTerrainWorld; protected SCR_BaseGameMode m_ERRTerrainOriginal; @@ -110,6 +116,7 @@ modded class ArmaReforgerScripted m_ERRTerrainStage = ERR_TerrainTakeoverStage.INACTIVE; m_ERRTerrainElapsed = 0; m_ERRTerrainRadio = null; + m_ERRTerrainChat = null; m_ERRTerrainOldLoadoutId = EntityID.INVALID; m_ERRTerrainWorld = null; m_ERRTerrainOriginal = null; @@ -130,7 +137,7 @@ modded class ArmaReforgerScripted ERR_FailTerrainTakeover("Select a plain scenario with SCR_BaseGameMode and a Soldiers navmesh; original scenario retained."); return false; } - Resource prefab = Resource.Load("{9D182739ABEF405C}Prefabs/ERR/TerrainTakeover.et"); + Resource prefab = Resource.Load(ERR_TerrainTakeoverRules.Bootstrap(GetChat() != null)); if (!prefab || !prefab.IsValid()) { ERR_FailTerrainTakeover("Escape bootstrap prefab is missing; original scenario retained."); @@ -210,8 +217,13 @@ modded class ArmaReforgerScripted { SCR_EditorManagerCore editorCore = SCR_EditorManagerCore.Cast(SCR_EditorManagerCore.GetInstance(SCR_EditorManagerCore)); if (editorCore) editorCore.ERR_SetTerrainModeEvents(m_ERRTerrainOriginal, false); - array managers = {m_ERRTerrainOriginal, GetLoadoutManager(), GetFactionManager(), GetPerceptionManager(), GetChat()}; + array managers = {m_ERRTerrainOriginal, GetLoadoutManager(), GetFactionManager(), GetPerceptionManager()}; ERR_TerrainTakeoverReplication.CollectDelegates(SCR_DelegateFactionManagerComponent.GetInstance(), managers); + // Native chat unregister can clear a newer registration after replication + // delivers the replacement. Keep the terrain's chat on both peers instead. + m_ERRTerrainChat = GetChat(); + if (m_ERRTerrainChat && !ERR_TerrainTakeoverReplication.RetainManager(m_ERRTerrainChat, managers)) + { ERR_FailTerrainTakeover("Could not retain the native chat service; original scenario retained."); return; } ChimeraWorld chimera = ChimeraWorld.CastFrom(GetWorld()); if (chimera) m_ERRTerrainRadio = chimera.GetRadioManager(); // Native radio teardown clears the world pointer even after a replacement @@ -230,7 +242,7 @@ modded class ArmaReforgerScripted ancestor = ancestor.GetParent(); } } - Print("Reintroduced [terrain takeover]: stopping original scenario; preserving radio manager"); + Print("Reintroduced [terrain takeover]: stopping original scenario; preserving radio and chat managers"); m_ERRTerrainOriginal.OnGameEnd(); if (m_DataCollectorComponent && m_DataCollectorComponent.GetOwner() == m_ERRTerrainOriginal) m_DataCollectorComponent = null; SCR_EntityCatalogManagerComponent.ERR_ReleaseTerrainCatalog(m_ERRTerrainOriginal); @@ -258,7 +270,7 @@ modded class ArmaReforgerScripted protected bool ERR_TerrainRegistrationsEmpty() { ChimeraWorld chimera = ChimeraWorld.CastFrom(GetWorld()); - return !GetGameMode() && !GetLoadoutManager() && !GetFactionManager() && !GetPerceptionManager() && !GetChat() && (!chimera || chimera.GetRadioManager() == m_ERRTerrainRadio) && !SCR_EntityCatalogManagerComponent.GetInstance() && !m_DataCollectorComponent; + return !GetGameMode() && !GetLoadoutManager() && !GetFactionManager() && !GetPerceptionManager() && GetChat() == m_ERRTerrainChat && (!chimera || chimera.GetRadioManager() == m_ERRTerrainRadio) && !SCR_EntityCatalogManagerComponent.GetInstance() && !m_DataCollectorComponent; } protected void ERR_CreateTerrainMode() { @@ -277,9 +289,9 @@ modded class ArmaReforgerScripted if (!marker) { ERR_FailTerrainTakeover("Could not create configured airport marker; restart the scenario."); return; } marker.SetName("ERR_Airfield_" + m_ERRTerrainMarkerSlots[i].ToString()); } - ERR_GameMode mode = ERR_GameMode.Cast(SpawnEntityPrefab(Resource.Load("{9D182739ABEF405C}Prefabs/ERR/TerrainTakeover.et"), GetWorld())); + ERR_GameMode mode = ERR_GameMode.Cast(SpawnEntityPrefab(Resource.Load(ERR_TerrainTakeoverRules.Bootstrap(m_ERRTerrainChat != null)), GetWorld())); if (!mode || GetGameMode() != mode) { ERR_FailTerrainTakeover("Escape game mode could not register; restart the scenario."); return; } - if (!ERR_TerrainTakeoverReplication.Link(mode)) { ERR_FailTerrainTakeover("Escape replication hierarchy could not register; restart the scenario."); return; } + if (!ERR_TerrainTakeoverReplication.Link(mode, !m_ERRTerrainChat)) { ERR_FailTerrainTakeover("Escape replication hierarchy could not register; restart the scenario."); return; } SCR_EditorManagerCore editorCore = SCR_EditorManagerCore.Cast(SCR_EditorManagerCore.GetInstance(SCR_EditorManagerCore)); if (editorCore) editorCore.ERR_SetTerrainModeEvents(mode, true); m_ERRTerrainStage = ERR_TerrainTakeoverStage.RUNNING; diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_TerrainTakeoverClient.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_TerrainTakeoverClient.c index 526dd63..c6595e6 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_TerrainTakeoverClient.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_TerrainTakeoverClient.c @@ -57,7 +57,6 @@ modded class ArmaReforgerScripted m_ERRClientOldManagers.Insert(GetLoadoutManager()); m_ERRClientOldManagers.Insert(GetFactionManager()); m_ERRClientOldManagers.Insert(GetPerceptionManager()); - m_ERRClientOldManagers.Insert(GetChat()); } override void UnregisterLoadoutManager(SCR_LoadoutManager instance) { @@ -89,6 +88,13 @@ modded class ArmaReforgerScripted void ERR_BeginClientTerrainTakeover(ERR_TerrainTakeoverMode incoming) { if (m_ERRClientOldManagers.IsEmpty()) return; + // Match authority retention before deleting any original parent. No second + // chat entity is created when the terrain already supplies one. + if (GetChat() && !ERR_TerrainTakeoverReplication.RetainManager(GetChat(), m_ERRClientOldManagers)) + { + Print("Reintroduced [terrain client]: could not retain native chat; original managers retained", LogLevel.ERROR); + return; + } m_ERRClientTerrainTakeover = true; SCR_BaseGameMode mode = SCR_BaseGameMode.Cast(m_ERRClientOldManagers[0]); PrintFormat("Reintroduced [terrain client]: drain retained mode=%1 incoming=%2", mode, incoming); diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_TerrainTakeoverManagers.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_TerrainTakeoverManagers.c index 00a8a9b..3b3c4a8 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_TerrainTakeoverManagers.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_TerrainTakeoverManagers.c @@ -256,6 +256,25 @@ class ERR_TerrainTakeoverReplication if (delegate && delegate.GetOwner() && !entities.Contains(delegate.GetOwner())) entities.Insert(delegate.GetOwner()); } } + // Shared world services must survive removal of an old game-mode hierarchy. + static bool RetainManager(IEntity entity, array removed) + { + if (!entity || !removed || removed.Contains(entity)) return false; + IEntity ancestor = entity.GetParent(); + while (ancestor) + { + if (removed.Contains(ancestor)) + { + entity.GetParent().RemoveChild(entity, true); + if (entity.GetParent()) return false; + RplComponent rpl = SCR_EntityHelper.GetEntityRplComponent(entity); + if (rpl && rpl.GetNode()) rpl.GetNode().SetParent(null); + return true; + } + ancestor = ancestor.GetParent(); + } + return true; + } protected static bool Parent(IEntity entity, IEntity parent) { RplComponent childRpl = SCR_EntityHelper.GetEntityRplComponent(entity); @@ -267,11 +286,14 @@ class ERR_TerrainTakeoverReplication if (childNode.GetParent() != parentNode) childNode.SetParent(parentNode); return childNode.GetParent() == parentNode; } - static bool Link(ERR_GameMode mode) + static bool Link(ERR_GameMode mode, bool ownsChat) { IEntity factions = GetGame().GetFactionManager(); if (!factions || !Parent(factions, mode)) return false; - if (!Parent(GetGame().GetLoadoutManager(), mode) || !Parent(GetGame().GetChat(), mode)) return false; + if (!Parent(GetGame().GetLoadoutManager(), mode)) return false; + // Retained native chat stays world-owned. Only a newly supplied fallback + // belongs to this replacement mode's replication node. + if (ownsChat && !Parent(GetGame().GetChat(), mode)) return false; SCR_DelegateFactionManagerComponent manager = SCR_DelegateFactionManagerComponent.GetInstance(); if (!manager) return false; map delegates = new map(); diff --git a/docs/terrain-takeover.md b/docs/terrain-takeover.md index fd4c74d..91b5d42 100644 --- a/docs/terrain-takeover.md +++ b/docs/terrain-takeover.md @@ -240,8 +240,12 @@ account admission. Publication and live-server changes remain with the owner. - The private `Prefabs/ERR/TerrainTakeover.et` triggers client cleanup. It releases only the captured original singleton components, waits for the new factions before catalog/identity initialization, and receives the server's faction - delegates. Chat is replicated to every client. The ordinary authored connector - remains separate from this bootstrap. + delegates. The terrain's native chat service is retained on both peers, detached + from any removed entity/replication parent, and never replaced or attached to + the new mode. The ordinary authored connector retains its own chat service. + If the terrain has no chat service, `TerrainTakeoverWithChat.et` supplies a + non-streaming replicated chat child instead. Selection depends on service + presence, not terrain name; both variants share the same takeover mode. - Arsenal, group, support-station and task managers release the original mode's cached instances on both peers. Task, map-marker and respawn getters resolve the current takeover mode even if an old destructor clears a shared cache. @@ -260,6 +264,18 @@ account admission. Publication and live-server changes remain with the owner. a separate connected Windows client. The connected test recreates the player's controller and verifies the replacement managers in both rounds. +On 14 October, the original Everon client log showed delayed removal of the +original `ScriptedChatEntity` after the replacement registered, followed by +`Cannot send message - ChatEntity is missing.` This blocked native chat commands, +including administrator login, before authentication. `test_terrain_chat_retention.py` +checks shared-service retention, nested parent detachment and fallback selection. +The revised scripts compiled in Workbench; the authored-world native restart +smoke completed two rounds and 21 assertions with no script/runtime failures. +That run still reported existing engine/dependency errors, and the full repository +check stopped on the existing missing root ID in `ERRM_AtlasLeaf.et`. +Live remote login and takeover of both chat variants remain separate verification +steps; the authored-world smoke does not exercise those paths. + The earlier isolated prototype passed a Nogova Workbench prisoner-spawn test with 16 generated sites and zero script errors. See [prototype evidence](evidence/scenario-takeover-2026-10-01/report.json). The current diff --git a/tests/test_terrain_chat_retention.py b/tests/test_terrain_chat_retention.py new file mode 100644 index 0000000..6ebe301 --- /dev/null +++ b/tests/test_terrain_chat_retention.py @@ -0,0 +1,89 @@ +"""Native chat stays world-owned across server/client terrain handover.""" +from policy_harness import ROOT, body, run + +core = ROOT / 'addons/core/Scripts/Game/ERR/00_Core' +server = core / 'ERR_TerrainTakeover.c' +client = core / 'ERR_TerrainTakeoverClient.c' +managers = core / 'ERR_TerrainTakeoverManagers.c' +run('#include \n#include \nusing ResourceName=std::string;\n' + + body(server, 'static ResourceName Bootstrap(') + r''' +int main(){ + assert(Bootstrap(true)=="{9D182739ABEF405C}Prefabs/ERR/TerrainTakeover.et"); + assert(Bootstrap(false)=="{DA8E364BC095712F}Prefabs/ERR/TerrainTakeoverWithChat.et"); +} +''') +retain = body(managers, 'static bool RetainManager(') +retain = (retain.replace('IEntity entity', 'IEntity* entity') + .replace('IEntity ancestor', 'IEntity* ancestor') + .replace('array removed', 'Entities* removed') + .replace('RplComponent rpl', 'RplComponent* rpl') + .replace('SCR_EntityHelper.', 'SCR_EntityHelper::') + .replace('null', 'nullptr')) +for value in ('entity', 'ancestor', 'removed', 'rpl', 'GetParent()', 'GetNode()'): + retain = retain.replace(value + '.', value + '->') +run(r''' +#include +#include +#include +struct Node {Node* parent=nullptr;int changes=0;void SetParent(Node* p){parent=p;changes++;}}; +struct RplComponent {Node* node=nullptr;Node* GetNode(){return node;}}; +struct IEntity { + IEntity* parent=nullptr;RplComponent* rpl=nullptr;bool refuseDetach=false;int removals=0; + IEntity* GetParent(){return parent;} + void RemoveChild(IEntity* child,bool keepWorld){assert(keepWorld&&child->parent==this);removals++;if(!refuseDetach)child->parent=nullptr;} +}; +struct Entities:std::vector { + using std::vector::vector; + bool Contains(IEntity* entity){return std::find(begin(),end(),entity)!=end();} +}; +namespace SCR_EntityHelper {RplComponent* GetEntityRplComponent(IEntity* e){return e->rpl;}} +''' + retain + r''' +int main(){ + IEntity oldMode,chat,intermediate,unrelated;Entities removed{&oldMode,nullptr}; + Node oldNode,chatNode;chatNode.parent=&oldNode;RplComponent chatRpl{&chatNode};chat.rpl=&chatRpl; + // Ordinary terrain chat is standalone: no unregister, replacement or reparent. + assert(RetainManager(&chat,&removed)&&chat.parent==nullptr&&chatNode.changes==0); + // Nested chat is detached before old hierarchy deletion; replication cannot + // later remove the retained service with the discarded mode either. + intermediate.parent=&oldMode;chat.parent=&intermediate; + assert(RetainManager(&chat,&removed)&&chat.parent==nullptr&&intermediate.removals==1); + assert(chatNode.parent==nullptr&&chatNode.changes==1); + assert(RetainManager(&chat,&removed)&&chatNode.changes==1); + chat.parent=&unrelated;assert(RetainManager(&chat,&removed)&&chat.parent==&unrelated&&unrelated.removals==0); + assert(!RetainManager(nullptr,&removed)&&!RetainManager(&chat,nullptr)); + removed.push_back(&chat);assert(!RetainManager(&chat,&removed)&&chat.parent==&unrelated);removed.pop_back(); + oldMode.refuseDetach=true;chat.parent=&oldMode;int changes=chatNode.changes; + assert(!RetainManager(&chat,&removed)&&chat.parent==&oldMode&&chatNode.changes==changes); + oldMode.refuseDetach=false;chat.rpl=nullptr;assert(RetainManager(&chat,&removed)&&chat.parent==nullptr); +} +''') + +drain = body(server, 'protected void ERR_DrainTerrainMode(') +capture = body(client, 'void ERR_ClientCaptureOriginal(') +handover = body(client, 'void ERR_BeginClientTerrainTakeover(') +assert 'GetChat()' not in drain.split('array managers =', 1)[1].split(';', 1)[0] +assert 'm_ERRClientOldManagers.Insert(GetChat())' not in capture +assert drain.index('RetainManager(m_ERRTerrainChat, managers)') < drain.index('m_ERRTerrainOriginal.OnGameEnd()') +assert handover.index('RetainManager(GetChat(), m_ERRClientOldManagers)') < handover.index('mode.OnGameEnd()') +assert 'GetChat() == m_ERRTerrainChat' in body(server, 'protected bool ERR_TerrainRegistrationsEmpty(') +assert 'm_ERRTerrainChat = null' in body(server, 'protected void ERR_ResetTerrainTakeover(') +preflight = body(server, 'protected bool ERR_PreflightTerrainTakeover(') +create = body(server, 'protected void ERR_CreateTerrainMode(') +assert 'Bootstrap(GetChat() != null)' in preflight +assert 'if (!GetChat())' not in preflight +assert 'Bootstrap(m_ERRTerrainChat != null)' in create +assert 'Link(mode, !m_ERRTerrainChat)' in create +assert 'if (m_ERRTerrainChat && !ERR_TerrainTakeoverReplication.RetainManager' in drain +assert 'if (GetChat() && !ERR_TerrainTakeoverReplication.RetainManager' in handover +assert '&& m_ERRTerrainChat &&' not in body(server, 'protected bool ERR_TerrainRegistrationsEmpty(') +assert 'if (ownsChat && !Parent(GetGame().GetChat(), mode)) return false' in body(managers, 'static bool Link(') +prefabs = ROOT / 'addons/core/Prefabs/ERR' +assert 'ScriptedChatEntity' not in (prefabs / 'TerrainTakeover.et').read_text() +fallback = (prefabs / 'TerrainTakeoverWithChat.et').read_text() +assert '"{9D182739ABEF405C}Prefabs/ERR/TerrainTakeover.et"' in fallback +assert fallback.count('ScriptedChatEntity Chat') == 1 +assert 'Streamable Disabled' in fallback +assert '{DA8E364BC095712F}Prefabs/ERR/TerrainTakeoverWithChat.et' in (prefabs / 'TerrainTakeoverWithChat.et.meta').read_text() +assert 'ScriptedChatEntity' in (prefabs / 'TerrainConnector.et').read_text() +assert 'ScriptedChatEntity' in (ROOT / 'addons/core/Worlds/ERR_Everon_Layers/default.layer').read_text() +print('PASS: native chat retained on both peers, nested ownership detached, absent chat supplied by replicated fallback, authored mission chat preserved') diff --git a/tools/check.py b/tools/check.py index 3eed607..6cd142a 100644 --- a/tools/check.py +++ b/tools/check.py @@ -8,6 +8,7 @@ from prefab_identity import check_prefabs ROOT=Path(__file__).resolve().parents[1] BASE='58D0FB3206B6F859' def check(): + subprocess.run([sys.executable,str(ROOT/'tests/test_terrain_chat_retention.py')],check=True) subprocess.run([sys.executable,str(ROOT/'tests/test_empty_server_restart.py')],check=True) subprocess.run([sys.executable,str(ROOT/'tests/test_rescue_factions.py')],check=True) subprocess.run([sys.executable,str(ROOT/'tests/test_rescue_landing.py')],check=True)