Prepare independent successor playtest with spawn recovery and casualty feedback

This commit is contained in:
2026-09-19 22:12:52 +02:00
parent 61e165ef5b
commit 6cf989a060
266 changed files with 134064 additions and 154 deletions
@@ -0,0 +1,51 @@
// Presentation only. The service still owns roster, settling and seat authority.
class ERR_CasualtyFeedback
{
static bool Visible(ERR_GameMode mode, IEntity caller, IEntity casualty)
{
if (!mode || !mode.Active() || mode.Phase() != ERR_Phase.EXTRACTION || !caller || caller == casualty || !ERR_CasualtyLoadingRules.Down(casualty)) return false;
Vehicle aircraft = mode.CasualtyLoadingAircraft();
Faction faction = mode.PlayerFaction();
return aircraft && faction && SCR_FactionManager.SGetFaction(caller) == faction && SCR_FactionManager.SGetFaction(casualty) == faction && ERR_CasualtyLoadingRules.Ranges(vector.Distance(caller.GetOrigin(), casualty.GetOrigin()), vector.Distance(caller.GetOrigin(), aircraft.GetOrigin()), vector.Distance(casualty.GetOrigin(), aircraft.GetOrigin()));
}
static string PhysicalReason(ERR_GameMode mode, IEntity caller, IEntity casualty)
{
if (!mode || !mode.Active() || mode.Phase() != ERR_Phase.EXTRACTION) return "Wait until helicopter extraction is available";
if (!caller || caller == casualty || !ERR_Actors.CanFight(caller)) return "A living, conscious rescuer is required";
if (CompartmentAccessComponent.GetVehicleIn(caller)) return "Leave the helicopter before loading a casualty";
if (!ERR_CasualtyLoadingRules.GroundSettled(casualty)) return "Place the living, unconscious casualty on stable ground";
Vehicle aircraft = mode.CasualtyLoadingAircraft();
if (!aircraft || !ERR_CasualtyLoadingRules.Ranges(vector.Distance(caller.GetOrigin(), casualty.GetOrigin()), vector.Distance(caller.GetOrigin(), aircraft.GetOrigin()), vector.Distance(casualty.GetOrigin(), aircraft.GetOrigin()))) return "Stay beside the casualty and within twelve metres of the extraction helicopter";
if (!ERR_CasualtyLoadingRules.AircraftReady(aircraft)) return "The helicopter must be upright, grounded and stationary";
if (!ERR_CasualtyLoadingRules.Nearby(mode, caller, casualty)) return "Keep clear access to a fellow prisoner's body";
BaseCompartmentManagerComponent manager = BaseCompartmentManagerComponent.Cast(aircraft.FindComponent(BaseCompartmentManagerComponent));
if (!manager) return "No accessible transport seat is available";
array<BaseCompartmentSlot> seats = {}; manager.GetCompartments(seats);
foreach (BaseCompartmentSlot seat : seats)
if (ERR_CasualtyLoadingRules.TransportBench(seat, aircraft) && !seat.GetOccupant() && !seat.IsReserved() && seat.IsCompartmentAccessible() && !seat.IsGetInLockedFor(casualty)) return string.Empty;
return "No accessible transport seat is available";
}
}
modded class SCR_PlayerController
{
// Only the server addresses the still-current initiating rescuer's controller.
void ERR_CasualtyFeedback(ERR_GameMode mode, IEntity actor, string message)
{
if (!Replication.IsServer() || !mode || mode != ERR_GameMode.Get() || !mode.Active() || !actor || actor != GetControlledEntity() || !mode.Players() || !mode.Players().CurrentActor(actor)) return;
if (GetGame().GetPlayerManager().GetPlayerController(GetPlayerId()) != this) return;
RplId modeId = ERR_CasualtyLoadingRules.EntityId(mode);
RplId actorId = ERR_CasualtyLoadingRules.EntityId(actor);
if (modeId == RplId.Invalid() || actorId == RplId.Invalid() || message.IsEmpty()) return;
if (this == GetGame().GetPlayerController()) ERR_CasualtyFeedbackOwner(modeId, actorId, message);
else Rpc(ERR_CasualtyFeedbackOwner, modeId, actorId, message);
}
[RplRpc(RplChannel.Reliable, RplRcver.Owner)]
protected void ERR_CasualtyFeedbackOwner(RplId modeId, RplId actorId, string message)
{
ERR_GameMode mode = ERR_GameMode.Get();
if (!GetGame() || this != GetGame().GetPlayerController() || !mode || !mode.Active() || mode.Phase() != ERR_Phase.EXTRACTION || ERR_CasualtyLoadingRules.EntityId(mode) != modeId || ERR_CasualtyLoadingRules.EntityId(GetControlledEntity()) != actorId || message.IsEmpty()) return;
SCR_HintManagerComponent hints = SCR_HintManagerComponent.GetInstance();
if (!hints || !hints.ShowCustom(message, "Casualty loading", 10)) SCR_ChatComponent.RadioProtocolMessage(message);
}
}
@@ -131,6 +131,7 @@ class ERR_CasualtyLoadTicket
int CommittedAt;
bool Committed;
bool OwnerReported;
bool FeedbackSubmitted; // presentation only; never authorizes entry
}
class ERR_CasualtyLoadingService
{
@@ -204,6 +205,34 @@ class ERR_CasualtyLoadingService
if (!CurrentLife(caller) || !CurrentLife(casualty) || !Settled(casualty, System.GetTickCount()) || PendingActor(caller, casualty)) return false;
return AvailableSeat(casualty, m_Mode.ExtractionVehicle()) != null;
}
// Reasons never replace CanLoad/Begin authority or reserve a seat.
string RejectionReason(IEntity caller, IEntity casualty)
{
if (!Replication.IsServer() || m_Stopped) return "Casualty loading is unavailable";
string physical = ERR_CasualtyFeedback.PhysicalReason(m_Mode, caller, casualty);
if (!physical.IsEmpty()) return physical;
if (!CurrentLife(caller) || !CurrentLife(casualty)) return "Both prisoners must still be connected to this mission";
if (!Settled(casualty, System.GetTickCount())) return "Let the casualty rest on the ground for five seconds";
if (PendingActor(caller, casualty)) return "A casualty loading request is already in progress";
if (!AvailableSeat(casualty, m_Mode.ExtractionVehicle())) return "No accessible transport seat is available";
return string.Empty;
}
protected void NotifyCaller(IEntity caller, string message)
{
if (!Replication.IsServer() || !m_Mode || !m_Mode.Active() || m_Mode.Phase() != ERR_Phase.EXTRACTION || message.IsEmpty()) return;
ERR_PlayerLife life = CurrentLife(caller);
if (!life) return;
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerManager().GetPlayerController(life.ConnectionId));
if (!controller || controller.GetControlledEntity() != caller) return;
controller.ERR_CasualtyFeedback(m_Mode, caller, message);
}
void Request(IEntity caller, IEntity casualty)
{
if (!Replication.IsServer()) return;
string reason = RejectionReason(caller, casualty);
if (!reason.IsEmpty()) { NotifyCaller(caller, reason); return; }
if (!Begin(caller, casualty)) NotifyCaller(caller, "Loading could not start. Check the casualty and available transport seats, then try again.");
}
bool Begin(IEntity caller, IEntity casualty)
{
if (!CanLoad(caller, casualty) || m_NextNonce >= 2147483646) return false;
@@ -221,6 +250,7 @@ class ERR_CasualtyLoadingService
m_Pending.Insert(ticket);
ticket.Seat.SetReserved(casualty);
if (!ticket.Seat.IsReservedBy(casualty)) { Resolve(ticket, false); return false; }
ticket.FeedbackSubmitted = true;
ScheduleMaintenance();
// Preparation never moves the actor. Its owner starts a local real-time lease.
controller.ERR_PrepareCasualtyLoad(ERR_CasualtyLoadingRules.EntityId(m_Mode), ticket.Nonce);
@@ -264,12 +294,18 @@ class ERR_CasualtyLoadingService
{
return m_Mode && m_Mode.Active() && m_Mode.Phase() == ERR_Phase.EXTRACTION && ticket.Aircraft == m_Mode.ExtractionVehicle() && ERR_Actors.Alive(ticket.Aircraft) && ERR_Actors.Alive(ticket.Casualty) && SameLife(ticket.Caller, ticket.CallerAccount, ticket.CallerConnection) && SameLife(ticket.Casualty, ticket.CasualtyAccount, ticket.CasualtyConnection) && ERR_CasualtyLoadingRules.TransportBench(ticket.Seat, ticket.Aircraft);
}
protected void NotifyResolution(ERR_CasualtyLoadTicket ticket, bool success)
{
if (!success && ticket.FeedbackSubmitted && !m_Stopped && SameLife(ticket.Caller, ticket.CallerAccount, ticket.CallerConnection))
NotifyCaller(ticket.Caller, "Casualty boarding could not be confirmed. Check the helicopter before retrying; keep it stationary with an accessible transport seat.");
}
protected void Resolve(ERR_CasualtyLoadTicket ticket, bool success)
{
int index = m_Pending.Find(ticket); if (index < 0) return;
m_Pending.Remove(index);
if (ticket.Seat && ticket.Casualty && ticket.Seat.IsReservedBy(ticket.Casualty)) ticket.Seat.SetReserved(null);
if (m_Pending.IsEmpty()) CancelMaintenance();
NotifyResolution(ticket, success);
m_OnResolved.Invoke(ticket.Casualty, success);
}
void Tick(float worldNow)
+33 -1
View File
@@ -35,6 +35,7 @@ class ERR_GameMode : SCR_BaseGameMode
[RplProp()]
protected string m_RecoveryStatus;
protected bool m_StartedPlayers;
protected ref map<int, PlayerController> m_ExhaustedInitialAdmissions = new map<int, PlayerController>();
protected bool m_Stopped;
protected bool m_NativeEndHandled;
protected float m_NoSurvivorsSince = -1;
@@ -263,6 +264,36 @@ class ERR_GameMode : SCR_BaseGameMode
m_NoSurvivorsSince = -1;
}
// Exhaustion is admission state, never a spawned participant or spent life.
void InitialAdmissionStarted(int playerId)
{
if (!Replication.IsServer()) return;
m_ExhaustedInitialAdmissions.Remove(playerId);
if (!m_StartedPlayers) m_NoSurvivorsSince = -1;
}
void InitialAdmissionExhausted(int playerId, PlayerController controller)
{
if (!Replication.IsServer() || !Active() || m_StartedPlayers || !controller) return;
ERR_PlayerLife life = m_Roster.Get(playerId);
if (!life || !life.Connected || life.UsedLife || life.Actor) return;
if (GetGame().GetPlayerManager().GetPlayerController(playerId) != controller || controller.GetControlledEntity()) return;
m_ExhaustedInitialAdmissions.Set(playerId, controller);
}
protected bool InitialAdmissionsExhausted()
{
array<int> connected = {};
GetGame().GetPlayerManager().GetPlayers(connected);
if (connected.IsEmpty()) return false;
foreach (int id : connected)
{
PlayerController controller = GetGame().GetPlayerManager().GetPlayerController(id);
if (!controller || !m_ExhaustedInitialAdmissions.Contains(id) || m_ExhaustedInitialAdmissions.Get(id) != controller || controller.GetControlledEntity()) return false;
ERR_PlayerLife life = m_Roster.Get(id);
if (!life || !life.Connected || life.UsedLife || life.Actor) return false;
}
return true;
}
protected string SettingsPath() { return ERR_Settings.PATH; }
protected override void EOnInit(IEntity owner)
@@ -379,12 +410,13 @@ class ERR_GameMode : SCR_BaseGameMode
m_NoSurvivorsSince = -1;
return;
}
if (m_StartedPlayers && !m_Roster.HasSurvivor())
if ((m_StartedPlayers || InitialAdmissionsExhausted()) && !m_Roster.HasSurvivor())
{
if (m_NoSurvivorsSince < 0) m_NoSurvivorsSince = now;
if (now - m_NoSurvivorsSince >= 30000)
{
m_EndReason = "No surviving prisoners remain.";
if (!m_StartedPlayers) m_EndReason = "No prisoners could be admitted to this round.";
Transition(ERR_Phase.DEFEAT);
return;
}
@@ -3,21 +3,26 @@ class ERR_LoadCasualtyAction : ScriptedUserAction
{
override bool CanBeShownScript(IEntity user)
{
return ERR_CasualtyLoadingRules.Nearby(ERR_GameMode.Get(), user, GetOwner());
return ERR_CasualtyFeedback.Visible(ERR_GameMode.Get(), user, GetOwner());
}
override bool CanBePerformedScript(IEntity user)
{
ERR_GameMode mode = ERR_GameMode.Get();
if (!ERR_CasualtyLoadingRules.Nearby(mode, user, GetOwner())) return false;
// Clients do not own the authoritative roster. Server always checks again.
if (!Replication.IsServer()) return true;
return mode.CasualtyLoading() && mode.CasualtyLoading().CanLoad(user, GetOwner());
SetCannotPerformReason(string.Empty);
string reason = ERR_CasualtyFeedback.PhysicalReason(mode, user, GetOwner());
if (reason.IsEmpty() && Replication.IsServer())
{
if (!mode.CasualtyLoading()) reason = "Casualty loading is unavailable";
else reason = mode.CasualtyLoading().RejectionReason(user, GetOwner());
}
if (!reason.IsEmpty()) { SetCannotPerformReason(reason); return false; }
return true;
}
override bool HasLocalEffectOnlyScript() { return false; }
override void PerformAction(IEntity pOwnerEntity, IEntity pUserEntity)
{
if (!Replication.IsServer() || pOwnerEntity != GetOwner()) return;
ERR_GameMode mode = ERR_GameMode.Get();
if (mode && mode.CasualtyLoading()) mode.CasualtyLoading().Begin(pUserEntity, pOwnerEntity);
if (mode && mode.CasualtyLoading()) mode.CasualtyLoading().Request(pUserEntity, pOwnerEntity);
}
}
@@ -16,7 +16,10 @@ class ERR_NativeDefinitionProbe
cache.Id = "everon-prison-1";
if (!Check(!definition.Valid(), "duplicate site identity rejected")) return false;
cache.Id = "everon-supply-1";
ERR_SiteStock documents = cache.Stock[3];
ERR_SiteStock documents;
foreach (ERR_SiteStock stock : cache.Stock)
if (stock.Prefab == ERR_Resources.ObjectiveDocument()) { documents = stock; break; }
if (!Check(documents != null, "clue stock located independently of supply order")) return false;
documents.TargetId = "missing-radio";
if (!Check(!definition.Valid(), "dangling document target rejected")) return false;
documents.TargetId = "everon-prison-1";
+12 -2
View File
@@ -94,6 +94,7 @@ class ERR_SpawnLogic : SCR_SpawnLogic
SCR_PlayerController.Cast(GetGame().GetPlayerManager().GetPlayerController(playerId)).ERR_SetObserver(ERR_ObserverReason.NONE);
return;
}
mode.InitialAdmissionStarted(playerId);
m_Attempts.Set(playerId, 0);
DoSpawn_S(playerId);
}
@@ -111,7 +112,12 @@ class ERR_SpawnLogic : SCR_SpawnLogic
// for the same server preparation result before consuming its retry budget.
if (!mode.Active()) { RetryLater(playerId); return; }
int attempts = m_Attempts.Get(playerId);
if (attempts >= 30) { Observe(playerId, ERR_ObserverReason.SPAWN_UNAVAILABLE); return; }
if (attempts >= 30)
{
Observe(playerId, ERR_ObserverReason.SPAWN_UNAVAILABLE);
mode.InitialAdmissionExhausted(playerId, controller);
return;
}
if (!mode.Players().CanEnter(playerId, mode.Phase()))
{
ERR_ObserverReason reason = ERR_ObserverReason.SPENT_LIFE;
@@ -333,7 +339,11 @@ class ERR_SpawnLogic : SCR_SpawnLogic
CancelAdmission(playerId, ERR_ObserverReason.NONE);
m_Identities.Remove(playerId);
ERR_GameMode mode = ERR_GameMode.Get();
if (mode) mode.Players().Disconnect(playerId);
if (mode)
{
mode.InitialAdmissionStarted(playerId);
mode.Players().Disconnect(playerId);
}
if (GetPlayerRespawnComponent_S(playerId)) super.OnPlayerDisconnected_S(playerId, cause, timeout);
}
bool Authorizes(int playerId, SCR_SpawnData data)