Stop successor services on native game-over and preserve escape results

This commit is contained in:
2026-09-19 13:56:15 +02:00
parent a880e0485c
commit 6f0e4c5978
70 changed files with 11457 additions and 23 deletions
@@ -9,8 +9,10 @@ class ERR_EscapeGameOverScreenInfo : SCR_BaseGameOverScreenInfo
}
override string GetTitle(int playerId, array<int> otherPlayerIds)
{
string title = "The helicopter escaped";
if (PersonalResult(playerId) == ERR_PersonalEscapeResult.ESCAPED) title = "You escaped Everon";
string title = "Round ended";
ERR_PersonalEscapeResult result = PersonalResult(playerId);
if (result == ERR_PersonalEscapeResult.ESCAPED) title = "You escaped Everon";
else if (result == ERR_PersonalEscapeResult.MISSED) title = "The helicopter escaped";
#ifdef WORKBENCH
PrintFormat("ERR RESULT UI: player=%1 title=%2", playerId, title);
#endif
+37 -3
View File
@@ -36,6 +36,7 @@ class ERR_GameMode : SCR_BaseGameMode
protected string m_RecoveryStatus;
protected bool m_StartedPlayers;
protected bool m_Stopped;
protected bool m_NativeEndHandled;
protected float m_NoSurvivorsSince = -1;
protected ref ScriptInvoker m_OnPhaseChanged = new ScriptInvoker();
protected ref ScriptInvoker m_OnTick = new ScriptInvoker();
@@ -149,7 +150,7 @@ class ERR_GameMode : SCR_BaseGameMode
m_OwnedEntities.Clear();
}
vector PrisonPosition() { return ERR_Actors.Ground(m_PrisonPosition); }
bool Active() { return m_Phase >= ERR_Phase.PRISON && !ERR_MissionRules.Terminal(m_Phase); }
bool Active() { return !m_Stopped && !m_NativeEndHandled && m_Phase >= ERR_Phase.PRISON && !ERR_MissionRules.Terminal(m_Phase); }
protected ERR_CommandService CreateCommandService() { return null; }
void ConfigureStart(vector position)
@@ -224,7 +225,7 @@ class ERR_GameMode : SCR_BaseGameMode
if (phase == ERR_Phase.VICTORY && m_Extraction) m_Extraction.ConnectedWinners(winners);
EGameOverTypes reason = EGameOverTypes.LOOSE;
if (phase == ERR_Phase.VICTORY) reason = EGameOverTypes.VICTORY;
EndGameMode(SCR_GameModeEndData.Create(reason, winners));
if (!m_NativeEndHandled) EndGameMode(SCR_GameModeEndData.Create(reason, winners));
}
return true;
}
@@ -304,6 +305,11 @@ class ERR_GameMode : SCR_BaseGameMode
protected void BindLocalObserver()
{
if (m_Stopped || m_NativeEndHandled)
{
GetGame().GetCallqueue().Remove(BindLocalObserver);
return;
}
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerController());
if (!controller) return;
controller.ERR_StartObserverMonitor();
@@ -312,7 +318,7 @@ class ERR_GameMode : SCR_BaseGameMode
protected void StartScenario()
{
if (m_Phase != ERR_Phase.PREPARING || !m_PreparationError.IsEmpty()) return;
if (m_Stopped || m_NativeEndHandled || m_Phase != ERR_Phase.PREPARING || !m_PreparationError.IsEmpty()) return;
if (!m_Factions || !m_Factions.WorldReady()) { FailPreparation("The configured player and enemy factions or required assets are missing, invalid or not hostile."); return; }
// Scenario content is created through a separate authoring component.
ERR_ScenarioComponent scenario = ERR_ScenarioComponent.Cast(FindComponent(ERR_ScenarioComponent));
@@ -392,11 +398,39 @@ class ERR_GameMode : SCR_BaseGameMode
m_OnTick.Invoke(now);
}
// Vanilla editor/session termination enters POSTGAME directly, without our
// Transition(). Close the successor before vanilla publishes/restarts the round.
protected override void OnGameModeEnd(SCR_GameModeEndData endData)
{
if (m_NativeEndHandled) return;
m_NativeEndHandled = true;
GetGame().GetCallqueue().Remove(BindLocalObserver);
if (Replication.IsServer())
{
if (!ERR_MissionRules.Terminal(m_Phase))
{
ERR_Phase previous = m_Phase;
// A native end is not proof that anyone crossed the escape boundary.
m_Phase = ERR_Phase.DEFEAT;
if (m_EndReason.IsEmpty()) m_EndReason = "The game session ended this round.";
RefreshRadioFeedback();
PublishEscapeResults();
Replication.BumpMe();
m_OnPhaseChanged.Invoke(previous, m_Phase);
}
StopServices();
}
// Preserve native end data and restart ownership. Never recurse via EndGameMode.
super.OnGameModeEnd(endData);
}
protected void StopServices()
{
if (m_Stopped) return;
m_Stopped = true;
GetGame().GetCallqueue().Remove(StartScenario);
GetGame().GetCallqueue().Remove(ServerTick);
GetGame().GetCallqueue().Remove(BindLocalObserver);
if (m_Command) m_Command.Stop();
m_OnStop.Invoke();
if (Replication.IsServer())
@@ -53,6 +53,8 @@ modded class ERR_GameMode
super.EOnInit(owner);
string enabled;
if (SCR_Global.IsEditMode() || !Replication.IsServer() || !System.GetCLIParam("errBaseSmoke", enabled) || enabled != "1") return;
string nativeEndCase;
if (System.GetCLIParam("errSmokeOutcome", nativeEndCase) && (nativeEndCase == "native-end-active" || nativeEndCase == "native-end-preparing")) return;
ERR_NativeObjectiveProbe.BeginRound();
ERR_NativeProgressionIdentityProbe.BeginRound();
string outcome;
@@ -0,0 +1,105 @@
#ifdef WORKBENCH
// Uses vanilla EndGameMode, deliberately bypassing successor Transition.
// No lifecycle override or synthetic phase change in this fixture.
modded class ERR_GameMode
{
protected static const string ERR_NATIVE_END_MARKER = "$profile:erri-native-end-round.json";
protected bool m_ERREndProbe;
protected bool m_ERREndPreparing;
protected bool m_ERREndRequested;
protected bool m_ERREndDone;
protected int m_ERREndRound = 1;
protected int m_ERREndStops;
protected int m_ERREndPhases;
protected int m_ERREndTicks;
protected int m_ERREndPreviousPhases;
protected int m_ERREndPreviousTicks;
protected float m_ERREndDeadline;
protected float m_ERREndObserveAt;
protected ref SCR_GameModeEndData m_ERREndData;
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
string enabled, outcome;
if (SCR_Global.IsEditMode() || !Replication.IsServer() || !System.GetCLIParam("errBaseSmoke", enabled) || enabled != "1" || !System.GetCLIParam("errSmokeOutcome", outcome)) return;
if (outcome != "native-end-active" && outcome != "native-end-preparing") return;
m_ERREndProbe = true; m_ERREndPreparing = outcome == "native-end-preparing";
if (FileIO.FileExists(ERR_NATIVE_END_MARKER))
{
JsonLoadContext marker = new JsonLoadContext(); int completed;
if (!ERR_EndCheck(marker.LoadFromFile(ERR_NATIVE_END_MARKER) && marker.ReadValue("completedRound", completed) && completed == 1, "second instance reads only first completed round marker")) return;
m_ERREndRound = 2;
}
if (!ERR_EndCheck(!m_Stopped && !m_NativeEndHandled && m_Phase == ERR_Phase.PREPARING && m_Extraction && m_Extraction.State() == ERR_ExtractionState.WAITING, "fresh mode resets native-end guard, phase, service ownership and extraction result")) return;
OnStop().Insert(ERR_EndStopped); OnPhaseChanged().Insert(ERR_EndPhase); OnTick().Insert(ERR_EndTicked);
m_ERREndDeadline = GetWorld().GetWorldTime() + 45000;
GetGame().GetCallqueue().CallLater(ERR_EndProbeTick, 100, true);
PrintFormat("ERR NATIVE END INSTANCE: round=%1 preparing=%2", m_ERREndRound, m_ERREndPreparing);
}
protected bool ERR_EndCheck(bool passed, string detail)
{
if (passed) { Print("ERR SMOKE PASS: native-end " + detail); return true; }
Print("ERR SMOKE FAIL: native-end " + detail, LogLevel.ERROR);
m_ERREndDone = true;
GetGame().GetCallqueue().Remove(ERR_EndProbeTick);
return false;
}
protected void ERR_EndStopped() { m_ERREndStops++; }
protected void ERR_EndPhase(ERR_Phase previous, ERR_Phase current) { m_ERREndPhases++; }
protected void ERR_EndTicked(float now) { m_ERREndTicks++; }
protected void ERR_EndProbeTick()
{
if (!m_ERREndProbe || m_ERREndDone) return;
float now = GetWorld().GetWorldTime();
if (now >= m_ERREndDeadline) { ERR_EndCheck(false, "bounded native-end observation"); return; }
if (!m_ERREndRequested)
{
if (!IsRunning()) return;
if (m_ERREndPreparing)
{
if (!ERR_EndCheck(Phase() == ERR_Phase.PREPARING && GetGame().GetCallqueue().GetRemainingTime(StartScenario) >= 0, "native end precedes the actual queued scenario preparation")) return;
}
else
{
if (Phase() == ERR_Phase.PREPARING) return;
if (!ERR_EndCheck(Phase() == ERR_Phase.PRISON && Active() && Sites() && Sites().Count() > 0, "native end starts from actual prepared active mission")) return;
}
if (!ERR_EndCheck(GetAutoReloadDelay() >= 2, "vanilla reload leaves time to observe stopped callbacks")) return;
m_ERREndRequested = true; m_ERREndPreviousPhases = m_ERREndPhases; m_ERREndPreviousTicks = m_ERREndTicks;
m_ERREndData = SCR_GameModeEndData.CreateSimple(EGameOverTypes.LOOSE);
EndGameMode(m_ERREndData);
if (!ERR_EndCheck(GetState() == SCR_EGameModeState.POSTGAME && GetEndGameData() == m_ERREndData, "actual native end retains supplied data and enters POSTGAME")) return;
if (!ERR_EndCheck(m_NativeEndHandled && m_Stopped && !Active() && Phase() == ERR_Phase.DEFEAT && m_ERREndStops == 1 && m_ERREndPhases == m_ERREndPreviousPhases + 1, "successor terminal phase and services close exactly once")) return;
if (!ERR_EndCheck(m_Extraction.State() == ERR_ExtractionState.WAITING && m_Extraction.EscapedCount() == 0, "external native end creates no escaped identities or extraction success")) return;
m_ERREndObserveAt = now + 1250;
return;
}
if (now < m_ERREndObserveAt) return;
if (!ERR_EndCheck(GetGame().GetCallqueue().GetRemainingTime(ServerTick) < 0 && GetGame().GetCallqueue().GetRemainingTime(StartScenario) < 0 && GetGame().GetCallqueue().GetRemainingTime(BindLocalObserver) < 0, "production tick, local observer binding and queued preparation callbacks are removed before reload")) return;
int ownedCount = m_OwnedEntities.Count(); int locations = m_Locations.Count();
// Exercise actual callback guards without recreating their work or invoking End twice.
StartScenario(); ServerTick(); StopServices();
if (!ERR_EndCheck(m_OwnedEntities.Count() == ownedCount && m_Locations.Count() == locations && m_ERREndTicks == m_ERREndPreviousTicks && m_ERREndStops == 1 && m_ERREndPhases == m_ERREndPreviousPhases + 1, "stale callback bodies and repeated shutdown perform no new work")) return;
if (!ERR_EndCheck(GetEndGameData() == m_ERREndData && Phase() == ERR_Phase.DEFEAT && !Active(), "native result and successor terminal state persist until vanilla reload")) return;
if (m_ERREndRound == 1)
{
JsonSaveContext marker = new JsonSaveContext(); marker.EnableTypeDiscriminator(false); marker.WriteValue("completedRound", 1);
if (!ERR_EndCheck(marker.SaveToFile(ERR_NATIVE_END_MARKER), "first instance writes completed observation marker for vanilla restart")) return;
}
else
{
FileIO.DeleteFile(ERR_NATIVE_END_MARKER);
if (!ERR_EndCheck(!FileIO.FileExists(ERR_NATIVE_END_MARKER), "second independently initialized instance removes its own marker")) return;
}
m_ERREndDone = true;
GetGame().GetCallqueue().Remove(ERR_EndProbeTick);
PrintFormat("ERR SMOKE COMPLETE: native-end round=%1 preparing=%2 actual native termination, stopped services and unchanged end data; vanilla owns restart", m_ERREndRound, m_ERREndPreparing);
}
void ~ERR_GameMode()
{
if (!m_ERREndProbe || !GetGame()) return;
GetGame().GetCallqueue().Remove(ERR_EndProbeTick);
OnStop().Remove(ERR_EndStopped); OnPhaseChanged().Remove(ERR_EndPhase); OnTick().Remove(ERR_EndTicked);
}
}
#endif
@@ -30,7 +30,7 @@ class ERR_NativeResultProbe
array<int> copiedIds = {};
staleData.GetWinnerIds(copiedIds);
controller.ERR_ResetEscapeResult();
if (!Check(controller.ERR_EscapeResult() == ERR_PersonalEscapeResult.UNKNOWN && info.GetTitle(playerId, copiedIds) == "The helicopter escaped", "unknown controller ignores stale native end-data winner ID")) return false;
if (!Check(controller.ERR_EscapeResult() == ERR_PersonalEscapeResult.UNKNOWN && info.GetTitle(playerId, copiedIds) == "Round ended", "unknown controller ignores stale native end-data winner ID")) return false;
ResourceName layout = "{C4507D07F3C54831}UI/layouts/HUD/GameOver/EndScreen/EndScreenContent_NoImage.layout";
s_Content = GetGame().GetWorkspace().CreateWidgets(layout);
if (!Check(s_Content != null, "native result content widgets created")) return false;
@@ -39,7 +39,7 @@ class ERR_NativeResultProbe
if (!Check(contentHandler != null, "native result content handler attached")) return false;
SCR_GameOverScreenUIContentData content = new SCR_GameOverScreenUIContentData(layout, info, staleData, "STALE WINNER", string.Empty, "STALE DEBRIEF", ResourceName.Empty, ResourceName.Empty, null, string.Empty, string.Empty, string.Empty);
contentHandler.InitContent(content);
if (!Check(NativeText("The helicopter escaped", "Your personal escape result is not available."), "opened native content ignores a stale winner while personal result is unknown")) return false;
if (!Check(NativeText("Round ended", "Your personal escape result is not available."), "opened native content ignores a stale winner while personal result is unknown")) return false;
ERR_PlayerRoster roster = new ERR_PlayerRoster();
if (!Check(roster.Bind(playerId, escapedAccount), "isolated presentation account binds to local controller ID")) return false;
roster.Spawned(playerId, controller.GetControlledEntity());
@@ -56,8 +56,8 @@ class ERR_NativeResultProbe
controller.ERR_PublishEscapeResult(roster, extraction);
if (!Check(info.GetTitle(playerId, noWinners) == "You escaped Everon", "presentation follows returning account rather than copied end data")) return false;
controller.ERR_ResetEscapeResult();
if (!Check(controller.ERR_EscapeResult() == ERR_PersonalEscapeResult.UNKNOWN && info.GetTitle(playerId, copiedIds) == "The helicopter escaped", "round reset clears personal result despite stale winner ID")) return false;
if (!Check(NativeText("The helicopter escaped", "Your personal escape result is not available."), "round reset clears the open native result text")) return false;
if (!Check(controller.ERR_EscapeResult() == ERR_PersonalEscapeResult.UNKNOWN && info.GetTitle(playerId, copiedIds) == "Round ended", "round reset clears personal result despite stale winner ID")) return false;
if (!Check(NativeText("Round ended", "Your personal escape result is not available."), "round reset clears the open native result text")) return false;
s_Content.RemoveFromHierarchy();
s_Content = null;
ERR_GameMode mode = ERR_GameMode.Get();