Preserve native chat during terrain takeover with fallback for missing chat

This commit is contained in:
2026-10-14 14:39:55 +02:00
parent e4ca37dfc1
commit 01994ed344
9 changed files with 170 additions and 15 deletions
@@ -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 } }
}
}
}
@@ -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 } }
}
}
}
@@ -0,0 +1,4 @@
MetaFileClass {
Name "{DA8E364BC095712F}Prefabs/ERR/TerrainTakeoverWithChat.et"
Configurations { ResourceClass PC {} ResourceClass HEADLESS : PC {} }
}
@@ -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<IEntity> managers = {m_ERRTerrainOriginal, GetLoadoutManager(), GetFactionManager(), GetPerceptionManager(), GetChat()};
array<IEntity> 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;
@@ -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);
@@ -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<IEntity> 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<Faction, SCR_EditableFactionComponent> delegates = new map<Faction, SCR_EditableFactionComponent>();
+18 -2
View File
@@ -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
+89
View File
@@ -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 <cassert>\n#include <string>\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<IEntity> 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 <cassert>
#include <vector>
#include <algorithm>
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<IEntity*> {
using std::vector<IEntity*>::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<IEntity> 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')
+1
View File
@@ -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)