Add independent extraction results and configurable mission clock

This commit is contained in:
2026-09-19 04:23:42 +02:00
parent 39f2e74527
commit e5857b6ddf
90 changed files with 9078 additions and 73 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ SCR_GameOverScreenConfig : "{651583FFFB7FC765}Configs/GameOverScreen/BaseGameOve
m_sAudioOneShot "SOUND_ONVICTORY"
}
}
SCR_BaseGameOverScreenInfo "{E66F29A7A8AD7438}" {
ERR_FailureGameOverScreenInfo "{E66F29A7A8AD7438}" {
m_GameOverScreenId LOOSE
m_GameOverContentLayout "{C4507D07F3C54831}UI/layouts/HUD/GameOver/EndScreen/EndScreenContent_NoImage.layout"
m_OptionalParams SCR_BaseGameOverScreenInfoOptional "{3DBF0BDE715945FA}" {
@@ -19,9 +19,32 @@ class ERR_AdminService
return "";
}
protected bool Extension(ERR_GameMode mode, string arguments, array<string> replies) { return false; }
protected bool TimeCommand(ERR_GameMode mode, array<string> parts, array<string> replies)
{
if (parts.Count() < 2 || parts[0] != "mission" || (parts[1] != "time" && parts[1] != "speed")) return false;
string key = "startTimeMinutes";
if (parts[1] == "speed") key = "timeMultiplier";
if (parts.Count() == 3 && parts[2] == "show")
{
if (mode.Time()) replies.Insert(mode.Time().Status());
replies.Insert("Next round: " + ERR_TimeService.Clock(mode.Settings().Get("startTimeMinutes")) + ", " + mode.Settings().Get("timeMultiplier").ToString() + "x progression.");
return true;
}
int value;
bool valid;
if (parts.Count() == 4 && parts[2] == "set")
{
if (key == "startTimeMinutes") valid = ERR_TimeService.ParseClock(parts[3], value);
else valid = ERR_CommandArguments.Integer(parts[3], value);
}
if (valid && mode.Settings().Set(key, value))
replies.Insert("Saved for next round: " + ERR_TimeService.Clock(mode.Settings().Get("startTimeMinutes")) + ", " + mode.Settings().Get("timeMultiplier").ToString() + "x progression. Current round clock unchanged.");
else replies.Insert("Invalid time or settings could not be saved. Use mission time set HH:MM (00:00-23:59) or mission speed set N (1-24). Previous setting retained.");
return true;
}
protected string Help()
{
return "/esc status | settings | mission radio show|set N | spectate next|previous";
return "/esc status | settings | mission radio show|set N | mission time show|set HH:MM | mission speed show|set N | spectate next|previous";
}
void Execute(ERR_GameMode mode, bool authorized, string arguments, array<string> replies)
{
@@ -40,6 +63,7 @@ class ERR_AdminService
if (Extension(mode, arguments, replies)) return;
array<string> parts = {};
arguments.Split(" ", parts, true);
if (TimeCommand(mode, parts, replies)) return;
if (parts.Count() == 3 || parts.Count() == 4)
{
string key = Setting(parts[0], parts[1]);
@@ -1,10 +1,16 @@
[BaseContainerProps()]
class ERR_EscapeGameOverScreenInfo : SCR_BaseGameOverScreenInfo
{
protected ERR_PersonalEscapeResult PersonalResult(int playerId)
{
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerController());
if (!controller || controller.GetPlayerId() != playerId) return ERR_PersonalEscapeResult.UNKNOWN;
return controller.ERR_EscapeResult();
}
override string GetTitle(int playerId, array<int> otherPlayerIds)
{
string title = "The helicopter escaped";
if (otherPlayerIds && otherPlayerIds.Contains(playerId)) title = "You escaped Everon";
if (PersonalResult(playerId) == ERR_PersonalEscapeResult.ESCAPED) title = "You escaped Everon";
#ifdef WORKBENCH
PrintFormat("ERR RESULT UI: player=%1 title=%2", playerId, title);
#endif
@@ -12,8 +18,55 @@ class ERR_EscapeGameOverScreenInfo : SCR_BaseGameOverScreenInfo
}
override string GetDebriefing(int playerId, array<int> otherPlayerIds)
{
if (otherPlayerIds && otherPlayerIds.Contains(playerId))
ERR_PersonalEscapeResult result = PersonalResult(playerId);
if (result == ERR_PersonalEscapeResult.ESCAPED)
return "You crossed the extraction boundary aboard the helicopter.";
return "You were not aboard when the helicopter crossed the extraction boundary.";
if (result == ERR_PersonalEscapeResult.MISSED) return "You did not escape with the helicopter.";
return "Your personal escape result is not available.";
}
}
// End data and the controller replicate independently. Refresh an already-open
// native result screen when the identity-derived controller result arrives.
modded class SCR_GameOverScreenContentUIComponent
{
protected bool m_ERREscapeContent;
override void InitContent(SCR_GameOverScreenUIContentData endScreenUIContent)
{
SCR_PlayerController.ERR_OnEscapeResultChanged().Remove(ERR_RefreshEscapeContent);
super.InitContent(endScreenUIContent);
m_ERREscapeContent = ERR_EscapeGameOverScreenInfo.Cast(endScreenUIContent.m_GameOverInfo) != null;
if (!m_ERREscapeContent) return;
SCR_PlayerController.ERR_OnEscapeResultChanged().Insert(ERR_RefreshEscapeContent);
ERR_RefreshEscapeContent();
}
protected void ERR_RefreshEscapeContent()
{
if (!m_ERREscapeContent || !m_wRoot) return;
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerController());
int playerId = -1;
if (controller) playerId = controller.GetPlayerId();
ERR_EscapeGameOverScreenInfo info = new ERR_EscapeGameOverScreenInfo();
TextWidget title = TextWidget.Cast(m_wRoot.FindAnyWidget(m_sTileName));
TextWidget debrief = TextWidget.Cast(m_wRoot.FindAnyWidget(m_sDebriefingName));
if (title) title.SetText(info.GetTitle(playerId, null));
if (debrief) debrief.SetText(info.GetDebriefing(playerId, null));
}
override void HandlerDeattached(Widget w)
{
SCR_PlayerController.ERR_OnEscapeResultChanged().Remove(ERR_RefreshEscapeContent);
m_ERREscapeContent = false;
super.HandlerDeattached(w);
}
}
[BaseContainerProps()]
class ERR_FailureGameOverScreenInfo : SCR_BaseGameOverScreenInfo
{
override string GetDebriefing(int playerId, array<int> otherPlayerIds)
{
ERR_GameMode mode = ERR_GameMode.Get();
if (mode && !mode.EndReason().IsEmpty()) return mode.EndReason();
return "No surviving prisoners can complete the escape, or the extraction helicopter has been lost.";
}
}
@@ -0,0 +1,101 @@
enum ERR_ExtractionState { WAITING, ESCAPED, LOST }
// Mission progression consumes the result; this service never spawns a vehicle.
class ERR_ExtractionService
{
static const int REQUIRED_SEATS = 12;
protected ref ERR_PlayerRoster m_Roster;
protected Vehicle m_Vehicle;
protected bool m_Assigned;
protected bool m_Begun;
protected bool m_Stopped;
protected vector m_Center;
protected float m_Radius;
protected ERR_ExtractionState m_State;
protected ref set<UUID> m_Escapees = new set<UUID>();
protected int m_ParticipantCount;
protected ref ScriptInvoker m_OnResolved = new ScriptInvoker();
void ERR_ExtractionService(ERR_PlayerRoster roster) { m_Roster = roster; }
Vehicle VehicleEntity() { return m_Vehicle; }
ERR_ExtractionState State() { return m_State; }
ScriptInvoker OnResolved() { return m_OnResolved; }
int EscapedCount() { return m_Escapees.Count(); }
int LeftBehindCount() { return m_ParticipantCount - m_Escapees.Count(); }
bool DidEscape(UUID identity) { return m_Escapees.Contains(identity); }
bool Assign(Vehicle vehicle)
{
if (!Replication.IsServer() || m_Assigned || m_Stopped || !ERR_Actors.Alive(vehicle)) return false;
m_Vehicle = vehicle;
m_Assigned = true;
return true;
}
int Seats(out array<BaseCompartmentSlot> seats)
{
seats.Clear();
if (!m_Vehicle) return 0;
BaseCompartmentManagerComponent manager = BaseCompartmentManagerComponent.Cast(m_Vehicle.FindComponent(BaseCompartmentManagerComponent));
if (!manager) return 0;
array<BaseCompartmentSlot> all = {};
manager.GetCompartments(all);
foreach (BaseCompartmentSlot slot : all)
{
if (!slot) continue;
ECompartmentType type = SCR_CompartmentAccessComponent.GetCompartmentType(slot);
if (type == ECompartmentType.PILOT || type == ECompartmentType.CARGO) seats.Insert(slot);
}
return seats.Count();
}
bool Ready()
{
if (!m_Assigned || !ERR_Actors.Alive(m_Vehicle) || !m_Vehicle.FindComponent(HelicopterControllerComponent)) return false;
array<BaseCompartmentSlot> seats = {};
if (Seats(seats) < REQUIRED_SEATS) return false;
foreach (BaseCompartmentSlot slot : seats)
if (SCR_CompartmentAccessComponent.GetCompartmentType(slot) == ECompartmentType.PILOT) return true;
return false;
}
bool Begin(vector center, float radius)
{
if (!Replication.IsServer() || m_Begun || m_Stopped || !m_Roster || radius <= 0 || !Ready()) return false;
m_Center = center;
m_Radius = radius;
m_Begun = true;
return true;
}
void Tick(ERR_Phase phase)
{
if (!Replication.IsServer() || !m_Begun || m_Stopped || m_State != ERR_ExtractionState.WAITING) return;
if (phase < ERR_Phase.PRISON || ERR_MissionRules.Terminal(phase)) return;
if (!ERR_Actors.Alive(m_Vehicle)) { Resolve(ERR_ExtractionState.LOST); return; }
if (phase != ERR_Phase.EXTRACTION || vector.DistanceXZ(m_Vehicle.GetOrigin(), m_Center) <= m_Radius) return;
array<ref ERR_PlayerLife> lives = {};
m_Roster.Participants(lives);
foreach (ERR_PlayerLife life : lives)
{
if (!ERR_Actors.Alive(life.Actor) || CompartmentAccessComponent.GetVehicleIn(life.Actor) != m_Vehicle) continue;
// Reserved disconnected characters and unconscious passengers remain alive.
// A recycled connection ID is never an identity for the escape snapshot.
m_Escapees.Insert(life.AccountId);
}
if (m_Escapees.IsEmpty()) return;
m_ParticipantCount = lives.Count();
Resolve(ERR_ExtractionState.ESCAPED);
}
protected void Resolve(ERR_ExtractionState result)
{
m_State = result;
m_OnResolved.Invoke(result);
}
void ConnectedWinners(out array<int> winners)
{
winners.Clear();
if (m_State != ERR_ExtractionState.ESCAPED || !m_Roster) return;
array<ref ERR_PlayerLife> lives = {};
m_Roster.Participants(lives);
foreach (ERR_PlayerLife life : lives)
if (life.Connected && m_Roster.Get(life.ConnectionId) == life && m_Escapees.Contains(life.AccountId)) winners.Insert(life.ConnectionId);
}
void Stop() { m_Stopped = true; }
}
+69 -18
View File
@@ -17,6 +17,7 @@ class ERR_GameMode : SCR_BaseGameMode
protected string m_PreparationError;
protected ref ERR_FactionCatalog m_Factions;
protected ref ERR_Settings m_Settings;
protected ref ERR_TimeService m_Time = new ERR_TimeService();
protected ref ERR_DiscoveryService m_Discovery;
protected ref ERR_Objectives m_Objectives;
protected ref ERR_PlayerRoster m_Roster = new ERR_PlayerRoster();
@@ -24,10 +25,10 @@ class ERR_GameMode : SCR_BaseGameMode
protected ref ERR_CommandService m_Command;
protected ref array<ERR_LocationComponent> m_Locations = {};
protected ref array<IEntity> m_OwnedEntities = {};
protected ref set<int> m_Participants = new set<int>();
protected ref set<int> m_Escaped = new set<int>();
protected ref ERR_ExtractionService m_Extraction;
[RplProp()]
protected string m_EndReason;
protected ERR_LocationComponent m_Radio;
protected Vehicle m_ExtractionVehicle;
protected bool m_StartedPlayers;
protected bool m_Stopped;
protected float m_NoSurvivorsSince = -1;
@@ -46,8 +47,16 @@ class ERR_GameMode : SCR_BaseGameMode
ERR_PlayerRoster Players() { return m_Roster; }
ERR_AdminService Admin() { return m_Admin; }
ERR_Settings Settings() { return m_Settings; }
ERR_TimeService Time() { return m_Time; }
ERR_CommandService Command() { return m_Command; }
ERR_Objectives Objectives() { return m_Objectives; }
ERR_ExtractionService Extraction() { return m_Extraction; }
string EndReason() { return m_EndReason; }
Vehicle ExtractionVehicle()
{
if (!m_Extraction) return null;
return m_Extraction.VehicleEntity();
}
float EscapeRadius() { return m_EscapeRadius; }
bool DefenceOccupied() { return m_DefenceSince >= 0; }
int DefenceSecondsRemaining()
@@ -82,6 +91,7 @@ class ERR_GameMode : SCR_BaseGameMode
string Status()
{
if (!m_PreparationError.IsEmpty()) return "Mission startup failed: " + m_PreparationError;
if (!m_EndReason.IsEmpty()) return m_EndReason;
if (m_Phase == ERR_Phase.PREPARING) return "Preparing mission sites and physical supplies.";
string phase;
if (m_Phase == ERR_Phase.PRISON) phase = "Escape the prison";
@@ -99,6 +109,10 @@ class ERR_GameMode : SCR_BaseGameMode
Replication.BumpMe();
Print("Rereintroduced: scenario preparation failed; spawning remains disabled: " + detail, LogLevel.ERROR);
StopServices();
ERR_SiteService sites = Sites();
if (sites) sites.Rollback();
for (int i = m_OwnedEntities.Count() - 1; i >= 0; i--) ERR_Actors.DeleteSpawned(m_OwnedEntities[i]);
m_OwnedEntities.Clear();
}
vector PrisonPosition() { return ERR_Actors.Ground(m_PrisonPosition); }
bool Active() { return m_Phase >= ERR_Phase.PRISON && !ERR_MissionRules.Terminal(m_Phase); }
@@ -133,7 +147,23 @@ class ERR_GameMode : SCR_BaseGameMode
void UnregisterLocation(ERR_LocationComponent location) { m_Locations.RemoveItem(location); }
void Locations(out array<ERR_LocationComponent> locations) { locations.Copy(m_Locations); }
void Own(IEntity entity) { if (entity && !m_OwnedEntities.Contains(entity)) m_OwnedEntities.Insert(entity); }
void SetExtractionVehicle(Vehicle vehicle) { if (Replication.IsServer()) m_ExtractionVehicle = vehicle; }
bool SetExtractionVehicle(Vehicle vehicle)
{
return Replication.IsServer() && m_Phase == ERR_Phase.PREPARING && m_Extraction && m_Extraction.Assign(vehicle);
}
void PublishEscapeResults(bool reset = false)
{
if (!Replication.IsServer()) return;
array<int> connected = {};
GetGame().GetPlayerManager().GetPlayers(connected);
foreach (int id : connected)
{
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerManager().GetPlayerController(id));
if (!controller) continue;
if (reset) controller.ERR_ResetEscapeResult();
else controller.ERR_PublishEscapeResult(m_Roster, m_Extraction);
}
}
void RegisterGroup(SCR_AIGroup group)
{
if (!Replication.IsServer() || !group) return;
@@ -147,6 +177,7 @@ class ERR_GameMode : SCR_BaseGameMode
if (!Replication.IsServer() || !m_PreparationError.IsEmpty() || !ERR_MissionRules.CanTransition(m_Phase, phase)) return false;
ERR_Phase previous = m_Phase;
m_Phase = phase;
if (ERR_MissionRules.Terminal(phase)) PublishEscapeResults();
Replication.BumpMe();
PrintFormat("Rereintroduced [mission]: phase %1 -> %2", previous, phase);
m_OnPhaseChanged.Invoke(previous, phase);
@@ -154,7 +185,7 @@ class ERR_GameMode : SCR_BaseGameMode
{
StopServices();
array<int> winners = {};
foreach (int playerId : m_Escaped) winners.Insert(playerId);
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));
@@ -183,7 +214,6 @@ class ERR_GameMode : SCR_BaseGameMode
{
if (!Replication.IsServer() || !actor || ERR_MissionRules.Terminal(m_Phase)) return;
m_Roster.Spawned(id, actor);
m_Participants.Insert(id);
m_StartedPlayers = true;
m_NoSurvivorsSince = -1;
}
@@ -196,6 +226,10 @@ class ERR_GameMode : SCR_BaseGameMode
if (!System.IsConsoleApp()) GetGame().GetCallqueue().CallLater(BindLocalObserver, 250, true);
if (!Replication.IsServer()) return;
m_Settings = new ERR_Settings();
m_Extraction = new ERR_ExtractionService(m_Roster);
PublishEscapeResults(true);
m_Extraction.OnResolved().Insert(ExtractionResolved);
OnStop().Insert(m_Extraction.Stop);
m_Discovery = new ERR_DiscoveryService(this);
OnTick().Insert(m_Discovery.Tick);
OnStop().Insert(m_Discovery.Stop);
@@ -236,15 +270,38 @@ class ERR_GameMode : SCR_BaseGameMode
FailPreparation(detail);
return;
}
if (!m_Extraction || !m_Extraction.Begin(m_EscapeCenter, m_EscapeRadius))
{
FailPreparation("The extraction vehicle must be a helicopter with a pilot position and at least twelve pilot/passenger seats, with a valid escape boundary.");
return;
}
if (!m_Time.Start(this)) { FailPreparation("The world clock could not be initialized."); return; }
Transition(ERR_Phase.PRISON);
}
protected void ExtractionResolved(ERR_ExtractionState result)
{
if (!Replication.IsServer() || !Active()) return;
if (result == ERR_ExtractionState.LOST)
{
m_EndReason = "The extraction helicopter was lost. No replacement is available.";
Transition(ERR_Phase.DEFEAT);
}
else if (result == ERR_ExtractionState.ESCAPED)
{
m_EndReason = string.Format("Escape complete: %1 escaped; %2 did not escape.", m_Extraction.EscapedCount(), m_Extraction.LeftBehindCount());
Transition(ERR_Phase.VICTORY);
}
}
protected void ServerTick()
{
if (!Replication.IsServer() || !Active()) return;
float now = GetWorld().GetWorldTime();
array<IEntity> players = {};
ERR_Actors.Players(players);
m_Extraction.Tick(m_Phase);
if (!Active()) return;
if (GetGame().GetPlayerManager().GetPlayerCount() == 0)
{
m_NoSurvivorsSince = -1;
@@ -254,7 +311,12 @@ class ERR_GameMode : SCR_BaseGameMode
if (m_StartedPlayers && !m_Roster.HasSurvivor())
{
if (m_NoSurvivorsSince < 0) m_NoSurvivorsSince = now;
if (now - m_NoSurvivorsSince >= 30000) { Transition(ERR_Phase.DEFEAT); return; }
if (now - m_NoSurvivorsSince >= 30000)
{
m_EndReason = "No surviving prisoners remain.";
Transition(ERR_Phase.DEFEAT);
return;
}
}
else m_NoSurvivorsSince = -1;
if (m_Phase == ERR_Phase.RADIO_DEFENCE)
@@ -271,17 +333,6 @@ class ERR_GameMode : SCR_BaseGameMode
Transition(ERR_Phase.EXTRACTION);
}
}
if (m_Phase == ERR_Phase.EXTRACTION)
{
if (!ERR_Actors.Alive(m_ExtractionVehicle)) { Transition(ERR_Phase.DEFEAT); return; }
foreach (IEntity actor : players)
{
if (CompartmentAccessComponent.GetVehicleIn(actor) != m_ExtractionVehicle) continue;
if (vector.DistanceXZ(actor.GetOrigin(), m_EscapeCenter) <= m_EscapeRadius) continue;
m_Escaped.Insert(GetGame().GetPlayerManager().GetPlayerIdFromControlledEntity(actor));
}
if (!m_Escaped.IsEmpty()) { Transition(ERR_Phase.VICTORY); return; }
}
array<int> connected = {};
GetGame().GetPlayerManager().GetPlayers(connected);
foreach (int id : connected)
@@ -5,6 +5,8 @@ modded class ERR_GameMode
protected int m_ERRBaseStep;
protected bool m_ERRStartupFailure;
protected bool m_ERRCacheLoss;
protected string m_ERRExtractionCase;
protected ref ERR_NativeExtractionProbe m_ERRExtractionProbe;
protected string m_ERRActiveCacheId = "everon-supply-1";
protected int m_ERRMedicalDiscoveries;
protected IEntity m_ERRSightBlocker;
@@ -21,6 +23,9 @@ modded class ERR_GameMode
if (SCR_Global.IsEditMode() || !Replication.IsServer() || !System.GetCLIParam("errBaseSmoke", enabled) || enabled != "1") return;
string outcome;
m_ERRStartupFailure = System.GetCLIParam("errSmokeOutcome", outcome) && outcome == "startup-failure";
m_ERRExtractionCase = outcome;
if (outcome == "extraction-startup-failure" && m_Factions) m_Factions.ExtractionPrefab = "{259EE7B78C51B624}Prefabs/Vehicles/Wheeled/UAZ469/UAZ469.et";
if (outcome == "extraction-boundary-failure") m_EscapeRadius = 0;
m_ERRCacheLoss = outcome == "cache-loss";
if (m_ERRCacheLoss) m_ERRActiveCacheId = "everon-supply-2";
OnLocationDiscovered().Insert(ERR_BaseDiscovered);
@@ -101,6 +106,20 @@ modded class ERR_GameMode
{
if (!ERR_MissionRules.Terminal(current)) return;
GetGame().GetCallqueue().Remove(ERR_BaseTick);
if (m_ERRExtractionCase == "early-helicopter-loss")
{
if (!ERR_BaseCheck(current == ERR_Phase.DEFEAT && m_ERRBaseStep == 201 && Players().HasSurvivor() && Extraction().State() == ERR_ExtractionState.LOST, "lost helicopter ends prison phase despite a living prisoner")) return;
ERR_FailureGameOverScreenInfo result = new ERR_FailureGameOverScreenInfo();
if (!ERR_BaseCheck(EndReason().Contains("helicopter was lost") && result.GetDebriefing(0, null) == EndReason(), "early loss has a specific replicated and debriefing reason")) return;
Print("ERR SMOKE COMPLETE: early extraction vehicle loss");
return;
}
if (m_ERRExtractionCase == "extraction-capacity")
{
if (!ERR_BaseCheck(m_ERRBaseStep == 202, "capacity fixture finished before its explicit test reset")) return;
Print("ERR SMOKE COMPLETE: physical capacity and isolated extraction results; reset requested by fixture");
return;
}
if (!ERR_BaseCheck(current == ERR_Phase.VICTORY && m_ERRBaseStep == 5, "escape and restart reached without companion services")) return;
Print("ERR SMOKE COMPLETE: core-only escape");
}
@@ -108,6 +127,18 @@ modded class ERR_GameMode
{
float now = GetWorld().GetWorldTime();
if (now > m_ERRBaseDeadline) { ERR_BaseCheck(false, "timeout at " + m_ERRBaseStep.ToString()); return; }
if (m_ERRExtractionCase == "extraction-startup-failure" || m_ERRExtractionCase == "extraction-boundary-failure")
{
if (PreparationError().IsEmpty()) return;
if (!ERR_BaseCheck(Phase() == ERR_Phase.PREPARING && !Active() && m_Stopped, "invalid extraction setup never starts gameplay")) return;
array<IEntity> players = {};
ERR_Actors.Players(players);
if (!ERR_BaseCheck(players.IsEmpty() && Sites() && Sites().Count() == 0 && m_OwnedEntities.IsEmpty() && !ExtractionVehicle(), "late startup failure rolls back all authored stock, scenery, groups and vehicle")) return;
if (!ERR_BaseCheck(PreparationError().Contains("twelve") && !Transition(ERR_Phase.PRISON), "invalid extraction retains actionable diagnosis and blocks progression")) return;
GetGame().GetCallqueue().Remove(ERR_BaseTick);
Print("ERR SMOKE COMPLETE: expected late extraction startup failure");
return;
}
if (m_ERRStartupFailure)
{
if (PreparationError().IsEmpty()) return;
@@ -123,6 +154,13 @@ modded class ERR_GameMode
Print("ERR SMOKE COMPLETE: expected startup-failure");
return;
}
if (m_ERRBaseStep == 200)
{
int result = m_ERRExtractionProbe.Tick(now);
if (result < 0) { GetGame().GetCallqueue().Remove(ERR_BaseTick); return; }
if (result == 1) { m_ERRBaseStep = 202; Transition(ERR_Phase.DEFEAT); }
return;
}
if (now < m_ERRBaseWait) return;
if (m_ERRBaseStep == 0)
{
@@ -130,6 +168,20 @@ modded class ERR_GameMode
ERR_Actors.Players(players, true);
if (players.Count() != 1 || Phase() != ERR_Phase.PRISON) return;
m_ERRBasePlayer = players[0];
if (m_ERRExtractionCase == "early-helicopter-loss")
{
if (!ERR_BaseCheck(Extraction().Ready() && Players().HasSurvivor(), "ready helicopter and living prisoner before deliberate loss")) return;
m_ERRBaseStep = 201;
ERR_Actors.DeleteSpawned(ExtractionVehicle());
return;
}
if (m_ERRExtractionCase == "extraction-capacity")
{
DamageManagerComponent.Cast(m_ERRBasePlayer.FindComponent(DamageManagerComponent)).EnableDamageHandling(false);
m_ERRExtractionProbe = new ERR_NativeExtractionProbe(this);
m_ERRBaseStep = 200;
return;
}
string survey;
if (System.GetCLIParam("errSmokeOutcome", survey) && survey == "site-survey")
{
@@ -138,7 +190,7 @@ modded class ERR_GameMode
GetGame().GetCallqueue().Remove(ERR_BaseTick);
return;
}
if (!ERR_NativeDefinitionProbe.Run() || !ERR_NativeContractsProbe.Run(this, m_ERRBasePlayer) || !ERR_BaseCover()) return;
if (!ERR_NativeDefinitionProbe.Run() || !ERR_NativeContractsProbe.Run(this, m_ERRBasePlayer) || !ERR_BaseCover() || !ERR_NativeTimeProbe.Run(this)) return;
DamageManagerComponent.Cast(m_ERRBasePlayer.FindComponent(DamageManagerComponent)).EnableDamageHandling(false);
if (!ERR_BaseCheck(Objectives() && Objectives().Current() && Objectives().Current().GetTaskName() == "Escape the prison", "native prison objective exists")) return;
ERR_SiteRuntime cache = Sites().Find(m_ERRActiveCacheId);
@@ -166,7 +218,7 @@ modded class ERR_GameMode
ERR_Actors.DeleteSpawned(Sites().Find("everon-supply-1").Container);
if (!ERR_BaseCheck(!Sites().Find("everon-supply-1").Container, "primary cache lost before any clues are recovered")) return;
}
if (!ERR_BaseCheck(lock && m_ERRBaseRadio && m_ERRBaseDocuments.Count() == 2 && ERR_Actors.Alive(m_ExtractionVehicle), "gate, clues, radio and helicopter created by core")) return;
if (!ERR_BaseCheck(lock && m_ERRBaseRadio && m_ERRBaseDocuments.Count() == 2 && ERR_Actors.Alive(ExtractionVehicle()), "gate, clues, radio and helicopter created by core")) return;
ERR_BaseMove(lock.GetOwner().GetOrigin() + "1 0 1");
lock.PerformAction(lock.GetOwner(), m_ERRBasePlayer);
if (!ERR_BaseCheck(Phase() == ERR_Phase.RADIO_SEARCH, "gate action begins escape")) return;
@@ -292,20 +344,20 @@ modded class ERR_GameMode
}
else if (m_ERRBaseStep == 4)
{
if (!ERR_BaseCheck(Phase() == ERR_Phase.EXTRACTION, "radio defence reveals extraction")) return;
if (!ERR_BaseCheck(Phase() == ERR_Phase.EXTRACTION, "radio defence reveals extraction") || !ERR_NativeTimeProbe.VerifyProgression(this)) return;
CompartmentAccessComponent access = CompartmentAccessComponent.Cast(m_ERRBasePlayer.FindComponent(CompartmentAccessComponent));
BaseCompartmentSlot slot = access.FindFreeCompartment(m_ExtractionVehicle, ECompartmentType.PILOT);
if (!ERR_BaseCheck(slot && access.GetInVehicle(m_ExtractionVehicle, slot, true, -1, ECloseDoorAfterActions.INVALID, false), "player physically boards extraction helicopter")) return;
BaseCompartmentSlot slot = access.FindFreeCompartment(ExtractionVehicle(), ECompartmentType.PILOT);
if (!ERR_BaseCheck(slot && access.GetInVehicle(ExtractionVehicle(), slot, true, -1, ECloseDoorAfterActions.INVALID, false), "player physically boards extraction helicopter")) return;
m_ERRBaseStep = 5;
m_ERRBaseWait = now + 3000;
}
else if (m_ERRBaseStep == 5)
{
if (!ERR_BaseCheck(CompartmentAccessComponent.GetVehicleIn(m_ERRBasePlayer) == m_ExtractionVehicle, "extraction requires real occupancy")) return;
if (!ERR_BaseCheck(CompartmentAccessComponent.GetVehicleIn(m_ERRBasePlayer) == ExtractionVehicle(), "extraction requires real occupancy")) return;
vector transform[4];
m_ExtractionVehicle.GetWorldTransform(transform);
ExtractionVehicle().GetWorldTransform(transform);
transform[3] = m_EscapeCenter + Vector(m_EscapeRadius + 100, 150, 0);
m_ExtractionVehicle.SetWorldTransform(transform);
ExtractionVehicle().SetWorldTransform(transform);
m_ERRBaseWait = now + 4000;
}
}
@@ -0,0 +1,172 @@
#ifdef WORKBENCH
// Real vehicle/character compartments with an isolated synthetic identity roster.
// This is not twelve network clients, manual boarding, medical treatment or flight.
class ERR_NativeExtractionProbe
{
protected ERR_GameMode m_Mode;
protected Vehicle m_Vehicle;
protected ref ERR_PlayerRoster m_Roster = new ERR_PlayerRoster();
protected ref ERR_ExtractionService m_All;
protected ref ERR_ExtractionService m_Partial;
protected ref array<IEntity> m_Actors = {};
protected ref array<UUID> m_Accounts = {};
protected ref array<BaseCompartmentSlot> m_Seats = {};
protected vector m_Original[4];
protected int m_Step;
protected int m_Boarding;
protected int m_Resolutions;
protected float m_Wait;
protected UUID m_NewAccount = "00e77200-0000-0000-0000-000000000099";
void ERR_NativeExtractionProbe(ERR_GameMode mode) { m_Mode = mode; }
protected bool Check(bool condition, string detail)
{
if (condition) { Print("ERR SMOKE PASS: extraction " + detail); return true; }
Print("ERR SMOKE FAIL: extraction " + detail, LogLevel.ERROR);
return false;
}
protected void Resolved(ERR_ExtractionState state) { m_Resolutions++; }
protected IEntity Character(int index)
{
IEntity actor = ERR_Actors.Spawn(m_Mode.Factions().PrisonerPrefab, ERR_Actors.Ground(m_Original[3] + Vector(20 + index * 2, 0, 15)));
if (!actor) return null;
m_Mode.Own(actor);
m_Actors.Insert(actor);
DamageManagerComponent.Cast(actor.FindComponent(DamageManagerComponent)).EnableDamageHandling(false);
return actor;
}
protected void MoveHelicopter(bool outside)
{
vector transform[4];
m_Vehicle.GetWorldTransform(transform);
transform[3] = m_Original[3];
if (outside) transform[3] = m_Original[3] + "650 150 0";
m_Vehicle.SetWorldTransform(transform);
}
int Tick(float now)
{
if (now < m_Wait) return 0;
if (m_Step == 0)
{
if (!ERR_NativeTimeProbe.Run(m_Mode)) return -1;
m_Vehicle = m_Mode.ExtractionVehicle();
if (!Check(m_Vehicle && m_Mode.Extraction().Ready(), "authored transport is ready for twelve prisoners")) return -1;
int count = m_Mode.Extraction().Seats(m_Seats);
PrintFormat("ERR EXTRACTION CAPACITY: usable pilot/passenger positions=%1", count);
if (!Check(count >= 12 && !m_Mode.SetExtractionVehicle(m_Vehicle), "running mission cannot rebind its extraction vehicle")) return -1;
m_Vehicle.GetWorldTransform(m_Original);
DamageManagerComponent.Cast(m_Vehicle.FindComponent(DamageManagerComponent)).EnableDamageHandling(false);
for (int i = 0; i < 12; i++)
{
IEntity actor = Character(i);
UUID account = "00e77200-0000-0000-0000-" + (i + 1).ToString(12, true);
if (!Check(actor && m_Roster.Bind(10001 + i, account), "isolated participant created")) return -1;
m_Accounts.Insert(account);
m_Roster.Spawned(10001 + i, actor);
}
m_All = new ERR_ExtractionService(m_Roster);
if (!Check(m_All.Assign(m_Vehicle) && !m_All.Begin(m_Original[3], 0) && m_All.Begin(m_Original[3], 500), "invalid boundary rejected without preventing a valid service start")) return -1;
if (!Check(!m_All.Assign(m_Vehicle) && !m_All.Begin(m_Original[3], 500), "duplicate binding and start rejected")) return -1;
m_All.OnResolved().Insert(Resolved);
m_Step = 1;
m_Wait = now + 1000;
return 0;
}
if (m_Step == 1)
{
CompartmentAccessComponent access = CompartmentAccessComponent.Cast(m_Actors[m_Boarding].FindComponent(CompartmentAccessComponent));
if (!Check(access && access.GetInVehicle(m_Vehicle, m_Seats[m_Boarding], true, -1, ECloseDoorAfterActions.INVALID, false), "native boarding request accepted")) return -1;
m_Boarding++;
m_Wait = now + 500;
if (m_Boarding == 12) { m_Step = 2; m_Wait = now + 3000; }
return 0;
}
if (m_Step == 2)
{
for (int i = 0; i < 12; i++)
if (!Check(CompartmentAccessComponent.GetVehicleIn(m_Actors[i]) == m_Vehicle && m_Seats[i].GetOccupant() == m_Actors[i], "participant occupies a distinct physical seat")) return -1;
m_All.Tick(ERR_Phase.EXTRACTION);
if (!Check(m_All.State() == ERR_ExtractionState.WAITING && m_All.EscapedCount() == 0, "boarding inside boundary does not win")) return -1;
MoveHelicopter(true);
m_Step = 3;
m_Wait = now + 2500;
return 0;
}
if (m_Step == 3)
{
m_All.Tick(ERR_Phase.RADIO_SEARCH);
if (!Check(m_All.State() == ERR_ExtractionState.WAITING, "leaving before extraction phase does not win")) return -1;
m_All.Tick(ERR_Phase.EXTRACTION);
array<int> winners = {};
m_All.ConnectedWinners(winners);
if (!Check(m_All.EscapedCount() == 12 && m_All.LeftBehindCount() == 0 && winners.Count() == 12 && m_Resolutions == 1, "all twelve physical occupants escape exactly once")) return -1;
m_All.Tick(ERR_Phase.EXTRACTION);
if (!Check(m_Resolutions == 1 && !m_All.Assign(m_Vehicle), "resolved extraction neither repeats nor replaces its vehicle")) return -1;
MoveHelicopter(false);
m_Step = 4;
m_Wait = now + 2500;
return 0;
}
if (m_Step == 4)
{
SCR_CharacterDamageManagerComponent damage = SCR_CharacterDamageManagerComponent.Cast(m_Actors[0].FindComponent(SCR_CharacterDamageManagerComponent));
damage.EnableDamageHandling(true);
damage.Kill(Instigator.CreateInstigator(m_Actors[0]));
vector exitTransform[4];
m_Actors[1].GetWorldTransform(exitTransform);
exitTransform[3] = ERR_Actors.Ground(m_Original[3] + "30 0 0");
CompartmentAccessComponent access = CompartmentAccessComponent.Cast(m_Actors[1].FindComponent(CompartmentAccessComponent));
if (!Check(access.GetOutVehicle_NoDoor(exitTransform, false, false), "one survivor leaves the helicopter")) return -1;
CharacterControllerComponent.Cast(m_Actors[2].FindComponent(CharacterControllerComponent)).SetUnconscious(true);
m_Roster.Disconnect(10004);
IEntity newcomer = Character(12);
if (!Check(newcomer && m_Roster.Bind(10004, m_NewAccount), "departed connection ID reused by a distinct account")) return -1;
m_Roster.Spawned(10004, newcomer);
m_Partial = new ERR_ExtractionService(m_Roster);
if (!Check(m_Partial.Assign(m_Vehicle) && m_Partial.Begin(m_Original[3], 500), "isolated partial escape case starts")) return -1;
m_Step = 5;
m_Wait = now + 3000;
return 0;
}
if (m_Step == 5)
{
if (!Check(!ERR_Actors.Alive(m_Actors[0]) && !CompartmentAccessComponent.GetVehicleIn(m_Actors[1]), "dead occupant and left-behind survivor are physically distinct cases")) return -1;
if (!Check(ERR_Actors.Alive(m_Actors[2]) && !ERR_Actors.CanFight(m_Actors[2]) && CompartmentAccessComponent.GetVehicleIn(m_Actors[2]) == m_Vehicle, "unconscious passenger remains physically aboard")) return -1;
MoveHelicopter(true);
m_Step = 6;
m_Wait = now + 2500;
return 0;
}
if (m_Step == 6)
{
m_Partial.Tick(ERR_Phase.EXTRACTION);
array<int> winners = {};
m_Partial.ConnectedWinners(winners);
if (!Check(m_Partial.EscapedCount() == 10 && m_Partial.LeftBehindCount() == 3 && winners.Count() == 9, "partial escape counts only living aboard participants")) return -1;
if (!Check(m_Partial.DidEscape(m_Accounts[2]) && m_Partial.DidEscape(m_Accounts[3]) && !m_Partial.DidEscape(m_Accounts[0]) && !m_Partial.DidEscape(m_Accounts[1]) && !m_Partial.DidEscape(m_NewAccount), "unconscious and reserved occupants escape; dead and absent people do not")) return -1;
if (!Check(winners.Contains(10003) && !winners.Contains(10004), "recycled connection ID does not inherit another account's win")) return -1;
if (!Check(m_Roster.Bind(10040, m_Accounts[3]), "escaped reserved account can rebind to a new connection")) return -1;
m_Partial.ConnectedWinners(winners);
if (!Check(winners.Count() == 10 && winners.Contains(10040) && !winners.Contains(10004), "result follows escaped identity after rebinding")) return -1;
m_Partial.Tick(ERR_Phase.EXTRACTION);
if (!Check(m_Partial.EscapedCount() == 10 && m_Partial.LeftBehindCount() == 3, "terminal result snapshot remains stable")) return -1;
ERR_PlayerRoster emptyRoster = new ERR_PlayerRoster();
ERR_ExtractionService empty = new ERR_ExtractionService(emptyRoster);
if (!Check(empty.Assign(m_Vehicle) && empty.Begin(m_Original[3], 500), "empty-roster control starts")) return -1;
empty.Tick(ERR_Phase.EXTRACTION);
if (!Check(empty.State() == ERR_ExtractionState.WAITING, "unregistered occupants cannot win the mission")) return -1;
ERR_ExtractionService stopped = new ERR_ExtractionService(m_Roster);
if (!Check(stopped.Assign(m_Vehicle) && stopped.Begin(m_Original[3], 500), "stopped-service control starts")) return -1;
stopped.Stop();
stopped.Tick(ERR_Phase.EXTRACTION);
if (!Check(stopped.State() == ERR_ExtractionState.WAITING, "stopped extraction cannot resolve")) return -1;
if (!ERR_NativeResultProbe.Run(m_Partial, m_Accounts[3], m_Accounts[0]) || !ERR_NativeTimeProbe.VerifyProgression(m_Mode)) return -1;
MoveHelicopter(false);
for (int i = m_Actors.Count() - 1; i >= 0; i--) ERR_Actors.DeleteSpawned(m_Actors[i]);
m_Step = 7;
return 1;
}
return 0;
}
}
#endif
@@ -0,0 +1,68 @@
#ifdef WORKBENCH
// Local controller and native presentation provider; no network clients are
// simulated. The extraction snapshot was produced by real occupied seats.
class ERR_NativeResultProbe
{
protected static Widget s_Content;
protected static bool Check(bool condition, string detail)
{
if (condition) { Print("ERR SMOKE PASS: result " + detail); return true; }
if (s_Content) s_Content.RemoveFromHierarchy();
s_Content = null;
Print("ERR SMOKE FAIL: result " + detail, LogLevel.ERROR);
return false;
}
protected static bool NativeText(string title, string debrief)
{
if (!s_Content) return false;
TextWidget titleWidget = TextWidget.Cast(s_Content.FindAnyWidget("GameOver_State"));
TextWidget debriefWidget = TextWidget.Cast(s_Content.FindAnyWidget("GameOver_Description"));
return titleWidget && debriefWidget && titleWidget.GetText() == title && debriefWidget.GetText() == debrief;
}
static bool Run(ERR_ExtractionService extraction, UUID escapedAccount, UUID missedAccount)
{
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerController());
if (!Check(controller && extraction && extraction.State() == ERR_ExtractionState.ESCAPED, "local controller and physical extraction snapshot exist")) return false;
int playerId = controller.GetPlayerId();
ERR_EscapeGameOverScreenInfo info = new ERR_EscapeGameOverScreenInfo();
array<int> staleIds = {playerId};
SCR_GameModeEndData staleData = SCR_GameModeEndData.Create(EGameOverTypes.VICTORY, staleIds);
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;
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;
s_Content.SetVisible(false);
SCR_GameOverScreenContentUIComponent contentHandler = SCR_GameOverScreenContentUIComponent.Cast(s_Content.FindHandler(SCR_GameOverScreenContentUIComponent));
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;
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());
controller.ERR_PublishEscapeResult(roster, extraction);
array<int> noWinners = {};
if (!Check(controller.ERR_EscapeResult() == ERR_PersonalEscapeResult.ESCAPED && info.GetTitle(playerId, noWinners) == "You escaped Everon", "identity snapshot publishes victory despite missing end-data ID")) return false;
if (!Check(NativeText("You escaped Everon", "You crossed the extraction boundary aboard the helicopter."), "already-open native content refreshes when personal victory arrives")) return false;
if (!Check(roster.Bind(playerId, missedAccount), "local presentation connection ID is recycled to non-escapee")) return false;
roster.Spawned(playerId, controller.GetControlledEntity());
controller.ERR_PublishEscapeResult(roster, extraction);
if (!Check(controller.ERR_EscapeResult() == ERR_PersonalEscapeResult.MISSED && info.GetTitle(playerId, copiedIds) == "The helicopter escaped" && info.GetDebriefing(playerId, copiedIds) == "You did not escape with the helicopter.", "recycled winner ID cannot grant escape or misdescribe dead occupants")) return false;
if (!Check(NativeText("The helicopter escaped", "You did not escape with the helicopter."), "native text refresh removes the previous account's win")) return false;
if (!Check(roster.Bind(playerId, escapedAccount), "escaped presentation account returns after simulated rebind")) return false;
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;
s_Content.RemoveFromHierarchy();
s_Content = null;
ERR_GameMode mode = ERR_GameMode.Get();
controller.ERR_PublishEscapeResult(mode.Players(), mode.Extraction());
return true;
}
}
#endif
@@ -0,0 +1,117 @@
#ifdef WORKBENCH
class ERR_NativeSettingsReader : ERR_Settings
{
bool Probe(string path, out map<string, int> values) { return Read(path, values); }
}
// Runs inside the disposable native test profile. No original settings touched.
class ERR_NativeTimeProbe
{
protected static float s_EngineTime;
protected static float s_ClockHours;
protected static int s_Multiplier;
protected static bool s_Started;
static bool VerifyProgression(ERR_GameMode mode)
{
ChimeraWorld world = ChimeraWorld.CastFrom(mode.GetWorld());
TimeAndWeatherManagerEntity manager = world.GetTimeAndWeatherManager();
if (!Check(s_Started && manager && manager.GetIsDayAutoAdvanced(), "clock advancement remains enabled")) return false;
float elapsed = manager.GetEngineTime() - s_EngineTime;
if (!Check(elapsed >= 10, "progression sampled across at least ten engine seconds")) return false;
float actual = manager.GetTimeOfTheDay() - s_ClockHours;
if (actual < 0) actual += 24;
float expected = elapsed * s_Multiplier / 3600.0;
return Check(Math.AbsFloat(actual - expected) < 0.001, "native clock advances at configured rate without periodic resets");
}
protected static bool Check(bool condition, string detail)
{
if (condition) { Print("ERR SMOKE PASS: mission time " + detail); return true; }
Print("ERR SMOKE FAIL: mission time " + detail, LogLevel.ERROR);
return false;
}
protected static bool WriteFixture(string path, string omit = "", bool clock = false, float minutes = 840, bool speed = false, float multiplier = 1)
{
JsonSaveContext json = new JsonSaveContext();
json.EnableTypeDiscriminator(false);
if (omit != "reportIntervalSeconds") json.WriteValue("reportIntervalSeconds", 20);
json.WriteValue("supportSquadLimit", 2);
json.WriteValue("responseRadiusMetres", 1500);
json.WriteValue("aiIntelChancePercent", 0);
json.WriteValue("intelRevealChancePercent", 100);
json.WriteValue("radioDefenceSeconds", 120);
if (clock) json.WriteValue("startTimeMinutes", minutes);
if (speed) json.WriteValue("timeMultiplier", multiplier);
return json.SaveToFile(path);
}
protected static bool SettingsMigration(string path)
{
if (!Check(WriteFixture(path), "legacy fixture created")) return false;
ERR_Settings legacy = new ERR_Settings(path);
if (!Check(legacy.Get("reportIntervalSeconds") == 20 && legacy.Get("radioDefenceSeconds") == 120 && legacy.Get("aiIntelChancePercent") == 0 && legacy.Get("intelRevealChancePercent") == 100, "existing six-key settings preserved")) return false;
if (!Check(legacy.Get("startTimeMinutes") == 840 && legacy.Get("timeMultiplier") == 1, "only absent time settings receive defaults")) return false;
JsonLoadContext unchanged = new JsonLoadContext();
if (!Check(unchanged.LoadFromFile(path) && !unchanged.DoesKeyExist("startTimeMinutes") && !unchanged.DoesKeyExist("timeMultiplier"), "loading does not rewrite legacy file")) return false;
if (!Check(legacy.Set("startTimeMinutes", 375) && legacy.Set("timeMultiplier", 4), "explicit save upgrades valid legacy settings")) return false;
ERR_Settings reloaded = new ERR_Settings(path);
if (!Check(reloaded.Get("startTimeMinutes") == 375 && reloaded.Get("timeMultiplier") == 4 && reloaded.Get("responseRadiusMetres") == 1500 && reloaded.Get("radioDefenceSeconds") == 120, "new keys and old values survive reload")) return false;
ERR_NativeSettingsReader reader = new ERR_NativeSettingsReader();
map<string, int> values;
if (!WriteFixture(path, "", true, 1440)) return false;
if (!Check(!reader.Probe(path, values), "present out-of-range time cannot silently default")) return false;
if (!WriteFixture(path, "", true, 375.5)) return false;
if (!Check(!reader.Probe(path, values), "fractional clock setting rejected")) return false;
if (!WriteFixture(path, "", false, 0, true, 0)) return false;
if (!Check(!reader.Probe(path, values), "zero multiplier rejected")) return false;
if (!WriteFixture(path, "reportIntervalSeconds")) return false;
if (!Check(!reader.Probe(path, values), "missing legacy setting remains invalid")) return false;
if (!WriteFixture(path, "", true, 375)) return false;
if (!Check(reader.Probe(path, values) && values.Get("startTimeMinutes") == 375 && values.Get("timeMultiplier") == 1, "partially upgraded file defaults only missing new field")) return false;
if (!WriteFixture(path, "", false, 0, true, 25)) return false;
ERR_Settings recovered = new ERR_Settings(path);
if (!Check(recovered.Get("startTimeMinutes") == 375 && recovered.Get("timeMultiplier") == 1 && recovered.Get("reportIntervalSeconds") == 20, "valid saved backup recovers invalid primary")) return false;
return true;
}
static bool Run(ERR_GameMode mode)
{
int minutes;
if (!Check(ERR_TimeService.ParseClock("00:00", minutes) && minutes == 0 && ERR_TimeService.ParseClock("23:59", minutes) && minutes == 1439 && ERR_TimeService.Clock(375) == "06:15", "clock parsing includes midnight and final minute")) return false;
if (!Check(!ERR_TimeService.ParseClock("24:00", minutes) && !ERR_TimeService.ParseClock("12:60", minutes) && !ERR_TimeService.ParseClock("1:00", minutes) && !ERR_TimeService.ParseClock("ab:cd", minutes) && !ERR_TimeService.ParseClock("06:15junk", minutes), "malformed clock commands rejected")) return false;
string path = "$profile:erri-native-time-probe.json";
if (!Check(!FileIO.FileExists(path) && !FileIO.FileExists(path + ".bak") && !FileIO.FileExists(path + ".tmp"), "isolated fixture paths unused")) return false;
bool migrated = SettingsMigration(path);
FileIO.DeleteFile(path); FileIO.DeleteFile(path + ".bak"); FileIO.DeleteFile(path + ".tmp");
if (!migrated) return false;
if (!Check(mode.Time() && mode.Time().Started(), "server starts world clock before prison phase")) return false;
ChimeraWorld world = ChimeraWorld.CastFrom(mode.GetWorld());
TimeAndWeatherManagerEntity manager = world.GetTimeAndWeatherManager();
if (!Check(manager && manager.GetIsDayAutoAdvanced() && Math.AbsFloat(manager.GetDayDuration() - ERR_TimeService.DayDuration(mode.Time().Multiplier())) < 0.1, "engine progression matches applied multiplier")) return false;
int originalTime = mode.Settings().Get("startTimeMinutes");
int originalSpeed = mode.Settings().Get("timeMultiplier");
array<string> replies = {};
mode.Admin().Execute(mode, false, "mission time set 06:15", replies);
if (!Check(mode.Settings().Get("startTimeMinutes") == originalTime && replies[0] == "Administrator access required.", "unauthorized clock mutation rejected")) return false;
mode.Admin().Execute(mode, true, "mission time set 24:00", replies);
if (!Check(mode.Settings().Get("startTimeMinutes") == originalTime && replies[0].Contains("Invalid"), "invalid clock command preserves setting")) return false;
float currentHours = manager.GetTimeOfTheDay();
float currentDuration = manager.GetDayDuration();
int appliedStart = mode.Time().StartMinutes();
int appliedSpeed = mode.Time().Multiplier();
mode.Admin().Execute(mode, true, "mission time set 06:15", replies);
bool savedTime = mode.Settings().Get("startTimeMinutes") == 375 && replies[0].Contains("next round");
mode.Admin().Execute(mode, true, "mission speed set 4", replies);
bool savedSpeed = mode.Settings().Get("timeMultiplier") == 4 && replies[0].Contains("next round");
bool idempotent = mode.Time().Start(mode) && Math.AbsFloat(manager.GetTimeOfTheDay() - currentHours) < 0.01 && manager.GetDayDuration() == currentDuration && mode.Time().StartMinutes() == appliedStart && mode.Time().Multiplier() == appliedSpeed;
mode.Admin().Execute(mode, true, "mission time show", replies);
bool shown = replies.Count() == 2 && replies[0].Contains("Clock") && replies[1] == "Next round: 06:15, 4x progression.";
bool restoredTime = mode.Settings().Set("startTimeMinutes", originalTime);
bool restoredSpeed = mode.Settings().Set("timeMultiplier", originalSpeed);
if (!Check(savedTime && savedSpeed && restoredTime && restoredSpeed, "authorized settings persist and test restores originals")) return false;
if (!Check(idempotent, "saved changes and repeated start leave current clock and speed intact")) return false;
if (!Check(shown, "admin sees current clock separately from next round settings")) return false;
s_EngineTime = manager.GetEngineTime();
s_ClockHours = manager.GetTimeOfTheDay();
s_Multiplier = mode.Time().Multiplier();
s_Started = true;
return true;
}
}
#endif
@@ -63,7 +63,7 @@ class ERR_Objectives
return string.Format("Transmission in progress: up to %1 seconds remaining. Keep a conscious survivor within 60 metres of the radio. Leaving resets progress.", displayed);
}
if (phase == ERR_Phase.EXTRACTION)
return string.Format("Reach the helicopter marked on your map. Board and fly beyond %1 kilometres from the prison. Everyone aboard escapes; survivors left behind do not.", m_Mode.EscapeRadius() / 1000.0);
return string.Format("Reach the helicopter marked on your map. Board and fly beyond %1 kilometres from the prison. Living prisoners aboard escape, including unconscious passengers. Dead or absent prisoners do not.", m_Mode.EscapeRadius() / 1000.0);
return "";
}
@@ -0,0 +1,56 @@
enum ERR_PersonalEscapeResult { UNKNOWN, ESCAPED, MISSED }
// Only the server resolves account identities. Clients receive their controller's
// result, never a persistent account ID or a reused end-data connection ID.
modded class SCR_PlayerController
{
[RplProp(onRplName: "ERR_OnEscapeResultReplicated")]
protected ERR_PersonalEscapeResult m_ERREscapeResult;
[RplProp(onRplName: "ERR_OnEscapeResultReplicated")]
protected RplId m_ERREscapeResultMission = RplId.Invalid();
protected static ref ScriptInvoker s_ERREscapeResultChanged = new ScriptInvoker();
static ScriptInvoker ERR_OnEscapeResultChanged() { return s_ERREscapeResultChanged; }
protected RplId ERR_CurrentResultMission()
{
ERR_GameMode mode = ERR_GameMode.Get();
if (!mode) return RplId.Invalid();
RplComponent component = RplComponent.Cast(mode.FindComponent(RplComponent));
if (!component) return RplId.Invalid();
return component.Id();
}
ERR_PersonalEscapeResult ERR_EscapeResult()
{
RplId mission = ERR_CurrentResultMission();
if (mission == RplId.Invalid() || mission != m_ERREscapeResultMission) return ERR_PersonalEscapeResult.UNKNOWN;
return m_ERREscapeResult;
}
void ERR_ResetEscapeResult()
{
if (!Replication.IsServer()) return;
m_ERREscapeResult = ERR_PersonalEscapeResult.UNKNOWN;
m_ERREscapeResultMission = ERR_CurrentResultMission();
Replication.BumpMe();
ERR_OnEscapeResultReplicated();
}
void ERR_PublishEscapeResult(ERR_PlayerRoster roster, ERR_ExtractionService extraction)
{
if (!Replication.IsServer()) return;
ERR_PersonalEscapeResult result = ERR_PersonalEscapeResult.UNKNOWN;
ERR_PlayerLife life;
if (roster) life = roster.Get(GetPlayerId());
if (life && life.Connected && life.ConnectionId == GetPlayerId() && life.UsedLife && extraction && extraction.State() == ERR_ExtractionState.ESCAPED)
{
result = ERR_PersonalEscapeResult.MISSED;
if (extraction.DidEscape(life.AccountId)) result = ERR_PersonalEscapeResult.ESCAPED;
}
m_ERREscapeResult = result;
m_ERREscapeResultMission = ERR_CurrentResultMission();
Replication.BumpMe();
ERR_OnEscapeResultReplicated();
}
protected void ERR_OnEscapeResultReplicated()
{
if (GetGame() && this == GetGame().GetPlayerController()) s_ERREscapeResultChanged.Invoke();
}
}
@@ -41,7 +41,7 @@ modded class SCR_PlayerController
else if (mode.Phase() == ERR_Phase.RADIO_DEFENCE)
message = "Keep a conscious survivor within 60 metres of the radio until the transmission finishes. Leaving the area resets the defence timer.";
else if (mode.Phase() == ERR_Phase.EXTRACTION)
message = "The extraction helicopter is marked on your map. Board it and fly at least 9 kilometres from the prison. Everyone aboard escapes; players left behind do not.";
message = "The extraction helicopter is marked on your map. Board it and fly at least 9 kilometres from the prison. Living prisoners aboard escape, including unconscious passengers. Dead or absent prisoners do not.";
if (!message.IsEmpty()) hints.ShowCustom(message, "Escape Everon", 15);
m_ERRGuidePhase = mode.Phase();
m_ERRGuideError = mode.PreparationError();
@@ -71,6 +71,12 @@ class ERR_PlayerRoster
m_Connections.Remove(playerId);
m_OnChanged.Invoke(playerId);
}
void Participants(out array<ref ERR_PlayerLife> lives)
{
lives.Clear();
foreach (UUID identity, ERR_PlayerLife life : m_Lives)
if (life.UsedLife) lives.Insert(life);
}
bool HasSurvivor()
{
// Native reconnect reservations retain the original actor until expiry.
+28 -9
View File
@@ -4,10 +4,11 @@ class ERR_Settings
static const string PATH = "$profile:rereintroduced.json";
protected ref map<string, int> m_Values = new map<string, int>();
protected bool m_ReadOnly;
protected string m_Path;
static array<string> Keys()
{
return {"reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "aiIntelChancePercent", "intelRevealChancePercent", "radioDefenceSeconds"};
return {"reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "aiIntelChancePercent", "intelRevealChancePercent", "radioDefenceSeconds", "startTimeMinutes", "timeMultiplier"};
}
static bool Valid(string key, int value)
@@ -17,21 +18,33 @@ class ERR_Settings
if (key == "responseRadiusMetres") return value >= 500 && value <= 5000;
if (key == "aiIntelChancePercent" || key == "intelRevealChancePercent") return value >= 0 && value <= 100;
if (key == "radioDefenceSeconds") return value >= 30 && value <= 1800;
if (key == "startTimeMinutes") return value >= 0 && value <= 1439;
if (key == "timeMultiplier") return value >= 1 && value <= 24;
return false;
}
void ERR_Settings()
static int AddedDefault(string key)
{
if (key == "startTimeMinutes") return 840;
if (key == "timeMultiplier") return 1;
return -1;
}
void ERR_Settings(string path = PATH)
{
m_Path = path;
m_Values.Set("reportIntervalSeconds", 10);
m_Values.Set("supportSquadLimit", 4);
m_Values.Set("responseRadiusMetres", 2000);
m_Values.Set("aiIntelChancePercent", 60);
m_Values.Set("intelRevealChancePercent", 15);
m_Values.Set("radioDefenceSeconds", 180);
m_Values.Set("startTimeMinutes", AddedDefault("startTimeMinutes"));
m_Values.Set("timeMultiplier", AddedDefault("timeMultiplier"));
if (!Replication.IsServer()) { m_ReadOnly = true; return; }
map<string, int> loaded;
if (Read(PATH, loaded) || Read(PATH + ".bak", loaded)) m_Values = loaded;
else if (FileIO.FileExists(PATH) || FileIO.FileExists(PATH + ".bak"))
if (Read(m_Path, loaded) || Read(m_Path + ".bak", loaded)) m_Values = loaded;
else if (FileIO.FileExists(m_Path) || FileIO.FileExists(m_Path + ".bak"))
{
m_ReadOnly = true;
Print("Rereintroduced: invalid settings; defaults active, existing files preserved", LogLevel.ERROR);
@@ -47,6 +60,12 @@ class ERR_Settings
values = new map<string, int>();
foreach (string key : Keys())
{
// Only newly introduced keys may be absent in an older valid file.
if (!json.DoesKeyExist(key) && AddedDefault(key) >= 0)
{
values.Set(key, AddedDefault(key));
continue;
}
float number;
if (!json.ReadValue(key, number)) return false;
int value = Math.Round(number);
@@ -68,7 +87,7 @@ class ERR_Settings
json.WriteValue(option, saved);
}
map<string, int> checked;
if (!json.SaveToFile(PATH + ".tmp") || !Read(PATH + ".tmp", checked)) return false;
if (!json.SaveToFile(m_Path + ".tmp") || !Read(m_Path + ".tmp", checked)) return false;
foreach (string option : Keys())
{
int expected = Get(option);
@@ -76,10 +95,10 @@ class ERR_Settings
if (checked.Get(option) != expected) return false;
}
map<string, int> previous;
if (Read(PATH, previous) && !FileIO.CopyFile(PATH, PATH + ".bak")) return false;
if (!FileIO.CopyFile(PATH + ".tmp", PATH) || !Read(PATH, checked))
if (Read(m_Path, previous) && !FileIO.CopyFile(m_Path, m_Path + ".bak")) return false;
if (!FileIO.CopyFile(m_Path + ".tmp", m_Path) || !Read(m_Path, checked))
{
if (FileIO.FileExists(PATH + ".bak")) FileIO.CopyFile(PATH + ".bak", PATH);
if (FileIO.FileExists(m_Path + ".bak")) FileIO.CopyFile(m_Path + ".bak", m_Path);
return false;
}
foreach (string option : Keys())
@@ -88,7 +107,7 @@ class ERR_Settings
if (option == key) expected = value;
if (checked.Get(option) != expected)
{
if (FileIO.FileExists(PATH + ".bak")) FileIO.CopyFile(PATH + ".bak", PATH);
if (FileIO.FileExists(m_Path + ".bak")) FileIO.CopyFile(m_Path + ".bak", m_Path);
return false;
}
}
@@ -90,7 +90,7 @@ class ERR_SiteService
{
Vehicle helicopter = Vehicle.Cast(Spawn(m_Mode.Factions().ExtractionPrefab, position + "12 0 0"));
if (!helicopter) return Fail("extraction vehicle");
m_Mode.SetExtractionVehicle(helicopter);
if (!m_Mode.SetExtractionVehicle(helicopter)) return Fail("extraction vehicle registration");
}
if (!spec.Container.IsEmpty())
{
@@ -20,11 +20,14 @@ class ERR_SpawnLogic : SCR_SpawnLogic
}
#endif
ERR_GameMode mode = ERR_GameMode.Get();
SCR_PlayerController resultController = SCR_PlayerController.Cast(GetGame().GetPlayerManager().GetPlayerController(playerId));
if (resultController) resultController.ERR_ResetEscapeResult();
if (!mode || !mode.Players().Bind(playerId, identity))
{
Print("Rereintroduced: player identity could not be bound; spawn withheld", LogLevel.ERROR);
return;
}
if (resultController) resultController.ERR_PublishEscapeResult(mode.Players(), mode.Extraction());
m_Identities.Set(playerId, identity);
if (ResolveReconnection(playerId))
{
@@ -0,0 +1,81 @@
// The authoritative engine manager owns progression and replication. This
// service sets it once per mission instance; saved changes apply next round.
class ERR_TimeService
{
protected TimeAndWeatherManagerEntity m_Manager;
protected bool m_Started;
protected int m_StartMinutes;
protected int m_Multiplier;
static float DayDuration(int multiplier)
{
if (!ERR_Settings.Valid("timeMultiplier", multiplier)) return -1;
return 86400.0 / multiplier;
}
static string Clock(int minutes)
{
if (!ERR_Settings.Valid("startTimeMinutes", minutes)) return "unavailable";
int hours = minutes / 60;
int remainder = minutes % 60;
string hourText = hours.ToString();
string minuteText = remainder.ToString();
if (hours < 10) hourText = "0" + hourText;
if (remainder < 10) minuteText = "0" + minuteText;
return hourText + ":" + minuteText;
}
static bool ParseClock(string text, out int minutes)
{
if (text.Length() != 5 || text.Substring(2, 1) != ":") return false;
int hours;
int remainder;
if (!ERR_CommandArguments.Integer(text.Substring(0, 2), hours) || !ERR_CommandArguments.Integer(text.Substring(3, 2), remainder)) return false;
if (hours > 23 || remainder > 59) return false;
minutes = hours * 60 + remainder;
return true;
}
bool Start(ERR_GameMode mode)
{
if (!Replication.IsServer() || !mode || !mode.Settings()) return false;
if (m_Started) return true;
ChimeraWorld world = ChimeraWorld.CastFrom(mode.GetWorld());
if (!world) return false;
m_Manager = world.GetTimeAndWeatherManager();
if (!m_Manager) return false;
int startMinutes = mode.Settings().Get("startTimeMinutes");
int multiplier = mode.Settings().Get("timeMultiplier");
if (!ERR_Settings.Valid("startTimeMinutes", startMinutes) || !ERR_Settings.Valid("timeMultiplier", multiplier)) return false;
float previousDuration = m_Manager.GetDayDuration();
bool previousAdvancement = m_Manager.GetIsDayAutoAdvanced();
if (!m_Manager.SetDayDuration(DayDuration(multiplier))) return false;
if (!m_Manager.SetIsDayAutoAdvanced(true))
{
m_Manager.SetDayDuration(previousDuration);
return false;
}
if (!m_Manager.SetTimeOfTheDay(startMinutes / 60.0, true))
{
m_Manager.SetDayDuration(previousDuration);
m_Manager.SetIsDayAutoAdvanced(previousAdvancement);
return false;
}
m_StartMinutes = startMinutes;
m_Multiplier = multiplier;
m_Started = true;
PrintFormat("Rereintroduced [time]: started at %1 with %2x progression", Clock(m_StartMinutes), m_Multiplier);
return true;
}
bool Started() { return m_Started; }
int StartMinutes() { return m_StartMinutes; }
int Multiplier() { return m_Multiplier; }
int CurrentMinutes()
{
if (!m_Started || !m_Manager) return -1;
int minutes = Math.Floor(m_Manager.GetTimeOfTheDay() * 60);
return minutes % 1440;
}
string Status()
{
if (!m_Started) return "Clock not started.";
return "Clock " + Clock(CurrentMinutes()) + ", " + m_Multiplier.ToString() + "x progression (round started " + Clock(m_StartMinutes) + ").";
}
}