Separate radio defence and harden player and objective lifecycles

This commit is contained in:
2026-09-19 05:33:36 +02:00
parent e5857b6ddf
commit 16f397e581
237 changed files with 29087 additions and 202 deletions
+2 -12
View File
@@ -230,14 +230,14 @@ modded class ERR_GameMode
}
else if (m_ERRSmokeStep == 4)
{
if (!ERR_Check(m_DefenceSince >= 0, "occupied defence timer running")) return;
if (!ERR_Check(DefenceOccupied(), "occupied defence timer running")) return;
ERR_MovePlayer(m_ERRSmokeRadio.Position() + "90 0 0");
m_ERRSmokeStep = 5;
m_ERRSmokeWait = now + 2500;
}
else if (m_ERRSmokeStep == 5)
{
if (!ERR_Check(m_DefenceSince < 0 && Phase() == ERR_Phase.RADIO_DEFENCE, "leaving area resets defence")) return;
if (!ERR_Check(!DefenceOccupied() && Phase() == ERR_Phase.RADIO_DEFENCE, "leaving area resets defence")) return;
ERR_MovePlayer(m_ERRSmokeRadio.Position() + "2 0 0");
m_ERRSmokeStep = 6;
m_ERRSmokeWait = now + 33000;
@@ -358,14 +358,4 @@ modded class ERR_GameMode
}
}
}
// Diagnose inherited teardown failures without changing their behavior.
modded class SCR_BaseItemSupportStationComponent
{
override void OnDelete(IEntity owner)
{
if (owner && !m_EntityCatalogManager)
PrintFormat("ERR TEARDOWN: uninitialized item support station on %1", owner);
super.OnDelete(owner);
}
}
#endif
+7
View File
@@ -64,6 +64,13 @@ ERR_ScenarioDefinition {
Container "{40986810D0B1F4AE}Prefabs/ERR/SupplyCache.et"
DiscoverRadius 25
RevealOnEscape 1
Transport ERR_SiteTransport {
Prefab "{259EE7B78C51B624}Prefabs/Vehicles/Wheeled/UAZ469/UAZ469.et"
Offset 0 0 -8
Heading 180
ClearanceRadius 4
MaximumTerrainRange 1
}
Stock {
ERR_SiteStock { Prefab "{FA5C25BF66A53DCF}Prefabs/Weapons/Rifles/AK74/Rifle_AK74.et" Count 4 }
ERR_SiteStock { Prefab "{BBB50A815A2F916B}Prefabs/Weapons/Magazines/Magazine_545x39_AK_30rnd_Ball.et" Count 8 }
+27 -32
View File
@@ -5,12 +5,10 @@ class ERR_GameMode : SCR_BaseGameMode
protected vector m_PrisonPosition;
[Attribute("6090 0 7160", UIWidgets.Coords, "Centre of extraction boundary")]
protected vector m_EscapeCenter;
[Attribute("9000", UIWidgets.EditBox, "Helicopter must cross this radius to escape")]
[RplProp(), Attribute("9000", UIWidgets.EditBox, "Helicopter must cross this radius to escape")]
protected float m_EscapeRadius;
[RplProp()]
protected ERR_Phase m_Phase;
[RplProp()]
protected float m_DefenceSince = -1;
[Attribute("{BE50B97B8E2B36E6}Configs/ERR/VanillaFactions.conf", UIWidgets.ResourceNamePicker, "Faction and asset catalog", "conf")]
protected ResourceName m_FactionDefinition;
[RplProp()]
@@ -28,7 +26,7 @@ class ERR_GameMode : SCR_BaseGameMode
protected ref ERR_ExtractionService m_Extraction;
[RplProp()]
protected string m_EndReason;
protected ERR_LocationComponent m_Radio;
protected ref ERR_RadioDefenceService m_RadioDefence;
protected bool m_StartedPlayers;
protected bool m_Stopped;
protected float m_NoSurvivorsSince = -1;
@@ -48,6 +46,7 @@ class ERR_GameMode : SCR_BaseGameMode
ERR_AdminService Admin() { return m_Admin; }
ERR_Settings Settings() { return m_Settings; }
ERR_TimeService Time() { return m_Time; }
ERR_RadioDefenceService RadioDefence() { return m_RadioDefence; }
ERR_CommandService Command() { return m_Command; }
ERR_Objectives Objectives() { return m_Objectives; }
ERR_ExtractionService Extraction() { return m_Extraction; }
@@ -58,13 +57,11 @@ class ERR_GameMode : SCR_BaseGameMode
return m_Extraction.VehicleEntity();
}
float EscapeRadius() { return m_EscapeRadius; }
bool DefenceOccupied() { return m_DefenceSince >= 0; }
bool DefenceOccupied() { return m_RadioDefence && m_RadioDefence.Occupied(); }
int DefenceSecondsRemaining()
{
if (!m_Settings) return 0;
int duration = m_Settings.Get("radioDefenceSeconds");
if (m_DefenceSince < 0) return duration;
return Math.Max(0, Math.Ceil(duration - (GetWorld().GetWorldTime() - m_DefenceSince) / 1000.0));
if (!m_RadioDefence) return 0;
return m_RadioDefence.SecondsRemaining(GetWorld().GetWorldTime());
}
ScriptInvoker OnPhaseChanged() { return m_OnPhaseChanged; }
ScriptInvoker OnTick() { return m_OnTick; }
@@ -144,6 +141,7 @@ class ERR_GameMode : SCR_BaseGameMode
}
m_Locations.Insert(location);
}
bool HasLocation(ERR_LocationComponent location) { return location && m_Locations.Contains(location); }
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); }
@@ -202,12 +200,19 @@ class ERR_GameMode : SCR_BaseGameMode
bool TuneRadio(IEntity player, ERR_LocationComponent location)
{
if (!Replication.IsServer() || m_Phase != ERR_Phase.RADIO_SEARCH || !location || !m_Locations.Contains(location)) return false;
if (location.Kind() != ERR_LocationKind.RADIO || location.Knowledge() < 2) return false;
if (!ERR_Actors.Player(player) || !ERR_Actors.CanFight(player) || vector.DistanceXZ(player.GetOrigin(), location.Position()) > 5) return false;
m_Radio = location;
m_DefenceSince = -1;
return Transition(ERR_Phase.RADIO_DEFENCE);
return m_RadioDefence && m_RadioDefence.Begin(player, location);
}
protected void RadioStarted(ERR_LocationComponent location)
{
if (!Replication.IsServer() || !m_RadioDefence || location != m_RadioDefence.Location()) return;
if (!Transition(ERR_Phase.RADIO_DEFENCE)) m_RadioDefence.Stop();
}
protected void RadioCompleted(ERR_LocationComponent location)
{
if (!Replication.IsServer() || m_Phase != ERR_Phase.RADIO_DEFENCE || !m_RadioDefence || m_RadioDefence.State() != ERR_RadioDefenceState.COMPLETED || location != m_RadioDefence.Location()) return;
foreach (ERR_LocationComponent candidate : m_Locations)
if (candidate.Kind() == ERR_LocationKind.EXTRACTION) candidate.Discover(0, 100);
Transition(ERR_Phase.EXTRACTION);
}
void PlayerSpawned(int id, IEntity actor)
@@ -226,6 +231,11 @@ class ERR_GameMode : SCR_BaseGameMode
if (!System.IsConsoleApp()) GetGame().GetCallqueue().CallLater(BindLocalObserver, 250, true);
if (!Replication.IsServer()) return;
m_Settings = new ERR_Settings();
m_RadioDefence = new ERR_RadioDefenceService(this);
m_RadioDefence.OnStarted().Insert(RadioStarted);
m_RadioDefence.OnCompleted().Insert(RadioCompleted);
OnPhaseChanged().Insert(m_RadioDefence.PhaseChanged);
OnStop().Insert(m_RadioDefence.Stop);
m_Extraction = new ERR_ExtractionService(m_Roster);
PublishEscapeResults(true);
m_Extraction.OnResolved().Insert(ExtractionResolved);
@@ -298,14 +308,13 @@ class ERR_GameMode : SCR_BaseGameMode
{
if (!Replication.IsServer() || !Active()) return;
float now = GetWorld().GetWorldTime();
array<IEntity> players = {};
ERR_Actors.Players(players);
m_Extraction.Tick(m_Phase);
if (!Active()) return;
m_RadioDefence.Tick(now);
if (!Active()) return;
if (GetGame().GetPlayerManager().GetPlayerCount() == 0)
{
m_NoSurvivorsSince = -1;
m_DefenceSince = -1;
return;
}
if (m_StartedPlayers && !m_Roster.HasSurvivor())
@@ -319,20 +328,6 @@ class ERR_GameMode : SCR_BaseGameMode
}
}
else m_NoSurvivorsSince = -1;
if (m_Phase == ERR_Phase.RADIO_DEFENCE)
{
bool occupied;
foreach (IEntity actor : players)
if (m_Radio && ERR_Actors.CanFight(actor) && vector.DistanceXZ(actor.GetOrigin(), m_Radio.Position()) < 60) occupied = true;
if (!occupied) m_DefenceSince = -1;
else if (m_DefenceSince < 0) m_DefenceSince = now;
if (ERR_MissionRules.DefenceComplete(now, m_DefenceSince, m_Settings.Get("radioDefenceSeconds") * 1000, occupied))
{
foreach (ERR_LocationComponent location : m_Locations)
if (location.Kind() == ERR_LocationKind.EXTRACTION) location.Discover(0, 100);
Transition(ERR_Phase.EXTRACTION);
}
}
array<int> connected = {};
GetGame().GetPlayerManager().GetPlayers(connected);
foreach (int id : connected)
@@ -3,10 +3,14 @@
modded class ERR_GameMode
{
protected int m_ERRBaseStep;
protected bool m_ERRObjectiveCloseChecked;
protected bool m_ERRStartupFailure;
protected bool m_ERRCacheLoss;
protected string m_ERRExtractionCase;
protected ref ERR_NativeExtractionProbe m_ERRExtractionProbe;
protected ref ERR_NativeTransportProbe m_ERRTransportProbe;
protected ref ERR_NativeLifecycleProbe m_ERRLifecycleProbe;
protected ref ERR_NativeRadioDefenceProbe m_ERRBaseRadioProbe;
protected string m_ERRActiveCacheId = "everon-supply-1";
protected int m_ERRMedicalDiscoveries;
protected IEntity m_ERRSightBlocker;
@@ -21,6 +25,7 @@ modded class ERR_GameMode
super.EOnInit(owner);
string enabled;
if (SCR_Global.IsEditMode() || !Replication.IsServer() || !System.GetCLIParam("errBaseSmoke", enabled) || enabled != "1") return;
ERR_NativeObjectiveProbe.BeginRound();
string outcome;
m_ERRStartupFailure = System.GetCLIParam("errSmokeOutcome", outcome) && outcome == "startup-failure";
m_ERRExtractionCase = outcome;
@@ -29,6 +34,8 @@ modded class ERR_GameMode
m_ERRCacheLoss = outcome == "cache-loss";
if (m_ERRCacheLoss) m_ERRActiveCacheId = "everon-supply-2";
OnLocationDiscovered().Insert(ERR_BaseDiscovered);
OnStop().Insert(ERR_NativeObjectiveProbe.Stop);
OnStop().Insert(ERR_StopRadioProbe);
if (m_ERRStartupFailure && m_Factions) m_Factions.EnemyFactionKey = m_Factions.PlayerFactionKey;
m_ERRBaseDeadline = GetWorld().GetWorldTime() + 210000;
OnPhaseChanged().Insert(ERR_BaseResult);
@@ -38,10 +45,13 @@ modded class ERR_GameMode
protected bool ERR_BaseCheck(bool condition, string detail)
{
if (condition) { Print("ERR SMOKE PASS: base " + detail); return true; }
ERR_NativeObjectiveProbe.Stop();
ERR_StopRadioProbe();
Print("ERR SMOKE FAIL: base " + detail, LogLevel.ERROR);
GetGame().GetCallqueue().Remove(ERR_BaseTick);
return false;
}
protected void ERR_StopRadioProbe() { if (m_ERRBaseRadioProbe) m_ERRBaseRadioProbe.Stop(); }
protected void ERR_BaseMove(vector position)
{
vector transform[4];
@@ -114,6 +124,24 @@ modded class ERR_GameMode
Print("ERR SMOKE COMPLETE: early extraction vehicle loss");
return;
}
if (m_ERRExtractionCase == "objective-ui")
{
if (!ERR_BaseCheck(current == ERR_Phase.DEFEAT && m_ERRBaseStep == 902, "objective UI fixture finished before explicit test reset")) return;
Print("ERR SMOKE COMPLETE: native objective UI close, reopen and deleted-task cleanup; reset requested by fixture");
return;
}
if (m_ERRExtractionCase == "lifecycle")
{
if (!ERR_BaseCheck(m_ERRBaseStep == 402, "lifecycle fixture finished before explicit test reset")) return;
Print("ERR SMOKE COMPLETE: native lifecycle admission guards; reset requested by fixture");
return;
}
if (m_ERRExtractionCase == "transport-loss")
{
if (!ERR_BaseCheck(m_ERRBaseStep == 302, "transport fixture finished before explicit test reset")) return;
Print("ERR SMOKE COMPLETE: finite native ground transport; reset requested by fixture");
return;
}
if (m_ERRExtractionCase == "extraction-capacity")
{
if (!ERR_BaseCheck(m_ERRBaseStep == 202, "capacity fixture finished before its explicit test reset")) return;
@@ -154,6 +182,20 @@ modded class ERR_GameMode
Print("ERR SMOKE COMPLETE: expected startup-failure");
return;
}
if (m_ERRBaseStep == 400)
{
int result = m_ERRLifecycleProbe.Tick(now);
if (result < 0) { GetGame().GetCallqueue().Remove(ERR_BaseTick); return; }
if (result == 1) { m_ERRBaseStep = 402; Transition(ERR_Phase.DEFEAT); }
return;
}
if (m_ERRBaseStep == 300)
{
int result = m_ERRTransportProbe.Tick(now);
if (result < 0) { GetGame().GetCallqueue().Remove(ERR_BaseTick); return; }
if (result == 1) { m_ERRBaseStep = 302; Transition(ERR_Phase.DEFEAT); }
return;
}
if (m_ERRBaseStep == 200)
{
int result = m_ERRExtractionProbe.Tick(now);
@@ -161,6 +203,28 @@ modded class ERR_GameMode
if (result == 1) { m_ERRBaseStep = 202; Transition(ERR_Phase.DEFEAT); }
return;
}
if (m_ERRBaseStep == 901)
{
int result = ERR_NativeObjectiveProbe.TickCloseReopen(this, now);
if (result < 0) { GetGame().GetCallqueue().Remove(ERR_BaseTick); return; }
if (result == 1)
{
if (m_ERRExtractionCase == "objective-ui")
{
if (!ERR_NativeObjectiveProbe.BeginDeletedTaskClose(this, now)) { GetGame().GetCallqueue().Remove(ERR_BaseTick); return; }
m_ERRBaseStep = 903;
}
else { m_ERRObjectiveCloseChecked = true; m_ERRBaseStep = 0; }
}
return;
}
if (m_ERRBaseStep == 903)
{
int result = ERR_NativeObjectiveProbe.TickDeletedTaskClose(this, now);
if (result < 0) { GetGame().GetCallqueue().Remove(ERR_BaseTick); return; }
if (result == 1) { m_ERRBaseStep = 902; Transition(ERR_Phase.DEFEAT); }
return;
}
if (now < m_ERRBaseWait) return;
if (m_ERRBaseStep == 0)
{
@@ -168,6 +232,25 @@ 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 == "lifecycle")
{
DamageManagerComponent.Cast(m_ERRBasePlayer.FindComponent(DamageManagerComponent)).EnableDamageHandling(false);
m_ERRLifecycleProbe = new ERR_NativeLifecycleProbe(this, m_ERRBasePlayer);
m_ERRBaseStep = 400;
return;
}
if (m_ERRExtractionCase == "transport-survey")
{
ERR_NativeTransportSurvey.Run(this);
GetGame().GetCallqueue().Remove(ERR_BaseTick);
return;
}
if (m_ERRExtractionCase == "transport-loss")
{
m_ERRTransportProbe = new ERR_NativeTransportProbe(this);
m_ERRBaseStep = 300;
return;
}
if (m_ERRExtractionCase == "early-helicopter-loss")
{
if (!ERR_BaseCheck(Extraction().Ready() && Players().HasSurvivor(), "ready helicopter and living prisoner before deliberate loss")) return;
@@ -190,9 +273,16 @@ modded class ERR_GameMode
GetGame().GetCallqueue().Remove(ERR_BaseTick);
return;
}
if (!m_ERRObjectiveCloseChecked)
{
if (!ERR_NativeObjectiveProbe.BeginCloseReopen(this, now)) { GetGame().GetCallqueue().Remove(ERR_BaseTick); return; }
m_ERRBaseStep = 901;
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;
if (!ERR_NativeObjectiveProbe.Observe(this)) return;
ERR_SiteRuntime cache = Sites().Find(m_ERRActiveCacheId);
if (!ERR_BaseCheck(Sites().Count() == 7 && cache && cache.Container && vector.DistanceXZ(cache.Container.GetOrigin(), PrisonPosition()) > 100, "authored supply cache is a separate scavenging location")) return;
array<IEntity> stock = {};
@@ -226,6 +316,7 @@ modded class ERR_GameMode
SCR_Task prisonTask = SCR_TaskSystem.GetTaskFromTaskID(ERR_Objectives.Id(ERR_Phase.PRISON), false);
if (!ERR_BaseCheck(prisonTask && prisonTask.GetTaskState() == SCR_ETaskState.COMPLETED, "previous native objective completes on phase change")) return;
if (!ERR_BaseCheck(Objectives().Current().GetTaskUIVisibility() == SCR_ETaskUIVisibility.LIST_ONLY, "search objective does not expose a hidden site")) return;
if (!ERR_NativeObjectiveProbe.Observe(this)) return;
if (!ERR_BaseCheck(Sites().Find("everon-supply-1").Location.Knowledge() == 2 && Sites().Find("everon-supply-2").Location.Knowledge() == 2, "both named supply routes revealed after escape")) return;
ERR_SiteRuntime medical = Sites().Find("everon-medical-1");
if (!ERR_BaseCheck(medical.Location.Knowledge() == 0 && Sites().Find("everon-outpost-1").Location.Knowledge() == 0, "optional sites remain hidden until approached")) return;
@@ -285,6 +376,7 @@ modded class ERR_GameMode
else if (m_ERRBaseStep == 3)
{
if (!ERR_BaseCheck(Objectives().Current().GetTaskDescription().Contains("identify the radio station on your map. Reach"), "objective guidance reacts to discovered radio")) return;
if (!ERR_NativeObjectiveProbe.Observe(this)) return;
ERR_SiteRuntime cache = Sites().Find(m_ERRActiveCacheId);
array<IEntity> stock = {};
InventoryStorageManagerComponent.Cast(cache.Container.FindComponent(InventoryStorageManagerComponent)).GetItems(stock);
@@ -298,6 +390,14 @@ modded class ERR_GameMode
}
else if (m_ERRBaseStep == 30)
{
m_ERRBaseRadioProbe = new ERR_NativeRadioDefenceProbe(this, m_ERRBasePlayer, m_ERRBaseRadio);
m_ERRBaseStep = 301;
}
else if (m_ERRBaseStep == 301)
{
int result = m_ERRBaseRadioProbe.Tick(now);
if (result < 0) { ERR_StopRadioProbe(); GetGame().GetCallqueue().Remove(ERR_BaseTick); return; }
if (result == 0) return;
if (!ERR_BaseCheck(TuneRadio(m_ERRBasePlayer, m_ERRBaseRadio), "identified radio accepts transmission")) return;
m_ERRBaseStep = 31;
m_ERRBaseWait = now + 5000;
@@ -310,6 +410,7 @@ modded class ERR_GameMode
if (!ERR_BaseCheck(stock.IsEmpty() && Sites().Prepare() == ERR_PrepareResult.READY, "depleted stock stays empty when preparation is queried again")) return;
ERR_Actors.DeleteSpawned(cache.Container);
if (!ERR_BaseCheck(DefenceOccupied() && DefenceSecondsRemaining() > 0 && DefenceSecondsRemaining() < 30 && Objectives().Current().GetTaskDescription().Contains("seconds remaining"), "native objective reports advancing radio progress")) return;
if (!ERR_NativeObjectiveProbe.Observe(this)) return;
CharacterControllerComponent.Cast(m_ERRBasePlayer.FindComponent(CharacterControllerComponent)).SetUnconscious(true);
m_ERRBaseStep = 310;
m_ERRBaseWait = now + 35000;
@@ -338,6 +439,7 @@ modded class ERR_GameMode
{
if (!ERR_BaseCheck(!Sites().Find(m_ERRActiveCacheId).Container, "lost supply cache is not replaced")) return;
if (!ERR_BaseCheck(!DefenceOccupied() && DefenceSecondsRemaining() == 30 && Objectives().Current().GetTaskDescription().Contains("interrupted"), "objective reports interruption and reset")) return;
if (!ERR_NativeObjectiveProbe.Observe(this)) return;
ERR_BaseMove(m_ERRBaseRadio.Position() + "2 0 0");
m_ERRBaseStep = 4;
m_ERRBaseWait = now + 33000;
@@ -345,6 +447,7 @@ modded class ERR_GameMode
else if (m_ERRBaseStep == 4)
{
if (!ERR_BaseCheck(Phase() == ERR_Phase.EXTRACTION, "radio defence reveals extraction") || !ERR_NativeTimeProbe.VerifyProgression(this)) return;
if (!ERR_NativeObjectiveProbe.Observe(this)) return;
CompartmentAccessComponent access = CompartmentAccessComponent.Cast(m_ERRBasePlayer.FindComponent(CompartmentAccessComponent));
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;
@@ -0,0 +1,190 @@
#ifdef WORKBENCH
[BaseContainerProps(category: "Respawn")]
modded class ERR_SpawnLogic
{
// Test-only ownership setup. This does not change a life record, start a real
// spawn request, replace a controller's actor or bypass production finalization.
bool ERR_TestPendingActor(SCR_SpawnRequestComponent request, SCR_SpawnData data, IEntity actor)
{
if (!Replication.IsServer() || !request || !data || !actor || ERR_Actors.Player(actor)) return false;
int id = request.GetPlayerId();
if (m_Pending.Contains(id) || !m_Identities.Contains(id)) return false;
ERR_SpawnTicket ticket = new ERR_SpawnTicket();
ticket.ConnectionId = id; ticket.AccountId = m_Identities.Get(id);
ticket.Mode = ERR_GameMode.Get();
ticket.Controller = SCR_PlayerController.Cast(request.GetPlayerController());
ticket.Request = request; ticket.Data = data; ticket.Actor = actor;
m_SpawnTickets.Insert(ticket);
m_Pending.Insert(id);
m_AuthorizedPositions.Set(id, data.GetPosition());
return true;
}
void ERR_TestClearAdmission(int playerId) { CancelAdmission(playerId, ERR_ObserverReason.NONE); }
bool ERR_TestDelayedLossPreservesAdmission(int playerId, SCR_PlayerController controller)
{
ObserveAfterLoss(playerId, m_Identities.Get(playerId), controller);
return m_Pending.Contains(playerId) && m_AuthorizedPositions.Contains(playerId);
}
bool ERR_TestHasAdmission(int playerId)
{
if (m_Pending.Contains(playerId) || m_AuthorizedPositions.Contains(playerId)) return true;
foreach (ERR_SpawnTicket ticket : m_SpawnTickets)
if (ticket.ConnectionId == playerId) return true;
return false;
}
}
class ERR_NativeLifecycleProbe
{
protected ERR_GameMode m_Mode;
protected IEntity m_Player;
protected IEntity m_Blocker;
protected IEntity m_Preload;
protected IEntity m_Cancelled;
protected IEntity m_Drift;
protected SCR_PlayerController m_Controller;
protected SCR_FreeSpawnRequestComponent m_Request;
protected SCR_FreeSpawnHandlerComponent m_Handler;
protected ERR_SpawnLogic m_Logic;
protected ref SCR_FreeSpawnData m_Data;
protected ref ERR_SpawnPlacement m_Placement = new ERR_SpawnPlacement();
protected vector m_Position;
protected int m_Step;
protected float m_Wait;
void ERR_NativeLifecycleProbe(ERR_GameMode mode, IEntity player) { m_Mode = mode; m_Player = player; }
protected bool Check(bool condition, string detail)
{
if (condition) { Print("ERR SMOKE PASS: lifecycle " + detail); return true; }
Print("ERR SMOKE FAIL: lifecycle " + detail, LogLevel.ERROR);
return false;
}
protected IEntity Actor(vector position)
{
IEntity actor = ERR_Actors.Spawn(m_Mode.Factions().PrisonerPrefab, position);
if (actor)
{
m_Mode.Own(actor);
DamageManagerComponent.Cast(actor.FindComponent(DamageManagerComponent)).EnableDamageHandling(false);
}
return actor;
}
protected bool Rules()
{
UUID account = "00e77300-0000-0000-0000-000000000001";
UUID other = "00e77300-0000-0000-0000-000000000002";
for (int phase = ERR_Phase.PREPARING; phase <= ERR_Phase.DEFEAT; phase++)
{
bool permitted = phase >= ERR_Phase.PRISON && phase <= ERR_Phase.RADIO_DEFENCE;
if (!Check(ERR_SpawnFinalizationRules.Allows(phase, true, false, account, account, true, true, true) == permitted, "finalization phase matrix " + phase.ToString())) return false;
}
if (!Check(!ERR_SpawnFinalizationRules.Allows(ERR_Phase.PRISON, true, true, account, account, true, true, true), "spent life cannot finalize a delayed admission")) return false;
if (!Check(!ERR_SpawnFinalizationRules.Allows(ERR_Phase.PRISON, false, false, account, account, true, true, true), "disconnected account cannot finalize")) return false;
if (!Check(!ERR_SpawnFinalizationRules.Allows(ERR_Phase.PRISON, true, false, account, other, true, true, true), "recycled connection identity cannot inherit a pending admission")) return false;
if (!Check(!ERR_SpawnFinalizationRules.Allows(ERR_Phase.PRISON, true, false, account, account, false, true, true), "changed controller rejects stale admission")) return false;
if (!Check(!ERR_SpawnFinalizationRules.Allows(ERR_Phase.PRISON, true, false, account, account, true, false, true), "changed request rejects stale admission")) return false;
if (!Check(!ERR_SpawnFinalizationRules.Allows(ERR_Phase.PRISON, true, false, account, account, true, true, false), "existing controlled actor cannot be replaced by delayed admission")) return false;
return Check(ERR_SpawnFinalizationRules.PositionClose("0.3 0.2 0", "0 0 0") && !ERR_SpawnFinalizationRules.PositionClose("0 2 0", "0 0 0") && !ERR_SpawnFinalizationRules.PositionClose("0.7 0 0", "0 0 0"), "native settling tolerance rejects horizontal and vertical preload drift");
}
protected bool Finalization()
{
m_Controller = SCR_PlayerController.Cast(GetGame().GetPlayerController());
m_Request = SCR_FreeSpawnRequestComponent.Cast(m_Controller.FindComponent(SCR_FreeSpawnRequestComponent));
if (m_Request) m_Handler = SCR_FreeSpawnHandlerComponent.Cast(m_Request.GetHandlerComponent());
SCR_RespawnSystemComponent system = SCR_RespawnSystemComponent.GetInstance();
m_Logic = ERR_SpawnLogic.Cast(system.GetSpawnLogic());
if (!Check(m_Request && m_Handler && m_Logic, "real local free-spawn request, handler and admission logic exist")) return false;
int id = m_Controller.GetPlayerId();
ERR_PlayerLife life = m_Mode.Players().Get(id);
if (!Check(life && life.UsedLife && life.Actor == m_Player && m_Controller.GetControlledEntity() == m_Player && !m_Logic.ERR_TestHasAdmission(id), "real prisoner has a spent life and no pending admission")) return false;
m_Preload = Actor(m_Position);
m_Data = new SCR_FreeSpawnData(m_Mode.Factions().PrisonerPrefab, m_Position);
if (!Check(m_Preload && m_Logic.ERR_TestPendingActor(m_Request, m_Data, m_Preload), "isolated unpossessed actor receives an explicit test-owned admission ticket")) return false;
SCR_FreeSpawnData different = new SCR_FreeSpawnData(m_Mode.Factions().PrisonerPrefab, m_Position);
if (!Check(m_Handler.FinalizeRequest_S(m_Request, different, m_Preload) == SCR_ESpawnResult.SPAWN_NOT_ALLOWED && ERR_Actors.Alive(m_Preload), "different request payload cannot claim or delete the owned actor")) return false;
return Check(m_Handler.FinalizeRequest_S(m_Request, m_Data, m_Preload) == SCR_ESpawnResult.SPAWN_NOT_ALLOWED, "native finalization refuses the spent-life preload");
}
int Tick(float now)
{
if (now < m_Wait) return 0;
if (m_Step == 0)
{
if (!Rules()) return -1;
map<int, vector> reservations = new map<int, vector>();
vector first, second;
if (!Check(m_Placement.Prison(m_Mode.PrisonPosition(), reservations, first), "native prison has a clear candidate")) return -1;
reservations.Set(1, first);
if (!Check(m_Placement.Prison(m_Mode.PrisonPosition(), reservations, second) && vector.DistanceXZ(first, second) >= 1.2, "IDs one and thirteen receive distinct reserved slots")) return -1;
reservations.Set(13, second);
vector position;
int count = 2;
while (count < 25 && m_Placement.Prison(m_Mode.PrisonPosition(), reservations, position))
{
reservations.Set(100 + count, position);
count++;
}
PrintFormat("ERR SPAWN CAPACITY: remaining clear prison positions=%1 plus current controlled prisoner", count);
if (!Check(count >= 11 && !m_Placement.Prison(m_Mode.PrisonPosition(), reservations, position), "occupied and pending slots exhaust a bounded grid without overlap or outside fallback")) return -1;
vector prison = m_Mode.PrisonPosition();
foreach (int id, vector reserved : reservations)
if (!Check(Math.AbsFloat(reserved[0] - prison[0]) <= 3.1 && Math.AbsFloat(reserved[2] - prison[2]) <= 3.1, "candidate remains inside authored prison")) return -1;
reservations.Remove(1);
if (!Check(m_Placement.Prison(m_Mode.PrisonPosition(), reservations, position) && vector.DistanceXZ(position, first) < 0.1, "released pending reservation becomes available again")) return -1;
m_Position = first;
m_Blocker = Actor(first);
if (!Check(m_Blocker != null, "uncontrolled physical body created at a previously clear slot")) return -1;
m_Step = 1; m_Wait = now + 1000;
return 0;
}
if (m_Step == 1)
{
map<int, vector> empty = new map<int, vector>();
if (!Check(!m_Placement.Clear(m_Position, empty), "native character body blocks a slot even without a connected player ID")) return -1;
ERR_Actors.DeleteSpawned(m_Blocker);
m_Step = 2; m_Wait = now + 1000;
return 0;
}
if (m_Step == 2)
{
if (!Finalization()) return -1;
m_Step = 3; m_Wait = now + 1000;
return 0;
}
if (m_Step == 3)
{
int id = m_Controller.GetPlayerId();
ERR_PlayerLife life = m_Mode.Players().Get(id);
if (!Check(!m_Preload, "refused native preload was deleted after lifecycle processing")) return -1;
m_Logic.ERR_TestClearAdmission(id);
if (!Check(!m_Logic.ERR_TestHasAdmission(id) && life.UsedLife && life.Actor == m_Player && m_Controller.GetControlledEntity() == m_Player, "refusal releases reservations without changing life or controlled actor")) return -1;
if (!Check(m_Handler.FinalizeRequest_S(m_Request, m_Data, m_Player) == SCR_ESpawnResult.SPAWN_NOT_ALLOWED && ERR_Actors.Alive(m_Player), "unticketed repeated callback cannot delete or replace the controlled prisoner")) return -1;
m_Cancelled = Actor(m_Position);
if (!Check(m_Cancelled && m_Logic.ERR_TestPendingActor(m_Request, m_Data, m_Cancelled), "second isolated pending actor prepared for cancellation")) return -1;
if (!Check(m_Logic.ERR_TestDelayedLossPreservesAdmission(id, m_Controller) && m_Controller.ERR_ObserverState() == ERR_ObserverReason.NONE, "delayed loss callback preserves reacquired living controller and admission metadata")) return -1;
m_Logic.ERR_TestClearAdmission(id);
m_Step = 4; m_Wait = now + 1000;
return 0;
}
if (m_Step == 4)
{
int id = m_Controller.GetPlayerId();
ERR_PlayerLife life = m_Mode.Players().Get(id);
if (!Check(!m_Cancelled && !m_Logic.ERR_TestHasAdmission(id) && life.UsedLife && life.Actor == m_Player, "pending-request cancellation cleans its actor and never grants another life")) return -1;
m_Drift = Actor(m_Position);
if (!Check(m_Drift != null, "physical actor created for vertical drift check")) return -1;
vector transform[4]; m_Drift.GetWorldTransform(transform);
transform[3] = m_Position + "0 10 0";
ChimeraCharacter.Cast(m_Drift).Teleport(transform);
m_Step = 5; m_Wait = now + 750;
return 0;
}
if (m_Step == 5)
{
if (!Check(!ERR_SpawnFinalizationRules.PositionClose(m_Drift.GetOrigin(), m_Position), "actual vertically displaced character fails finalization position tolerance after native movement")) return -1;
ERR_Actors.DeleteSpawned(m_Drift);
m_Step = 6;
return 1;
}
return 0;
}
}
#endif
@@ -0,0 +1,238 @@
#ifdef WORKBENCH
// Exercise native task entries and detail text on the private test display.
// This is local presentation evidence, not remote task replication acceptance.
class ERR_NativeObjectiveProbe
{
protected static bool s_Failed;
protected static Widget s_Entry;
protected static Widget s_Detail;
protected static SCR_Task s_Task;
protected static SCR_TaskListEntryUIComponent s_EntryHandler;
protected static SCR_TaskListEntryDescriptionUIComponent s_DetailHandler;
protected static ref SCR_TaskListEntryDescriptionUIComponent s_ClosedHandler;
protected static float s_CloseReadyAt;
protected static SCR_Task s_IsolatedTask;
protected static bool s_IsolatedDeleteRequested;
protected static SCR_Task s_PreservedMissionTask;
protected static ref SCR_TaskExecutor s_RestoreExecutor;
static void BeginRound()
{
Stop();
s_Failed = false;
}
protected static bool Check(bool condition, string detail)
{
if (condition) { Print("ERR SMOKE PASS: objective UI " + detail); return true; }
s_Failed = true;
Stop();
Print("ERR SMOKE FAIL: objective UI " + detail, LogLevel.ERROR);
return false;
}
static void Stop()
{
if (s_EntryHandler && s_DetailHandler) s_EntryHandler.GetOnTaskVisualChanged().Remove(s_DetailHandler.InitDescription);
if (s_Detail) s_Detail.RemoveFromHierarchy();
if (s_Entry) s_Entry.RemoveFromHierarchy();
s_Detail = null;
s_Entry = null;
s_DetailHandler = null;
s_EntryHandler = null;
s_Task = null;
s_ClosedHandler = null;
s_CloseReadyAt = 0;
SCR_TaskSystem tasks = SCR_TaskSystem.GetInstance();
if (tasks && s_IsolatedTask && !s_IsolatedDeleteRequested) tasks.DeleteTask(s_IsolatedTask);
if (tasks && s_PreservedMissionTask && s_RestoreExecutor && !s_PreservedMissionTask.IsTaskAssignedTo(s_RestoreExecutor))
tasks.AssignTask(s_PreservedMissionTask, s_RestoreExecutor, true);
s_IsolatedTask = null;
s_IsolatedDeleteRequested = false;
s_PreservedMissionTask = null;
s_RestoreExecutor = null;
}
// Emit unchanged task state: exercise actual invoker subscriptions without
// changing assignment, visibility, ownership or mission progression.
protected static void EmitTaskCallbacks(SCR_Task task)
{
task.GetOnTaskAssigneeAdded().Invoke(task, null, 0);
task.GetOnTaskAssigneeRemoved().Invoke(task, null, 0);
task.GetOnTaskUIVisibilityChanged().Invoke(task, task.GetTaskUIVisibility());
task.GetOnTaskVisibilityChanged().Invoke(task, task.GetTaskVisibility());
task.GetOnTaskOwnershipChanged().Invoke(task, task.GetTaskOwnership());
task.GetOnDisplayDataChanged().Invoke();
}
static bool BeginCloseReopen(ERR_GameMode mode, float now)
{
if (!Observe(mode)) return false;
// Reinitialization is the vanilla route when the selected task changes.
s_DetailHandler.InitDescription(s_Task);
s_DetailHandler.ERR_ProbeWatch();
EmitTaskCallbacks(s_Task);
if (!Check(s_DetailHandler.ERR_ProbeTaskCallbacks() == 5, "rebinding keeps one subscription to each native task event")) return false;
// Keep only the handler alive so a leaked event or queued overflow callback
// can be counted safely after actual widget destruction. No manual unbinding.
SCR_Task task = s_Task;
ref SCR_TaskListEntryDescriptionUIComponent closed = s_DetailHandler;
Stop();
s_ClosedHandler = closed;
s_ClosedHandler.ERR_ProbeClosed();
if (!Check(s_ClosedHandler.ERR_ProbeWasDetached() && s_ClosedHandler.ERR_ProbeHasNoTask(), "closing native widget uses production detach and releases its task")) return false;
EmitTaskCallbacks(task);
if (!Check(s_ClosedHandler.ERR_ProbeLateCallbacks() == 0, "closed native widget receives no task callbacks")) return false;
s_CloseReadyAt = now + 500;
return true;
}
// One normal smoke tick allows native 100ms overflow work to become due.
static int TickCloseReopen(ERR_GameMode mode, float now)
{
if (s_Failed || !s_ClosedHandler) return -1;
if (now < s_CloseReadyAt) return 0;
if (!Check(s_ClosedHandler.ERR_ProbeLateCallbacks() == 0, "closed native widget has no delayed overflow callbacks")) return -1;
if (!Observe(mode)) return -1;
if (!Check(s_DetailHandler != null, "details reopen through native layout and current objective binding")) return -1;
return 1;
}
// Dedicated objective-ui case: delete an owned test task before its panel.
// The real objective remains alive and regains its local assignment immediately.
static bool BeginDeletedTaskClose(ERR_GameMode mode, float now)
{
Stop();
SCR_TaskSystem tasks = SCR_TaskSystem.GetInstance();
s_PreservedMissionTask = mode.Objectives().Current();
s_RestoreExecutor = SCR_TaskSystem.TaskExecutorFromPlayerID(SCR_PlayerController.GetLocalPlayerId());
if (!Check(tasks && s_PreservedMissionTask && s_RestoreExecutor, "isolated deletion has a native task system, mission task and local executor")) return false;
s_IsolatedTask = tasks.CreateTask("{1D0F815858EE24AD}Prefabs/Tasks/BaseTask.et", "erri-mission-probe-teardown", "Objective cleanup probe", "Isolated native task for widget teardown.");
if (!Check(s_IsolatedTask && s_IsolatedTask != s_PreservedMissionTask, "deletion fixture owns a separate native task")) return false;
tasks.SetTaskUIVisibility(s_IsolatedTask, SCR_ETaskUIVisibility.LIST_ONLY);
// Vanilla permits one assigned task per executor. Cleanup restores the real one.
tasks.AssignTask(s_IsolatedTask, s_RestoreExecutor, true);
if (!Check(s_IsolatedTask.IsTaskAssignedTo(s_RestoreExecutor), "isolated task has an actual local assignee")) return false;
s_Detail = GetGame().GetWorkspace().CreateWidgets("{552EB1921B5C548C}UI/layouts/Task/TaskListEntryDescription.layout");
if (!Check(s_Detail != null, "isolated native details layout created")) return false;
s_Detail.SetVisible(false);
s_DetailHandler = SCR_TaskListEntryDescriptionUIComponent.Cast(s_Detail.FindHandler(SCR_TaskListEntryDescriptionUIComponent));
if (!Check(s_DetailHandler != null, "isolated native details handler attached")) return false;
s_DetailHandler.InitDescription(s_IsolatedTask);
s_DetailHandler.ERR_ProbeWatch();
s_IsolatedDeleteRequested = true;
tasks.DeleteTask(s_IsolatedTask);
// These are native weak references, not manually cleared probe variables.
if (!Check(s_IsolatedTask == null && s_DetailHandler.ERR_ProbeHasNoTask(), "native task deletion clears both task and bound-widget weak references")) return false;
s_ClosedHandler = s_DetailHandler;
s_Detail.RemoveFromHierarchy();
s_Detail = null;
s_DetailHandler = null;
s_ClosedHandler.ERR_ProbeClosed();
if (!Check(s_ClosedHandler.ERR_ProbeWasDetached(), "deleted-task panel closes through production detach")) return false;
tasks.AssignTask(s_PreservedMissionTask, s_RestoreExecutor, true);
if (!Check(mode.Objectives().Current() == s_PreservedMissionTask && s_PreservedMissionTask.IsTaskAssignedTo(s_RestoreExecutor), "real mission objective survives isolated deletion and assignment is restored")) return false;
s_CloseReadyAt = now + 500;
return true;
}
static int TickDeletedTaskClose(ERR_GameMode mode, float now)
{
if (s_Failed || !s_ClosedHandler) return -1;
if (now < s_CloseReadyAt) return 0;
if (!Check(s_ClosedHandler.ERR_ProbeLateCallbacks() == 0, "deleted-task panel has no delayed callbacks after weak references clear")) return -1;
if (!Check(s_PreservedMissionTask && mode.Objectives().Current() == s_PreservedMissionTask && s_PreservedMissionTask.IsTaskAssignedTo(s_RestoreExecutor), "real mission objective remains intact after delayed teardown check")) return -1;
Stop();
return 1;
}
static bool Observe(ERR_GameMode mode)
{
if (s_Failed) return false;
SCR_Task task = mode.Objectives().Current();
if (!Check(task != null, "current native task exists")) return false;
if (s_Task != task)
{
Stop();
s_Task = task;
s_Entry = GetGame().GetWorkspace().CreateWidgets("{B71975032150BAFB}UI/layouts/Task/TaskListEntry.layout");
s_Detail = GetGame().GetWorkspace().CreateWidgets("{552EB1921B5C548C}UI/layouts/Task/TaskListEntryDescription.layout");
if (!Check(s_Entry && s_Detail, "native list and detail layouts created")) return false;
s_Entry.SetVisible(false);
s_Detail.SetVisible(false);
s_EntryHandler = SCR_TaskListEntryUIComponent.Cast(s_Entry.FindHandler(SCR_TaskListEntryUIComponent));
s_DetailHandler = SCR_TaskListEntryDescriptionUIComponent.Cast(s_Detail.FindHandler(SCR_TaskListEntryDescriptionUIComponent));
if (!Check(s_EntryHandler && s_DetailHandler, "native handlers attached")) return false;
s_EntryHandler.InitTask(task);
s_DetailHandler.InitDescription(task);
// Same visual-change route used by the vanilla task list.
s_EntryHandler.GetOnTaskVisualChanged().Insert(s_DetailHandler.InitDescription);
}
RichTextWidget title = RichTextWidget.Cast(s_Entry.FindAnyWidget("m_wTaskTitle"));
RichTextWidget description = RichTextWidget.Cast(s_Detail.FindAnyWidget("m_wDescription"));
if (!Check(title && description && title.GetText() == task.GetTaskName() && description.GetText() == task.GetTaskDescription(), "open list and details contain current title and instructions")) return false;
Widget showMap = s_Detail.FindAnyWidget("m_wButtonShowMap");
if (!Check(showMap && !showMap.IsVisible(), "list-only task cannot expose an undiscovered map position")) return false;
SCR_TaskExecutor player = SCR_TaskSystem.TaskExecutorFromPlayerID(SCR_PlayerController.GetLocalPlayerId());
if (!Check(player && task.IsTaskAssignedTo(player), "actual local player receives the active objective")) return false;
if (!Check(ERR_ObjectiveText.Extraction(12500).Contains("12.5") && !ERR_ObjectiveText.Extraction(12500).Contains("9 kilometres"), "extraction instructions follow a nondefault boundary")) return false;
return true;
}
}
// Observers exist only in the opt-in native fixture. They do not unsubscribe or
// cancel work: a late callback is counted as failure before avoiding dead-widget
// dereferences, allowing the fixture to report the production cleanup defect.
modded class SCR_TaskListEntryDescriptionUIComponent
{
protected bool m_ERRProbeWatching;
protected bool m_ERRProbeClosed;
protected bool m_ERRProbeDetached;
protected int m_ERRProbeTaskCallbacks;
protected int m_ERRProbeLateCallbacks;
void ERR_ProbeWatch() { m_ERRProbeWatching = true; m_ERRProbeTaskCallbacks = 0; }
void ERR_ProbeClosed() { m_ERRProbeClosed = true; }
int ERR_ProbeTaskCallbacks() { return m_ERRProbeTaskCallbacks; }
int ERR_ProbeLateCallbacks() { return m_ERRProbeLateCallbacks; }
bool ERR_ProbeWasDetached() { return m_ERRProbeDetached; }
bool ERR_ProbeHasNoTask() { return m_Task == null; }
protected bool ERR_ProbeCallback(bool taskEvent, string kind)
{
if (!m_ERRProbeWatching) return false;
if (m_ERRProbeClosed)
{
m_ERRProbeLateCallbacks++;
PrintFormat("ERR OBJECTIVE LATE CALLBACK: kind=%1 count=%2 task=%3 detached=%4 root=%5", kind, m_ERRProbeLateCallbacks, m_Task != null, m_ERRProbeDetached, m_wRoot != null);
return true;
}
if (taskEvent) m_ERRProbeTaskCallbacks++;
return false;
}
override void HandlerDeattached(Widget w)
{
m_ERRProbeDetached = true;
super.HandlerDeattached(w);
}
protected override void UpdateAssignees(notnull SCR_Task task, SCR_TaskExecutor newAssignee = null, int requesterID = 0)
{
if (ERR_ProbeCallback(true, "UpdateAssignees")) return;
super.UpdateAssignees(task, newAssignee, requesterID);
}
protected override void OnTaskUIVisibilityChanged(SCR_Task task, SCR_ETaskUIVisibility visible)
{
if (ERR_ProbeCallback(true, "OnTaskUIVisibilityChanged")) return;
super.OnTaskUIVisibilityChanged(task, visible);
}
protected override void OnTaskVisibilityChanged(SCR_Task task, SCR_ETaskVisibility visible)
{
if (ERR_ProbeCallback(true, "OnTaskVisibilityChanged")) return;
super.OnTaskVisibilityChanged(task, visible);
}
protected override void OnTaskOwnershipChanged(SCR_Task task, SCR_ETaskOwnership ownership)
{
if (ERR_ProbeCallback(true, "OnTaskOwnershipChanged")) return;
super.OnTaskOwnershipChanged(task, ownership);
}
protected override void CheckOverflow(Widget w)
{
if (ERR_ProbeCallback(false, "CheckOverflow")) return;
super.CheckOverflow(w);
}
override void UpdateTask()
{
if (ERR_ProbeCallback(false, "UpdateTask")) return;
super.UpdateTask();
}
}
#endif
@@ -0,0 +1,367 @@
#ifdef WORKBENCH
// Only connection membership is simulated for the second character. Native
// actor mutations settle on later frames; isolated service timestamps stay exact.
class ERR_NativeRadioSample : ERR_RadioDefenceService
{
ref array<IEntity> Members = {};
int Connections = 2;
void ERR_NativeRadioSample(ERR_GameMode mode) { m_Mode = mode; }
protected override bool IsPlayer(IEntity actor) { return Members.Contains(actor); }
protected override int ConnectedPlayers() { return Connections; }
protected override void Players(out array<IEntity> players) { players.Copy(Members); }
}
class ERR_NativeRadioDefenceProbe
{
protected ERR_GameMode m_Mode;
protected IEntity m_Player;
protected IEntity m_Second;
protected ERR_LocationComponent m_Radio;
protected FactionAffiliationComponent m_Affiliation;
protected ref ERR_NativeRadioSample m_Service;
protected ref ERR_RadioDefenceService m_Authoritative;
protected int m_Starts;
protected int m_Completions;
protected int m_Changes;
protected int m_OriginalDuration;
protected vector m_Original[4];
protected bool m_OriginalUnconscious;
protected bool m_Snapshot;
protected bool m_SettingsChanged;
protected bool m_Restored;
protected bool m_RestoreSucceeded;
protected int m_Step;
protected int m_Result;
protected float m_Wait;
protected float m_StateDeadline;
protected float m_NextStateDiagnostic;
void ERR_NativeRadioDefenceProbe(ERR_GameMode mode, IEntity player, ERR_LocationComponent radio)
{
m_Mode = mode; m_Player = player; m_Radio = radio;
}
protected bool Check(bool condition, string detail)
{
if (condition) { Print("ERR SMOKE PASS: radio service " + detail); return true; }
Print("ERR SMOKE FAIL: radio service " + detail, LogLevel.ERROR);
m_Result = -1;
Restore();
return false;
}
protected void Started(ERR_LocationComponent location) { m_Starts++; }
protected void Changed(ERR_RadioDefenceService service) { m_Changes++; }
protected void Completed(ERR_LocationComponent location)
{
m_Completions++;
m_Service.Tick(1000000); // Re-entrant observer cannot complete twice.
}
protected void Move(IEntity actor, vector position)
{
vector transform[4]; actor.GetWorldTransform(transform);
transform[3] = ERR_Actors.Ground(position);
ChimeraCharacter.Cast(actor).Teleport(transform);
}
protected void Conscious(IEntity actor, bool value)
{
CharacterControllerComponent.Cast(actor.FindComponent(CharacterControllerComponent)).SetUnconscious(!value);
}
protected float Distance(string label)
{
float distance = vector.DistanceXZ(m_Player.GetOrigin(), m_Radio.Position());
PrintFormat("ERR RADIO POSITION: %1 actor=%2 radio=%3 distanceXZ=%4", label, m_Player.GetOrigin(), m_Radio.Position(), distance);
return distance;
}
protected void WaitFor(int step, float now)
{
m_Step = step;
m_Wait = now + 750;
m_StateDeadline = now + 12000;
m_NextStateDiagnostic = now;
}
protected void RecoveryDiagnostic(IEntity actor, string label)
{
CharacterControllerComponent controller;
if (actor) controller = CharacterControllerComponent.Cast(actor.FindComponent(CharacterControllerComponent));
int unconscious = -1;
int lifeState = -1;
if (controller)
{
unconscious = controller.IsUnconscious();
lifeState = controller.GetLifeState();
}
PrintFormat("ERR RADIO RECOVERY: %1 step=%2 alive=%3 canFight=%4 unconscious=%5 lifeState=%6 origin=%7", label, m_Step, ERR_Actors.Alive(actor), ERR_Actors.CanFight(actor), unconscious, lifeState, actor.GetOrigin());
}
// Native recovery includes controller/ragdoll state transitions beyond one
// frame. Poll the observed state without reissuing SetUnconscious or altering
// health. Dead/missing actors fail immediately; delayed recovery has a bound.
protected bool Recovered(IEntity actor, float now, string label)
{
if (!actor || !ERR_Actors.Alive(actor))
{
Check(false, label + " lost its living actor during recovery");
return false;
}
if (ERR_Actors.CanFight(actor))
{
RecoveryDiagnostic(actor, label + " ready");
return true;
}
if (now >= m_NextStateDiagnostic)
{
RecoveryDiagnostic(actor, label + " waiting");
m_NextStateDiagnostic = now + 1000;
}
if (now >= m_StateDeadline)
{
Check(false, label + " did not regain conscious native state within twelve seconds");
return false;
}
m_Wait = now + 250;
return false;
}
protected void AuthorizationDiagnostic()
{
int id = GetGame().GetPlayerManager().GetPlayerIdFromControlledEntity(m_Player);
ERR_PlayerLife life = m_Mode.Players().Get(id);
bool connected = life && life.Connected;
bool usedLife = life && life.UsedLife;
bool sameActor = life && life.Actor == m_Player;
bool friendly = m_Mode.PlayerFaction() && SCR_FactionManager.SGetFaction(m_Player) == m_Mode.PlayerFaction();
PrintFormat("ERR RADIO AUTH: active=%1 phase=%2 registered=%3 kind=%4 knowledge=%5 serviceState=%6 distance=%7", m_Mode.Active(), m_Mode.Phase(), m_Mode.HasLocation(m_Radio), m_Radio.Kind(), m_Radio.Knowledge(), m_Authoritative.State(), vector.DistanceXZ(m_Player.GetOrigin(), m_Radio.Position()));
PrintFormat("ERR RADIO AUTH: playerId=%1 connected=%2 usedLife=%3 sameActor=%4 friendly=%5 canFight=%6 canBegin=%7", id, connected, usedLife, sameActor, friendly, ERR_Actors.CanFight(m_Player), m_Authoritative.CanBegin(m_Player, m_Radio));
}
protected void Restore()
{
if (m_Restored) return;
m_Restored = true;
m_RestoreSucceeded = true;
if (m_Service)
{
m_Service.OnStarted().Remove(Started);
m_Service.OnProgressChanged().Remove(Changed);
m_Service.OnCompleted().Remove(Completed);
m_Service.Stop();
}
if (m_Authoritative) m_Authoritative.Stop();
if (!GetGame() || !Replication.IsServer()) return;
if (m_SettingsChanged && m_Mode && m_Mode.Settings())
m_RestoreSucceeded = m_Mode.Settings().Set("radioDefenceSeconds", m_OriginalDuration);
if (m_Snapshot && m_Player)
{
CharacterControllerComponent controller = CharacterControllerComponent.Cast(m_Player.FindComponent(CharacterControllerComponent));
ChimeraCharacter character = ChimeraCharacter.Cast(m_Player);
if (controller && character && ERR_Actors.Alive(m_Player))
{
controller.SetUnconscious(m_OriginalUnconscious);
character.Teleport(m_Original);
}
else m_RestoreSucceeded = false;
}
ERR_Actors.DeleteSpawned(m_Second);
m_Second = null;
}
// The parent must call this on cancellation/fixture timeout as well as failure.
// No fixture-owned callqueue is installed; no later mutation runs after Stop.
void Stop()
{
if (m_Result == 0) m_Result = -1;
Restore();
}
int Tick(float now)
{
if (m_Result != 0) return m_Result;
if (now < m_Wait) return 0;
if (!Replication.IsServer() || !m_Mode || !m_Player || !m_Radio || !m_Mode.Settings())
{
Check(false, "async fixture lost authoritative mission/player/radio");
return -1;
}
if (m_Step == 0)
{
if (!Check(m_Mode.Phase() == ERR_Phase.RADIO_SEARCH && m_Radio.Knowledge() == 2 && ERR_Actors.Player(m_Player), "identified radio and real controlled prisoner available")) return -1;
m_OriginalDuration = m_Mode.Settings().Get("radioDefenceSeconds");
m_Player.GetWorldTransform(m_Original);
CharacterControllerComponent controller = CharacterControllerComponent.Cast(m_Player.FindComponent(CharacterControllerComponent));
if (!Check(controller && ChimeraCharacter.Cast(m_Player), "controlled prisoner supports native state restoration")) return -1;
m_OriginalUnconscious = controller.IsUnconscious();
m_Snapshot = true;
if (!Check(m_Mode.Settings().Set("radioDefenceSeconds", 30), "isolated thirty-second duration saved")) return -1;
m_SettingsChanged = true;
m_Second = ERR_Actors.Spawn(m_Mode.Factions().PrisonerPrefab, ERR_Actors.Ground(m_Radio.Position() + "-2 0 0"));
if (!Check(m_Second != null, "second physical defender spawned")) return -1;
m_Mode.Own(m_Second);
DamageManagerComponent.Cast(m_Second.FindComponent(DamageManagerComponent)).EnableDamageHandling(false);
WaitFor(1, now);
return 0;
}
if (m_Step == 1)
{
if (!Check(ERR_Actors.CanFight(m_Second), "second physical defender settles with simulated connection membership")) return -1;
m_Authoritative = new ERR_RadioDefenceService(m_Mode);
if (!Check(!m_Authoritative.Begin(m_Second, m_Radio), "ordinary AI cannot tune even while alive and nearby")) return -1;
if (!Check(!m_Authoritative.Begin(m_Player, null), "missing radio rejected")) return -1;
Move(m_Player, m_Radio.Position() + "6 0 0");
WaitFor(2, now);
return 0;
}
if (m_Step == 2)
{
if (!Check(Distance("outside tuning range after deferred teleport") > 5 && !m_Authoritative.Begin(m_Player, m_Radio), "tuning outside five metres rejected")) return -1;
Move(m_Player, m_Radio.Position() + "2 0 0");
Conscious(m_Player, false);
WaitFor(3, now);
return 0;
}
if (m_Step == 3)
{
CharacterControllerComponent controller = CharacterControllerComponent.Cast(m_Player.FindComponent(CharacterControllerComponent));
if (!Check(Distance("unconscious tuning range after deferred teleport") <= 5 && controller.IsUnconscious() && !m_Authoritative.Begin(m_Player, m_Radio), "unconscious prisoner cannot tune")) return -1;
Conscious(m_Player, true);
WaitFor(4, now);
return 0;
}
if (m_Step == 4)
{
if (!Recovered(m_Player, now, "tuning prisoner")) return m_Result;
AuthorizationDiagnostic();
m_Service = new ERR_NativeRadioSample(m_Mode);
if (!Check(ERR_Actors.CanFight(m_Player) && m_Authoritative.CanBegin(m_Player, m_Radio), "registered friendly conscious prisoner remains authorized")) return -1;
if (!Check(!m_Service.Begin(m_Player, m_Radio), "unregistered defender cannot start the isolated service")) return -1;
m_Service.Members.Insert(m_Player); m_Service.Members.Insert(m_Second);
m_Affiliation = FactionAffiliationComponent.Cast(m_Second.FindComponent(FactionAffiliationComponent));
if (!Check(m_Affiliation != null, "physical defender has native faction affiliation")) return -1;
m_Affiliation.SetAffiliatedFaction(m_Mode.EnemyFaction());
WaitFor(5, now);
return 0;
}
if (m_Step == 5)
{
if (!Check(SCR_FactionManager.SGetFaction(m_Second) == m_Mode.EnemyFaction() && !m_Service.Begin(m_Second, m_Radio), "hostile controlled defender cannot tune")) return -1;
m_Affiliation.SetAffiliatedFaction(m_Mode.PlayerFaction());
WaitFor(6, now);
return 0;
}
if (m_Step == 6)
{
m_Service.OnStarted().Insert(Started);
m_Service.OnProgressChanged().Insert(Changed);
m_Service.OnCompleted().Insert(Completed);
if (!Check(m_Service.Begin(m_Player, m_Radio) && !m_Service.Begin(m_Player, m_Radio) && m_Starts == 1, "start event emitted once and duplicate start rejected")) return -1;
m_Service.Tick(1000);
if (!Check(m_Service.DefenderCount() == 2 && m_Service.Since() == 1000 && m_Service.Occupied(), "two conscious physical defenders share one timer")) return -1;
m_Affiliation.SetAffiliatedFaction(m_Mode.EnemyFaction());
WaitFor(7, now);
return 0;
}
if (m_Step == 7)
{
m_Service.Tick(1500);
if (!Check(!m_Service.CanDefend(m_Second) && m_Service.DefenderCount() == 1 && m_Service.Since() == 1000, "hostile actor cannot defend even with simulated connection membership")) return -1;
m_Affiliation.SetAffiliatedFaction(m_Mode.PlayerFaction());
WaitFor(8, now);
return 0;
}
if (m_Step == 8)
{
m_Service.Tick(1600);
int changed = m_Changes;
m_Service.Tick(6000);
if (!Check(m_Service.SecondsRemaining(6000) == 25 && m_Changes == changed, "elapsed countdown does not emit redundant progress events")) return -1;
Conscious(m_Player, false);
WaitFor(9, now);
return 0;
}
if (m_Step == 9)
{
m_Service.Tick(7000);
if (!Check(m_Service.DefenderCount() == 1 && m_Service.Since() == 1000, "second defender preserves progress while first is unconscious")) return -1;
Conscious(m_Second, false);
WaitFor(10, now);
return 0;
}
if (m_Step == 10)
{
m_Service.Tick(8000);
if (!Check(!m_Service.Occupied() && m_Service.Since() == -1 && m_Service.SecondsRemaining(8000) == 30, "all unconscious defenders reset accumulated time")) return -1;
Conscious(m_Player, true);
WaitFor(11, now);
return 0;
}
if (m_Step == 11)
{
if (!Recovered(m_Player, now, "first defender")) return m_Result;
m_Service.Tick(9000);
if (!Check(m_Service.DefenderCount() == 1 && m_Service.Since() == 9000, "conscious recovery starts a fresh continuous interval")) return -1;
Conscious(m_Second, true);
WaitFor(12, now);
return 0;
}
if (m_Step == 12)
{
if (!Recovered(m_Second, now, "second defender")) return m_Result;
m_Service.Tick(10000);
m_Service.Connections = 0; m_Service.Tick(11000);
if (!Check(m_Service.DefenderCount() == 0 && !m_Service.Occupied(), "empty server resets despite remaining character bodies")) return -1;
m_Service.Connections = 2; m_Service.Tick(12000);
Move(m_Player, m_Radio.Position() + "60.5 0 0");
WaitFor(13, now);
return 0;
}
if (m_Step == 13)
{
m_Service.Tick(13000);
if (!Check(Distance("outside defence radius after deferred teleport") >= 60 && !m_Service.CanDefend(m_Player) && m_Service.DefenderCount() == 1 && m_Service.Since() == 12000, "outside sixty metres excludes first defender without resetting second")) return -1;
DamageManagerComponent damage = DamageManagerComponent.Cast(m_Second.FindComponent(DamageManagerComponent));
damage.EnableDamageHandling(true);
SCR_CharacterDamageManagerComponent.Cast(damage).Kill(Instigator.CreateInstigator(m_Second));
WaitFor(14, now);
return 0;
}
if (m_Step == 14)
{
m_Service.Tick(14000);
if (!Check(!ERR_Actors.Alive(m_Second) && !m_Service.CanDefend(m_Second) && !m_Service.Occupied(), "dead defender cannot maintain transmission")) return -1;
Move(m_Player, m_Radio.Position() + "2 0 0");
WaitFor(15, now);
return 0;
}
if (m_Step == 15)
{
if (!Check(Distance("returned to radio after deferred teleport") <= 5 && ERR_Actors.CanFight(m_Player), "survivor returns physically before final transmission samples")) return -1;
m_Service.Tick(15000);
if (!Check(m_Mode.Settings().Set("radioDefenceSeconds", 45), "live duration increase saved")) return -1;
m_Service.Tick(45000);
if (!Check(m_Service.State() == ERR_RadioDefenceState.DEFENDING && m_Service.Since() == 15000 && m_Service.SecondsRemaining(45000) == 15, "live duration increase preserves accumulated occupancy")) return -1;
if (!Check(m_Mode.Settings().Set("radioDefenceSeconds", 30), "live duration reduction saved")) return -1;
m_Service.Tick(45000);
if (!Check(m_Service.State() == ERR_RadioDefenceState.COMPLETED && m_Completions == 1 && m_Service.SecondsRemaining(45000) == 0, "live duration reduction completes at exact continuous boundary")) return -1;
m_Service.Tick(46000); m_Service.Stop(); m_Service.Tick(47000);
if (!Check(m_Completions == 1 && !m_Service.Begin(m_Player, m_Radio), "completed service cannot restart or emit duplicate completion after stop")) return -1;
ERR_NativeRadioSample stopped = new ERR_NativeRadioSample(m_Mode);
stopped.Members.Insert(m_Player); stopped.OnCompleted().Insert(Completed);
if (!Check(stopped.Begin(m_Player, m_Radio), "isolated cancellation service begins")) return -1;
stopped.Tick(1000); stopped.Stop(); stopped.Stop(); stopped.Tick(60000);
if (!Check(stopped.State() == ERR_RadioDefenceState.STOPPED && !stopped.Occupied() && m_Completions == 1 && !stopped.Begin(m_Player, m_Radio), "stop cancels progress and future starts without completing")) return -1;
ERR_NativeRadioSample terminal = new ERR_NativeRadioSample(m_Mode);
terminal.Members.Insert(m_Player); terminal.OnCompleted().Insert(Completed);
if (!Check(terminal.Begin(m_Player, m_Radio), "isolated terminal-phase service begins")) return -1;
terminal.Tick(1000); terminal.PhaseChanged(ERR_Phase.RADIO_DEFENCE, ERR_Phase.DEFEAT); terminal.Tick(60000);
if (!Check(terminal.State() == ERR_RadioDefenceState.STOPPED && m_Completions == 1, "terminal phase event prevents later completion")) return -1;
if (!Check(m_Mode.Phase() == ERR_Phase.RADIO_SEARCH && m_Mode.RadioDefence().State() == ERR_RadioDefenceState.IDLE, "isolated event tests leave live objective ready for normal tuning")) return -1;
Restore();
WaitFor(90, now);
return 0;
}
if (m_Step == 90)
{
if (!m_OriginalUnconscious && !Recovered(m_Player, now, "restored prisoner")) return m_Result;
CharacterControllerComponent controller = CharacterControllerComponent.Cast(m_Player.FindComponent(CharacterControllerComponent));
PrintFormat("ERR RADIO RESTORE: actual=%1 original=%2 distance=%3", m_Player.GetOrigin(), m_Original[3], vector.Distance(m_Player.GetOrigin(), m_Original[3]));
if (!Check(m_RestoreSucceeded && m_Mode.Settings().Get("radioDefenceSeconds") == m_OriginalDuration && controller.IsUnconscious() == m_OriginalUnconscious && vector.Distance(m_Player.GetOrigin(), m_Original[3]) < 1.5, "isolated test restores live duration and physical player state after a world frame")) return -1;
m_Result = 1;
return 1;
}
Check(false, "unknown asynchronous fixture step");
return -1;
}
}
#endif
@@ -0,0 +1,62 @@
// Opt-in local diagnostic only. It does not bypass validation or alter cleanup.
#ifdef WORKBENCH
modded class SCR_BaseItemSupportStationComponent
{
protected static int s_ERRStationTeardownLines;
protected bool m_ERRStationInDelete;
protected IEntity m_ERRStationDeleteOwner;
override void OnDelete(IEntity owner)
{
string enabled;
bool diagnose = System.GetCLIParam("errStationTeardownDiag", enabled) && enabled == "1";
if (diagnose)
{
m_ERRStationInDelete = true;
m_ERRStationDeleteOwner = owner;
}
// Preserve vanilla OnDelete, including its call to InitValidSetup.
super.OnDelete(owner);
m_ERRStationInDelete = false;
m_ERRStationDeleteOwner = null;
}
protected override bool InitValidSetup()
{
if (m_ERRStationInDelete && s_ERRStationTeardownLines < 64)
{
s_ERRStationTeardownLines++;
IEntity owner = m_ERRStationDeleteOwner;
string ownerName = "<deleted>";
ResourceName prefab;
if (owner)
{
ownerName = owner.GetName();
if (owner.GetPrefabData()) prefab = owner.GetPrefabData().GetPrefabName();
}
SCR_EntityCatalogManagerComponent catalog = SCR_EntityCatalogManagerComponent.GetInstance();
SCR_BaseGameMode baseMode;
if (GetGame()) baseMode = SCR_BaseGameMode.Cast(GetGame().GetGameMode());
int gameState = -1;
int phase = -1;
bool hasEndData;
if (baseMode)
{
gameState = baseMode.GetState();
hasEndData = baseMode.GetEndGameData() != null;
ERR_GameMode mode = ERR_GameMode.Cast(baseMode);
if (mode) phase = mode.Phase();
}
string detail = string.Format("ERR STATION TEARDOWN DIAG: n=%1 owner=%2 prefab=%3 cachedCatalog=%4 globalCatalog=%5", s_ERRStationTeardownLines, ownerName, prefab, m_EntityCatalogManager != null, catalog != null);
detail += string.Format(" modePresent=%1 gameState=%2 errPhase=%3 endData=%4 call=OnDelete/InitValidSetup", baseMode != null, gameState, phase, hasEndData);
Print(detail);
if (s_ERRStationTeardownLines == 64) Print("ERR STATION TEARDOWN DIAG: 64-line diagnostic limit reached; vanilla validation and cleanup continue unchanged.");
}
// Retain every original validation result and error message.
return super.InitValidSetup();
}
}
#endif
@@ -0,0 +1,146 @@
#ifdef WORKBENCH
// Explicit destructive test of successor-owned transport in the disposable
// scenario. Scripted occupants and engine input are not manual driving or MP.
class ERR_NativeTransportProbe
{
protected ERR_GameMode m_Mode;
protected ERR_SiteRuntime m_Site;
protected Vehicle m_Vehicle;
protected CarControllerComponent m_Controller;
protected SCR_FuelManagerComponent m_Fuel;
protected ref array<IEntity> m_Actors = {};
protected BaseCompartmentSlot m_Driver;
protected BaseCompartmentSlot m_Passenger;
protected int m_Step;
protected float m_Wait;
void ERR_NativeTransportProbe(ERR_GameMode mode) { m_Mode = mode; }
protected bool Check(bool condition, string detail)
{
if (condition) { Print("ERR SMOKE PASS: transport " + detail); return true; }
Print("ERR SMOKE FAIL: transport " + detail, LogLevel.ERROR);
return false;
}
protected bool DefinitionChecks()
{
ERR_ScenarioDefinition definition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab("{BC310DC0CD27F523}Configs/ERR/EveronSites.conf"));
ERR_SiteDefinition site;
if (definition) site = definition.Find("everon-supply-2");
if (!Check(site && site.Transport && definition.Valid(), "authored transport definition resolves")) return false;
ERR_SiteTransport transport = site.Transport;
vector offset = transport.Offset;
transport.Offset = "61 0 0";
if (!Check(!definition.Valid(), "unbounded parking offset rejected")) return false;
transport.Offset = "0 1 0";
if (!Check(!definition.Valid(), "floating parking offset rejected")) return false;
transport.Offset = offset;
float radius = transport.ClearanceRadius;
transport.ClearanceRadius = 1;
if (!Check(!definition.Valid(), "undersized parking footprint rejected")) return false;
transport.ClearanceRadius = 9;
if (!Check(!definition.Valid(), "unbounded parking footprint rejected")) return false;
transport.ClearanceRadius = radius;
float range = transport.MaximumTerrainRange;
transport.MaximumTerrainRange = 2;
if (!Check(!definition.Valid(), "unsafe authoring slope allowance rejected")) return false;
transport.MaximumTerrainRange = range;
ERR_SiteDefinition radio = definition.Find("everon-radio-1");
radio.Transport = transport;
if (!Check(!definition.Valid(), "transport restricted to optional depot and outpost sites")) return false;
radio.Transport = null;
return Check(definition.Valid(), "valid authoring restored after isolated mutations");
}
protected IEntity Character(int index)
{
IEntity actor = ERR_Actors.Spawn(m_Mode.Factions().PrisonerPrefab, ERR_Actors.Ground(m_Vehicle.GetOrigin() + Vector(-8 - index * 2, 0, 0)));
if (!actor) return null;
m_Mode.Own(actor);
m_Actors.Insert(actor);
DamageManagerComponent damage = DamageManagerComponent.Cast(actor.FindComponent(DamageManagerComponent));
if (damage) damage.EnableDamageHandling(false);
return actor;
}
int Tick(float now)
{
if (now < m_Wait) return 0;
if (m_Step == 0)
{
if (!DefinitionChecks()) return -1;
m_Site = m_Mode.Sites().Find("everon-supply-2");
if (!Check(m_Site && m_Site.Transport && m_Mode.Sites().TransportCount() == 1 && m_Mode.Sites().Owns(m_Site.Transport), "exactly one authored transport belongs to the finite site service")) return -1;
m_Vehicle = m_Site.Transport;
m_Controller = CarControllerComponent.Cast(m_Vehicle.FindComponent(CarControllerComponent));
m_Fuel = SCR_FuelManagerComponent.Cast(m_Vehicle.FindComponent(SCR_FuelManagerComponent));
if (!Check(m_Controller && m_Fuel && m_Fuel.HasFuel() && m_Controller.GetPersistentHandBrake(), "wheeled transport starts parked with vanilla finite fuel")) return -1;
PrintFormat("ERR TRANSPORT FUEL: current=%1 maximum=%2", m_Fuel.GetTotalFuel(), m_Fuel.GetTotalMaxFuel());
BaseCompartmentManagerComponent manager = BaseCompartmentManagerComponent.Cast(m_Vehicle.FindComponent(BaseCompartmentManagerComponent));
array<BaseCompartmentSlot> seats = {};
if (manager) manager.GetCompartments(seats);
int usable;
foreach (BaseCompartmentSlot slot : seats)
{
if (!slot) continue;
ECompartmentType type = SCR_CompartmentAccessComponent.GetCompartmentType(slot);
if (type == ECompartmentType.PILOT) { usable++; if (!m_Driver) m_Driver = slot; }
if (type == ECompartmentType.CARGO) { usable++; if (!m_Passenger) m_Passenger = slot; }
}
PrintFormat("ERR TRANSPORT SEATS: usable driver/passenger positions=%1", usable);
if (!Check(m_Driver && m_Passenger && m_Driver != m_Passenger, "native driver and passenger compartments are distinct")) return -1;
DamageManagerComponent.Cast(m_Vehicle.FindComponent(DamageManagerComponent)).EnableDamageHandling(false);
IEntity driver = Character(0);
IEntity passenger = Character(1);
if (!Check(driver && passenger, "isolated physical occupants created")) return -1;
CompartmentAccessComponent access = CompartmentAccessComponent.Cast(driver.FindComponent(CompartmentAccessComponent));
if (!Check(access && access.GetInVehicle(m_Vehicle, m_Driver, true, -1, ECloseDoorAfterActions.INVALID, false), "native driver boarding request accepted")) return -1;
m_Step = 1;
m_Wait = now + 1500;
return 0;
}
if (m_Step == 1)
{
CompartmentAccessComponent access = CompartmentAccessComponent.Cast(m_Actors[1].FindComponent(CompartmentAccessComponent));
if (!Check(access && access.GetInVehicle(m_Vehicle, m_Passenger, true, -1, ECloseDoorAfterActions.INVALID, false), "native passenger boarding request accepted")) return -1;
m_Step = 2;
m_Wait = now + 2500;
return 0;
}
if (m_Step == 2)
{
if (!Check(m_Driver.GetOccupant() == m_Actors[0] && m_Passenger.GetOccupant() == m_Actors[1] && CompartmentAccessComponent.GetVehicleIn(m_Actors[0]) == m_Vehicle && CompartmentAccessComponent.GetVehicleIn(m_Actors[1]) == m_Vehicle, "driver and passenger physically occupy their native seats")) return -1;
if (!Check(m_Controller.IsEngineOn() || m_Controller.StartEngine(), "ordinary native engine start request accepted")) return -1;
m_Step = 3;
m_Wait = now + 6000;
return 0;
}
if (m_Step == 3)
{
if (!Check(m_Controller.IsEngineOn(), "native wheeled engine starts with available fuel")) return -1;
m_Fuel.SetTotalFuelPercentage(0);
if (!Check(m_Mode.Sites().Prepare() == ERR_PrepareResult.READY && m_Site.Transport == m_Vehicle, "preparation queries preserve the original vehicle after deliberate fuel depletion")) return -1;
m_Step = 4;
m_Wait = now + 5000;
return 0;
}
if (m_Step == 4)
{
if (!Check(!m_Fuel.HasFuel() && m_Fuel.GetTotalFuel() <= 0.001 && m_Mode.Sites().Prepare() == ERR_PrepareResult.READY && m_Mode.Sites().TransportCount() == 1 && m_Site.Transport == m_Vehicle, "empty transport stays empty and is not refilled or replaced")) return -1;
foreach (IEntity actor : m_Actors) ERR_Actors.DeleteSpawned(actor);
m_Actors.Clear();
ERR_Actors.DeleteSpawned(m_Vehicle);
m_Step = 5;
m_Wait = now + 3000;
return 0;
}
if (m_Step == 5)
{
if (!Check(!m_Site.Transport && m_Mode.Sites().TransportCount() == 0 && m_Mode.Sites().Prepare() == ERR_PrepareResult.READY, "lost transport remains absent after service ticks and preparation queries")) return -1;
ERR_ScenarioDefinition definition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab("{BC310DC0CD27F523}Configs/ERR/EveronSites.conf"));
if (!Check(!m_Mode.Sites().Begin(definition, ERR_Supplies.Map()) && m_Mode.Sites().TransportCount() == 0, "duplicate initialization cannot replace lost transport")) return -1;
m_Mode.Sites().Stop();
if (!Check(m_Mode.Sites().Prepare() == ERR_PrepareResult.FAILED && !m_Mode.Sites().Begin(definition, ERR_Supplies.Map()), "stopped finite owner cannot reinitialize transport")) return -1;
m_Step = 6;
return 1;
}
return 0;
}
}
#endif
@@ -0,0 +1,36 @@
#ifdef WORKBENCH
// Authoring-only survey: omit transport in the fixture's in-memory definition so
// an invalid parking candidate cannot block inspecting nearby terrain/scenery.
modded class ERR_SiteService
{
override bool Begin(ERR_ScenarioDefinition definition, ResourceName fieldMap)
{
string outcome;
if (definition && System.GetCLIParam("errSmokeOutcome", outcome) && outcome == "transport-survey")
foreach (ERR_SiteDefinition site : definition.Sites) site.Transport = null;
return super.Begin(definition, fieldMap);
}
}
class ERR_NativeTransportSurvey
{
static void Run(ERR_GameMode mode)
{
ERR_ScenarioDefinition definition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab("{BC310DC0CD27F523}Configs/ERR/EveronSites.conf"));
ERR_SiteDefinition site = definition.Find("everon-supply-2");
int clear;
for (int x = -40; x <= 40; x += 8)
for (int z = -40; z <= 40; z += 8)
{
vector offset = Vector(x, 0, z);
if (offset.Length() < 8) continue;
vector position = site.Position + offset;
if (!mode.Sites().TransportClearance(position, site.Transport.ClearanceRadius, site.Transport.MaximumTerrainRange)) continue;
clear++;
PrintFormat("ERR PARKING CANDIDATE: offset=%1 position=%2", offset, ERR_Actors.Ground(position));
}
PrintFormat("ERR PARKING SURVEY: clear=%1; no scenery removed and no transport spawned", clear);
Print("ERR SMOKE PASS: transport parking survey completed");
Print("ERR SMOKE COMPLETE: bounded transport parking survey");
}
}
#endif
@@ -0,0 +1,59 @@
// Native task descriptions otherwise remain stale while their entry is open.
// Own only this scenario's additional binding and missing native detach cleanup.
modded class SCR_TaskListEntryDescriptionUIComponent
{
protected SCR_Task m_ERRObservedObjective;
// Entity references become null when a task is deleted; work ownership must survive.
protected bool m_ERROwnsObjectiveBinding;
override void InitDescription(notnull SCR_Task task)
{
// Native InitDescription deletes assignee labels; cancel their delayed work first.
ERR_UnbindObjectiveDetails();
super.InitDescription(task);
if (!ERR_GameMode.Get() || !task.GetTaskID().StartsWith("erri-mission-")) return;
m_ERRObservedObjective = task;
m_ERROwnsObjectiveBinding = true;
task.GetOnDisplayDataChanged().Insert(ERR_RefreshObjectiveDetails);
}
protected void ERR_RefreshObjectiveDetails()
{
if (m_wRoot && m_Task && m_Task == m_ERRObservedObjective) UpdateTask();
}
protected override void UpdateAssignees(notnull SCR_Task task, SCR_TaskExecutor newAssignee = null, int requesterID = 0)
{
// Native assignment refresh also deletes the old labels before rebuilding them.
if (m_ERROwnsObjectiveBinding && task == m_Task)
ERR_CancelObjectiveOverflow();
super.UpdateAssignees(task, newAssignee, requesterID);
}
protected void ERR_CancelObjectiveOverflow()
{
if (GetGame() && GetGame().GetCallqueue()) GetGame().GetCallqueue().Remove(CheckOverflow);
}
protected void ERR_UnbindObjectiveDetails()
{
if (!m_ERROwnsObjectiveBinding) return;
ERR_CancelObjectiveOverflow();
if (m_ERRObservedObjective) m_ERRObservedObjective.GetOnDisplayDataChanged().Remove(ERR_RefreshObjectiveDetails);
m_ERRObservedObjective = null;
m_ERROwnsObjectiveBinding = false;
}
override void HandlerDeattached(Widget w)
{
// Vanilla detaches widget/manager listeners but leaves these task listeners.
// Preserve that vanilla path for every task outside the successor scenario.
if (m_ERROwnsObjectiveBinding)
{
// These five native invokers are static and survive task entity deletion.
SCR_Task.GetOnTaskAssigneeAdded().Remove(UpdateAssignees);
SCR_Task.GetOnTaskAssigneeRemoved().Remove(UpdateAssignees);
SCR_Task.GetOnTaskUIVisibilityChanged().Remove(OnTaskUIVisibilityChanged);
SCR_Task.GetOnTaskVisibilityChanged().Remove(OnTaskVisibilityChanged);
SCR_Task.GetOnTaskOwnershipChanged().Remove(OnTaskOwnershipChanged);
if (m_TaskComponent) m_TaskComponent.GetOnTaskVisualChanged().Remove(OnTaskVisualChanged);
m_Task = null;
}
ERR_UnbindObjectiveDetails();
super.HandlerDeattached(w);
}
}
@@ -0,0 +1,48 @@
// Shared player-facing copy; reads replicated mission state and never changes it.
class ERR_ObjectiveText
{
static string Title(ERR_Phase phase)
{
if (phase == ERR_Phase.PRISON) return "Escape the prison";
if (phase == ERR_Phase.RADIO_SEARCH) return "Locate the radio station";
if (phase == ERR_Phase.RADIO_DEFENCE) return "Defend the radio transmission";
if (phase == ERR_Phase.EXTRACTION) return "Escape Everon by helicopter";
return "Escape Everon";
}
static string Extraction(float radius)
{
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.", radius / 1000.0);
}
static string Description(ERR_GameMode mode, bool progress = true)
{
if (!mode) return "";
ERR_Phase phase = mode.Phase();
if (phase == ERR_Phase.PREPARING)
{
if (!mode.PreparationError().IsEmpty()) return "The mission could not start. An administrator can use /esc status for the cause. Spawning is disabled until the configuration is corrected and the scenario restarted.";
return "Preparing mission sites and equipment. You will spawn when the mission is ready.";
}
if (phase == ERR_Phase.PRISON)
return "Search the supply box inside the prison for weapons and a field map. Pick the gate lock, then get clear of the guards.";
if (phase == ERR_Phase.RADIO_SEARCH)
{
array<ERR_LocationComponent> locations = {};
mode.Locations(locations);
foreach (ERR_LocationComponent location : locations)
if (location.Kind() == ERR_LocationKind.RADIO && location.Knowledge() == 2)
return "The recovered documents identify the radio station on your map. Reach the station and tune its radio to start the transmission.";
return "Choose either marked supply cache. Each has equipment and enough radio documents to identify the station. If one cache is lost, use the other. Nearby medical and guard sites can be discovered by approaching with a clear view.";
}
if (phase == ERR_Phase.RADIO_DEFENCE)
{
if (!progress) return "Keep a conscious survivor within 60 metres of the radio until the transmission finishes. Leaving the area resets the defence timer.";
int seconds = mode.DefenceSecondsRemaining();
if (!mode.DefenceOccupied())
return string.Format("Transmission interrupted. Keep a conscious survivor within 60 metres of the radio for %1 seconds. Leaving resets progress.", seconds);
int displayed = Math.Ceil(seconds / 5.0) * 5;
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 Extraction(mode.EscapeRadius());
return "";
}
}
+2 -32
View File
@@ -24,11 +24,7 @@ class ERR_Objectives
}
m_Current = null;
if (ERR_MissionRules.Terminal(current)) return;
string title;
if (current == ERR_Phase.PRISON) title = "Escape the prison";
else if (current == ERR_Phase.RADIO_SEARCH) title = "Locate the radio station";
else if (current == ERR_Phase.RADIO_DEFENCE) title = "Defend the radio transmission";
else if (current == ERR_Phase.EXTRACTION) title = "Escape Everon by helicopter";
string title = ERR_ObjectiveText.Title(current);
if (title.IsEmpty()) return;
m_Current = tasks.CreateTask("{1D0F815858EE24AD}Prefabs/Tasks/BaseTask.et", Id(current), title, Description());
if (!m_Current) { Print("Rereintroduced: objective creation failed", LogLevel.ERROR); return; }
@@ -39,33 +35,7 @@ class ERR_Objectives
Tick(GetGame().GetWorld().GetWorldTime());
}
protected string Description()
{
ERR_Phase phase = m_Mode.Phase();
if (phase == ERR_Phase.PRISON)
return "Search the supply box inside the prison for weapons and a field map. Pick the gate lock, then get clear of the guards.";
if (phase == ERR_Phase.RADIO_SEARCH)
{
array<ERR_LocationComponent> locations = {};
m_Mode.Locations(locations);
foreach (ERR_LocationComponent location : locations)
if (location.Kind() == ERR_LocationKind.RADIO && location.Knowledge() == 2)
return "The recovered documents identify the radio station on your map. Reach the station and tune its radio to start the transmission.";
return "Choose either marked supply cache. Each has equipment and enough radio documents to identify the station. If one cache is lost, use the other. Nearby medical and guard sites can be discovered by approaching with a clear view.";
}
if (phase == ERR_Phase.RADIO_DEFENCE)
{
int seconds = m_Mode.DefenceSecondsRemaining();
if (!m_Mode.DefenceOccupied())
return string.Format("Transmission interrupted. Keep a conscious survivor within 60 metres of the radio for %1 seconds. Leaving resets progress.", seconds);
// Update at most once per five-second interval; do not broadcast every frame.
int displayed = Math.Ceil(seconds / 5.0) * 5;
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. Living prisoners aboard escape, including unconscious passengers. Dead or absent prisoners do not.", m_Mode.EscapeRadius() / 1000.0);
return "";
}
protected string Description() { return ERR_ObjectiveText.Description(m_Mode); }
void Tick(float now)
{
+7 -19
View File
@@ -3,7 +3,7 @@ modded class SCR_PlayerController
{
protected int m_ERRGuidePhase = -1;
protected bool m_ERRGuideStarted;
protected string m_ERRGuideError;
protected string m_ERRGuideMessage;
override void OnControlledEntityChanged(IEntity from, IEntity to)
{
@@ -24,27 +24,15 @@ modded class SCR_PlayerController
protected void ERR_UpdateGuide()
{
ERR_GameMode mode = ERR_GameMode.Get();
if (!mode || (m_ERRGuidePhase == mode.Phase() && m_ERRGuideError == mode.PreparationError())) return;
if (!mode) return;
if (mode.Phase() != ERR_Phase.PREPARING && !GetControlledEntity()) return;
string message = ERR_ObjectiveText.Description(mode, false);
if (m_ERRGuidePhase == mode.Phase() && m_ERRGuideMessage == message) return;
SCR_HintManagerComponent hints = SCR_HintManagerComponent.GetInstance();
if (!hints) return;
string message;
if (mode.Phase() == ERR_Phase.PREPARING)
{
message = "Preparing mission sites and equipment. You will spawn when the mission is ready.";
if (!mode.PreparationError().IsEmpty()) message = "The mission could not start. An administrator can use /esc status for the cause. Spawning is disabled until the configuration is corrected and the scenario restarted.";
}
else if (!GetControlledEntity()) return;
else if (mode.Phase() == ERR_Phase.PRISON)
message = "Search the supply box inside the prison for weapons and a field map. Pick the gate lock to begin your escape.";
else if (mode.Phase() == ERR_Phase.RADIO_SEARCH)
message = "Two supply caches are marked on your map. Either contains enough radio documents to identify the station; use the other if one is lost. Search nearby medical and guard sites for additional finite supplies.";
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. Living prisoners aboard escape, including unconscious passengers. Dead or absent prisoners do not.";
if (!message.IsEmpty()) hints.ShowCustom(message, "Escape Everon", 15);
if (!message.IsEmpty()) hints.ShowCustom(message, ERR_ObjectiveText.Title(mode.Phase()), 15);
m_ERRGuidePhase = mode.Phase();
m_ERRGuideError = mode.PreparationError();
m_ERRGuideMessage = message;
}
void ~SCR_PlayerController()
{
@@ -0,0 +1,140 @@
enum ERR_RadioDefenceState
{
IDLE,
DEFENDING,
COMPLETED,
STOPPED
}
class ERR_RadioDefenceRules
{
static float DefenceRadius() { return 60; }
static float TuneRadius() { return 5; }
static bool CanTune(int phase, bool registered, bool radio, int knowledge, bool player, bool conscious, float distance)
{
return phase == ERR_Phase.RADIO_SEARCH && registered && radio && knowledge >= 2 && player && conscious && distance >= 0 && distance <= TuneRadius();
}
static bool Defender(bool player, bool conscious, float distance)
{
return player && conscious && distance >= 0 && distance < DefenceRadius();
}
static float SinceAfterSample(float since, float previousSample, float now, int defenders)
{
if (now < 0 || defenders <= 0) return -1;
if (since < 0 || now < since || (previousSample >= 0 && now < previousSample)) return now;
return since;
}
}
// Server-owned radio objective. Emits facts; GameMode alone advances the mission.
// Native tasks replicate presentation; no client can start/tick this service.
class ERR_RadioDefenceService
{
protected ERR_GameMode m_Mode;
protected ERR_LocationComponent m_Location;
protected ERR_RadioDefenceState m_State;
protected float m_Since = -1;
protected float m_LastSample = -1;
protected int m_Defenders;
protected int m_LastDuration = -1;
protected ref ScriptInvoker m_OnStarted = new ScriptInvoker();
protected ref ScriptInvoker m_OnProgressChanged = new ScriptInvoker();
protected ref ScriptInvoker m_OnCompleted = new ScriptInvoker();
void ERR_RadioDefenceService(ERR_GameMode mode) { m_Mode = mode; }
static float DefenceRadius() { return ERR_RadioDefenceRules.DefenceRadius(); }
static float TuneRadius() { return ERR_RadioDefenceRules.TuneRadius(); }
ERR_RadioDefenceState State() { return m_State; }
ERR_LocationComponent Location() { return m_Location; }
float Since() { return m_Since; }
int DefenderCount() { return m_Defenders; }
bool Occupied() { return m_State == ERR_RadioDefenceState.DEFENDING && m_Since >= 0; }
ScriptInvoker OnStarted() { return m_OnStarted; } // (ERR_LocationComponent)
ScriptInvoker OnProgressChanged() { return m_OnProgressChanged; } // (ERR_RadioDefenceService)
ScriptInvoker OnCompleted() { return m_OnCompleted; } // (ERR_LocationComponent), once
int DurationSeconds()
{
if (!m_Mode || !m_Mode.Settings()) return 0;
return m_Mode.Settings().Get("radioDefenceSeconds");
}
int SecondsRemaining(float now)
{
int duration = DurationSeconds();
if (m_State == ERR_RadioDefenceState.COMPLETED) return 0;
if (m_Since < 0 || now < m_Since) return duration;
return Math.Max(0, Math.Ceil(duration - (now - m_Since) / 1000.0));
}
protected bool IsPlayer(IEntity actor)
{
if (!m_Mode || !ERR_Actors.Player(actor)) return false;
int id = GetGame().GetPlayerManager().GetPlayerIdFromControlledEntity(actor);
ERR_PlayerLife life = m_Mode.Players().Get(id);
return life && life.Connected && life.UsedLife && life.Actor == actor;
}
protected bool Friendly(IEntity actor)
{
return m_Mode && m_Mode.PlayerFaction() && SCR_FactionManager.SGetFaction(actor) == m_Mode.PlayerFaction();
}
protected int ConnectedPlayers() { return GetGame().GetPlayerManager().GetPlayerCount(); }
protected void Players(out array<IEntity> players) { ERR_Actors.Players(players); }
bool CanBegin(IEntity actor, ERR_LocationComponent location)
{
if (!Replication.IsServer() || !m_Mode || !m_Mode.Active() || m_State != ERR_RadioDefenceState.IDLE || !actor || !location) return false;
return ERR_RadioDefenceRules.CanTune(m_Mode.Phase(), m_Mode.HasLocation(location), location.Kind() == ERR_LocationKind.RADIO, location.Knowledge(), IsPlayer(actor) && Friendly(actor), ERR_Actors.CanFight(actor), vector.DistanceXZ(actor.GetOrigin(), location.Position()));
}
bool Begin(IEntity actor, ERR_LocationComponent location)
{
if (!CanBegin(actor, location) || !ERR_Settings.Valid("radioDefenceSeconds", DurationSeconds())) return false;
m_Location = location;
m_State = ERR_RadioDefenceState.DEFENDING;
m_Since = -1;
m_LastSample = -1;
m_Defenders = 0;
m_LastDuration = DurationSeconds();
m_OnStarted.Invoke(location);
m_OnProgressChanged.Invoke(this);
return m_State == ERR_RadioDefenceState.DEFENDING;
}
bool CanDefend(IEntity actor)
{
if (!Replication.IsServer() || !m_Location || !actor) return false;
return ERR_RadioDefenceRules.Defender(IsPlayer(actor) && Friendly(actor), ERR_Actors.CanFight(actor), vector.DistanceXZ(actor.GetOrigin(), m_Location.Position()));
}
void Tick(float now)
{
if (!Replication.IsServer() || m_State != ERR_RadioDefenceState.DEFENDING || !m_Mode || !m_Mode.Active()) return;
float previousSince = m_Since;
int previousDefenders = m_Defenders;
m_Defenders = 0;
if (ConnectedPlayers() > 0 && m_Location && m_Mode.HasLocation(m_Location))
{
array<IEntity> players = {};
Players(players);
foreach (IEntity actor : players) if (CanDefend(actor)) m_Defenders++;
}
m_Since = ERR_RadioDefenceRules.SinceAfterSample(m_Since, m_LastSample, now, m_Defenders);
m_LastSample = now;
int duration = DurationSeconds();
bool completed = ERR_MissionRules.DefenceComplete(now, m_Since, duration * 1000, m_Defenders > 0);
if (completed) m_State = ERR_RadioDefenceState.COMPLETED;
bool changed = completed || previousSince != m_Since || previousDefenders != m_Defenders || m_LastDuration != duration;
m_LastDuration = duration;
if (changed) m_OnProgressChanged.Invoke(this);
// Latch before callbacks: nested ticks or duplicate calls cannot complete twice.
if (completed) m_OnCompleted.Invoke(m_Location);
}
void PhaseChanged(ERR_Phase previous, ERR_Phase current)
{
if (current > ERR_Phase.RADIO_DEFENCE) Stop();
}
void Stop()
{
if (!Replication.IsServer() || m_State == ERR_RadioDefenceState.STOPPED || m_State == ERR_RadioDefenceState.COMPLETED) return;
m_State = ERR_RadioDefenceState.STOPPED;
m_Since = -1;
m_Defenders = 0;
m_Location = null;
m_OnProgressChanged.Invoke(this);
}
}
@@ -19,6 +19,21 @@ class ERR_SiteProp
[Attribute("0 0 0", UIWidgets.Coords, "Yaw, pitch, roll in degrees")] vector Angles;
}
[BaseContainerProps()]
class ERR_SiteTransport
{
[Attribute("", UIWidgets.ResourceNamePicker, "One finite wheeled transport; never replaced", "et")]
ResourceName Prefab;
[Attribute("0 0 0", UIWidgets.Coords, "Parking offset from the site; ground height is sampled")]
vector Offset;
[Attribute("0", UIWidgets.EditBox, "Parking heading in degrees")]
float Heading;
[Attribute("3", UIWidgets.EditBox, "Clear square half-width including vehicle and boarding space")]
float ClearanceRadius;
[Attribute("1", UIWidgets.EditBox, "Maximum terrain height range across parking footprint")]
float MaximumTerrainRange;
}
[BaseContainerProps()]
class ERR_SiteDefinition
{
@@ -28,6 +43,7 @@ class ERR_SiteDefinition
[Attribute("0", UIWidgets.EditBox, "Conscious visual discovery radius for optional sites") ]
float DiscoverRadius;
[Attribute()] ref array<ref ERR_SiteProp> Props;
[Attribute()] ref ERR_SiteTransport Transport;
[Attribute("0", UIWidgets.ComboBox, "Location kind", enums: ParamEnumArray.FromEnum(ERR_LocationKind))]
ERR_LocationKind Kind;
[Attribute("", UIWidgets.ResourceNamePicker, "Successor site composition", "et")]
@@ -69,7 +85,7 @@ class ERR_ScenarioDefinition
{
if (!Sites || Sites.IsEmpty() || Sites.Count() > 32) return false;
set<string> ids = new set<string>();
int prisons, radios, extractions, groups, items, sources, maps, props;
int prisons, radios, extractions, groups, items, sources, maps, props, transports;
array<vector> cluePositions = {};
if (MinimumRadioSources < 1 || MinimumRadioSources > 8) return false;
foreach (ERR_SiteDefinition site : Sites)
@@ -79,6 +95,14 @@ class ERR_ScenarioDefinition
if (site.DiscoverRadius < 0 || site.DiscoverRadius > 60) return false;
bool optionalSite = site.Kind == ERR_LocationKind.DEPOT || site.Kind == ERR_LocationKind.OUTPOST;
if (!optionalSite && (site.DiscoverRadius > 0 || site.RevealOnEscape)) return false;
if (site.Transport)
{
ERR_SiteTransport transport = site.Transport;
if (!optionalSite || transport.Prefab.IsEmpty() || transport.Offset.Length() > 60 || Math.AbsFloat(transport.Offset[1]) > 0.01) return false;
if (transport.Heading < -360 || transport.Heading > 360 || transport.ClearanceRadius < 2 || transport.ClearanceRadius > 8) return false;
if (transport.MaximumTerrainRange < 0 || transport.MaximumTerrainRange > 1.5) return false;
transports++;
}
if (site.Props)
{
if (site.Props.Count() > 12) return false;
@@ -118,6 +142,6 @@ class ERR_ScenarioDefinition
sources++;
}
}
return prisons == 1 && radios == 1 && extractions == 1 && groups <= 8 && items <= 256 && props <= 64 && sources >= MinimumRadioSources && maps >= 1;
return prisons == 1 && radios == 1 && extractions == 1 && groups <= 8 && items <= 256 && props <= 64 && transports <= 4 && sources >= MinimumRadioSources && maps >= 1;
}
}
@@ -13,6 +13,7 @@ class ERR_SiteRuntime
ERR_LocationComponent Location;
IEntity Container;
SCR_AIGroup Group;
Vehicle Transport;
}
// One owner for finite physical site contents. No population or stock refill tick.
@@ -34,6 +35,14 @@ class ERR_SiteService
void ERR_SiteService(ERR_GameMode mode) { m_Mode = mode; }
int Count() { return m_Sites.Count(); }
bool Owns(IEntity entity) { return entity && m_Entities.Contains(entity); }
int TransportCount()
{
int count;
foreach (ERR_SiteRuntime site : m_Sites)
if (site.Transport) count++;
return count;
}
ERR_SiteRuntime Find(string id)
{
foreach (ERR_SiteRuntime site : m_Sites)
@@ -59,6 +68,82 @@ class ERR_SiteService
if (entity) m_Entities.Insert(entity);
return entity;
}
// Read-only placement survey. It neither removes scenery nor searches for a
// replacement position; authors must correct a rejected parking location.
bool TransportClearance(vector position, float radius, float maximumRange)
{
if (radius < 2 || radius > 8 || maximumRange < 0 || maximumRange > 1.5) return false;
vector ground = ERR_Actors.Ground(position);
float lowest = ground[1];
float highest = ground[1];
for (int x = -1; x <= 1; x++)
{
for (int z = -1; z <= 1; z++)
{
vector sample = ERR_Actors.Ground(position + Vector(x * radius, 0, z * radius));
lowest = Math.Min(lowest, sample[1]);
highest = Math.Max(highest, sample[1]);
}
}
m_Obstructed = false;
vector low = Vector(position[0] - radius, lowest + 0.1, position[2] - radius);
vector high = Vector(position[0] + radius, highest + 3, position[2] + radius);
GetGame().GetWorld().QueryEntitiesByAABB(low, high, CheckClearance);
bool clear = lowest >= 1 && highest - lowest <= maximumRange && !m_Obstructed;
PrintFormat("Rereintroduced [sites]: transport parking=%1 radius=%2 terrainRange=%3 obstructed=%4 clear=%5", ERR_Actors.Ground(position), radius, highest - lowest, m_Obstructed, clear);
return clear;
}
protected bool CreateTransport(ERR_SiteRuntime site, vector sitePosition)
{
ERR_SiteTransport spec = site.Definition.Transport;
if (!spec) return true;
vector position = ERR_Actors.Ground(sitePosition + spec.Offset);
if (!TransportClearance(position, spec.ClearanceRadius, spec.MaximumTerrainRange)) return Fail("obstructed or unsafe transport parking: " + site.Definition.Id);
IEntity entity = ERR_Actors.Spawn(spec.Prefab, position, Vector(spec.Heading, 0, 0));
if (!entity) return Fail("transport prefab: " + site.Definition.Id);
m_Entities.Insert(entity);
site.Transport = Vehicle.Cast(entity);
if (!site.Transport) return Fail("transport must be a vehicle: " + site.Definition.Id);
CarControllerComponent controller = CarControllerComponent.Cast(site.Transport.FindComponent(CarControllerComponent));
if (!controller) return Fail("transport must be wheeled: " + site.Definition.Id);
controller.SetPersistentHandBrake(true);
return true;
}
protected bool TransportReady(ERR_SiteRuntime site)
{
if (!site.Definition.Transport) return true;
Vehicle vehicle = site.Transport;
if (!ERR_Actors.Alive(vehicle)) return false;
vector low, high;
vehicle.GetWorldBounds(low, high);
vector center = ERR_Actors.Ground(site.Definition.Position + site.Definition.Transport.Offset);
float radius = site.Definition.Transport.ClearanceRadius - 0.5;
if (Math.AbsFloat(low[0] - center[0]) > radius || Math.AbsFloat(high[0] - center[0]) > radius || Math.AbsFloat(low[2] - center[2]) > radius || Math.AbsFloat(high[2] - center[2]) > radius)
{
if (GetGame().GetWorld().GetWorldTime() > m_Deadline) PrintFormat("Rereintroduced [sites]: transport footprint low=%1 high=%2 surveyedCenter=%3 origin=%4 allowedRadius=%5", low, high, center, vehicle.GetOrigin(), radius);
return false;
}
FuelManagerComponent fuel = FuelManagerComponent.Cast(vehicle.FindComponent(FuelManagerComponent));
if (!fuel || !fuel.HasFuel())
{
if (GetGame().GetWorld().GetWorldTime() > m_Deadline) PrintFormat("Rereintroduced [sites]: transport fuel manager=%1 hasFuel=%2", fuel != null, fuel && fuel.HasFuel());
return false;
}
BaseCompartmentManagerComponent manager = BaseCompartmentManagerComponent.Cast(vehicle.FindComponent(BaseCompartmentManagerComponent));
if (!manager) return false;
array<BaseCompartmentSlot> seats = {};
manager.GetCompartments(seats);
bool driver, passenger;
foreach (BaseCompartmentSlot slot : seats)
{
if (!slot) continue;
ECompartmentType type = SCR_CompartmentAccessComponent.GetCompartmentType(slot);
if (type == ECompartmentType.PILOT) driver = true;
if (type == ECompartmentType.CARGO) passenger = true;
}
if ((!driver || !passenger) && GetGame().GetWorld().GetWorldTime() > m_Deadline) PrintFormat("Rereintroduced [sites]: transport seats=%1 driver=%2 passenger=%3", seats.Count(), driver, passenger);
return driver && passenger;
}
protected bool Fail(string reason)
{
m_FailureReason = reason;
@@ -139,6 +224,7 @@ class ERR_SiteService
m_Entities.Insert(scenery);
}
}
if (!CreateTransport(site, position)) return false;
if (spec.Guarded || !spec.Guards.IsEmpty())
{
vector guardPosition = position + spec.GuardOffset;
@@ -161,6 +247,11 @@ class ERR_SiteService
int pending, accepted;
foreach (ERR_SiteRuntime site : m_Sites)
{
if (!TransportReady(site))
{
if (GetGame().GetWorld().GetWorldTime() > m_Deadline) Fail("transport needs fuel, driver/passenger seats and a fitting parking footprint: " + site.Definition.Id);
return m_State;
}
if (!site.Group) continue;
if (site.Group.GetFaction() != m_Mode.EnemyFaction())
{
@@ -0,0 +1,84 @@
class ERR_SpawnTicket
{
int ConnectionId;
UUID AccountId;
ERR_GameMode Mode;
SCR_PlayerController Controller;
SCR_SpawnRequestComponent Request;
ref SCR_SpawnData Data;
IEntity Actor;
}
class ERR_SpawnFinalizationRules
{
static bool PositionClose(vector actual, vector requested)
{
return vector.DistanceXZ(actual, requested) <= 0.6 && Math.AbsFloat(actual[1] - requested[1]) <= 1;
}
static bool Allows(int phase, bool connected, bool usedLife, UUID expected, UUID current, bool sameController, bool sameRequest, bool noControlledEntity)
{
return connected && ERR_PlayerLifeRules.CanEnter(usedLife, phase) && !expected.IsNull() && expected == current && sameController && sameRequest && noControlledEntity;
}
}
modded class SCR_SpawnRequestComponent
{
// Only a successor-owned admission ticket calls this. Disconnect or a mission
// phase change must not leave an old preload callback assigning an actor later.
void ERR_CancelAdmission(SCR_SpawnData data, bool notifyOwner)
{
if (!GetGame()) return;
GetGame().GetCallqueue().Remove(AwaitFinalization_S);
if (!Replication.IsServer()) return;
if (notifyOwner && GetPlayerController() && GetRespawnComponent()) SendResponse_S(SCR_ESpawnResult.SPAWN_NOT_ALLOWED, data);
}
}
modded class SCR_FreeSpawnHandlerComponent
{
protected ERR_SpawnLogic ERR_AdmissionLogic()
{
SCR_RespawnSystemComponent system = GetRespawnSystemComponent();
if (!system) return null;
return ERR_SpawnLogic.Cast(system.GetSpawnLogic());
}
protected void ERR_RejectOwnedActor(SCR_SpawnRequestComponent request, IEntity entity, SCR_SpawnData data)
{
// A repeated/malformed callback must never delete a currently possessed actor.
if (entity && !ERR_Actors.Player(entity)) HandleSpawnEntityFailure_S(request, entity, data, SCR_ESpawnResult.SPAWN_NOT_ALLOWED);
}
override SCR_ESpawnResult HandleRequest_S(SCR_SpawnRequestComponent requestComponent, SCR_SpawnData data, out IEntity spawnedEntity)
{
ERR_SpawnLogic logic = ERR_AdmissionLogic();
if (!ERR_GameMode.Get() && !logic) return super.HandleRequest_S(requestComponent, data, spawnedEntity);
ERR_SpawnTicket ticket;
if (logic) ticket = logic.CreateSpawnTicket(requestComponent, data);
if (!ticket) return SCR_ESpawnResult.SPAWN_NOT_ALLOWED;
SCR_ESpawnResult result = super.HandleRequest_S(requestComponent, data, spawnedEntity);
if (result != SCR_ESpawnResult.OK) return result;
ticket.Actor = spawnedEntity;
if (!logic.TrackSpawnTicket(ticket))
{
ERR_RejectOwnedActor(requestComponent, spawnedEntity, data);
return SCR_ESpawnResult.SPAWN_NOT_ALLOWED;
}
return result;
}
override SCR_ESpawnResult FinalizeRequest_S(SCR_SpawnRequestComponent requestComponent, SCR_SpawnData data, IEntity entity)
{
ERR_SpawnLogic logic = ERR_AdmissionLogic();
if (!ERR_GameMode.Get() && !logic) return super.FinalizeRequest_S(requestComponent, data, entity);
ERR_SpawnTicket ticket;
if (logic) ticket = logic.TakeSpawnTicket(requestComponent, data, entity);
// No ticket means no ownership proof: reject without deleting arbitrary input.
if (!ticket) return SCR_ESpawnResult.SPAWN_NOT_ALLOWED;
if (!logic.MayFinalizeSpawn(ticket))
{
ERR_RejectOwnedActor(requestComponent, entity, data);
return SCR_ESpawnResult.SPAWN_NOT_ALLOWED;
}
SCR_ESpawnResult result = super.FinalizeRequest_S(requestComponent, data, entity);
if (result != SCR_ESpawnResult.OK) ERR_RejectOwnedActor(requestComponent, entity, data);
return result;
}
}
+143 -35
View File
@@ -6,6 +6,24 @@ class ERR_SpawnLogic : SCR_SpawnLogic
protected ref map<int, UUID> m_Retries = new map<int, UUID>();
protected ref map<int, vector> m_AuthorizedPositions = new map<int, vector>();
protected ref set<int> m_Pending = new set<int>();
protected ref ERR_SpawnPlacement m_Placement = new ERR_SpawnPlacement();
protected ref array<ref ERR_SpawnTicket> m_SpawnTickets = {};
protected ERR_GameMode m_LifecycleMode;
protected bool m_AdmissionsStopped;
protected void BindLifecycle(ERR_GameMode mode)
{
if (!mode || m_LifecycleMode == mode) return;
if (m_LifecycleMode)
{
m_LifecycleMode.OnPhaseChanged().Remove(AdmissionPhaseChanged);
m_LifecycleMode.OnStop().Remove(StopAdmissions);
}
m_AdmissionsStopped = false;
m_LifecycleMode = mode;
mode.OnPhaseChanged().Insert(AdmissionPhaseChanged);
mode.OnStop().Insert(StopAdmissions);
}
override void OnPlayerAuditSuccess_S(int playerId)
{
@@ -20,6 +38,7 @@ class ERR_SpawnLogic : SCR_SpawnLogic
}
#endif
ERR_GameMode mode = ERR_GameMode.Get();
BindLifecycle(mode);
SCR_PlayerController resultController = SCR_PlayerController.Cast(GetGame().GetPlayerManager().GetPlayerController(playerId));
if (resultController) resultController.ERR_ResetEscapeResult();
if (!mode || !mode.Players().Bind(playerId, identity))
@@ -43,7 +62,7 @@ class ERR_SpawnLogic : SCR_SpawnLogic
{
ERR_GameMode mode = ERR_GameMode.Get();
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerManager().GetPlayerController(playerId));
if (!controller || !mode || !m_Identities.Contains(playerId) || !m_Attempts.Contains(playerId) || m_Pending.Contains(playerId)) return;
if (!controller || !mode || m_AdmissionsStopped || !m_Identities.Contains(playerId) || !m_Attempts.Contains(playerId) || m_Pending.Contains(playerId)) return;
if (controller.GetControlledEntity()) return;
if (ERR_MissionRules.Terminal(mode.Phase())) return;
if (!mode.PreparationError().IsEmpty()) { Observe(playerId, ERR_ObserverReason.SPAWN_UNAVAILABLE); return; }
@@ -52,7 +71,6 @@ class ERR_SpawnLogic : SCR_SpawnLogic
if (!mode.Active()) { RetryLater(playerId); return; }
int attempts = m_Attempts.Get(playerId);
if (attempts >= 30) { Observe(playerId, ERR_ObserverReason.SPAWN_UNAVAILABLE); return; }
m_Attempts.Set(playerId, attempts + 1);
if (!mode.Players().CanEnter(playerId, mode.Phase()))
{
ERR_ObserverReason reason = ERR_ObserverReason.SPENT_LIFE;
@@ -66,7 +84,9 @@ class ERR_SpawnLogic : SCR_SpawnLogic
Faction faction = mode.PlayerFaction();
if (!affiliation || !respawn || !faction) { RetryLater(playerId); return; }
affiliation.RequestFaction(faction);
vector position = SpawnPosition(mode, playerId);
vector position;
if (!SpawnPosition(mode, position)) { RetryLater(playerId); return; }
m_Attempts.Set(playerId, attempts + 1);
SCR_FreeSpawnData request = new SCR_FreeSpawnData(mode.Factions().PrisonerPrefab, position);
if (!respawn.CanSpawn(request)) { RetryLater(playerId); return; }
m_AuthorizedPositions.Set(playerId, position);
@@ -74,36 +94,116 @@ class ERR_SpawnLogic : SCR_SpawnLogic
if (!respawn.RequestSpawn(request)) OnPlayerSpawnFailed_S(playerId);
}
protected vector SpawnPosition(ERR_GameMode mode, int playerId)
protected bool SpawnPosition(ERR_GameMode mode, out vector position)
{
int column = playerId % 3;
int row = playerId % 4;
vector offset = Vector(column - 1, 0, row - 1);
vector prison = ERR_Actors.Ground(mode.PrisonPosition() + offset);
if (mode.Phase() == ERR_Phase.PRISON) return prison;
if (mode.Phase() == ERR_Phase.PRISON) return m_Placement.Prison(mode.PrisonPosition(), m_AuthorizedPositions, position);
array<IEntity> survivors = {};
ERR_Actors.Players(survivors, true);
foreach (IEntity survivor : survivors)
{
if (CompartmentAccessComponent.GetVehicleIn(survivor)) continue;
vector anchor = survivor.GetOrigin();
vector ground = ERR_Actors.Ground(anchor);
if (Math.AbsFloat(anchor[1] - ground[1]) > 1.5) continue;
vector position = ERR_Actors.Ground(anchor + "3 0 0" + offset);
if (position[1] < 1 || Math.AbsFloat(position[1] - anchor[1]) > 1.5) continue;
TraceParam trace = new TraceParam();
trace.Start = anchor + "0 1 0";
trace.End = position + "0 1 0";
trace.Flags = TraceFlags.WORLD | TraceFlags.ENTS;
trace.LayerMask = TRACE_LAYER_CAMERA;
trace.Exclude = survivor;
if (GetGame().GetWorld().TraceMove(trace, null) < 1) continue;
trace.Start = position + "0 0.15 0";
trace.End = position + "0 2 0";
if (GetGame().GetWorld().TraceMove(trace, null) < 1) continue;
return position;
if (SCR_FactionManager.SGetFaction(survivor) != mode.PlayerFaction()) continue;
if (m_Placement.Near(survivor, m_AuthorizedPositions, position)) return true;
}
return prison;
return m_Placement.Prison(mode.PrisonPosition(), m_AuthorizedPositions, position);
}
ERR_SpawnTicket CreateSpawnTicket(SCR_SpawnRequestComponent request, SCR_SpawnData data)
{
if (!Replication.IsServer() || !request || !data) return null;
int id = request.GetPlayerId();
if (!Authorizes(id, data)) return null;
ERR_SpawnTicket ticket = new ERR_SpawnTicket();
ticket.ConnectionId = id;
ticket.AccountId = m_Identities.Get(id);
ticket.Mode = ERR_GameMode.Get();
ticket.Controller = SCR_PlayerController.Cast(request.GetPlayerController());
ticket.Request = request;
ticket.Data = data;
return ticket;
}
bool TrackSpawnTicket(ERR_SpawnTicket ticket)
{
if (!Replication.IsServer() || !ticket || !ticket.Actor || ticket.Mode != ERR_GameMode.Get() || !Authorizes(ticket.ConnectionId, ticket.Data)) return false;
foreach (ERR_SpawnTicket existing : m_SpawnTickets)
if (existing.Request == ticket.Request || existing.Actor == ticket.Actor) return false;
m_SpawnTickets.Insert(ticket);
return true;
}
ERR_SpawnTicket TakeSpawnTicket(SCR_SpawnRequestComponent request, SCR_SpawnData data, IEntity entity)
{
if (!Replication.IsServer() || !request || !data || !entity) return null;
for (int i = 0; i < m_SpawnTickets.Count(); i++)
{
ERR_SpawnTicket ticket = m_SpawnTickets[i];
if (ticket.Request != request || ticket.Data != data || ticket.Actor != entity) continue;
m_SpawnTickets.Remove(i);
return ticket;
}
return null;
}
bool MayFinalizeSpawn(ERR_SpawnTicket ticket)
{
ERR_GameMode mode = ERR_GameMode.Get();
if (!Replication.IsServer() || m_AdmissionsStopped || !mode || !ticket || ticket.Mode != mode || !ticket.Actor || !ticket.Request || !ticket.Controller) return false;
ERR_PlayerLife life = mode.Players().Get(ticket.ConnectionId);
if (!life || !m_Identities.Contains(ticket.ConnectionId)) return false;
PlayerController controller = GetGame().GetPlayerManager().GetPlayerController(ticket.ConnectionId);
bool sameController = controller == ticket.Controller && ticket.Request.GetPlayerController() == ticket.Controller;
bool sameRequest = ticket.Request.GetPlayerId() == ticket.ConnectionId && m_Identities.Get(ticket.ConnectionId) == ticket.AccountId;
if (!ERR_SpawnFinalizationRules.Allows(mode.Phase(), life.Connected, life.UsedLife, ticket.AccountId, life.AccountId, sameController, sameRequest, ticket.Controller.GetControlledEntity() == null)) return false;
if (!ERR_Actors.Alive(ticket.Actor) || !Authorizes(ticket.ConnectionId, ticket.Data)) return false;
// The preloaded body may have fallen or the reserved space become occupied.
if (!ERR_SpawnFinalizationRules.PositionClose(ticket.Actor.GetOrigin(), ticket.Data.GetPosition())) return false;
return m_Placement.Clear(ticket.Data.GetPosition(), m_AuthorizedPositions, ticket.ConnectionId, ticket.Actor);
}
protected void CancelOwnedTickets(int playerId, bool sendResponse)
{
for (int i = m_SpawnTickets.Count() - 1; i >= 0; i--)
{
ERR_SpawnTicket ticket = m_SpawnTickets[i];
if (ticket.ConnectionId != playerId) continue;
m_SpawnTickets.Remove(i);
bool notify = sendResponse && ticket.Controller && ticket.Controller == GetGame().GetPlayerManager().GetPlayerController(playerId);
if (ticket.Request) ticket.Request.ERR_CancelAdmission(ticket.Data, notify);
ERR_Actors.DeleteSpawned(ticket.Actor);
}
}
protected void CancelAdmission(int playerId, ERR_ObserverReason reason)
{
m_Attempts.Remove(playerId);
m_Retries.Remove(playerId);
ClearPending(playerId);
m_AuthorizedPositions.Remove(playerId);
CancelOwnedTickets(playerId, true);
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerManager().GetPlayerController(playerId));
if (controller) controller.ERR_SetObserver(reason);
}
protected void AdmissionPhaseChanged(ERR_Phase previous, ERR_Phase current)
{
if (!Replication.IsServer() || current <= ERR_Phase.RADIO_DEFENCE) return;
array<int> pending = {};
foreach (int id, vector position : m_AuthorizedPositions) pending.Insert(id);
foreach (ERR_SpawnTicket ticket : m_SpawnTickets)
if (!pending.Contains(ticket.ConnectionId)) pending.Insert(ticket.ConnectionId);
foreach (int playerId : pending)
{
ERR_ObserverReason reason = ERR_ObserverReason.NONE;
if (current == ERR_Phase.EXTRACTION) reason = ERR_ObserverReason.LATE_EXTRACTION;
CancelAdmission(playerId, reason);
}
}
protected void StopAdmissions()
{
if (!Replication.IsServer()) return;
m_AdmissionsStopped = true;
array<int> pending = {};
foreach (int id, vector position : m_AuthorizedPositions) pending.Insert(id);
foreach (ERR_SpawnTicket ticket : m_SpawnTickets)
if (!pending.Contains(ticket.ConnectionId)) pending.Insert(ticket.ConnectionId);
foreach (int playerId : pending) CancelAdmission(playerId, ERR_ObserverReason.NONE);
m_Attempts.Clear();
m_Retries.Clear();
GetGame().GetCallqueue().Remove(RetrySpawn);
}
protected void ClearPending(int playerId)
@@ -127,6 +227,7 @@ class ERR_SpawnLogic : SCR_SpawnLogic
}
protected override void OnPlayerSpawnFailed_S(int playerId)
{
CancelOwnedTickets(playerId, false);
ClearPending(playerId);
m_AuthorizedPositions.Remove(playerId);
RetryLater(playerId);
@@ -144,11 +245,13 @@ class ERR_SpawnLogic : SCR_SpawnLogic
{
super.OnPlayerEntityLost_S(playerId);
if (!m_Identities.Contains(playerId)) return;
GetGame().GetCallqueue().CallLater(ObserveAfterLoss, 100, false, playerId, m_Identities.Get(playerId));
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerManager().GetPlayerController(playerId));
GetGame().GetCallqueue().CallLater(ObserveAfterLoss, 100, false, playerId, m_Identities.Get(playerId), controller);
}
protected void ObserveAfterLoss(int playerId, UUID identity)
protected void ObserveAfterLoss(int playerId, UUID identity, SCR_PlayerController expectedController)
{
if (!m_Identities.Contains(playerId) || m_Identities.Get(playerId) != identity) return;
if (!expectedController || !m_Identities.Contains(playerId) || m_Identities.Get(playerId) != identity) return;
if (GetGame().GetPlayerManager().GetPlayerController(playerId) != expectedController || ERR_Actors.Alive(expectedController.GetControlledEntity())) return;
ERR_GameMode mode = ERR_GameMode.Get();
ERR_PlayerLife life;
if (mode) life = mode.Players().Get(playerId);
@@ -169,10 +272,7 @@ class ERR_SpawnLogic : SCR_SpawnLogic
}
override void OnPlayerDisconnected_S(int playerId, KickCauseCode cause, int timeout)
{
m_Attempts.Remove(playerId);
m_Retries.Remove(playerId);
ClearPending(playerId);
m_AuthorizedPositions.Remove(playerId);
CancelAdmission(playerId, ERR_ObserverReason.NONE);
m_Identities.Remove(playerId);
ERR_GameMode mode = ERR_GameMode.Get();
if (mode) mode.Players().Disconnect(playerId);
@@ -181,13 +281,21 @@ class ERR_SpawnLogic : SCR_SpawnLogic
bool Authorizes(int playerId, SCR_SpawnData data)
{
ERR_GameMode mode = ERR_GameMode.Get();
if (!Replication.IsServer() || !mode || !data || !mode.Players().CanEnter(playerId, mode.Phase())) return false;
if (!Replication.IsServer() || m_AdmissionsStopped || !mode || !data || !m_Identities.Contains(playerId) || !mode.Players().CanEnter(playerId, mode.Phase())) return false;
ERR_PlayerLife life = mode.Players().Get(playerId);
if (!life || life.AccountId != m_Identities.Get(playerId)) return false;
if (!m_Pending.Contains(playerId) || !m_AuthorizedPositions.Contains(playerId) || data.GetPrefab() != mode.Factions().PrisonerPrefab) return false;
return vector.Distance(data.GetPosition(), m_AuthorizedPositions.Get(playerId)) < 0.1;
}
void ~ERR_SpawnLogic()
{
if (!GetGame()) return;
StopAdmissions();
if (m_LifecycleMode)
{
m_LifecycleMode.OnPhaseChanged().Remove(AdmissionPhaseChanged);
m_LifecycleMode.OnStop().Remove(StopAdmissions);
}
GetGame().GetCallqueue().Remove(RetrySpawn);
GetGame().GetCallqueue().Remove(ObserveAfterLoss);
}
@@ -0,0 +1,76 @@
// Allocation is based on occupied space, never on a transient player ID.
class ERR_SpawnPlacement
{
protected IEntity m_Exclude;
protected bool m_Blocked;
protected bool Obstacle(IEntity entity)
{
if (!entity || (!entity.GetVObject() && !ChimeraCharacter.Cast(entity))) return true;
IEntity parent = entity;
for (int depth = 0; parent && depth < 32; depth++)
{
if (parent == m_Exclude) return true;
parent = parent.GetParent();
}
m_Blocked = true;
return false;
}
bool Clear(vector position, map<int, vector> reservations, int ownReservation = -1, IEntity exclude = null)
{
if (!Replication.IsServer()) return false;
if (reservations)
foreach (int id, vector reserved : reservations)
if (id != ownReservation && vector.DistanceXZ(position, reserved) < 1.2 && Math.AbsFloat(position[1] - reserved[1]) < 2) return false;
vector ground = ERR_Actors.Ground(position);
if (ground[1] < 1 || Math.AbsFloat(position[1] - ground[1]) > 0.3) return false;
// Feet need supported, reasonably level terrain across the character width.
for (int x = -1; x <= 1; x++)
for (int z = -1; z <= 1; z++)
{
vector sample = ERR_Actors.Ground(position + Vector(x * 0.45, 0, z * 0.45));
if (Math.AbsFloat(sample[1] - ground[1]) > 0.3) return false;
}
m_Blocked = false;
m_Exclude = exclude;
GetGame().GetWorld().QueryEntitiesByAABB(ground + "-0.45 0.15 -0.45", ground + "0.45 1.9 0.45", Obstacle);
m_Exclude = null;
return !m_Blocked;
}
bool Prison(vector center, map<int, vector> reservations, out vector position)
{
// 25 bounded candidates lie inside the authored 12 by 12 metre prison.
for (int z = -2; z <= 2; z++)
for (int x = -2; x <= 2; x++)
{
vector candidate = ERR_Actors.Ground(center + Vector(x * 1.5, 0, z * 1.5));
if (!Clear(candidate, reservations)) continue;
position = candidate;
return true;
}
return false;
}
bool Near(IEntity survivor, map<int, vector> reservations, out vector position)
{
if (!ERR_Actors.CanFight(survivor) || CompartmentAccessComponent.GetVehicleIn(survivor)) return false;
vector anchor = survivor.GetOrigin();
vector floor = ERR_Actors.Ground(anchor);
if (Math.AbsFloat(anchor[1] - floor[1]) > 1.5) return false;
for (int z = -2; z <= 2; z++)
for (int x = -2; x <= 2; x++)
{
if (x == 0 && z == 0) continue;
vector candidate = ERR_Actors.Ground(anchor + Vector(x * 1.5, 0, z * 1.5));
if (Math.AbsFloat(candidate[1] - anchor[1]) > 1.5 || !Clear(candidate, reservations)) continue;
TraceParam trace = new TraceParam();
trace.Start = anchor + "0 1 0";
trace.End = candidate + "0 1 0";
trace.Flags = TraceFlags.WORLD | TraceFlags.ENTS;
trace.LayerMask = TRACE_LAYER_CAMERA;
trace.Exclude = survivor;
if (GetGame().GetWorld().TraceMove(trace, null) < 1) continue;
position = candidate;
return true;
}
return false;
}
}