Preserve native chat during terrain takeover with fallback for missing chat
This commit is contained in:
@@ -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>();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user