Add bounded aircraft control and verify current packaged runtime closure
This commit is contained in:
+436
@@ -0,0 +1,436 @@
|
||||
// Prepared control mechanism only. No global hooks, force allocation or HQ selection.
|
||||
enum ERR_AircraftFlightState
|
||||
{
|
||||
IDLE, HOOKS, STARTING, POSITIVE, NEGATIVE, CLIMB, LANDING, SHUTDOWN,
|
||||
COMPLETE, CANCELLED, FAILED, RELINQUISHED, TRANSLATING
|
||||
}
|
||||
class ERR_AircraftFlightRules
|
||||
{
|
||||
static bool Terminal(int state) { return state >= ERR_AircraftFlightState.COMPLETE && state <= ERR_AircraftFlightState.RELINQUISHED; }
|
||||
static bool Deadline(float now, float deadline) { return now >= 0 && deadline > now && deadline - now <= 240000; }
|
||||
static bool Envelope(float height, float drift, float upright)
|
||||
{
|
||||
return height >= -3 && height <= 18 && drift >= 0 && drift < 8 && upright >= 0.85;
|
||||
}
|
||||
static bool Hover(float height, float speed, float vertical, float upright)
|
||||
{
|
||||
return height >= 7 && height <= 9 && speed >= 0 && speed < 1.5 && Math.AbsFloat(vertical) < 0.5 && upright > 0.98;
|
||||
}
|
||||
static bool Landed(bool contact, float height, float speed, float angular, float upright)
|
||||
{
|
||||
return contact && height > -1 && height < 1 && speed >= 0 && speed < 0.5 && angular >= 0 && angular < 0.1 && upright > 0.98;
|
||||
}
|
||||
static float Vertical(float height, bool landing)
|
||||
{
|
||||
float target = 8; if (landing) target = 0;
|
||||
return Math.Clamp((target - height) * 0.6, -0.8, 1.5);
|
||||
}
|
||||
// Ground aborts may stop the engine; airborne cancellation retains the flight lease.
|
||||
static int CancelAction(bool ownershipValid, bool landed, bool engineOn)
|
||||
{
|
||||
if (!ownershipValid) return 0;
|
||||
if (!landed) return 1;
|
||||
if (engineOn) return 2;
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
// Bounded migration of the separately proven native-autohover translation.
|
||||
class ERR_AircraftTranslationRules
|
||||
{
|
||||
static bool Target(vector home, vector target)
|
||||
{
|
||||
float distance = vector.DistanceXZ(home, target);
|
||||
return distance >= 0 && distance <= 5;
|
||||
}
|
||||
static bool Reachable(float distance) { return distance >= 0 && distance <= 4.05; }
|
||||
static vector Demand(vector target, vector position, vector velocity)
|
||||
{
|
||||
vector error = target - position; error[1] = 0;
|
||||
vector desired = error * 0.25;
|
||||
float speed = desired.Length();
|
||||
if (speed > 0.5) desired = desired * (0.5 / speed);
|
||||
velocity[1] = 0;
|
||||
vector demand = (desired - velocity) * 0.05;
|
||||
float magnitude = demand.Length();
|
||||
if (magnitude > 0.03) demand = demand * (0.03 / magnitude);
|
||||
return demand;
|
||||
}
|
||||
static bool Held(bool contact, float height, float speed, float vertical, float upright, float horizontalSpeed, float targetError, float displacement, float distance)
|
||||
{
|
||||
if (contact || !ERR_AircraftFlightRules.Hover(height, speed, vertical, upright)) return false;
|
||||
if (!(horizontalSpeed >= 0 && horizontalSpeed < 0.25 && targetError >= 0 && targetError <= 0.75)) return false;
|
||||
return Reachable(distance) && displacement >= Math.Max(0, distance - 0.75);
|
||||
}
|
||||
}
|
||||
|
||||
class ERR_AircraftFlight
|
||||
{
|
||||
protected static ref map<Vehicle, ERR_AircraftFlight> s_Owners = new map<Vehicle, ERR_AircraftFlight>();
|
||||
protected ERR_GameMode m_Mode;
|
||||
protected Vehicle m_Aircraft;
|
||||
protected SCR_AIGroup m_Group;
|
||||
protected IEntity m_Pilot;
|
||||
protected PilotCompartmentSlot m_Seat;
|
||||
protected HelicopterControllerComponent m_Controller;
|
||||
protected VehicleHelicopterSimulation m_Simulation;
|
||||
protected CharacterControllerComponent m_Character;
|
||||
protected CharacterInputContext m_Input;
|
||||
protected AIAgent m_Agent;
|
||||
protected ActionManager m_Actions;
|
||||
protected FuelManagerComponent m_Fuel;
|
||||
protected ref ERR_AircraftFlightEnvelope m_Qualification;
|
||||
protected ERR_AircraftFlightState m_State;
|
||||
protected int m_Token;
|
||||
protected vector m_Home;
|
||||
protected float m_Heading;
|
||||
protected float m_Deadline;
|
||||
protected float m_StageDeadline;
|
||||
protected float m_Stable = -1;
|
||||
protected float m_LastTick = -1;
|
||||
protected float m_LastControl = -1;
|
||||
protected float m_InitialFuel;
|
||||
protected float m_Collective = -0.8;
|
||||
protected int m_ActionUpdates;
|
||||
protected int m_HelicopterUpdates;
|
||||
protected int m_GateUpdates;
|
||||
protected int m_OriginalLOD;
|
||||
protected CollectiveMode m_OriginalMode;
|
||||
protected bool m_OriginalAutohover;
|
||||
protected bool m_SettingsOwned;
|
||||
protected bool m_Readback = true;
|
||||
protected bool m_Cancelled;
|
||||
protected bool m_StartAttempted;
|
||||
protected string m_Reason;
|
||||
protected bool m_TranslationRequested;
|
||||
protected bool m_TranslationStarted;
|
||||
protected bool m_TranslationProven;
|
||||
protected vector m_TranslationTarget;
|
||||
protected vector m_TranslationOrigin;
|
||||
protected float m_TranslationDistance;
|
||||
protected int m_TranslationUpdates;
|
||||
|
||||
ERR_AircraftFlightState State() { return m_State; }
|
||||
string Reason() { return m_Reason; }
|
||||
int OrderToken() { return m_Token; }
|
||||
bool Finished() { return ERR_AircraftFlightRules.Terminal(m_State); }
|
||||
Vehicle Aircraft() { return m_Aircraft; }
|
||||
bool TranslationStarted() { return m_TranslationStarted; }
|
||||
bool TranslationProven() { return m_TranslationProven; }
|
||||
vector TranslationTarget() { return m_TranslationTarget; }
|
||||
vector TranslationOrigin() { return m_TranslationOrigin; }
|
||||
int TranslationUpdates() { return m_TranslationUpdates; }
|
||||
// Absolute horizontal target fixed before takeoff. Its altitude is the existing
|
||||
// eight-metre hover, not a caller-supplied route or a new qualification home.
|
||||
bool StartTranslation(ERR_GameMode mode, Vehicle aircraft, SCR_AIGroup group, IEntity pilot, PilotCompartmentSlot seat, ERR_AircraftFlightEnvelope qualification, int token, float deadline, vector target)
|
||||
{
|
||||
if (m_StartAttempted || m_TranslationRequested || !qualification || !ERR_AircraftTranslationRules.Target(qualification.Home(), target)) return false;
|
||||
m_TranslationRequested = true; m_TranslationTarget = target;
|
||||
vector home = qualification.Home(); m_TranslationTarget[1] = home[1] + 8;
|
||||
return Start(mode, aircraft, group, pilot, seat, qualification, token, deadline);
|
||||
}
|
||||
protected float Now()
|
||||
{
|
||||
if (!GetGame() || !GetGame().GetWorld()) return -1;
|
||||
return GetGame().GetWorld().GetWorldTime();
|
||||
}
|
||||
protected bool NoPlayers()
|
||||
{
|
||||
if (!GetGame() || !GetGame().GetPlayerManager()) return false;
|
||||
if (!m_Group || m_Group.GetPlayerCount(true) > 0 || ERR_Actors.Player(m_Pilot)) return false;
|
||||
array<int> players = {}; GetGame().GetPlayerManager().GetPlayers(players);
|
||||
foreach (int id : players)
|
||||
{
|
||||
IEntity actor = GetGame().GetPlayerManager().GetPlayerControlledEntity(id);
|
||||
if (actor && (actor == m_Pilot || CompartmentAccessComponent.GetVehicleIn(actor) == m_Aircraft)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
protected bool Owns()
|
||||
{
|
||||
if (!GetGame() || !GetGame().GetWorld() || !GetGame().GetPlayerManager()) return false;
|
||||
if (!Replication.IsServer() || !m_Mode || m_Mode != ERR_GameMode.Get() || !m_Mode.Active() || !m_Aircraft || !m_Group || !m_Pilot) return false;
|
||||
if (!m_Controller || m_Controller.GetOwner() != m_Aircraft || !m_Seat || m_Controller.GetPilotCompartmentSlot() != m_Seat || m_Seat.GetOccupant() != m_Pilot) return false;
|
||||
if (CompartmentAccessComponent.GetVehicleIn(m_Pilot) != m_Aircraft || ERR_Actors.Group(m_Pilot) != m_Group || m_Group.GetFaction() != m_Mode.EnemyFaction()) return false;
|
||||
if (SCR_FactionManager.SGetFaction(m_Aircraft) != m_Mode.EnemyFaction() || !NoPlayers()) return false;
|
||||
return m_Agent && m_Agent.GetControlledEntity() == m_Pilot && m_Agent.GetActionManager() == m_Actions && m_Actions && m_Actions != GetGame().GetInputManager();
|
||||
}
|
||||
protected bool Healthy()
|
||||
{
|
||||
if (!ERR_Actors.Alive(m_Aircraft) || !ERR_Actors.CanFight(m_Pilot) || !m_Simulation || !m_Simulation.IsValid() || !m_Fuel || !m_Fuel.HasFuel()) return false;
|
||||
SCR_VehicleDamageManagerComponent damage = SCR_VehicleDamageManagerComponent.Cast(m_Aircraft.FindComponent(SCR_VehicleDamageManagerComponent));
|
||||
if (!damage || !damage.GetEngineFunctional() || damage.GetMovementDamage() >= 1 || damage.IsOnFire() || m_Simulation.RotorCount() != 2) return false;
|
||||
for (int rotor = 0; rotor < 2; rotor++) if (m_Simulation.RotorGetState(rotor) == RotorState.DESTROYED) return false;
|
||||
return true;
|
||||
}
|
||||
protected bool Grounded()
|
||||
{
|
||||
if (!GetGame() || !GetGame().GetWorld() || !m_Aircraft || !m_Simulation) return false;
|
||||
vector transform[4]; m_Aircraft.GetWorldTransform(transform);
|
||||
return ERR_AircraftFlightRules.Landed(m_Simulation.HasAnyGroundContact(), transform[3][1] - m_Home[1], m_Simulation.GetVelocity().Length(), m_Simulation.GetAngularVelocity().Length(), transform[1][1]);
|
||||
}
|
||||
// Single use. A future HQ owner supplies its immutable unique order token and
|
||||
// retains this instance, calling Tick and the two scoped callbacks until terminal.
|
||||
bool Start(ERR_GameMode mode, Vehicle aircraft, SCR_AIGroup group, IEntity pilot, PilotCompartmentSlot seat, ERR_AircraftFlightEnvelope qualification, int token, float deadline)
|
||||
{
|
||||
if (m_StartAttempted || m_State != ERR_AircraftFlightState.IDLE || m_Token != 0 || !Replication.IsServer() || !GetGame() || !qualification || !aircraft || !pilot || !group || !seat) return false;
|
||||
m_StartAttempted = true;
|
||||
float now = Now();
|
||||
if (!ERR_AircraftFlightRules.Deadline(now, deadline) || token <= 0 || s_Owners.Get(aircraft)) return false;
|
||||
m_Mode = mode; m_Aircraft = aircraft; m_Group = group; m_Pilot = pilot; m_Seat = seat;
|
||||
m_Controller = HelicopterControllerComponent.Cast(aircraft.FindComponent(HelicopterControllerComponent));
|
||||
if (m_Controller) m_Simulation = m_Controller.GetSimulation();
|
||||
m_Character = CharacterControllerComponent.Cast(pilot.FindComponent(CharacterControllerComponent));
|
||||
if (m_Character) m_Input = m_Character.GetInputContext();
|
||||
AIControlComponent control = AIControlComponent.Cast(pilot.FindComponent(AIControlComponent));
|
||||
if (control) m_Agent = control.GetControlAIAgent();
|
||||
if (m_Agent) m_Actions = m_Agent.GetActionManager();
|
||||
m_Fuel = FuelManagerComponent.Cast(aircraft.FindComponent(FuelManagerComponent));
|
||||
if (!Owns() || !Healthy() || !m_Input || m_Controller.IsEngineOn() || !ERR_CasualtyLoadingRules.AircraftReady(aircraft)) return false;
|
||||
Physics physics = aircraft.GetPhysics();
|
||||
if (!physics || !physics.IsDynamic() || !physics.IsActive() || !m_Simulation.HasAnyGroundContact()) return false;
|
||||
SignalsManagerComponent signals = m_Controller.GetSignalsManager();
|
||||
if (!signals || signals.FindSignal("MainRotorCollective") < 0) return false;
|
||||
if (!qualification.Claim(mode, aircraft, pilot, token)) return false;
|
||||
m_Qualification = qualification; m_Home = qualification.Home(); m_Token = token; m_Deadline = deadline;
|
||||
m_Heading = aircraft.GetYawPitchRoll()[0]; m_InitialFuel = m_Fuel.GetTotalFuel();
|
||||
m_OriginalMode = m_Controller.GetCollectiveMode(); m_OriginalAutohover = m_Controller.GetAutohoverEnabled(); m_OriginalLOD = m_Agent.GetPermanentLOD();
|
||||
m_SettingsOwned = true; s_Owners.Set(aircraft, this);
|
||||
m_Agent.SetPermanentLOD(0); m_Agent.ActivateAI();
|
||||
m_Controller.SetAutohoverEnabled(false);
|
||||
if (m_Controller.GetAutohoverEnabled()) { Fail("native autohover disable refused"); return false; }
|
||||
m_State = ERR_AircraftFlightState.HOOKS; m_StageDeadline = Math.Min(deadline, now + 5000);
|
||||
PrintFormat("ERR AIRCRAFT CONTROL: token=%1 bound qualified finite aircraft; no HQ dispatch installed", token);
|
||||
return true;
|
||||
}
|
||||
protected bool Lease()
|
||||
{
|
||||
return m_Token > 0 && !Finished() && m_State != ERR_AircraftFlightState.IDLE && s_Owners.Get(m_Aircraft) == this && m_Qualification && m_Qualification.ClaimedBy(m_Aircraft, m_Pilot, m_Token);
|
||||
}
|
||||
// Every input entry checks the immutable deadlines even if owner Tick stalls.
|
||||
protected bool ControlLease()
|
||||
{
|
||||
if (!Lease()) return false;
|
||||
if (!Owns()) { Relinquish("ownership or world unavailable at control callback"); return false; }
|
||||
float now = Now();
|
||||
if (now < 0 || now >= m_Deadline || now >= m_StageDeadline) { Fail("control callback reached bounded flight deadline"); return false; }
|
||||
if (!Healthy() || !m_Readback) { Fail("flight capability or input readback lost"); return false; }
|
||||
vector transform[4]; m_Aircraft.GetWorldTransform(transform);
|
||||
if (!ERR_AircraftFlightRules.Envelope(transform[3][1] - m_Home[1], vector.DistanceXZ(transform[3], m_Home), transform[1][1])) { Fail("control callback exceeded qualified flight envelope"); return false; }
|
||||
return true;
|
||||
}
|
||||
// Must be called after the native helicopter's OnPrepareControls.
|
||||
void PrepareHelicopter(HelicopterControllerComponent controller)
|
||||
{
|
||||
if (!Lease() || controller != m_Controller) return;
|
||||
if (!ControlLease()) return;
|
||||
controller.SetCollectiveMode(CollectiveMode.Direct); m_HelicopterUpdates++;
|
||||
}
|
||||
protected bool Pair(string positive, string negative, float demand)
|
||||
{
|
||||
float a = Math.Clamp(demand, 0, 1), b = Math.Clamp(-demand, 0, 1);
|
||||
m_Actions.SetActionValue(positive, a); m_Actions.SetActionValue(negative, b);
|
||||
return Math.AbsFloat(m_Actions.GetActionValue(positive) - a) < 0.001 && Math.AbsFloat(m_Actions.GetActionValue(negative) - b) < 0.001;
|
||||
}
|
||||
// Must be called on the exact AI actor manager before native OnPrepareControls.
|
||||
void PrepareActions(CharacterControllerComponent controller, IEntity owner, ActionManager actions, float dt)
|
||||
{
|
||||
if (!Lease() || controller != m_Character || owner != m_Pilot || actions != m_Actions) return;
|
||||
if (!ControlLease()) return;
|
||||
if (!m_Readback || dt <= 0 || dt > 0.2) return;
|
||||
if (!actions.IsContextActive("HelicopterContext")) actions.ActivateContext("HelicopterContext", 1);
|
||||
if (!actions.IsContextActive("HelicopterContext")) { m_Readback = false; return; }
|
||||
float collective, torque, forward, aside;
|
||||
if (m_State == ERR_AircraftFlightState.POSITIVE) collective = 0.05;
|
||||
else if (m_State == ERR_AircraftFlightState.NEGATIVE) collective = -0.05;
|
||||
else if (m_State == ERR_AircraftFlightState.SHUTDOWN) collective = -1;
|
||||
else if (m_State == ERR_AircraftFlightState.CLIMB || m_State == ERR_AircraftFlightState.LANDING || m_State == ERR_AircraftFlightState.TRANSLATING)
|
||||
{
|
||||
if (!m_Controller.GetAutohoverEnabled()) { Fail("native autohover lost while flying"); return; }
|
||||
float height = m_Aircraft.GetOrigin()[1] - m_Home[1];
|
||||
float error = ERR_AircraftFlightRules.Vertical(height, m_State == ERR_AircraftFlightState.LANDING) - m_Simulation.GetVelocity()[1];
|
||||
m_Collective = Math.Clamp(m_Collective + error * dt * 0.12, -0.95, 0.8);
|
||||
collective = Math.Clamp(m_Collective + error * 0.12, -1, 0.9);
|
||||
float headingError = m_Heading - m_Aircraft.GetYawPitchRoll()[0];
|
||||
while (headingError > 180) headingError -= 360;
|
||||
while (headingError < -180) headingError += 360;
|
||||
torque = Math.Clamp(headingError * 0.01 - m_Simulation.GetAngularVelocityLocal()[1] * 0.3, -0.3, 0.3);
|
||||
if (m_State == ERR_AircraftFlightState.TRANSLATING)
|
||||
{
|
||||
if (!m_TranslationStarted || !ERR_AircraftTranslationRules.Target(m_Home, m_TranslationTarget)) { Fail("immutable translation target unavailable"); return; }
|
||||
vector transform[4]; m_Aircraft.GetWorldTransform(transform);
|
||||
vector demand = ERR_AircraftTranslationRules.Demand(m_TranslationTarget, transform[3], m_Simulation.GetVelocity());
|
||||
forward = Math.Clamp(vector.Dot(demand, transform[2]), -0.03, 0.03);
|
||||
aside = Math.Clamp(vector.Dot(demand, transform[0]), -0.03, 0.03);
|
||||
m_TranslationUpdates++;
|
||||
}
|
||||
if (m_State == ERR_AircraftFlightState.LANDING && Grounded()) collective = -1;
|
||||
}
|
||||
bool a = Pair("HelicopterCollectiveIncrease", "HelicopterCollectiveDecrease", collective);
|
||||
bool b = Pair("HelicopterCyclicForward", "HelicopterCyclicBack", forward);
|
||||
bool c = Pair("HelicopterCyclicRight", "HelicopterCyclicLeft", aside);
|
||||
bool d = Pair("HelicopterAntiTorqueRight", "HelicopterAntiTorqueLeft", torque);
|
||||
m_Readback = a && b && c && d; m_ActionUpdates++; m_LastControl = Now();
|
||||
}
|
||||
protected void Release(bool restore)
|
||||
{
|
||||
// No writes after capture/group/seat change, including apparently harmless zeros.
|
||||
if (m_SettingsOwned && Owns())
|
||||
{
|
||||
Pair("HelicopterCollectiveIncrease", "HelicopterCollectiveDecrease", 0);
|
||||
Pair("HelicopterCyclicForward", "HelicopterCyclicBack", 0);
|
||||
Pair("HelicopterCyclicRight", "HelicopterCyclicLeft", 0);
|
||||
Pair("HelicopterAntiTorqueRight", "HelicopterAntiTorqueLeft", 0);
|
||||
// Restore native modes only after verified ground completion. Airborne
|
||||
// failure leaves native autohover enabled rather than disabling it midair.
|
||||
if (restore && Grounded())
|
||||
{
|
||||
m_Controller.SetAutohoverEnabled(m_OriginalAutohover);
|
||||
m_Controller.SetCollectiveMode(m_OriginalMode);
|
||||
m_Agent.SetPermanentLOD(m_OriginalLOD);
|
||||
}
|
||||
}
|
||||
if (m_Aircraft && s_Owners && s_Owners.Get(m_Aircraft) == this) s_Owners.Remove(m_Aircraft);
|
||||
m_SettingsOwned = false;
|
||||
}
|
||||
protected void RecordState()
|
||||
{
|
||||
string detail = string.Format("token=%1 state=%2 reason=%3; physical availability remains with the allocating owner", m_Token, m_State, m_Reason);
|
||||
Print("ERR AIRCRAFT CONTROL: " + detail);
|
||||
if (m_Mode && m_Mode == ERR_GameMode.Get()) ERR_HQ.Event("AIRCRAFT CONTROL", detail);
|
||||
}
|
||||
protected void Relinquish(string reason)
|
||||
{
|
||||
if (Finished()) return;
|
||||
m_Reason = reason; m_State = ERR_AircraftFlightState.RELINQUISHED; Release(false); RecordState();
|
||||
}
|
||||
protected void Fail(string reason)
|
||||
{
|
||||
if (Finished()) return;
|
||||
m_Reason = reason; m_State = ERR_AircraftFlightState.FAILED; Release(true); RecordState();
|
||||
}
|
||||
protected bool Landing(float now)
|
||||
{
|
||||
m_Controller.SetAutohoverEnabled(true);
|
||||
if (!m_Controller.GetAutohoverEnabled()) { Fail("native autohover enable refused"); return false; }
|
||||
m_State = ERR_AircraftFlightState.LANDING; m_Stable = -1;
|
||||
m_StageDeadline = Math.Min(m_Deadline, now + 65000);
|
||||
return true;
|
||||
}
|
||||
protected void Shutdown(float now)
|
||||
{
|
||||
m_Controller.StopEngine(false);
|
||||
m_State = ERR_AircraftFlightState.SHUTDOWN; m_StageDeadline = Math.Min(m_Deadline, now + 15000); m_Stable = -1;
|
||||
}
|
||||
void Cancel(string reason)
|
||||
{
|
||||
if (!Lease()) return;
|
||||
if (m_Cancelled) return; // repeated invalid reports cannot extend a landing deadline
|
||||
m_Cancelled = true; m_Reason = reason; RecordState();
|
||||
// After flight begins, even a current contact sample must pass the normal
|
||||
// two-second landing observation; cancellation cannot shortcut touchdown.
|
||||
bool preflightGround = m_State < ERR_AircraftFlightState.CLIMB && Grounded();
|
||||
int action = ERR_AircraftFlightRules.CancelAction(Owns(), preflightGround, m_Controller && m_Controller.IsEngineOn());
|
||||
if (action == 0) { Relinquish(reason); return; }
|
||||
if (!Healthy()) { Fail(reason + "; flight capability lost"); return; }
|
||||
if (action == 3) { m_State = ERR_AircraftFlightState.CANCELLED; Release(true); RecordState(); return; }
|
||||
float now = Now();
|
||||
if (action == 2) { Shutdown(now); return; }
|
||||
if (m_State != ERR_AircraftFlightState.LANDING && m_State != ERR_AircraftFlightState.SHUTDOWN) Landing(now);
|
||||
}
|
||||
protected bool BeginTranslation(float now, vector position)
|
||||
{
|
||||
if (!m_TranslationRequested || m_TranslationStarted || m_State != ERR_AircraftFlightState.CLIMB) { Cancel("translation stage ownership refused"); return false; }
|
||||
float distance = vector.DistanceXZ(position, m_TranslationTarget);
|
||||
if (!ERR_AircraftTranslationRules.Target(m_Home, m_TranslationTarget) || !ERR_AircraftTranslationRules.Reachable(distance)) { Cancel("fixed target beyond qualified four-metre translation after hover drift"); return false; }
|
||||
SignalsManagerComponent signals = m_Controller.GetSignalsManager();
|
||||
if (!signals || signals.FindSignal("MainRotorCyclicForward") < 0 || signals.FindSignal("MainRotorCyclicAside") < 0 || !m_Controller.GetAutohoverEnabled()) { Cancel("native autohover cyclic signals unavailable"); return false; }
|
||||
m_TranslationOrigin = position; m_TranslationDistance = distance;
|
||||
m_TranslationStarted = true; m_State = ERR_AircraftFlightState.TRANSLATING;
|
||||
m_Stable = -1; m_StageDeadline = Math.Min(m_Deadline, now + 45000);
|
||||
PrintFormat("ERR AIRCRAFT TRANSLATION: token=%1 origin=%2 immutableTarget=%3 home=%4 distance=%5 deadline=%6", m_Token, position, m_TranslationTarget, m_Home, distance, m_StageDeadline);
|
||||
return true;
|
||||
}
|
||||
protected void CheckTranslation(float now, vector position, float height, float speed, float upright)
|
||||
{
|
||||
if (!m_TranslationStarted || !m_Controller.GetAutohoverEnabled() || !ERR_AircraftTranslationRules.Target(m_Home, m_TranslationTarget)) { Cancel("translation ownership or autohover lost"); return; }
|
||||
vector velocity = m_Simulation.GetVelocity(); vector horizontal = velocity; horizontal[1] = 0;
|
||||
bool held = m_TranslationUpdates >= 3 && ERR_AircraftTranslationRules.Held(m_Simulation.HasAnyGroundContact(), height, speed, velocity[1], upright, horizontal.Length(), vector.DistanceXZ(position, m_TranslationTarget), vector.DistanceXZ(position, m_TranslationOrigin), m_TranslationDistance);
|
||||
if (!held) { m_Stable = -1; return; }
|
||||
if (m_Stable < 0) m_Stable = now;
|
||||
if (now - m_Stable < 5000) return;
|
||||
m_TranslationProven = true;
|
||||
PrintFormat("ERR AIRCRAFT TRANSLATION HELD: token=%1 targetError=%2 displacement=%3 updates=%4", m_Token, vector.DistanceXZ(position, m_TranslationTarget), vector.DistanceXZ(position, m_TranslationOrigin), m_TranslationUpdates);
|
||||
Landing(now);
|
||||
}
|
||||
// The owner continues ticking after Cancel until Finished, including landing.
|
||||
void Tick()
|
||||
{
|
||||
if (!Lease()) return;
|
||||
float now = Now();
|
||||
if (!Owns()) { Relinquish("ownership or mission changed"); return; }
|
||||
if (!Healthy()) { Fail("flight capability lost"); return; }
|
||||
if (!m_Readback) { Fail("native action readback failed"); return; }
|
||||
if (now >= m_Deadline || now >= m_StageDeadline || now < 0) { Fail("bounded flight stage expired"); return; }
|
||||
if (m_LastTick >= 0 && (now < m_LastTick || now - m_LastTick > 1500)) m_Stable = -1;
|
||||
m_LastTick = now;
|
||||
vector transform[4]; m_Aircraft.GetWorldTransform(transform);
|
||||
float height = transform[3][1] - m_Home[1], speed = m_Simulation.GetVelocity().Length();
|
||||
if (!ERR_AircraftFlightRules.Envelope(height, vector.DistanceXZ(transform[3], m_Home), transform[1][1])) { Fail("qualified flight envelope exceeded"); return; }
|
||||
if (m_State == ERR_AircraftFlightState.HOOKS)
|
||||
{
|
||||
if (m_ActionUpdates < 3 || m_HelicopterUpdates < 3) return;
|
||||
if (m_Controller.GetCollectiveMode() != CollectiveMode.Direct || m_Input.GetVehicleCollective() != 0 || m_Controller.IsEngineOn()) { Fail("native initial control state refused"); return; }
|
||||
m_Controller.StartEngine(); m_State = ERR_AircraftFlightState.STARTING; m_StageDeadline = Math.Min(m_Deadline, now + 65000); return;
|
||||
}
|
||||
if (m_LastControl < 0 || now - m_LastControl > 1000) { Fail("pilot callback stopped"); return; }
|
||||
if (m_State == ERR_AircraftFlightState.STARTING)
|
||||
{
|
||||
float rpm = m_Simulation.RotorGetRPMTarget(0);
|
||||
if (!m_Controller.IsEngineOn() || rpm <= 0 || m_Simulation.RotorGetRPM(0) < rpm * 0.9 || m_Fuel.GetTotalFuel() >= m_InitialFuel) return;
|
||||
m_State = ERR_AircraftFlightState.POSITIVE; m_StageDeadline = Math.Min(m_Deadline, now + 3000); m_GateUpdates = m_ActionUpdates; m_Stable = -1; return;
|
||||
}
|
||||
if (m_State != ERR_AircraftFlightState.SHUTDOWN && !m_Controller.IsEngineOn()) { Fail("running engine lost"); return; }
|
||||
if (m_State == ERR_AircraftFlightState.POSITIVE || m_State == ERR_AircraftFlightState.NEGATIVE)
|
||||
{
|
||||
SignalsManagerComponent signals = m_Controller.GetSignalsManager();
|
||||
int index = -1; if (signals) index = signals.FindSignal("MainRotorCollective");
|
||||
if (index < 0 || m_Controller.GetCollectiveMode() != CollectiveMode.Direct || m_Controller.GetAutohoverEnabled()) { Fail("native collective proof unavailable"); return; }
|
||||
float expected = 0.05; if (m_State == ERR_AircraftFlightState.NEGATIVE) expected = -0.05;
|
||||
bool matched = m_ActionUpdates >= m_GateUpdates + 3 && Math.AbsFloat(signals.GetSignalValue(index) - expected) <= 0.01 && Math.AbsFloat(m_Input.GetVehicleCollective() - expected) <= 0.01;
|
||||
if (!matched) { m_Stable = -1; return; }
|
||||
if (m_Stable < 0) m_Stable = now;
|
||||
if (now - m_Stable < 500) return;
|
||||
m_Stable = -1;
|
||||
if (m_State == ERR_AircraftFlightState.POSITIVE) { m_State = ERR_AircraftFlightState.NEGATIVE; m_StageDeadline = Math.Min(m_Deadline, now + 3000); m_GateUpdates = m_ActionUpdates; return; }
|
||||
m_Controller.SetAutohoverEnabled(true);
|
||||
if (!m_Controller.GetAutohoverEnabled()) { Fail("native autohover enable refused"); return; }
|
||||
m_State = ERR_AircraftFlightState.CLIMB; m_StageDeadline = Math.Min(m_Deadline, now + 65000); return;
|
||||
}
|
||||
if (m_State == ERR_AircraftFlightState.TRANSLATING) { CheckTranslation(now, transform[3], height, speed, transform[1][1]); return; }
|
||||
if (m_State == ERR_AircraftFlightState.CLIMB || m_State == ERR_AircraftFlightState.LANDING)
|
||||
{
|
||||
if (!m_Controller.GetAutohoverEnabled()) { Fail("native autohover lost"); return; }
|
||||
bool stable = Grounded(); float duration = 2000;
|
||||
if (m_State == ERR_AircraftFlightState.CLIMB) { stable = !m_Simulation.HasAnyGroundContact() && ERR_AircraftFlightRules.Hover(height, speed, m_Simulation.GetVelocity()[1], transform[1][1]); duration = 5000; }
|
||||
if (!stable) m_Stable = -1;
|
||||
if (stable && m_Stable < 0) m_Stable = now;
|
||||
if (m_Stable < 0 || now - m_Stable < duration) return;
|
||||
if (m_State == ERR_AircraftFlightState.CLIMB)
|
||||
{
|
||||
if (m_TranslationRequested) BeginTranslation(now, transform[3]);
|
||||
else Landing(now);
|
||||
return;
|
||||
}
|
||||
Shutdown(now); return;
|
||||
}
|
||||
if (m_State == ERR_AircraftFlightState.SHUTDOWN && !m_Controller.IsEngineOn())
|
||||
{
|
||||
if (!Grounded()) { Fail("engine-off physical ground check failed"); return; }
|
||||
if (m_Cancelled) m_State = ERR_AircraftFlightState.CANCELLED;
|
||||
else m_State = ERR_AircraftFlightState.COMPLETE;
|
||||
Release(true); RecordState();
|
||||
}
|
||||
}
|
||||
void ~ERR_AircraftFlight() { Release(true); }
|
||||
}
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
// Successor-owned measured envelope, independent of aircraft yaw/pitch/roll.
|
||||
// Adapted from the separately qualified native vertical fixture; no native hook.
|
||||
// All distances are around the native physics origin, not the rotor centre.
|
||||
class ERR_AircraftFlightFootprintRules
|
||||
{
|
||||
static float CylinderSphere(float originDistance, float radius, float thickness)
|
||||
{
|
||||
// Triangle inequality deliberately overbounds the one-sided rotor cylinder.
|
||||
return originDistance + radius + thickness;
|
||||
}
|
||||
static bool Fits(float radius, float initialDrift, float initialHeight)
|
||||
{
|
||||
if (!(radius > 0 && radius < 32 && initialDrift >= 0 && initialDrift < 32)) return false;
|
||||
if (!(initialHeight >= -3 && initialHeight <= 5)) return false;
|
||||
return radius + initialDrift + 8 + 1 <= 40 && radius + initialHeight + 18 + 1 <= 60;
|
||||
}
|
||||
}
|
||||
|
||||
class ERR_AircraftFlightFootprint
|
||||
{
|
||||
static const int MAX_ENTITIES = 256;
|
||||
static const int MAX_DEPTH = 16;
|
||||
static const int MAX_HITZONES = 512;
|
||||
protected static bool Refuse(string reason)
|
||||
{
|
||||
Print("ERR AIRCRAFT FOOTPRINT: refused " + reason);
|
||||
return false;
|
||||
}
|
||||
protected static bool PointRadius(vector point, vector origin, inout float radius)
|
||||
{
|
||||
float distance = vector.Distance(point, origin);
|
||||
if (!(distance >= 0 && distance < 1000)) return false;
|
||||
radius = Math.Max(radius, distance);
|
||||
return true;
|
||||
}
|
||||
static bool Measure(Vehicle aircraft, VehicleHelicopterSimulation simulation, vector pad, out vector origin, out float radius)
|
||||
{
|
||||
radius = 0;
|
||||
if (!aircraft || !simulation || !simulation.IsValid() || !aircraft.GetPrefabData()) return Refuse("missing initialized native airframe");
|
||||
if (aircraft.GetPrefabData().GetPrefabName() != "{DF5CCB7C0FF049F4}Prefabs/Vehicles/Helicopters/Mi8MT/Mi8MT_unarmed_transport.et") return Refuse("different airframe resource");
|
||||
// Model bounds and damage pivots are world-space. The simulation getter
|
||||
// exposes internal physics space (identity at the airport in 100422), so
|
||||
// use this same physical aircraft's actual entity world transform.
|
||||
vector worldTransform[4]; aircraft.GetWorldTransform(worldTransform);
|
||||
origin = worldTransform[3];
|
||||
for (int axis = 0; axis < 3; axis++)
|
||||
if (!(Math.AbsFloat(worldTransform[axis].Length() - 1) < 0.001)) return Refuse("non-unit native entity world basis");
|
||||
|
||||
array<IEntity> entities = {aircraft};
|
||||
array<int> depths = {0};
|
||||
array<HitZone> seenZones = {};
|
||||
int meshes, mainSweeps, tailSweeps;
|
||||
float modelRadius, damageRadius, simulationRadius;
|
||||
for (int index = 0; index < entities.Count(); index++)
|
||||
{
|
||||
IEntity entity = entities[index];
|
||||
if (!entity) return Refuse("hierarchy changed during measurement");
|
||||
if (entity.GetVObject())
|
||||
{
|
||||
vector mins, maxs; entity.GetWorldBounds(mins, maxs);
|
||||
for (int dimension = 0; dimension < 3; dimension++)
|
||||
if (!(mins[dimension] <= maxs[dimension])) return Refuse("invalid model bounds");
|
||||
// A sphere around every corner encloses the complete world AABB. Its radius
|
||||
// remains conservative under any rigid orientation of the whole aircraft.
|
||||
for (int x = 0; x < 2; x++)
|
||||
for (int y = 0; y < 2; y++)
|
||||
for (int z = 0; z < 2; z++)
|
||||
{
|
||||
vector corner = mins;
|
||||
if (x == 1) corner[0] = maxs[0];
|
||||
if (y == 1) corner[1] = maxs[1];
|
||||
if (z == 1) corner[2] = maxs[2];
|
||||
if (!PointRadius(corner, origin, modelRadius)) return Refuse("non-finite or excessive model bounds");
|
||||
}
|
||||
meshes++;
|
||||
}
|
||||
HitZoneContainerComponent container = HitZoneContainerComponent.Cast(entity.FindComponent(HitZoneContainerComponent));
|
||||
if (container)
|
||||
{
|
||||
int count = container.CountAllHitZones();
|
||||
if (count < 0 || count + seenZones.Count() > MAX_HITZONES) return Refuse("hitzone traversal bound");
|
||||
array<HitZone> zones = {};
|
||||
if (container.GetAllHitZones(zones) != count || zones.Count() != count) return Refuse("hitzones changed during measurement");
|
||||
foreach (HitZone zone : zones)
|
||||
{
|
||||
if (!zone || seenZones.Contains(zone)) continue;
|
||||
seenZones.Insert(zone);
|
||||
SCR_RotorHitZone rotorZone = SCR_RotorHitZone.Cast(zone);
|
||||
if (!rotorZone || rotorZone.GetRotorRadius() <= 0) continue; // transmission zones have no sweep
|
||||
int rotorIndex = rotorZone.GetRotorIndex();
|
||||
float expectedRadius, thickness;
|
||||
if (rotorIndex == 0) { expectedRadius = 12; thickness = 1.5; mainSweeps++; }
|
||||
else if (rotorIndex == 1) { expectedRadius = 2.3; thickness = 0.75; tailSweeps++; }
|
||||
else return Refuse("unknown rotor sweep index");
|
||||
// Stock thickness is protected with no getter. Scope to the exact prefab
|
||||
// and verify its public sweep radius against the audited stock constants.
|
||||
if (!(Math.AbsFloat(rotorZone.GetRotorRadius() - expectedRadius) < 0.001)) return Refuse("stock damage sweep radius changed");
|
||||
PointInfo point = rotorZone.GetPointInfo();
|
||||
if (!point) return Refuse("missing actual rotor sweep pivot");
|
||||
vector rotorWorld[4]; point.GetWorldTransform(rotorWorld);
|
||||
// Frobenius norm bounds the transform's maximum stretch even with scale
|
||||
// or shear; do not assume the PointInfo basis is orthonormal.
|
||||
float scaleSquared = 0;
|
||||
for (int basis = 0; basis < 3; basis++) scaleSquared += vector.Dot(rotorWorld[basis], rotorWorld[basis]);
|
||||
float scale = Math.Sqrt(scaleSquared);
|
||||
if (!(scale > 0 && scale < 4)) return Refuse("invalid damage sweep basis");
|
||||
float distance = vector.Distance(origin, rotorWorld[3]);
|
||||
float sweepBound = ERR_AircraftFlightFootprintRules.CylinderSphere(distance, expectedRadius * scale, thickness * scale);
|
||||
if (!(sweepBound > 0 && sweepBound < 1000)) return Refuse("invalid damage sweep extent");
|
||||
damageRadius = Math.Max(damageRadius, sweepBound);
|
||||
PrintFormat("ERR AIRCRAFT FOOTPRINT ROTOR: index=%1 pivot=%2 damageRadius=%3 thickness=%4 scale=%5 sphereBound=%6", rotorIndex, rotorWorld[3], expectedRadius, thickness, scale, sweepBound);
|
||||
}
|
||||
}
|
||||
IEntity child = entity.GetChildren();
|
||||
while (child)
|
||||
{
|
||||
if (entities.Count() >= MAX_ENTITIES || depths[index] >= MAX_DEPTH || entities.Contains(child)) return Refuse("entity/depth/cycle traversal bound");
|
||||
entities.Insert(child); depths.Insert(depths[index] + 1);
|
||||
child = child.GetSibling();
|
||||
}
|
||||
}
|
||||
if (meshes == 0 || mainSweeps != 1 || tailSweeps != 1) return Refuse("complete model and both actual damage sweeps required");
|
||||
if (simulation.RotorCount() != 2) return Refuse("stock simulation rotor count changed");
|
||||
for (int rotor = 0; rotor < 2; rotor++)
|
||||
{
|
||||
vector localRotor[4]; simulation.RotorGetTransform(rotor, localRotor);
|
||||
float physicalRadius = simulation.RotorGetRadius(rotor);
|
||||
float physicalBound = localRotor[3].Length() + physicalRadius;
|
||||
if (!(physicalRadius > 0 && physicalBound > 0 && physicalBound < 1000)) return Refuse("invalid native simulation rotor extent");
|
||||
simulationRadius = Math.Max(simulationRadius, physicalBound);
|
||||
PrintFormat("ERR AIRCRAFT FOOTPRINT PHYSICS ROTOR: index=%1 localPivot=%2 radius=%3 sphereBound=%4", rotor, localRotor[3], physicalRadius, physicalBound);
|
||||
}
|
||||
radius = Math.Max(modelRadius, Math.Max(damageRadius, simulationRadius));
|
||||
float initialDrift = vector.DistanceXZ(origin, pad);
|
||||
float initialHeight = origin[1] - pad[1];
|
||||
PrintFormat("ERR AIRCRAFT FOOTPRINT: entities=%1 meshes=%2 zones=%3 modelSphere=%4 damageSphere=%5 simulationSphere=%6 completeSphere=%7 initialDrift=%8 initialHeight=%9", entities.Count(), meshes, seenZones.Count(), modelRadius, damageRadius, simulationRadius, radius, initialDrift, initialHeight);
|
||||
if (!ERR_AircraftFlightFootprintRules.Fits(radius, initialDrift, initialHeight)) return Refuse("measured footprint plus initial placement,8m drift and1m margin exceeds40m corridor or60m ceiling");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Issued only by physical qualification; fields have no public mutators.
|
||||
// One claim binds this short-lived record to one original aircraft/pilot/order.
|
||||
class ERR_AircraftFlightEnvelope
|
||||
{
|
||||
protected ERR_GameMode m_Mode;
|
||||
protected Vehicle m_Aircraft;
|
||||
protected IEntity m_Pilot;
|
||||
protected vector m_Home;
|
||||
protected float m_Radius;
|
||||
protected float m_QualifiedAt;
|
||||
protected int m_Claim;
|
||||
vector Home() { return m_Home; }
|
||||
float Radius() { return m_Radius; }
|
||||
bool ClaimedBy(Vehicle aircraft, IEntity pilot, int token)
|
||||
{
|
||||
return m_Claim > 0 && m_Claim == token && m_Aircraft == aircraft && m_Pilot == pilot;
|
||||
}
|
||||
bool Claim(ERR_GameMode mode, Vehicle aircraft, IEntity pilot, int token)
|
||||
{
|
||||
if (!GetGame() || !GetGame().GetWorld()) return false;
|
||||
float now = GetGame().GetWorld().GetWorldTime();
|
||||
if (!Replication.IsServer() || !mode || mode != m_Mode || mode != ERR_GameMode.Get() || !mode.Active() || m_Claim != 0 || token <= 0) return false;
|
||||
if (aircraft != m_Aircraft || pilot != m_Pilot || !aircraft || !pilot || now < m_QualifiedAt || now - m_QualifiedAt > 2000) return false;
|
||||
if (vector.Distance(aircraft.GetOrigin(), m_Home) > 0.1) return false;
|
||||
m_Claim = token;
|
||||
return true;
|
||||
}
|
||||
protected static bool Hierarchy(IEntity root, array<IEntity> excluded)
|
||||
{
|
||||
if (!root) return false;
|
||||
array<IEntity> pending = {root};
|
||||
array<int> depths = {0};
|
||||
for (int i = 0; i < pending.Count(); i++)
|
||||
{
|
||||
IEntity entity = pending[i];
|
||||
if (!entity || excluded.Count() >= 512) return false;
|
||||
if (!excluded.Contains(entity)) excluded.Insert(entity);
|
||||
IEntity child = entity.GetChildren();
|
||||
while (child)
|
||||
{
|
||||
if (pending.Count() >= 256 || depths[i] >= 16 || pending.Contains(child)) return false;
|
||||
pending.Insert(child); depths.Insert(depths[i] + 1); child = child.GetSibling();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
protected static bool ClearColumn(Vehicle aircraft, IEntity pilot, vector pad)
|
||||
{
|
||||
array<IEntity> excluded = {};
|
||||
if (!Hierarchy(aircraft, excluded) || !Hierarchy(pilot, excluded)) return false;
|
||||
for (int edge = 0; edge < 4; edge++)
|
||||
{
|
||||
float side = edge * Math.PI / 2;
|
||||
vector point = pad + Vector(Math.Cos(side) * 14, 0, Math.Sin(side) * 14);
|
||||
if (!(Math.AbsFloat(SCR_TerrainHelper.GetTerrainY(point) - pad[1]) <= 0.8)) return false;
|
||||
}
|
||||
// Same four finite diagonal cylinder traces as the native fixture, with
|
||||
// exclusions restricted to this already-existing aircraft and seated pilot.
|
||||
array<vector> diagonals = {"40 2 0", "-40 2 0", "0 2 40", "0 2 -40"};
|
||||
foreach (vector diagonal : diagonals)
|
||||
{
|
||||
TraceParam trace = new TraceParam();
|
||||
trace.Start = pad + "0 2.5 0" + diagonal;
|
||||
trace.End = pad + "0 2.5 0" - diagonal;
|
||||
trace.Flags = TraceFlags.ENTS; trace.ExcludeArray = excluded;
|
||||
if (!(aircraft.GetWorld().TraceMove(trace, null) >= 1)) return false;
|
||||
}
|
||||
TraceBox clearance = new TraceBox();
|
||||
clearance.Start = pad + "0 3 0"; clearance.End = pad + "0 60 0";
|
||||
clearance.Mins = "-40 -1 -40"; clearance.Maxs = "40 1 40";
|
||||
clearance.Flags = TraceFlags.ENTS | TraceFlags.WORLD; clearance.ExcludeArray = excluded;
|
||||
return aircraft.GetWorld().TraceMove(clearance, null) >= 1;
|
||||
}
|
||||
static ERR_AircraftFlightEnvelope Qualify(ERR_GameMode mode, Vehicle aircraft, IEntity pilot)
|
||||
{
|
||||
if (!GetGame() || !GetGame().GetWorld() || !GetGame().GetPlayerManager()) return null;
|
||||
if (!Replication.IsServer() || !mode || mode != ERR_GameMode.Get() || !mode.Active() || !aircraft || !pilot || ERR_Actors.Player(pilot)) return null;
|
||||
if (CompartmentAccessComponent.GetVehicleIn(pilot) != aircraft || !ERR_Actors.Alive(aircraft) || !ERR_Actors.CanFight(pilot)) return null;
|
||||
HelicopterControllerComponent controller = HelicopterControllerComponent.Cast(aircraft.FindComponent(HelicopterControllerComponent));
|
||||
if (!controller || controller.IsEngineOn() || !controller.GetSimulation() || !controller.GetSimulation().IsValid()) return null;
|
||||
PilotCompartmentSlot seat = controller.GetPilotCompartmentSlot();
|
||||
if (!seat || seat.GetOccupant() != pilot) return null;
|
||||
if (!ERR_CasualtyLoadingRules.AircraftReady(aircraft)) return null;
|
||||
vector pad = ERR_Actors.Ground(aircraft.GetOrigin());
|
||||
if (!(pad[1] >= 2) || !ClearColumn(aircraft, pilot, pad)) return null;
|
||||
vector origin; float radius;
|
||||
if (!ERR_AircraftFlightFootprint.Measure(aircraft, controller.GetSimulation(), pad, origin, radius)) return null;
|
||||
ERR_AircraftFlightEnvelope record = new ERR_AircraftFlightEnvelope();
|
||||
record.m_Mode = mode; record.m_Aircraft = aircraft; record.m_Pilot = pilot;
|
||||
record.m_Home = origin; record.m_Radius = radius;
|
||||
record.m_QualifiedAt = GetGame().GetWorld().GetWorldTime();
|
||||
return record;
|
||||
}
|
||||
}
|
||||
+391
@@ -0,0 +1,391 @@
|
||||
class ERR_HQUnit
|
||||
{
|
||||
int Id;
|
||||
SCR_AIGroup Group;
|
||||
SCR_AIGroupUtilityComponent Utility;
|
||||
IEntity Radio;
|
||||
float NextReport;
|
||||
ref ERR_HQRoute Uplink;
|
||||
ref ERR_HQReport PendingReport;
|
||||
ref ERR_HQRoute Downlink;
|
||||
ref ERR_HQReport PendingOrder;
|
||||
ref ERR_HQReport Assignment;
|
||||
AIWaypoint Waypoint;
|
||||
bool ReservedForSupport;
|
||||
int LogisticsReservation;
|
||||
ref ERR_MortarSupplyStatus PendingSupplyStatus;
|
||||
|
||||
bool Eligible(ERR_GameMode mode)
|
||||
{
|
||||
if (ReservedForSupport || LogisticsReservation > 0 || !Group || !Utility || Group.GetPlayerCount(true) > 0 || Group.IsSlave() || Group.GetFaction() != mode.EnemyFaction()) return false;
|
||||
array<AIAgent> agents = {};
|
||||
Group.GetAgents(agents);
|
||||
bool survivor;
|
||||
foreach (AIAgent agent : agents)
|
||||
{
|
||||
if (!agent || !ERR_Actors.CanFight(agent.GetControlledEntity())) continue;
|
||||
survivor = true;
|
||||
if (CompartmentAccessComponent.GetVehicleIn(agent.GetControlledEntity())) return false;
|
||||
}
|
||||
return survivor;
|
||||
}
|
||||
|
||||
void Release()
|
||||
{
|
||||
if (Waypoint)
|
||||
{
|
||||
if (Group) Group.RemoveWaypoint(Waypoint);
|
||||
SCR_EntityHelper.DeleteEntityAndChildren(Waypoint);
|
||||
}
|
||||
Waypoint = null;
|
||||
Assignment = null;
|
||||
PendingOrder = null;
|
||||
Downlink = null;
|
||||
}
|
||||
}
|
||||
|
||||
class ERR_HQ : ERR_CommandService
|
||||
{
|
||||
protected ERR_GameMode m_Mode;
|
||||
protected ref ERR_MortarSystem m_Mortars;
|
||||
protected ref ERR_MortarLogistics m_Logistics;
|
||||
protected ref array<ref ERR_HQUnit> m_Units = {};
|
||||
protected ref array<ref ERR_HQReport> m_Contacts = {};
|
||||
protected ref ERR_HQNetwork m_Network = new ERR_HQNetwork();
|
||||
protected ref ERR_HQJournal m_Journal = new ERR_HQJournal();
|
||||
protected int m_NextId;
|
||||
protected float m_NextNetwork;
|
||||
protected float m_NextAssignment;
|
||||
|
||||
void ERR_HQ(ERR_GameMode mode) { m_Mode = mode; m_Mortars = new ERR_MortarSystem(this); m_Logistics = new ERR_MortarLogistics(this, mode); }
|
||||
static ERR_HQ Get()
|
||||
{
|
||||
ERR_GameMode mode = ERR_GameMode.Get();
|
||||
if (!mode) return null;
|
||||
return ERR_HQ.Cast(mode.Command());
|
||||
}
|
||||
static bool MissionActive()
|
||||
{
|
||||
ERR_GameMode mode = ERR_GameMode.Get();
|
||||
return mode && mode.Active();
|
||||
}
|
||||
static void Event(string kind, string message, int messageId = 0)
|
||||
{
|
||||
ERR_HQ hq = Get();
|
||||
if (hq) hq.Journal().Write(kind, message, messageId);
|
||||
}
|
||||
ERR_MortarSystem Mortars() { return m_Mortars; }
|
||||
bool PrepareSupport() { return m_Mortars.Prepare() && m_Logistics.Prepare(); }
|
||||
ERR_MortarLogistics Logistics() { return m_Logistics; }
|
||||
void ReserveGroup(SCR_AIGroup group)
|
||||
{
|
||||
foreach (ERR_HQUnit unit : m_Units)
|
||||
{
|
||||
if (unit.Group != group) continue;
|
||||
unit.Release();
|
||||
unit.ReservedForSupport = true;
|
||||
}
|
||||
}
|
||||
// Temporary reservations never alter the permanent support reservation.
|
||||
int LogisticsToken(SCR_AIGroup group)
|
||||
{
|
||||
foreach (ERR_HQUnit unit : m_Units) if (unit.Group == group) return unit.LogisticsReservation;
|
||||
return 0;
|
||||
}
|
||||
bool ReserveLogistics(SCR_AIGroup group, int token)
|
||||
{
|
||||
if (!Replication.IsServer() || token <= 0 || !MissionActive()) return false;
|
||||
foreach (ERR_HQUnit unit : m_Units)
|
||||
{
|
||||
if (unit.Group != group || !unit.Eligible(m_Mode) || !unit.Radio || unit.Assignment || unit.PendingOrder || unit.Downlink || unit.Waypoint) continue;
|
||||
if (unit.Utility.m_Perception && !unit.Utility.m_Perception.m_aTargets.IsEmpty()) continue;
|
||||
unit.LogisticsReservation = token;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
void ReleaseLogistics(SCR_AIGroup group, int token)
|
||||
{
|
||||
foreach (ERR_HQUnit unit : m_Units)
|
||||
if (unit.Group == group && unit.LogisticsReservation == token) unit.LogisticsReservation = 0;
|
||||
}
|
||||
bool QueueSupplyStatus(ERR_MortarBattery battery, float now)
|
||||
{
|
||||
if (!Replication.IsServer() || !MissionActive() || !battery || !battery.StartupActivated || !battery.Operational() || !m_Logistics.CanSample(battery) || !ERR_MortarLogisticsRules.Need(battery.CountShells())) return false;
|
||||
foreach (ERR_HQUnit unit : m_Units)
|
||||
{
|
||||
if (unit.Group != battery.Group || unit.Uplink || !unit.Radio || unit.Radio != battery.RadioOperator) continue;
|
||||
ERR_HQRoute route = m_Network.Route(unit.Radio);
|
||||
if (!route) return false;
|
||||
ERR_MortarSupplyStatus report = new ERR_MortarSupplyStatus();
|
||||
report.Group = battery.Group; report.Gun = battery.Gun; report.Position = battery.Gun.GetOrigin();
|
||||
report.Shells = battery.CountShells(); report.ObservedAt = now; report.MessageId = m_Journal.Message();
|
||||
route.MessageId = report.MessageId;
|
||||
route.Payload = string.Format("mortar supply status; usable HE=%1; position=%2; sampled=%3", report.Shells, report.Position, report.ObservedAt);
|
||||
unit.PendingSupplyStatus = report; unit.Uplink = route;
|
||||
m_Journal.Write("SUPPLY REPORT", route.Payload, route.MessageId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
void PhysicalForces(out array<IEntity> entities)
|
||||
{
|
||||
entities.Clear();
|
||||
foreach (ERR_HQUnit unit : m_Units)
|
||||
{
|
||||
if (!unit.Group) continue;
|
||||
array<AIAgent> agents = {};
|
||||
unit.Group.GetAgents(agents);
|
||||
foreach (AIAgent agent : agents)
|
||||
{
|
||||
if (!agent || !agent.GetControlledEntity()) continue;
|
||||
IEntity actor = agent.GetControlledEntity();
|
||||
if (!entities.Contains(actor)) entities.Insert(actor);
|
||||
IEntity vehicle = CompartmentAccessComponent.GetVehicleIn(actor);
|
||||
if (vehicle && !entities.Contains(vehicle)) entities.Insert(vehicle);
|
||||
}
|
||||
}
|
||||
m_Mortars.AppendGuns(entities);
|
||||
}
|
||||
bool MortarConnected(SCR_AIGroup group)
|
||||
{
|
||||
foreach (ERR_HQUnit unit : m_Units)
|
||||
if (unit.Group == group && unit.Radio) return m_Network.Route(unit.Radio) != null;
|
||||
return false;
|
||||
}
|
||||
ERR_HQRoute Downlink(SCR_AIGroup group, string kind, string detail, int sourceId)
|
||||
{
|
||||
foreach (ERR_HQUnit unit : m_Units)
|
||||
{
|
||||
if (unit.Group != group || !unit.Radio) continue;
|
||||
ERR_HQRoute route = m_Network.Route(unit.Radio, true);
|
||||
if (!route) return null;
|
||||
route.MessageId = m_Journal.Message();
|
||||
route.Payload = string.Format("%1 from report M%2; %3", kind, sourceId, detail);
|
||||
m_Journal.Write("ORDER QUEUED", route.Payload, route.MessageId);
|
||||
return route;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
void SupportContacts(out array<ref ERR_HQReport> contacts, float now)
|
||||
{
|
||||
contacts.Clear();
|
||||
foreach (ERR_HQReport contact : m_Contacts)
|
||||
if (ERR_MortarPrediction.Fresh(contact, now)) contacts.Insert(contact.Snapshot());
|
||||
}
|
||||
|
||||
ERR_HQJournal Journal() { return m_Journal; }
|
||||
string Status()
|
||||
{
|
||||
return string.Format("HQ: %1 groups, %2 delivered contacts, %3 assignments", m_Units.Count(), m_Contacts.Count(), Assigned()) + "; " + m_Mortars.Status() + "; " + m_Logistics.Status();
|
||||
}
|
||||
|
||||
override void RegisterGroup(SCR_AIGroup group)
|
||||
{
|
||||
if (!Replication.IsServer() || !group || m_Units.Count() >= 128) return;
|
||||
foreach (ERR_HQUnit known : m_Units) if (known.Group == group) return;
|
||||
SCR_AIGroupUtilityComponent utility = SCR_AIGroupUtilityComponent.Cast(group.FindComponent(SCR_AIGroupUtilityComponent));
|
||||
if (!utility || group.GetFaction() != m_Mode.EnemyFaction()) return;
|
||||
ERR_HQUnit unit = new ERR_HQUnit();
|
||||
unit.Id = ++m_NextId;
|
||||
unit.Group = group;
|
||||
unit.Utility = utility;
|
||||
int phase = unit.Id % 5;
|
||||
unit.NextReport = GetGame().GetWorld().GetWorldTime() + phase * 1000;
|
||||
m_Units.Insert(unit);
|
||||
m_Journal.Write("REGISTER", string.Format("group %1", unit.Id));
|
||||
}
|
||||
|
||||
override void UnregisterGroup(SCR_AIGroup group)
|
||||
{
|
||||
foreach (ERR_HQUnit unit : m_Units)
|
||||
{
|
||||
if (unit.Group != group) continue;
|
||||
unit.Release();
|
||||
unit.Group = null;
|
||||
unit.Utility = null;
|
||||
}
|
||||
}
|
||||
|
||||
protected int Assigned()
|
||||
{
|
||||
int total;
|
||||
foreach (ERR_HQUnit unit : m_Units) if (unit.Assignment || unit.PendingOrder) total++;
|
||||
return total;
|
||||
}
|
||||
|
||||
protected void IndexRadios()
|
||||
{
|
||||
m_Network.Clear();
|
||||
foreach (ERR_HQUnit unit : m_Units)
|
||||
{
|
||||
unit.Radio = null;
|
||||
if (!unit.Group || unit.Group.GetPlayerCount(true) > 0 || unit.Group.GetFaction() != m_Mode.EnemyFaction()) continue;
|
||||
array<AIAgent> agents = {};
|
||||
unit.Group.GetAgents(agents);
|
||||
int best;
|
||||
foreach (AIAgent agent : agents)
|
||||
{
|
||||
if (!agent) continue;
|
||||
IEntity actor = agent.GetControlledEntity();
|
||||
int capability = ERR_HQRadio.Capability(actor);
|
||||
if (capability <= 0) continue;
|
||||
m_Network.Add(actor, unit.Group, unit.Id, capability);
|
||||
if (capability > best) { unit.Radio = actor; best = capability; }
|
||||
}
|
||||
}
|
||||
m_Network.Build();
|
||||
}
|
||||
|
||||
protected void Observe(ERR_HQUnit unit, float now)
|
||||
{
|
||||
if (!unit.Utility || !unit.Radio || unit.Uplink || now < unit.NextReport) return;
|
||||
unit.NextReport = now + m_Mode.Settings().Get("reportIntervalSeconds") * 1000;
|
||||
array<ref ERR_HQVisualContact> samples = {};
|
||||
ERR_HQObservation.Gather(unit.Utility, now, samples);
|
||||
if (samples.IsEmpty()) return;
|
||||
ERR_HQVisualContact seen = samples[0];
|
||||
ERR_HQRoute route = m_Network.Route(unit.Radio);
|
||||
if (!route) return;
|
||||
ERR_HQReport report = new ERR_HQReport();
|
||||
report.MessageId = m_Journal.Message();
|
||||
report.Target = seen.Entity;
|
||||
report.Source = unit.Group;
|
||||
report.Position = seen.Position;
|
||||
report.ObservedAt = seen.ObservedAt;
|
||||
ERR_HQObservation.Describe(report, samples, now);
|
||||
route.MessageId = report.MessageId;
|
||||
route.Payload = report.Description(now);
|
||||
unit.PendingReport = report;
|
||||
unit.Uplink = route;
|
||||
m_Journal.Write("REPORT", route.Payload, report.MessageId);
|
||||
}
|
||||
|
||||
protected void Accept(ERR_HQReport report, float now)
|
||||
{
|
||||
if (!report || !ERR_MissionRules.ContactFresh(report.ObservedAt, now)) return;
|
||||
for (int i = 0; i < m_Contacts.Count(); i++)
|
||||
{
|
||||
ERR_HQReport previous = m_Contacts[i];
|
||||
if (previous.Target != report.Target) continue;
|
||||
if (previous.ObservedAt >= report.ObservedAt) return;
|
||||
float seconds = (report.ObservedAt - previous.ObservedAt) / 1000;
|
||||
if (seconds > 0 && seconds < 30 && vector.DistanceXZ(previous.Position, report.Position) < 500)
|
||||
{
|
||||
report.Velocity = (report.Position - previous.Position) / seconds;
|
||||
report.HasVelocity = report.Velocity.Length() <= 100;
|
||||
}
|
||||
m_Contacts[i] = report.Snapshot();
|
||||
return;
|
||||
}
|
||||
if (m_Contacts.Count() >= 64) m_Contacts.Remove(0);
|
||||
m_Contacts.Insert(report.Snapshot());
|
||||
}
|
||||
|
||||
protected void DeliverOrder(ERR_HQUnit unit, float now)
|
||||
{
|
||||
ERR_HQReport order = unit.PendingOrder;
|
||||
if (!order || !ERR_MissionRules.ContactFresh(order.ObservedAt, now) || !unit.Eligible(m_Mode)) { unit.Release(); return; }
|
||||
SCR_AIGroupPerception perception = unit.Utility.m_Perception;
|
||||
if (perception && !perception.m_aTargets.IsEmpty()) { unit.Release(); return; }
|
||||
int deliveryId;
|
||||
if (unit.Downlink) deliveryId = unit.Downlink.MessageId;
|
||||
unit.Release();
|
||||
vector position = order.Position;
|
||||
if (order.HasVelocity) position = ERR_HQReport.Predict(position, order.Velocity, (now - order.ObservedAt) / 1000);
|
||||
AIWaypoint waypoint = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.SearchWaypoint(), ERR_Actors.Ground(position)));
|
||||
if (!waypoint) { m_Journal.Write("ORDER FAILED", "waypoint spawn failed", order.MessageId); return; }
|
||||
unit.Waypoint = waypoint;
|
||||
unit.Assignment = order;
|
||||
unit.Group.AddWaypointAt(waypoint, 0);
|
||||
m_Journal.Write("ORDER DELIVERED", string.Format("group %1 from report M%2: %3", unit.Id, order.MessageId, order.Description(now)), deliveryId);
|
||||
}
|
||||
|
||||
protected void Assign(float now)
|
||||
{
|
||||
int limit = m_Mode.Settings().Get("supportSquadLimit");
|
||||
int radius = m_Mode.Settings().Get("responseRadiusMetres");
|
||||
int assigned = Assigned();
|
||||
foreach (ERR_HQUnit unit : m_Units)
|
||||
{
|
||||
if (unit.Assignment && (!ERR_MissionRules.ContactFresh(unit.Assignment.ObservedAt, now) || !unit.Eligible(m_Mode) || assigned > limit))
|
||||
{
|
||||
unit.Release(); assigned--;
|
||||
}
|
||||
if (unit.PendingOrder && (!ERR_MissionRules.ContactFresh(unit.PendingOrder.ObservedAt, now) || !unit.Eligible(m_Mode) || assigned > limit))
|
||||
{
|
||||
unit.Release(); assigned--;
|
||||
}
|
||||
}
|
||||
foreach (ERR_HQReport report : m_Contacts)
|
||||
{
|
||||
if (!ERR_MissionRules.ContactFresh(report.ObservedAt, now)) continue;
|
||||
foreach (ERR_HQUnit unit : m_Units)
|
||||
{
|
||||
if (assigned >= limit) return;
|
||||
if (unit.Assignment || unit.PendingOrder || !unit.Eligible(m_Mode) || !unit.Radio) continue;
|
||||
if (vector.DistanceXZ(unit.Group.GetOrigin(), report.Position) > radius) continue;
|
||||
SCR_AIGroupPerception perception = unit.Utility.m_Perception;
|
||||
if (perception && !perception.m_aTargets.IsEmpty()) continue;
|
||||
ERR_HQRoute route = m_Network.Route(unit.Radio, true);
|
||||
if (!route) continue;
|
||||
unit.PendingOrder = report.Snapshot();
|
||||
unit.Downlink = route;
|
||||
route.MessageId = m_Journal.Message();
|
||||
route.Payload = "order from report M" + report.MessageId.ToString();
|
||||
assigned++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override void Tick(float now)
|
||||
{
|
||||
if (!Replication.IsServer()) return;
|
||||
if (now >= m_NextNetwork) { IndexRadios(); m_NextNetwork = now + 5000; }
|
||||
for (int i = m_Contacts.Count() - 1; i >= 0; i--)
|
||||
if (!ERR_MissionRules.ContactFresh(m_Contacts[i].ObservedAt, now)) m_Contacts.Remove(i);
|
||||
for (int i = m_Units.Count() - 1; i >= 0; i--)
|
||||
{
|
||||
ERR_HQUnit unit = m_Units[i];
|
||||
if (unit.Uplink)
|
||||
{
|
||||
int delivered = unit.Uplink.Advance(now, m_Journal);
|
||||
if (delivered == 1)
|
||||
{
|
||||
if (unit.PendingSupplyStatus) m_Logistics.Receive(unit.PendingSupplyStatus, now);
|
||||
else Accept(unit.PendingReport, now);
|
||||
}
|
||||
if (delivered != 0) { unit.Uplink = null; unit.PendingReport = null; unit.PendingSupplyStatus = null; }
|
||||
}
|
||||
if (unit.Downlink)
|
||||
{
|
||||
int delivered = unit.Downlink.Advance(now, m_Journal);
|
||||
if (delivered == 1) DeliverOrder(unit, now);
|
||||
else if (delivered < 0) unit.Release();
|
||||
}
|
||||
if (!unit.Group && !unit.Uplink) { unit.Release(); m_Units.Remove(i); continue; }
|
||||
Observe(unit, now);
|
||||
}
|
||||
if (now >= m_NextAssignment) { Assign(now); m_NextAssignment = now + 5000; }
|
||||
m_Logistics.Tick(now);
|
||||
m_Mortars.Tick(now);
|
||||
m_Journal.Flush();
|
||||
}
|
||||
|
||||
override void Stop()
|
||||
{
|
||||
m_Logistics.Stop();
|
||||
m_Mortars.Stop();
|
||||
foreach (ERR_HQUnit unit : m_Units) unit.Release();
|
||||
m_Units.Clear();
|
||||
m_Contacts.Clear();
|
||||
m_Network.Clear();
|
||||
m_Journal.Write("STOP", "HQ stopped; tactical orders released");
|
||||
m_Journal.Flush();
|
||||
}
|
||||
}
|
||||
|
||||
modded class ERR_GameMode
|
||||
{
|
||||
protected override ERR_CommandService CreateCommandService() { return new ERR_HQ(this); }
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
// Server-owned audit stream. The file contains every event; chat reads a bounded
|
||||
// recent-history window with explicit gap notices for clients that fall behind.
|
||||
class ERR_HQJournal
|
||||
{
|
||||
protected ref array<string> m_Recent = {};
|
||||
protected ref array<string> m_Pending = {};
|
||||
protected int m_Sequence;
|
||||
protected int m_Message;
|
||||
protected static int s_Part;
|
||||
protected static int s_Session;
|
||||
protected string m_Session;
|
||||
|
||||
void ERR_HQJournal()
|
||||
{
|
||||
s_Session++;
|
||||
m_Session = string.Format("%1-%2", System.GetUnixTime(), s_Session);
|
||||
}
|
||||
protected float m_RetryAt;
|
||||
protected int m_Lost;
|
||||
|
||||
string Path()
|
||||
{
|
||||
if (s_Part == 0)
|
||||
return "$logs:rereintroduced-hq.log";
|
||||
return "$logs:rereintroduced-hq-" + s_Part.ToString() + ".log";
|
||||
}
|
||||
|
||||
int Message()
|
||||
{
|
||||
return ++m_Message;
|
||||
}
|
||||
|
||||
int Sequence()
|
||||
{
|
||||
return m_Sequence;
|
||||
}
|
||||
|
||||
void Write(string kind, string message, int messageId = 0)
|
||||
{
|
||||
if (!Replication.IsServer())
|
||||
return;
|
||||
float seconds = GetGame().GetWorld().GetWorldTime() / 1000;
|
||||
string line = string.Format("#%1 t=%2s M%3 %4 %5", ++m_Sequence, Math.Round(seconds * 10) / 10.0, messageId, kind, message) + " [session=" + m_Session + "]";
|
||||
if (m_Recent.Count() >= 300)
|
||||
m_Recent.Remove(0);
|
||||
m_Recent.Insert(line);
|
||||
if (m_Pending.Count() < 4096)
|
||||
m_Pending.Insert(line);
|
||||
else
|
||||
m_Lost++;
|
||||
}
|
||||
|
||||
void Read(int after, out array<string> lines, out int cursor, int limit = 8)
|
||||
{
|
||||
lines.Clear();
|
||||
cursor = Math.Clamp(after, 0, m_Sequence);
|
||||
int first = m_Sequence - m_Recent.Count();
|
||||
if (cursor < first)
|
||||
{
|
||||
lines.Insert(string.Format("HQ log: %1 older entries are outside chat history; see %2", first - cursor, Path()));
|
||||
cursor = first;
|
||||
}
|
||||
int characters;
|
||||
for (int i = cursor - first; i < m_Recent.Count() && lines.Count() < Math.Clamp(limit, 1, 40); i++)
|
||||
{
|
||||
// Bound each menu RPC without skipping unread records. Chat still
|
||||
// requests eight; the live menu can drain a busy journal faster.
|
||||
if (characters + m_Recent[i].Length() > 16000 && !lines.IsEmpty())
|
||||
break;
|
||||
lines.Insert(m_Recent[i]);
|
||||
characters += m_Recent[i].Length();
|
||||
cursor++;
|
||||
}
|
||||
}
|
||||
|
||||
void Flush()
|
||||
{
|
||||
if (m_Pending.IsEmpty() || !Replication.IsServer())
|
||||
return;
|
||||
float now = GetGame().GetWorld().GetWorldTime();
|
||||
if (now < m_RetryAt)
|
||||
return;
|
||||
FileHandle file = FileIO.OpenFile(Path(), FileMode.APPEND);
|
||||
if (!file)
|
||||
{
|
||||
m_RetryAt = now + 30000;
|
||||
PrintFormat("Escapists AI [HQ log]: cannot open %1; retry in 30s, chat history remains available", Path(), level: LogLevel.ERROR);
|
||||
return;
|
||||
}
|
||||
foreach (string line : m_Pending)
|
||||
file.WriteLine(line);
|
||||
if (m_Lost > 0)
|
||||
file.WriteLine(string.Format("HQ log: %1 entries lost while file was unavailable and buffer full", m_Lost));
|
||||
bool rotate = file.GetLength() >= 5242880;
|
||||
file.Close();
|
||||
m_Pending.Clear();
|
||||
m_Lost = 0;
|
||||
if (rotate)
|
||||
s_Part++;
|
||||
}
|
||||
}
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
// Radio propagation is simulated using actual living carriers and inventory.
|
||||
// A manpack is the HQ uplink; short-range hops are limited to 500 m and three
|
||||
// retransmissions. No virtual relay is fabricated when the network is broken.
|
||||
class ERR_HQRadioNode
|
||||
{
|
||||
IEntity Carrier;
|
||||
SCR_AIGroup Group;
|
||||
int UnitId;
|
||||
int Capability;
|
||||
protected float m_CheckedAt = -1;
|
||||
protected int m_LiveCapability;
|
||||
|
||||
bool Valid(bool manpack = false)
|
||||
{
|
||||
ERR_GameMode factions = ERR_GameMode.Get();
|
||||
if (!Carrier || !Group || Group.GetPlayerCount(true) > 0 || !factions || Group.GetFaction() != factions.EnemyFaction() || ERR_Actors.Group(Carrier) != Group)
|
||||
return false;
|
||||
int required = 1;
|
||||
if (manpack)
|
||||
required = 2;
|
||||
float now = GetGame().GetWorld().GetWorldTime();
|
||||
if (m_CheckedAt < 0 || now < m_CheckedAt || now - m_CheckedAt >= 1000)
|
||||
{
|
||||
m_CheckedAt = now;
|
||||
m_LiveCapability = ERR_HQRadio.Capability(Carrier);
|
||||
}
|
||||
return m_LiveCapability >= required;
|
||||
}
|
||||
|
||||
string Label()
|
||||
{
|
||||
return "unit " + UnitId.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
class ERR_HQRoute
|
||||
{
|
||||
ref array<ref ERR_HQRadioNode> Nodes = {};
|
||||
bool Downlink;
|
||||
int Cursor;
|
||||
bool Sending;
|
||||
float Due;
|
||||
vector SentFrom;
|
||||
float StartedAt = -1;
|
||||
int MessageId;
|
||||
string Payload;
|
||||
bool Failed;
|
||||
|
||||
// -1 failed, 0 travelling, 1 delivered. Each hop really gates delivery.
|
||||
int Advance(float now, ERR_HQJournal journal)
|
||||
{
|
||||
if (Failed)
|
||||
return -1;
|
||||
if (StartedAt < 0)
|
||||
{
|
||||
StartedAt = now;
|
||||
if (Downlink)
|
||||
Cursor = -1; // first leg is HQ to the manpack
|
||||
}
|
||||
if (now - StartedAt > 30000)
|
||||
return Fail(journal, "route timed out");
|
||||
int target = Cursor + 1;
|
||||
if (Sending)
|
||||
{
|
||||
if (now < Due)
|
||||
return 0;
|
||||
if (target < Nodes.Count())
|
||||
{
|
||||
if (!Nodes[target].Valid(Downlink && target == 0))
|
||||
return Fail(journal, "receiving carrier lost radio, died or changed ownership");
|
||||
if (Cursor >= 0 && vector.DistanceXZ(SentFrom, Nodes[target].Carrier.GetOrigin()) > 500)
|
||||
return Fail(journal, "receiver moved beyond the 500m link");
|
||||
journal.Write("RECEIVE", Nodes[target].Label() + " received from " + SenderLabel(), MessageId);
|
||||
}
|
||||
else
|
||||
journal.Write("RECEIVE", "HQ received from " + SenderLabel(), MessageId);
|
||||
Cursor++;
|
||||
Sending = false;
|
||||
if ((!Downlink && Cursor >= Nodes.Count()) || (Downlink && Cursor == Nodes.Count() - 1))
|
||||
return 1;
|
||||
target = Cursor + 1;
|
||||
}
|
||||
if (Nodes.IsEmpty())
|
||||
return Fail(journal, "no physical radio route");
|
||||
if (Cursor >= 0 && !Nodes[Cursor].Valid(!Downlink && Cursor == Nodes.Count() - 1))
|
||||
return Fail(journal, "transmitting carrier lost radio, died or changed ownership");
|
||||
string recipient = "HQ";
|
||||
string link = "long-range";
|
||||
if (target < Nodes.Count())
|
||||
{
|
||||
if (!Nodes[target].Valid(Downlink && target == 0))
|
||||
return Fail(journal, "next relay unavailable");
|
||||
recipient = Nodes[target].Label();
|
||||
if (Cursor >= 0)
|
||||
{
|
||||
SentFrom = Nodes[Cursor].Carrier.GetOrigin();
|
||||
float distance = vector.DistanceXZ(SentFrom, Nodes[target].Carrier.GetOrigin());
|
||||
if (distance > 500)
|
||||
return Fail(journal, "next relay beyond 500m");
|
||||
link = Math.Round(distance).ToString() + "m short-range";
|
||||
}
|
||||
}
|
||||
journal.Write("TRANSMIT", SenderLabel() + " -> " + recipient + " (" + link + ")", MessageId);
|
||||
Sending = true;
|
||||
Due = now + 2000;
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected string SenderLabel()
|
||||
{
|
||||
if (Cursor < 0)
|
||||
return "HQ";
|
||||
return Nodes[Cursor].Label();
|
||||
}
|
||||
|
||||
protected int Fail(ERR_HQJournal journal, string reason)
|
||||
{
|
||||
Failed = true;
|
||||
journal.Write("LOST", reason + "; " + Payload, MessageId);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
class ERR_HQNetwork
|
||||
{
|
||||
protected ref array<ref ERR_HQRadioNode> m_Nodes = {};
|
||||
protected ref array<int> m_Parents = {};
|
||||
protected ref array<int> m_Depths = {};
|
||||
protected ref map<IEntity, int> m_Indices = new map<IEntity, int>();
|
||||
protected ref map<string, ref array<int>> m_Cells = new map<string, ref array<int>>();
|
||||
bool Overflow;
|
||||
|
||||
void Clear()
|
||||
{
|
||||
m_Nodes.Clear();
|
||||
m_Parents.Clear();
|
||||
m_Depths.Clear();
|
||||
m_Indices.Clear();
|
||||
m_Cells.Clear();
|
||||
Overflow = false;
|
||||
}
|
||||
|
||||
protected string Cell(int x, int z)
|
||||
{
|
||||
return string.Format("%1:%2", x, z);
|
||||
}
|
||||
|
||||
void Add(IEntity carrier, SCR_AIGroup group, int unitId, int capability)
|
||||
{
|
||||
if (m_Indices.Contains(carrier))
|
||||
return;
|
||||
if (m_Nodes.Count() >= 2048)
|
||||
{
|
||||
Overflow = true;
|
||||
return;
|
||||
}
|
||||
ERR_HQRadioNode node = new ERR_HQRadioNode();
|
||||
node.Carrier = carrier;
|
||||
node.Group = group;
|
||||
node.UnitId = unitId;
|
||||
node.Capability = capability;
|
||||
int index = m_Nodes.Count();
|
||||
m_Nodes.Insert(node);
|
||||
m_Indices.Insert(carrier, index);
|
||||
m_Parents.Insert(-2);
|
||||
m_Depths.Insert(99);
|
||||
vector position = carrier.GetOrigin();
|
||||
string key = Cell(Math.Floor(position[0] / 500), Math.Floor(position[2] / 500));
|
||||
array<int> cell = m_Cells.Get(key);
|
||||
if (!cell)
|
||||
{
|
||||
cell = {};
|
||||
m_Cells.Insert(key, cell);
|
||||
}
|
||||
cell.Insert(index);
|
||||
}
|
||||
|
||||
void Build()
|
||||
{
|
||||
array<int> queue = {};
|
||||
int checks;
|
||||
for (int root = 0; root < m_Nodes.Count(); root++)
|
||||
{
|
||||
if (m_Nodes[root].Capability == 2)
|
||||
{
|
||||
m_Parents[root] = -1;
|
||||
m_Depths[root] = 0;
|
||||
queue.Insert(root);
|
||||
}
|
||||
}
|
||||
for (int head = 0; head < queue.Count(); head++)
|
||||
{
|
||||
int from = queue[head];
|
||||
if (m_Depths[from] >= 3)
|
||||
continue;
|
||||
vector position = m_Nodes[from].Carrier.GetOrigin();
|
||||
int x = Math.Floor(position[0] / 500);
|
||||
int z = Math.Floor(position[2] / 500);
|
||||
for (int dx = -1; dx <= 1; dx++)
|
||||
{
|
||||
for (int dz = -1; dz <= 1; dz++)
|
||||
{
|
||||
array<int> neighbors = m_Cells.Get(Cell(x + dx, z + dz));
|
||||
if (!neighbors)
|
||||
continue;
|
||||
foreach (int candidate : neighbors)
|
||||
{
|
||||
if (++checks > 100000)
|
||||
{
|
||||
Overflow = true;
|
||||
return;
|
||||
}
|
||||
if (m_Parents[candidate] != -2 || vector.DistanceXZ(position, m_Nodes[candidate].Carrier.GetOrigin()) > 500)
|
||||
continue;
|
||||
m_Parents[candidate] = from;
|
||||
m_Depths[candidate] = m_Depths[from] + 1;
|
||||
queue.Insert(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ERR_HQRoute Route(IEntity caller, bool downlink = false)
|
||||
{
|
||||
int index;
|
||||
if (!m_Indices.Find(caller, index) || m_Parents[index] == -2)
|
||||
return null;
|
||||
ERR_HQRoute route = new ERR_HQRoute();
|
||||
route.Downlink = downlink;
|
||||
while (index >= 0)
|
||||
{
|
||||
if (downlink)
|
||||
route.Nodes.InsertAt(m_Nodes[index], 0);
|
||||
else
|
||||
route.Nodes.Insert(m_Nodes[index]);
|
||||
index = m_Parents[index];
|
||||
}
|
||||
return route;
|
||||
}
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
// These samples exist only at intake. HQ never revisits their entity loadouts.
|
||||
class ERR_HQVisualContact
|
||||
{
|
||||
IEntity Entity;
|
||||
vector Position;
|
||||
float ObservedAt;
|
||||
bool Escapist;
|
||||
string Travel = "unknown";
|
||||
string Weapon = "unknown";
|
||||
}
|
||||
|
||||
class ERR_HQObservation
|
||||
{
|
||||
// Require current native line of sight before reading visible equipment.
|
||||
static bool IsClearSighting(float age, float traceFraction, float exposure)
|
||||
{
|
||||
return age >= 0 && age <= 0.5 && traceFraction >= 1 && exposure > 0;
|
||||
}
|
||||
|
||||
static bool CanIdentifyWeapon(float distance, float exposure)
|
||||
{
|
||||
return distance >= 0 && distance <= 150 && exposure >= 0.5;
|
||||
}
|
||||
|
||||
static string WeaponCategory(EWeaponType type)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case EWeaponType.WT_HANDGUN: return "pistol";
|
||||
case EWeaponType.WT_RIFLE: return "rifle";
|
||||
case EWeaponType.WT_SNIPERRIFLE: return "sniper rifle";
|
||||
case EWeaponType.WT_MACHINEGUN: return "machine gun";
|
||||
case EWeaponType.WT_ROCKETLAUNCHER: return "rocket launcher";
|
||||
case EWeaponType.WT_GRENADELAUNCHER: return "grenade launcher";
|
||||
case EWeaponType.WT_FRAGGRENADE: return "grenade";
|
||||
case EWeaponType.WT_SMOKEGRENADE: return "smoke grenade";
|
||||
}
|
||||
return "unidentified weapon";
|
||||
}
|
||||
|
||||
static ERR_HQVisualContact Capture(BaseTarget target, float now)
|
||||
{
|
||||
if (!target || target.GetTargetCategory() != ETargetCategory.ENEMY || !IsClearSighting(target.GetTimeSinceSeen(), target.GetTraceFraction(), target.GetExposure()))
|
||||
return null;
|
||||
DamageManagerComponent damage = target.GetDamageManagerComponent();
|
||||
if (damage && damage.IsDestroyed())
|
||||
return null;
|
||||
IEntity entity = target.GetTargetEntity();
|
||||
if (!entity)
|
||||
return null;
|
||||
ERR_HQVisualContact seen = new ERR_HQVisualContact();
|
||||
seen.Entity = entity;
|
||||
seen.Position = target.GetLastSeenPosition();
|
||||
seen.ObservedAt = now - target.GetTimeSinceSeen() * 1000;
|
||||
SCR_ChimeraCharacter character = SCR_ChimeraCharacter.Cast(entity);
|
||||
if (character)
|
||||
{
|
||||
if (GetGame().GetPlayerManager())
|
||||
seen.Escapist = GetGame().GetPlayerManager().GetPlayerIdFromControlledEntity(entity) > 0;
|
||||
seen.Travel = "on foot";
|
||||
if (CompartmentAccessComponent.GetVehicleIn(entity))
|
||||
seen.Travel = "in vehicle";
|
||||
// Read only the drawn weapon; never enumerate weapons or inventory.
|
||||
if (CanIdentifyWeapon(target.GetDistance(), target.GetExposure()))
|
||||
{
|
||||
CharacterControllerComponent controller = CharacterControllerComponent.Cast(entity.FindComponent(CharacterControllerComponent));
|
||||
if (controller)
|
||||
{
|
||||
IEntity heldItem = controller.GetCurrentItemInHands();
|
||||
BaseWeaponComponent weapon;
|
||||
if (heldItem)
|
||||
weapon = BaseWeaponComponent.Cast(heldItem.FindComponent(BaseWeaponComponent));
|
||||
seen.Weapon = "none drawn";
|
||||
if (weapon)
|
||||
seen.Weapon = WeaponCategory(weapon.GetWeaponType());
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (Vehicle.Cast(entity))
|
||||
{
|
||||
seen.Travel = "vehicle";
|
||||
if (target.GetUnitType() == EAIUnitType.UnitType_Aircraft)
|
||||
seen.Travel = "aircraft";
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
static void Gather(SCR_AIGroupUtilityComponent utility, float now, out array<ref ERR_HQVisualContact> samples)
|
||||
{
|
||||
samples.Clear();
|
||||
if (!utility)
|
||||
return;
|
||||
array<BaseTarget> visible = {};
|
||||
utility.ERR_HQGetVisualTargets(visible);
|
||||
foreach (BaseTarget target : visible)
|
||||
{
|
||||
ERR_HQVisualContact seen = Capture(target, now);
|
||||
if (!seen)
|
||||
continue;
|
||||
ERR_HQVisualContact existing;
|
||||
foreach (ERR_HQVisualContact sample : samples)
|
||||
{
|
||||
if (sample.Entity == seen.Entity)
|
||||
{
|
||||
existing = sample;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!existing)
|
||||
{
|
||||
if (samples.Count() < 64)
|
||||
samples.Insert(seen);
|
||||
}
|
||||
else if (seen.ObservedAt > existing.ObservedAt || (seen.ObservedAt == existing.ObservedAt && existing.Weapon == "unknown" && seen.Weapon != "unknown"))
|
||||
{
|
||||
existing.Position = seen.Position;
|
||||
existing.ObservedAt = seen.ObservedAt;
|
||||
existing.Travel = seen.Travel;
|
||||
existing.Weapon = seen.Weapon;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void Describe(ERR_HQReport report, array<ref ERR_HQVisualContact> samples, float now)
|
||||
{
|
||||
// New observations must not silently inherit old equipment/travel details.
|
||||
report.SeenEscapists = -1;
|
||||
report.TravelMode = "unknown";
|
||||
report.SeenWeapons = "unknown";
|
||||
report.DetailsObservedAt = -1;
|
||||
if (report.Distress)
|
||||
return;
|
||||
ERR_HQVisualContact anchor;
|
||||
foreach (ERR_HQVisualContact sample : samples)
|
||||
{
|
||||
if (sample.Entity == report.Target && now - sample.ObservedAt >= 0 && now - sample.ObservedAt <= 500)
|
||||
{
|
||||
anchor = sample;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!anchor)
|
||||
return;
|
||||
report.DetailsObservedAt = anchor.ObservedAt;
|
||||
report.TravelMode = anchor.Travel;
|
||||
report.SeenWeapons = anchor.Weapon;
|
||||
array<IEntity> counted = {};
|
||||
array<string> weapons = {};
|
||||
string travel;
|
||||
foreach (ERR_HQVisualContact sighting : samples)
|
||||
{
|
||||
if (!sighting.Escapist || !sighting.Entity || counted.Contains(sighting.Entity) || now - sighting.ObservedAt < 0 || now - sighting.ObservedAt > 500 || vector.DistanceXZ(anchor.Position, sighting.Position) > 100)
|
||||
continue;
|
||||
counted.Insert(sighting.Entity);
|
||||
if (travel.IsEmpty())
|
||||
travel = sighting.Travel;
|
||||
else if (travel != sighting.Travel)
|
||||
travel = "mixed travel";
|
||||
if (sighting.Weapon != "unknown" && !weapons.Contains(sighting.Weapon) && weapons.Count() < 4)
|
||||
weapons.Insert(sighting.Weapon);
|
||||
}
|
||||
if (counted.IsEmpty())
|
||||
return; // A visible vehicle does not reveal its hidden occupants.
|
||||
report.SeenEscapists = counted.Count();
|
||||
// A vehicle anchor remains a vehicle report, even with people near it.
|
||||
if (anchor.Escapist)
|
||||
report.TravelMode = travel;
|
||||
report.SeenWeapons = "unknown";
|
||||
if (!weapons.IsEmpty())
|
||||
{
|
||||
report.SeenWeapons = weapons[0];
|
||||
for (int i = 1; i < weapons.Count(); i++)
|
||||
report.SeenWeapons += ", " + weapons[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
modded class SCR_AIGroupUtilityComponent
|
||||
{
|
||||
void ERR_HQGetVisualTargets(out array<BaseTarget> targets)
|
||||
{
|
||||
targets.Clear();
|
||||
foreach (SCR_AIInfoComponent info : m_aInfoComponents)
|
||||
{
|
||||
if (!info || !info.m_Perception)
|
||||
continue;
|
||||
AIAgent observer = AIAgent.Cast(info.GetOwner());
|
||||
if (!observer || !ERR_Actors.CanFight(observer.GetControlledEntity()))
|
||||
continue;
|
||||
array<BaseTarget> own = {};
|
||||
info.m_Perception.GetTargetsList(own, ETargetCategory.ENEMY);
|
||||
foreach (BaseTarget target : own)
|
||||
{
|
||||
if (target && ERR_HQObservation.IsClearSighting(target.GetTimeSinceSeen(), target.GetTraceFraction(), target.GetExposure()))
|
||||
targets.Insert(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
// A radio's category is protected in the stock gadget component.
|
||||
modded class SCR_RadioComponent
|
||||
{
|
||||
bool ERR_IsHQManpack()
|
||||
{
|
||||
return m_iRadioCategory == ERadioCategory.MANPACK;
|
||||
}
|
||||
}
|
||||
|
||||
class ERR_HQRadio
|
||||
{
|
||||
// 0: no radio, 1: basic radio, 2: manpack. Inventory is checked live.
|
||||
static int Capability(IEntity character)
|
||||
{
|
||||
if (!ERR_Actors.CanFight(character))
|
||||
return 0;
|
||||
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(character.FindComponent(InventoryStorageManagerComponent));
|
||||
if (!inventory)
|
||||
return 0;
|
||||
array<IEntity> items = {};
|
||||
inventory.GetItems(items);
|
||||
int result;
|
||||
foreach (IEntity item : items)
|
||||
{
|
||||
if (!item || !item.FindComponent(BaseRadioComponent))
|
||||
continue;
|
||||
result = 1;
|
||||
SCR_RadioComponent gadget = SCR_RadioComponent.Cast(item.FindComponent(SCR_RadioComponent));
|
||||
if (gadget && gadget.ERR_IsHQManpack())
|
||||
return 2;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// HQ uses entity identity only for matching observations, never for its origin.
|
||||
// Visible details are captured at intake and copied as scalar snapshots.
|
||||
// Reports and orders contain immutable observed coordinates and timestamps.
|
||||
class ERR_HQReport
|
||||
{
|
||||
int MessageId;
|
||||
IEntity Target;
|
||||
SCR_AIGroup Source;
|
||||
vector Position;
|
||||
vector Velocity;
|
||||
float ObservedAt = -1;
|
||||
float LastSent = -60000;
|
||||
float PerceptionStamp = -1;
|
||||
bool HasVelocity;
|
||||
bool Engaged;
|
||||
bool Distress;
|
||||
int SeenEscapists = -1;
|
||||
string TravelMode = "unknown";
|
||||
string SeenWeapons = "unknown";
|
||||
float DetailsObservedAt = -1;
|
||||
|
||||
ERR_HQReport Snapshot()
|
||||
{
|
||||
ERR_HQReport copy = new ERR_HQReport();
|
||||
copy.MessageId = MessageId;
|
||||
copy.Target = Target;
|
||||
copy.Source = Source;
|
||||
copy.Position = Position;
|
||||
copy.Velocity = Velocity;
|
||||
copy.ObservedAt = ObservedAt;
|
||||
copy.Engaged = Engaged;
|
||||
copy.Distress = Distress;
|
||||
copy.HasVelocity = HasVelocity;
|
||||
copy.SeenEscapists = SeenEscapists;
|
||||
copy.TravelMode = TravelMode;
|
||||
copy.SeenWeapons = SeenWeapons;
|
||||
copy.DetailsObservedAt = DetailsObservedAt;
|
||||
return copy;
|
||||
}
|
||||
|
||||
string TravelDirection()
|
||||
{
|
||||
if (!HasVelocity)
|
||||
return "unknown";
|
||||
if (Velocity.Length() < 0.5)
|
||||
return "stationary";
|
||||
float x = Math.AbsFloat(Velocity[0]);
|
||||
float z = Math.AbsFloat(Velocity[2]);
|
||||
string northSouth = "N";
|
||||
string eastWest = "E";
|
||||
if (Velocity[2] < 0)
|
||||
northSouth = "S";
|
||||
if (Velocity[0] < 0)
|
||||
eastWest = "W";
|
||||
if (x < z * 0.414214)
|
||||
return northSouth;
|
||||
if (z < x * 0.414214)
|
||||
return eastWest;
|
||||
return northSouth + eastWest;
|
||||
}
|
||||
|
||||
string Description(float now, bool brief = false)
|
||||
{
|
||||
if (Distress)
|
||||
return string.Format("Distress at friendly X=%1 Z=%2; enemy details unknown (%3s ago)", Math.Round(Position[0]), Math.Round(Position[2]), Math.Round(Math.Max(0, now - ObservedAt) / 1000));
|
||||
string count = "count unknown";
|
||||
if (SeenEscapists >= 0)
|
||||
count = "at least " + SeenEscapists.ToString() + " spotted nearby";
|
||||
string motion = "speed/direction unknown";
|
||||
if (HasVelocity)
|
||||
{
|
||||
float speed = Math.Round(Velocity.Length() * 10) / 10.0;
|
||||
float azimuth = Math.Atan2(Velocity[0], Velocity[2]) * Math.RAD2DEG;
|
||||
if (azimuth < 0)
|
||||
azimuth += 360;
|
||||
int heading = Math.Round(azimuth);
|
||||
motion = string.Format("%1 km/h, azimuth %2 degrees (%3)", Math.Round(speed * 3.6), heading % 360, TravelDirection());
|
||||
}
|
||||
string details = count + "; " + TravelMode + "; " + motion + "; seen weapons: " + SeenWeapons;
|
||||
if (brief)
|
||||
return details;
|
||||
string velocity = "unknown";
|
||||
if (HasVelocity)
|
||||
velocity = string.Format("[%1,%2] m/s", Math.Round(Velocity[0] * 10) / 10.0, Math.Round(Velocity[2] * 10) / 10.0);
|
||||
return string.Format("X=%1 Z=%2: %3; vector X/Z=%4 (%5s ago)", Math.Round(Position[0]), Math.Round(Position[2]), details, velocity, Math.Round(Math.Max(0, now - ObservedAt) / 1000));
|
||||
}
|
||||
|
||||
static vector Predict(vector position, vector velocity, float seconds)
|
||||
{
|
||||
vector offset = velocity * Math.Clamp(seconds, 0, 20);
|
||||
offset[1] = 0;
|
||||
if (offset.Length() > 300)
|
||||
offset = offset.Normalized() * 300;
|
||||
return position + offset;
|
||||
}
|
||||
|
||||
int Priority(float now)
|
||||
{
|
||||
if (Distress)
|
||||
return 0;
|
||||
if (now - ObservedAt <= 15000)
|
||||
{
|
||||
if (Engaged)
|
||||
return 3;
|
||||
return 2;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
// An idle rotor or zero speed alone is normal during takeoff, landing and reload.
|
||||
// Confirm prolonged grounding, but recognize actual loss of flight capability.
|
||||
class ERR_HelicopterAvailability
|
||||
{
|
||||
protected float m_fGroundedSince = -1;
|
||||
|
||||
string Check(float now, float spawnedAt, bool destroyed, bool disabled, bool livingPilot, bool playerControlled, bool servicing, float height, float speed)
|
||||
{
|
||||
if (destroyed) return "destroyed";
|
||||
if (now - spawnedAt < 20000)
|
||||
{
|
||||
m_fGroundedSince = -1;
|
||||
return "";
|
||||
}
|
||||
if (disabled) return "engine, rotor or airframe disabled";
|
||||
if (playerControlled)
|
||||
{
|
||||
m_fGroundedSince = -1;
|
||||
return "";
|
||||
}
|
||||
if (!livingPilot) return "no living pilot";
|
||||
if (servicing || height > 5 || speed > 2)
|
||||
{
|
||||
m_fGroundedSince = -1;
|
||||
return "";
|
||||
}
|
||||
if (m_fGroundedSince < 0) m_fGroundedSince = now;
|
||||
if (now - m_fGroundedSince >= 60000) return "grounded for 60 seconds";
|
||||
return "";
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
// The stored geometry is the command actually emitted to the native aiming
|
||||
// tree, never a chain of refreshed approximations. Entity/report/node links
|
||||
// are weak; cancellation and physical projectile observation clear ownership.
|
||||
class ERR_MortarAimSolution
|
||||
{
|
||||
IEntity Gun;
|
||||
// Last delivered observation proved equivalent to this original geometry.
|
||||
ERR_HQReport Report;
|
||||
int OrderId;
|
||||
int Shot;
|
||||
vector GunOrigin;
|
||||
vector Impact;
|
||||
vector AimTarget;
|
||||
vector SalvoOffset;
|
||||
int Charge;
|
||||
float FlightTime;
|
||||
|
||||
bool Matches(ERR_MortarBattery battery, ERR_HQReport report)
|
||||
{
|
||||
return battery && Gun && Gun == battery.Gun && Report && Report == report && OrderId == battery.OrderId && Shot == battery.OrderShots;
|
||||
}
|
||||
}
|
||||
|
||||
class ERR_MortarAimRules
|
||||
{
|
||||
// Three-dimensional tolerances include terrain/aim-height changes. These are
|
||||
// equivalence limits, not permission to move the gun or redirect its barrel.
|
||||
static bool Equivalent(int commandedCharge, int updatedCharge, vector commandedOrigin, vector updatedOrigin, vector commandedImpact, vector updatedImpact, vector commandedAim, vector updatedAim, float commandedFlight, float updatedFlight)
|
||||
{
|
||||
return commandedCharge >= 0 && commandedCharge == updatedCharge && commandedFlight > 0 && updatedFlight > 0 && vector.Distance(commandedOrigin, updatedOrigin) <= 0.05 && vector.Distance(commandedImpact, updatedImpact) <= 0.1 && vector.Distance(commandedAim, updatedAim) <= 0.1 && Math.AbsFloat(commandedFlight - updatedFlight) <= 0.05;
|
||||
}
|
||||
}
|
||||
+557
@@ -0,0 +1,557 @@
|
||||
// Migrated from the independently authored AI extension; successor-owned physical groups.
|
||||
// Each site/phase has its own repeat limiter. Changed reasons and lifecycle
|
||||
// events are immediate; only identical polling results are summarized.
|
||||
class ERR_MortarTraceState
|
||||
{
|
||||
string LastReason;
|
||||
float NextLog;
|
||||
int Suppressed;
|
||||
|
||||
bool Due(string reason, float now)
|
||||
{
|
||||
if (reason != LastReason || now >= NextLog)
|
||||
{
|
||||
LastReason = reason;
|
||||
NextLog = now + 30000;
|
||||
return true;
|
||||
}
|
||||
Suppressed++;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class ERR_MortarBattery
|
||||
{
|
||||
static const ResourceName GUN = "{D1FFE458E8AC4BDB}Prefabs/Weapons/Mortars/2B14/Mortar_2B14.et";
|
||||
static const ResourceName SHELL = "{98EC9C526AFBA282}Prefabs/Weapons/Ammo/Ammo_Shell_82mm_HE_O832DU.et";
|
||||
static const ResourceName CREW = "{86E565387A634103}Prefabs/Characters/Campaign/Final/OPFOR/USSR_Army/Regular/Campaign_USSR_Player_MortarCrew.et";
|
||||
static const ResourceName RADIO_OPERATOR = "{612F43A4D5AE765F}Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_RTO.et";
|
||||
IEntity RadioOperator;
|
||||
IEntity Gun;
|
||||
SCR_AIGroup Group;
|
||||
ref SCR_AIStaticArtilleryActivity Activity;
|
||||
ref ERR_HQReport Report;
|
||||
ref ERR_HQRoute RadioOrder;
|
||||
ref ERR_HQReport PendingRadioReport;
|
||||
ref ERR_HQReport PendingSupplyReport;
|
||||
ref ERR_MortarRefresh Refresh;
|
||||
bool RefreshRejected; // latched through an already committed projectile
|
||||
protected ref ERR_MortarReadiness m_Readiness;
|
||||
protected ref ERR_MortarSupplyTransfer m_Transfer;
|
||||
protected ref array<IEntity> m_AttemptedShells = {};
|
||||
protected bool m_Stopped;
|
||||
protected SCR_AIStaticArtilleryActivity m_ObservedActivity;
|
||||
protected int m_ObservedActivityOrder;
|
||||
protected EAIActionState m_ActivityTerminal = EAIActionState.EVALUATED;
|
||||
protected ref ERR_MortarReportLedger m_ReportLedger = new ERR_MortarReportLedger();
|
||||
vector Position;
|
||||
vector Impact;
|
||||
vector AimOffset;
|
||||
int AimedShot = -1;
|
||||
float FlightTime;
|
||||
float AimedAt = -1;
|
||||
float AimValidatedAt = -1;
|
||||
SCR_AIGetArtilleryAimDistanceCompensation AimSolver;
|
||||
ref ERR_MortarAimSolution AimSolution;
|
||||
float AirspaceUntil;
|
||||
vector AirspaceImpact;
|
||||
float AirspaceRadius = 600;
|
||||
float OrderedAt;
|
||||
float NextOrder;
|
||||
float SpawnedAt;
|
||||
bool StartupActivated;
|
||||
bool Released;
|
||||
bool Rejected;
|
||||
bool Claimed; // keeps the world slot occupied after the battery is destroyed
|
||||
int SiteId;
|
||||
int OrderId;
|
||||
int RadioId;
|
||||
int SourceId;
|
||||
int OrderShots;
|
||||
int TotalShots;
|
||||
bool CommandOpen;
|
||||
string LastFailure;
|
||||
string SpawnFailure;
|
||||
vector LastCandidate;
|
||||
ref map<string, int> SearchCounts = new map<string, int>();
|
||||
ref map<string, ref ERR_MortarTraceState> Traces = new map<string, ref ERR_MortarTraceState>();
|
||||
|
||||
void Audit(string kind, string detail)
|
||||
{
|
||||
ERR_HQ.Event("MORTAR " + kind, string.Format("site=%1 order=%2 radio=M%3 source=M%4 position=%5; %6", SiteId, OrderId, RadioId, SourceId, Position, detail), RadioId);
|
||||
}
|
||||
|
||||
void Trace(string phase, string reason, string detail)
|
||||
{
|
||||
ERR_MortarTraceState state;
|
||||
if (!Traces.Find(phase, state))
|
||||
{
|
||||
state = new ERR_MortarTraceState();
|
||||
Traces.Insert(phase, state);
|
||||
}
|
||||
string previous = state.LastReason;
|
||||
if (!state.Due(reason, GetGame().GetWorld().GetWorldTime()))
|
||||
return;
|
||||
Audit(phase, string.Format("reason=%1; %2; previous=%3 repeated polls omitted=%4", reason, detail, previous, state.Suppressed));
|
||||
state.Suppressed = 0;
|
||||
}
|
||||
|
||||
void FlushTraces()
|
||||
{
|
||||
foreach (string phase, ERR_MortarTraceState state : Traces)
|
||||
{
|
||||
if (state.Suppressed > 0)
|
||||
Audit("REPEATS", string.Format("phase=%1 reason=%2 repeated polls omitted=%3", phase, state.LastReason, state.Suppressed));
|
||||
}
|
||||
Traces.Clear();
|
||||
}
|
||||
|
||||
string OperationalReason()
|
||||
{
|
||||
if (m_Stopped) return "battery stopped";
|
||||
if (!Gun) return "gun missing";
|
||||
if (!SpawnFailure.IsEmpty()) return SpawnFailure;
|
||||
if (!Group || !Group.GetGroupUtilityComponent()) return "crew group missing";
|
||||
if (Group.GetFaction() != ERR_GameMode.Get().EnemyFaction()) return "crew changed faction";
|
||||
if (Group.GetPlayerCount(true) > 0) return "player in crew group";
|
||||
if (Group.GetAgentsCount() == 0) return "crew group empty";
|
||||
DamageManagerComponent damage = DamageManagerComponent.Cast(Gun.FindComponent(DamageManagerComponent));
|
||||
if (damage && damage.IsDestroyed()) return "gun destroyed";
|
||||
return "";
|
||||
}
|
||||
|
||||
bool CanRequest(ERR_HQReport report, float now)
|
||||
{
|
||||
return !m_Stopped && ERR_MortarPrediction.Fresh(report, now) && m_ReportLedger.Available(report.MessageId, report.ObservedAt, now);
|
||||
}
|
||||
bool BeginRequest(ERR_HQReport report, float now)
|
||||
{
|
||||
if (!CanRequest(report, now) || !m_ReportLedger.Claim(report.MessageId, report.ObservedAt, now)) return false;
|
||||
Cancel("superseded by new HQ order");
|
||||
FlushTraces();
|
||||
OrderId++;
|
||||
RadioId = 0;
|
||||
SourceId = report.MessageId;
|
||||
OrderShots = 0;
|
||||
Released = false;
|
||||
CommandOpen = true;
|
||||
OrderedAt = now;
|
||||
LastFailure = "";
|
||||
Audit("COMMAND", string.Format("requested rounds=%1 HE radius=%2m; %3", ERR_MortarSalvo.ROUNDS, ERR_MortarSalvo.RADIUS_METRES, report.Description(now)));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool CanRefresh(ERR_HQReport report, float now)
|
||||
{
|
||||
if (m_Stopped || !CommandOpen || !Activity || Refresh || RefreshRejected || Rejected || !Operational()) return false;
|
||||
return ERR_MortarRefreshRules.Newer(Report, report, now) && m_ReportLedger.Available(report.MessageId, report.ObservedAt, now);
|
||||
}
|
||||
bool BeginRefresh(ERR_HQReport report, ERR_HQRoute route, float now)
|
||||
{
|
||||
if (!route || !CanRefresh(report, now) || !m_ReportLedger.Claim(report.MessageId, report.ObservedAt, now)) return false;
|
||||
Refresh = new ERR_MortarRefresh(this, report, route);
|
||||
Audit("UPDATE QUEUED", string.Format("same order; update radio=M%1 newer observation=M%2 observed=%3", route.MessageId, report.MessageId, report.ObservedAt));
|
||||
return true;
|
||||
}
|
||||
void RejectRefresh(string reason)
|
||||
{
|
||||
// Never clear the commit latch or steal the physical projectile callback.
|
||||
// AllowShot refuses further loads now; HQ cancels after it is observed.
|
||||
RefreshRejected = true;
|
||||
Refresh = null;
|
||||
LastFailure = reason;
|
||||
Audit("UPDATE REJECTED", reason);
|
||||
}
|
||||
|
||||
void ObserveProjectile(IEntity projectileEntity, IEntity firingCharacter, bool playerGunner)
|
||||
{
|
||||
if (!projectileEntity)
|
||||
{
|
||||
Audit("FIRE CALLBACK", "native OnWeaponFired had no projectile; no confirmed shot counted");
|
||||
return;
|
||||
}
|
||||
TotalShots++;
|
||||
if (CommandOpen && Released && !playerGunner)
|
||||
{
|
||||
OrderShots++;
|
||||
// Only a physical projectile unlocks the next load. It must be aimed again.
|
||||
Released = false;
|
||||
AimedAt = -1;
|
||||
AimValidatedAt = -1;
|
||||
AimSolver = null;
|
||||
AimSolution = null;
|
||||
Audit("FIRED", string.Format("physical projectile=%1 origin=%2 order shots=%3 total shots=%4 predicted impact=%5 predicted flight=%6s gunner=%7 (impact not observed)", projectileEntity, projectileEntity.GetOrigin(), OrderShots, TotalShots, Impact, FlightTime, firingCharacter));
|
||||
}
|
||||
else
|
||||
Audit("FIRED UNASSIGNED", string.Format("physical projectile=%1 origin=%2 player gunner=%3 order open=%4 committed=%5 total shots=%6; no active HQ order attribution", projectileEntity, projectileEntity.GetOrigin(), playerGunner, CommandOpen, Released, TotalShots));
|
||||
}
|
||||
|
||||
void ReserveShellAirspace(float now)
|
||||
{
|
||||
// Enclose every still-airborne shell, including an earlier, longer flight.
|
||||
// A later round must not move/shrink the existing exclusion or deadline.
|
||||
if (now >= AirspaceUntil)
|
||||
{
|
||||
AirspaceImpact = Impact;
|
||||
AirspaceRadius = 600;
|
||||
}
|
||||
AirspaceRadius = Math.Max(AirspaceRadius, vector.DistanceXZ(AirspaceImpact, Impact) + 600);
|
||||
AirspaceUntil = Math.Max(AirspaceUntil, now + (FlightTime + 10) * 1000);
|
||||
}
|
||||
|
||||
bool SpawnFailed(string reason)
|
||||
{
|
||||
SpawnFailure = reason;
|
||||
Audit("DEPLOY FAILED", string.Format("reason=%1 claimed=%2 gun=%3 group=%4", reason, Claimed, Gun, Group));
|
||||
return false;
|
||||
}
|
||||
|
||||
void SearchResult(vector candidate, string reason)
|
||||
{
|
||||
LastCandidate = candidate;
|
||||
int count;
|
||||
SearchCounts.Find(reason, count);
|
||||
SearchCounts.Set(reason, count + 1);
|
||||
}
|
||||
|
||||
void SiteStatus(float now)
|
||||
{
|
||||
if (!SearchCounts.IsEmpty())
|
||||
{
|
||||
string counts;
|
||||
foreach (string reason, int count : SearchCounts) counts += string.Format("%1=%2; ", reason, count);
|
||||
Audit("SITE SEARCH", string.Format("candidate counts since last summary: %1 last candidate=%2", counts, LastCandidate));
|
||||
SearchCounts.Clear();
|
||||
}
|
||||
int agents, living;
|
||||
int actionState = -1;
|
||||
if (Group)
|
||||
{
|
||||
array<AIAgent> crew = {};
|
||||
Group.GetAgents(crew);
|
||||
agents = crew.Count();
|
||||
foreach (AIAgent agent : crew)
|
||||
{
|
||||
if (agent && ERR_Actors.CanFight(agent.GetControlledEntity())) living++;
|
||||
}
|
||||
}
|
||||
if (Activity) actionState = Activity.GetActionState();
|
||||
bool connected;
|
||||
ERR_HQ hq = ERR_HQ.Get();
|
||||
if (hq && Group) connected = hq.MortarConnected(Group);
|
||||
Audit("SITE STATUS", string.Format("claimed=%1 startup=%2 operational=%3 crew agents=%4 fighting=%5 inventory HE=%6 radio connected=%7 cooldown remaining=%8s", Claimed, StartupActivated, Operational(), agents, living, CountShells(), connected, Math.Max(0, NextOrder - now) / 1000));
|
||||
Audit("ORDER STATUS", string.Format("open=%1 route pending=%2 native action=%3 shell committed=%4 order shots=%5 total shots=%6 age=%7s last failure=%8", CommandOpen, RadioOrder != null, actionState, Released, OrderShots, TotalShots, Math.Max(0, now - OrderedAt) / 1000, LastFailure));
|
||||
if (!SpawnFailure.IsEmpty()) Audit("PARTIAL DEPLOYMENT", SpawnFailure);
|
||||
if (!Operational()) Trace("SITE BLOCK", OperationalReason(), "spawn failure=" + SpawnFailure);
|
||||
}
|
||||
|
||||
bool Operational()
|
||||
{
|
||||
return OperationalReason().IsEmpty();
|
||||
}
|
||||
|
||||
int CountShells()
|
||||
{
|
||||
if (!Group)
|
||||
return 0;
|
||||
int count;
|
||||
array<IEntity> items = {};
|
||||
array<AIAgent> agents = {};
|
||||
Group.GetAgents(agents);
|
||||
foreach (AIAgent agent : agents)
|
||||
{
|
||||
if (!agent || !ERR_Actors.CanFight(agent.GetControlledEntity()))
|
||||
continue;
|
||||
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(agent.GetControlledEntity().FindComponent(InventoryStorageManagerComponent));
|
||||
if (!inventory)
|
||||
continue;
|
||||
ERR_Inventory.AppendUnique(inventory, items);
|
||||
}
|
||||
foreach (IEntity item : items)
|
||||
if (item && item.GetPrefabData() && item.GetPrefabData().GetPrefabName() == SHELL) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
void Bind(int siteId, SCR_AIGroup group, IEntity gun, float now)
|
||||
{
|
||||
SiteId = siteId;
|
||||
Group = group;
|
||||
Gun = gun;
|
||||
Position = gun.GetOrigin();
|
||||
SpawnedAt = now;
|
||||
Claimed = true;
|
||||
m_Readiness = new ERR_MortarReadiness(this);
|
||||
}
|
||||
|
||||
|
||||
int InventoryShells(InventoryStorageManagerComponent inventory)
|
||||
{
|
||||
if (!inventory)
|
||||
return 0;
|
||||
int count;
|
||||
array<IEntity> items = {};
|
||||
ERR_Inventory.Items(inventory, items);
|
||||
foreach (IEntity item : items)
|
||||
{
|
||||
if (item && item.GetPrefabData() && item.GetPrefabData().GetPrefabName() == SHELL)
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
bool Stopped() { return m_Stopped; }
|
||||
bool SupplyPending() { return m_Transfer && m_Transfer.Unresolved(); }
|
||||
bool SupplyAuthorized(float now)
|
||||
{
|
||||
if (!Replication.IsServer() || m_Stopped || !StartupActivated || !Operational() || !CommandOpen || !ERR_HQ.MissionActive()) return false;
|
||||
ERR_MortarSystem system = ERR_MortarSystem.Get();
|
||||
if (!system || system.FindGun(Gun) != this) return false;
|
||||
ERR_HQReport suppliedReport = Report;
|
||||
if (!suppliedReport) suppliedReport = PendingSupplyReport;
|
||||
return ERR_MortarPrediction.Fresh(suppliedReport, now);
|
||||
}
|
||||
void Stop()
|
||||
{
|
||||
if (m_Stopped) return;
|
||||
m_Stopped = true;
|
||||
Cancel("HQ stopped");
|
||||
if (m_Readiness) m_Readiness.Stop();
|
||||
}
|
||||
|
||||
ERR_MortarSupplyState SupplyState(float now)
|
||||
{
|
||||
// Observe an existing native operation even after its order was canceled.
|
||||
// No second request may race an unresolved transfer on this battery.
|
||||
if (m_Transfer)
|
||||
{
|
||||
ERR_MortarSupplyState observed = m_Transfer.Poll(now);
|
||||
if (m_Transfer.CapturedOrder() == OrderId)
|
||||
{
|
||||
if (observed == ERR_MortarSupplyState.READY) m_Transfer = null;
|
||||
return observed;
|
||||
}
|
||||
if (m_Transfer.Unresolved()) return ERR_MortarSupplyState.FAILED;
|
||||
m_Transfer = null;
|
||||
}
|
||||
if (!SupplyAuthorized(now)) return SupplyStateFailed("current HQ order, crew or report is unavailable");
|
||||
SCR_AIVehicleUsageComponent usage = SCR_AIVehicleUsageComponent.Cast(Gun.FindComponent(SCR_AIVehicleUsageComponent));
|
||||
IEntity gunner;
|
||||
if (usage && usage.GetTurretCompartmentSlot())
|
||||
gunner = usage.GetTurretCompartmentSlot().GetOccupant();
|
||||
array<AIAgent> agents = {};
|
||||
Group.GetAgents(agents);
|
||||
float nearest = float.MAX;
|
||||
if (!gunner)
|
||||
{
|
||||
foreach (AIAgent candidate : agents)
|
||||
{
|
||||
if (!candidate || candidate.GetControlledEntity() == RadioOperator || !ERR_Actors.CanFight(candidate.GetControlledEntity()))
|
||||
continue;
|
||||
float distance = vector.DistanceXZ(Gun.GetOrigin(), candidate.GetControlledEntity().GetOrigin());
|
||||
if (distance < nearest)
|
||||
{
|
||||
nearest = distance;
|
||||
gunner = candidate.GetControlledEntity();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!gunner) return SupplyStateFailed("no fighting gunner found");
|
||||
if (ERR_Actors.Group(gunner) != Group) return SupplyStateFailed("turret occupant belongs to another group");
|
||||
if (!ERR_Actors.CanFight(gunner)) return SupplyStateFailed("gunner cannot fight");
|
||||
InventoryStorageManagerComponent destination = InventoryStorageManagerComponent.Cast(gunner.FindComponent(InventoryStorageManagerComponent));
|
||||
if (!destination)
|
||||
return SupplyStateFailed("gunner inventory missing");
|
||||
if (InventoryShells(destination) > 0)
|
||||
return ERR_MortarSupplyState.READY;
|
||||
int nearbyDonors, donorShells, storageRejected;
|
||||
array<IEntity> consideredShells = {};
|
||||
foreach (AIAgent bearer : agents)
|
||||
{
|
||||
if (!bearer || !ERR_Actors.CanFight(bearer.GetControlledEntity()))
|
||||
continue;
|
||||
IEntity donor = bearer.GetControlledEntity();
|
||||
if (donor == gunner || vector.DistanceXZ(donor.GetOrigin(), gunner.GetOrigin()) > 12)
|
||||
continue;
|
||||
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(donor.FindComponent(InventoryStorageManagerComponent));
|
||||
if (!inventory)
|
||||
continue;
|
||||
nearbyDonors++;
|
||||
array<IEntity> items = {};
|
||||
ERR_Inventory.Items(inventory, items);
|
||||
foreach (IEntity item : items)
|
||||
{
|
||||
if (!item || !item.GetPrefabData() || item.GetPrefabData().GetPrefabName() != SHELL)
|
||||
continue;
|
||||
if (consideredShells.Contains(item) || m_AttemptedShells.Contains(item)) continue;
|
||||
consideredShells.Insert(item);
|
||||
donorShells++;
|
||||
BaseInventoryStorageComponent storage = destination.FindStorageForItem(item, EStoragePurpose.PURPOSE_DEPOSIT);
|
||||
if (!storage) { storageRejected++; continue; }
|
||||
m_AttemptedShells.Insert(item); // even rejected/ambiguous requests are never retried
|
||||
m_Transfer = new ERR_MortarSupplyTransfer(this, donor, gunner, item, storage, now);
|
||||
if (!m_Transfer.Begin(now)) return SupplyStateFailed("exact shell transfer request rejected; no retry");
|
||||
Audit("AMMO TRANSFER", string.Format("one exact-item move pending donor=%1 gunner=%2 shell=%3 order=%4; awaiting callback AND physical destination", donor, gunner, item, OrderId));
|
||||
return ERR_MortarSupplyState.PENDING;
|
||||
}
|
||||
}
|
||||
return SupplyStateFailed(string.Format("gunner empty; fighting donors within 12m=%1 unattempted HE=%2 destination storage rejected=%3", nearbyDonors, donorShells, storageRejected));
|
||||
}
|
||||
|
||||
// Compatibility for existing native callers; request acceptance is NOT ready.
|
||||
bool SupplyGunner() { return SupplyState(GetGame().GetWorld().GetWorldTime()) == ERR_MortarSupplyState.READY; }
|
||||
protected ERR_MortarSupplyState SupplyStateFailed(string reason)
|
||||
{
|
||||
SupplyFailed(reason);
|
||||
return ERR_MortarSupplyState.FAILED;
|
||||
}
|
||||
|
||||
bool SupplyFailed(string reason)
|
||||
{
|
||||
LastFailure = reason;
|
||||
Audit("AMMO FAILED", reason);
|
||||
return false;
|
||||
}
|
||||
|
||||
protected void WatchActivity()
|
||||
{
|
||||
DetachActivity();
|
||||
if (!Activity || !CommandOpen || m_Stopped) return;
|
||||
m_ObservedActivity = Activity;
|
||||
m_ObservedActivityOrder = OrderId;
|
||||
Activity.m_OnActionCompleted.Insert(OnActivityCompleted);
|
||||
Activity.m_OnActionFailed.Insert(OnActivityFailed);
|
||||
}
|
||||
protected void DetachActivity()
|
||||
{
|
||||
if (m_ObservedActivity)
|
||||
{
|
||||
m_ObservedActivity.m_OnActionCompleted.Remove(OnActivityCompleted);
|
||||
m_ObservedActivity.m_OnActionFailed.Remove(OnActivityFailed);
|
||||
}
|
||||
m_ObservedActivity = null;
|
||||
m_ObservedActivityOrder = 0;
|
||||
m_ActivityTerminal = EAIActionState.EVALUATED;
|
||||
}
|
||||
protected void OnActivityCompleted(SCR_AIActionBase action)
|
||||
{
|
||||
ObserveActivityTerminal(action, EAIActionState.COMPLETED);
|
||||
}
|
||||
protected void OnActivityFailed(SCR_AIActionBase action)
|
||||
{
|
||||
ObserveActivityTerminal(action, EAIActionState.FAILED);
|
||||
}
|
||||
protected void ObserveActivityTerminal(SCR_AIActionBase action, EAIActionState terminal)
|
||||
{
|
||||
if (m_Stopped || !CommandOpen || !Activity || action != Activity || action != m_ObservedActivity || OrderId != m_ObservedActivityOrder) return;
|
||||
if (m_ActivityTerminal != EAIActionState.EVALUATED) return;
|
||||
if (terminal != EAIActionState.COMPLETED && terminal != EAIActionState.FAILED) return;
|
||||
// Vanilla deselection can overwrite COMPLETED/FAILED with EVALUATED
|
||||
// before the next HQ tick. Record its actual event, never infer completion
|
||||
// from projectile totals, and never cancel from inside the native callback.
|
||||
m_ActivityTerminal = terminal;
|
||||
Audit("ACTIVITY EVENT", string.Format("native terminal event=%1 current state=%2 captured order=%3 physical order shots=%4 total shots=%5 committed=%6", terminal, Activity.GetActionState(), m_ObservedActivityOrder, OrderShots, TotalShots, Released));
|
||||
}
|
||||
protected EAIActionState ActivityTerminal()
|
||||
{
|
||||
if (Activity == m_ObservedActivity && OrderId == m_ObservedActivityOrder)
|
||||
if (m_ActivityTerminal == EAIActionState.COMPLETED || m_ActivityTerminal == EAIActionState.FAILED) return m_ActivityTerminal;
|
||||
return Activity.GetActionState();
|
||||
}
|
||||
|
||||
void Cancel(string reason = "cancel requested")
|
||||
{
|
||||
if (CommandOpen)
|
||||
{
|
||||
FlushTraces();
|
||||
Audit("ORDER END", string.Format("reason=%1 observed shots=%2 shell committed=%3 elapsed=%4s last failure=%5", reason, OrderShots, Released, (GetGame().GetWorld().GetWorldTime() - OrderedAt) / 1000, LastFailure));
|
||||
}
|
||||
CommandOpen = false;
|
||||
// Remove before our own cleanup calls Complete; that is not a native
|
||||
// successful fire mission and must not manufacture an observed event.
|
||||
DetachActivity();
|
||||
RadioOrder = null;
|
||||
PendingRadioReport = null;
|
||||
PendingSupplyReport = null;
|
||||
Refresh = null;
|
||||
RefreshRejected = false;
|
||||
if (m_Transfer) m_Transfer.Invalidate();
|
||||
Report = null;
|
||||
AimedAt = -1;
|
||||
AimValidatedAt = -1;
|
||||
AimSolver = null;
|
||||
AimSolution = null;
|
||||
if (Activity)
|
||||
Activity.Complete();
|
||||
Activity = null;
|
||||
}
|
||||
|
||||
// Idle distant batteries still report through HQ's scheduler. Their native
|
||||
// AI needs full detail while firing or when players can interact with them.
|
||||
protected void UpdateCrewDetail(float now, bool firing = false)
|
||||
{
|
||||
if (!Group || Group.GetPlayerCount(true) > 0)
|
||||
return;
|
||||
bool active = firing || Activity != null || ERR_MortarSystem.NearPlayer(Position);
|
||||
int lod = -1;
|
||||
if (active)
|
||||
lod = 0;
|
||||
if (Group.GetPermanentLOD() != lod)
|
||||
Group.SetPermanentLOD(lod);
|
||||
if (active && !Group.IsAIActivated())
|
||||
Group.ActivateAI();
|
||||
array<AIAgent> agents = {};
|
||||
Group.GetAgents(agents);
|
||||
foreach (AIAgent agent : agents)
|
||||
{
|
||||
if (!agent || EntityUtils.IsPlayer(agent.GetControlledEntity()))
|
||||
continue;
|
||||
if (agent.GetPermanentLOD() != lod)
|
||||
agent.SetPermanentLOD(lod);
|
||||
if (active && !agent.IsAIActivated() && ERR_Actors.CanFight(agent.GetControlledEntity()))
|
||||
agent.ActivateAI();
|
||||
}
|
||||
}
|
||||
|
||||
void Update(float now)
|
||||
{
|
||||
if (m_Stopped) return;
|
||||
if (!StartupActivated)
|
||||
{
|
||||
if (!SpawnFailure.IsEmpty()) return;
|
||||
if (!m_Readiness) { SpawnFailed("initial support readiness owner missing"); return; }
|
||||
ERR_MortarSupplyState startup = m_Readiness.Poll(now);
|
||||
if (startup != ERR_MortarSupplyState.READY) return;
|
||||
StartupActivated = true;
|
||||
}
|
||||
if (m_Transfer) m_Transfer.Poll(now);
|
||||
if (!Operational())
|
||||
{
|
||||
Cancel(OperationalReason());
|
||||
return;
|
||||
}
|
||||
if (Activity)
|
||||
{
|
||||
Trace("ACTIVITY", string.Format("native state=%1", Activity.GetActionState()), string.Format("age=%1s aim age=%2s committed=%3 shots=%4", (now - OrderedAt) / 1000, (now - AimedAt) / 1000, Released, OrderShots));
|
||||
EAIActionState terminal = ActivityTerminal();
|
||||
if (terminal == EAIActionState.FAILED) Cancel("native artillery activity FAILED");
|
||||
else if (terminal == EAIActionState.COMPLETED) Cancel("native artillery activity COMPLETED (shot count is separate)");
|
||||
else if (now - OrderedAt > ERR_MortarSalvo.TIMEOUT_MS) Cancel("120s artillery salvo timeout");
|
||||
}
|
||||
if (StartupActivated)
|
||||
UpdateCrewDetail(now);
|
||||
}
|
||||
|
||||
void Order(ERR_HQReport report, float now)
|
||||
{
|
||||
if (m_Stopped || !StartupActivated || !Operational() || !CommandOpen || Activity || !ERR_MortarPrediction.Fresh(report, now)) return;
|
||||
Report = report.Snapshot();
|
||||
OrderedAt = now;
|
||||
NextOrder = now + 30000;
|
||||
Released = false;
|
||||
UpdateCrewDetail(now, true);
|
||||
Activity = new SCR_AIStaticArtilleryActivity(Group.GetGroupUtilityComponent(), null, report.Position, SCR_EAIArtilleryAmmoType.HIGH_EXPLOSIVE, ERR_MortarSalvo.ROUNDS);
|
||||
WatchActivity();
|
||||
Group.GetGroupUtilityComponent().AddAction(Activity);
|
||||
Audit("AIM", string.Format("native activity created; latest source=M%1 observed=%2", report.MessageId, report.Description(now)));
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
class ERR_MortarCourierCallback : ScriptedInventoryOperationCallback
|
||||
{
|
||||
ERR_MortarCourierTransfer Transfer;
|
||||
protected override void OnComplete() { if (Transfer) Transfer.Acknowledge(true); }
|
||||
protected override void OnFailed() { if (Transfer) Transfer.Acknowledge(false); }
|
||||
}
|
||||
// One exact native move belonging to one courier trip. Never retries ambiguous work.
|
||||
class ERR_MortarCourierTransfer
|
||||
{
|
||||
protected ERR_MortarLogistics m_Service;
|
||||
protected IEntity m_Shell;
|
||||
protected IEntity m_From;
|
||||
protected IEntity m_To;
|
||||
protected InventoryStorageManagerComponent m_Source;
|
||||
protected InventoryStorageManagerComponent m_Destination;
|
||||
protected BaseInventoryStorageComponent m_Storage;
|
||||
protected ref ERR_MortarCourierCallback m_Callback;
|
||||
protected int m_Trip;
|
||||
protected int m_Acknowledgement;
|
||||
protected float m_Deadline;
|
||||
protected bool m_Accepted;
|
||||
protected bool m_Invalidated;
|
||||
protected bool m_Resolved;
|
||||
void ERR_MortarCourierTransfer(ERR_MortarLogistics service, int trip, IEntity shell, IEntity from, IEntity to, BaseInventoryStorageComponent storage, float now)
|
||||
{
|
||||
m_Service = service; m_Trip = trip; m_Shell = shell; m_From = from; m_To = to; m_Storage = storage; m_Deadline = now + 8000;
|
||||
m_Source = InventoryStorageManagerComponent.Cast(from.FindComponent(InventoryStorageManagerComponent));
|
||||
m_Destination = InventoryStorageManagerComponent.Cast(to.FindComponent(InventoryStorageManagerComponent));
|
||||
}
|
||||
static bool Proven(bool complete, bool source, bool destination, bool exact) { return complete && !source && destination && exact; }
|
||||
protected bool Contains(InventoryStorageManagerComponent inventory)
|
||||
{
|
||||
if (!inventory || !m_Shell) return false;
|
||||
array<IEntity> items = {}; ERR_Inventory.Items(inventory, items); return items.Contains(m_Shell);
|
||||
}
|
||||
void Acknowledge(bool success)
|
||||
{
|
||||
if (m_Acknowledgement != 0) return;
|
||||
if (success) m_Acknowledgement = 1;
|
||||
else m_Acknowledgement = -1;
|
||||
}
|
||||
bool Begin(float now)
|
||||
{
|
||||
if (m_Callback || !Replication.IsServer() || !m_Service.TransferAllowed(m_Trip, m_Shell, m_From, m_To, now) || !Contains(m_Source) || Contains(m_Destination)) return false;
|
||||
m_Callback = new ERR_MortarCourierCallback(); m_Callback.Transfer = this;
|
||||
m_Accepted = m_Source.TryMoveItemToStorage(m_Shell, m_Storage, -1, m_Callback);
|
||||
if (!m_Accepted) { Acknowledge(false); m_Resolved = true; }
|
||||
return m_Accepted;
|
||||
}
|
||||
void Invalidate() { m_Invalidated = true; }
|
||||
bool Unresolved() { return m_Accepted && !m_Resolved; }
|
||||
IEntity Shell() { return m_Shell; }
|
||||
int Poll(float now)
|
||||
{
|
||||
// A deleted exact entity cannot complete later; close as a permanent loss.
|
||||
if (!m_Shell) { m_Resolved = true; m_Invalidated = true; return -1; }
|
||||
bool exact;
|
||||
InventoryItemComponent item;
|
||||
if (m_Shell) item = InventoryItemComponent.Cast(m_Shell.FindComponent(InventoryItemComponent));
|
||||
if (item && item.GetParentSlot()) exact = item.GetParentSlot().GetStorage() == m_Storage;
|
||||
bool proven = Proven(m_Acknowledgement == 1, Contains(m_Source), Contains(m_Destination), exact);
|
||||
if (proven || m_Acknowledgement < 0) m_Resolved = true;
|
||||
if (m_Invalidated || now >= m_Deadline || !m_Service.TransferAllowed(m_Trip, m_Shell, m_From, m_To, now)) { m_Invalidated = true; return -1; }
|
||||
if (proven) return 1;
|
||||
if (m_Acknowledgement < 0 || !m_Accepted) return -1;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
+308
@@ -0,0 +1,308 @@
|
||||
// The dedicated manpack operator has no mortar-shell backpack. Preserve the
|
||||
// native chooser and its caller's original index for every eligible gunner.
|
||||
modded class SCR_AIStaticArtilleryActivity
|
||||
{
|
||||
protected override AIAgent FindBestAgentForArtillery(notnull array<AIAgent> agents, notnull IEntity artilleryEntity, out int outIndex)
|
||||
{
|
||||
ERR_MortarSystem system = ERR_MortarSystem.Get();
|
||||
ERR_MortarBattery battery;
|
||||
if (system) battery = system.FindGun(artilleryEntity);
|
||||
if (!battery) return super.FindBestAgentForArtillery(agents, artilleryEntity, outIndex);
|
||||
array<AIAgent> gunners = {};
|
||||
foreach (AIAgent agent : agents)
|
||||
{
|
||||
if (agent && agent.GetControlledEntity() != battery.RadioOperator && ERR_Actors.CanFight(agent.GetControlledEntity()))
|
||||
gunners.Insert(agent);
|
||||
}
|
||||
int filteredIndex;
|
||||
AIAgent selected = super.FindBestAgentForArtillery(gunners, artilleryEntity, filteredIndex);
|
||||
outIndex = -1;
|
||||
if (selected) outIndex = agents.Find(selected);
|
||||
return selected;
|
||||
}
|
||||
}
|
||||
|
||||
// Leave all other artillery (including player-operated captured guns) native.
|
||||
modded class SCR_AIGetArtilleryAimDistanceCompensation
|
||||
{
|
||||
override ENodeResult EOnTaskSimulate(AIAgent owner, float dt)
|
||||
{
|
||||
IEntity gun;
|
||||
GetVariableIn(PORT_ARTILLERY_ENTITY, gun);
|
||||
ERR_MortarSystem system = ERR_MortarSystem.Get();
|
||||
ERR_MortarBattery battery;
|
||||
if (system)
|
||||
battery = system.FindGun(gun);
|
||||
if (!battery)
|
||||
return super.EOnTaskSimulate(owner, dt);
|
||||
battery.AimedAt = -1;
|
||||
battery.AimValidatedAt = -1;
|
||||
battery.AimSolver = null;
|
||||
battery.AimSolution = null;
|
||||
float now = GetGame().GetWorld().GetWorldTime();
|
||||
ERR_HQReport report = system.Latest(battery, now);
|
||||
if (!ERR_HQ.MissionActive()) { system.Withhold(battery, "aim: mission inactive"); return ENodeResult.FAIL; }
|
||||
if (!battery.CommandOpen || battery.OrderShots >= ERR_MortarSalvo.ROUNDS) { system.Withhold(battery, "aim: salvo complete or order closed"); return ENodeResult.FAIL; }
|
||||
if (battery.Released) { system.Withhold(battery, "aim: waiting for committed projectile"); return ENodeResult.FAIL; }
|
||||
if (!report) { system.Withhold(battery, "aim: " + system.ContactFailure(battery)); return ENodeResult.FAIL; }
|
||||
battery.Trace("AIM START", "solving moving intercept", string.Format("latest source=M%1; %2", report.MessageId, report.Description(now)));
|
||||
ERR_MortarAimSolution solution = ERR_Solve(battery, system, report, now);
|
||||
if (!solution) return ENodeResult.FAIL;
|
||||
battery.Impact = solution.Impact;
|
||||
battery.AimOffset = solution.SalvoOffset;
|
||||
battery.AimedShot = battery.OrderShots;
|
||||
battery.FlightTime = solution.FlightTime;
|
||||
battery.AimSolution = solution;
|
||||
battery.AimSolver = this; // weak: do not retain a destroyed native behavior tree
|
||||
battery.AimedAt = now;
|
||||
battery.AimValidatedAt = now;
|
||||
battery.LastFailure = "";
|
||||
battery.Trace("AIM SOLUTION", "ready", string.Format("predicted impact=%1 flight=%2s charge index=%3 aim target=%4 latest source=M%5", solution.Impact, solution.FlightTime, solution.Charge, solution.AimTarget, report.MessageId));
|
||||
SetVariableOut(PORT_AIM_POS, solution.AimTarget);
|
||||
SetVariableOut(PORT_INIT_SPEED_ID, solution.Charge);
|
||||
return ENodeResult.SUCCESS;
|
||||
}
|
||||
|
||||
// Reuse this exact native node's coefficient/elevation APIs. A slow native
|
||||
// barrel can keep its command only if a NEW solve still matches that command.
|
||||
bool ERR_RevalidateAim(ERR_MortarBattery battery, ERR_MortarSystem system, ERR_HQReport report, float now)
|
||||
{
|
||||
IEntity gun;
|
||||
ResourceName ammo;
|
||||
GetVariableIn(PORT_ARTILLERY_ENTITY, gun);
|
||||
GetVariableIn(PORT_AMMO_PREFAB, ammo);
|
||||
ERR_MortarAimSolution commanded = battery.AimSolution;
|
||||
if (battery.AimSolver != this || !commanded || !commanded.Matches(battery, report) || gun != battery.Gun || ammo != ERR_MortarBattery.SHELL)
|
||||
return system.Withhold(battery, "original aim solver or command no longer available");
|
||||
if (!ERR_MortarPrediction.Fresh(report, now)) return system.Withhold(battery, system.ContactFailure(battery));
|
||||
ERR_MortarAimSolution updated = ERR_Solve(battery, system, report, now);
|
||||
if (!updated) return false;
|
||||
if (!ERR_MortarAimRules.Equivalent(commanded.Charge, updated.Charge, commanded.GunOrigin, updated.GunOrigin, commanded.Impact, updated.Impact, commanded.AimTarget, updated.AimTarget, commanded.FlightTime, updated.FlightTime))
|
||||
return system.Withhold(battery, "new ballistic solution requires native re-aim", string.Format("gun drift=%1m impact drift=%2m aim drift=%3m charge=%4/%5 flight=%6/%7s", vector.Distance(commanded.GunOrigin, updated.GunOrigin), vector.Distance(commanded.Impact, updated.Impact), vector.Distance(commanded.AimTarget, updated.AimTarget), commanded.Charge, updated.Charge, commanded.FlightTime, updated.FlightTime));
|
||||
// Keep the ORIGINAL command/impact/flight. Comparing successive refreshed
|
||||
// solutions would allow a moving target to drift away in small increments.
|
||||
battery.AimValidatedAt = now;
|
||||
battery.Trace("AIM REVALIDATED", "same native command", string.Format("fresh ballistic solve matched original; source=M%1 observed age=%2s", report.MessageId, (now - report.ObservedAt) / 1000));
|
||||
return true;
|
||||
}
|
||||
|
||||
// The report may change only after a real HQ downlink. Re-solve against the
|
||||
// ORIGINAL geometry; never replace the commanded solution with an
|
||||
// approximately equivalent successor (which would permit tolerance creep).
|
||||
bool ERR_AdoptReport(ERR_MortarBattery battery, ERR_MortarSystem system, ERR_HQReport report, float now)
|
||||
{
|
||||
IEntity gun;
|
||||
ResourceName ammo;
|
||||
GetVariableIn(PORT_ARTILLERY_ENTITY, gun);
|
||||
GetVariableIn(PORT_AMMO_PREFAB, ammo);
|
||||
ERR_MortarAimSolution commanded = battery.AimSolution;
|
||||
if (battery.Released || battery.RefreshRejected || !battery.CommandOpen || !battery.Refresh || !battery.Refresh.DeliveredFor(battery, report))
|
||||
return system.Withhold(battery, "target update lacks completed delivery for this uncommitted order");
|
||||
if (battery.AimSolver != this || !commanded || !commanded.Matches(battery, battery.Report) || gun != battery.Gun || ammo != ERR_MortarBattery.SHELL)
|
||||
return system.Withhold(battery, "target update lost its original native aim owner");
|
||||
if (battery.AimedAt < 0 || now < battery.AimedAt) return system.Withhold(battery, "original native aim timestamp invalid");
|
||||
if (!ERR_MortarRefreshRules.Newer(battery.Report, report, now)) return system.Withhold(battery, "target update is not a newer fresh same-target observation");
|
||||
ERR_MortarAimSolution updated = ERR_Solve(battery, system, report, now);
|
||||
if (!updated) return false;
|
||||
if (!ERR_MortarAimRules.Equivalent(commanded.Charge, updated.Charge, commanded.GunOrigin, updated.GunOrigin, commanded.Impact, updated.Impact, commanded.AimTarget, updated.AimTarget, commanded.FlightTime, updated.FlightTime))
|
||||
return system.Withhold(battery, "target update requires a different native aim command");
|
||||
// Synchronous adoption changes accepted observation identity together.
|
||||
// Original command time/geometry, order deadline and shot count survive.
|
||||
commanded.Report = report;
|
||||
battery.Report = report;
|
||||
battery.AimValidatedAt = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
protected ERR_MortarAimSolution ERR_Solve(ERR_MortarBattery battery, ERR_MortarSystem system, ERR_HQReport report, float now)
|
||||
{
|
||||
IEntity gun = battery.Gun;
|
||||
vector gunOrigin = gun.GetOrigin();
|
||||
ResourceName ammo;
|
||||
GetVariableIn(PORT_AMMO_PREFAB, ammo);
|
||||
if (ammo != ERR_MortarBattery.SHELL) { system.Withhold(battery, "unsupported ammunition prefab", ammo); return null; }
|
||||
Resource resource = Resource.Load(ammo);
|
||||
if (!resource || !resource.IsValid() || !resource.GetResource()) { system.Withhold(battery, "shell resource load failed", ammo); return null; }
|
||||
IEntitySource source = resource.GetResource().ToEntitySource();
|
||||
if (!source) { system.Withhold(battery, "shell resource has no entity source", ammo); return null; }
|
||||
array<float> coefficients = {};
|
||||
GetAmmoInitialSpeedCoefficients(ammo, coefficients);
|
||||
if (coefficients.IsEmpty())
|
||||
coefficients.Insert(1.0);
|
||||
float minimum, maximum;
|
||||
GetVerticalAimLimits(gun, minimum, maximum);
|
||||
float flight = 30;
|
||||
float offset;
|
||||
int charge;
|
||||
vector impact;
|
||||
vector salvoOffset = ERR_MortarSalvo.Offset(battery.SiteId, battery.OrderId, battery.OrderShots);
|
||||
bool converged;
|
||||
// Iterate the moving intercept using the native shell's flight time.
|
||||
// Reject non-convergence rather than fire with an arbitrary lead time.
|
||||
for (int iteration = 0; iteration < 4; iteration++)
|
||||
{
|
||||
if (!ERR_MortarPrediction.Predict(report, now, flight + 2, impact)) { system.Withhold(battery, "aim prediction: " + ERR_MortarPrediction.FailureReason, string.Format("flight lead=%1s; %2", flight + 2, report.Description(now))); return null; }
|
||||
if (!system.OutsideSettlements(impact)) { system.Withhold(battery, "aim target inside settlement exclusion", system.SettlementReason); return null; }
|
||||
impact += salvoOffset;
|
||||
impact[1] = SCR_TerrainHelper.GetTerrainY(impact);
|
||||
float distance = vector.DistanceXZ(gunOrigin, impact);
|
||||
float altitude = impact[1] - gunOrigin[1];
|
||||
if (distance < 350 || distance > 4000) { system.Withhold(battery, "predicted impact out of range", string.Format("iteration=%1 distance=%2m impact=%3 allowed=350..4000m", iteration, distance, impact)); return null; }
|
||||
bool reachable;
|
||||
float calculated;
|
||||
for (int i = 0; i < coefficients.Count(); i++)
|
||||
{
|
||||
if (!BallisticTable.GetAimHeightOfProjectileAltitudeFromSource(distance, offset, calculated, source, altitude, coefficients[i]))
|
||||
continue;
|
||||
float tangent = (offset + altitude) / distance;
|
||||
if (calculated <= 0 || calculated > 75 || tangent < Math.Tan(minimum) || tangent > Math.Tan(maximum))
|
||||
continue;
|
||||
charge = i;
|
||||
reachable = true;
|
||||
break;
|
||||
}
|
||||
if (!reachable) { system.Withhold(battery, "no native ballistic solution within elevation/flight-time limits", string.Format("iteration=%1 distance=%2 altitude=%3 charges=%4 elevation radians=%5..%6 flight limit=75s", iteration, distance, altitude, coefficients.Count(), minimum, maximum)); return null; }
|
||||
converged = Math.AbsFloat(calculated - flight) < 1;
|
||||
flight = calculated;
|
||||
if (converged)
|
||||
break;
|
||||
}
|
||||
if (!converged) { system.Withhold(battery, "moving intercept did not converge in four iterations"); return null; }
|
||||
if (!system.Safe(battery, impact, flight + 2)) { system.Withhold(battery, "aim impact safety rejected", system.SafetyReason); return null; }
|
||||
ERR_MortarAimSolution solution = new ERR_MortarAimSolution();
|
||||
solution.Gun = gun;
|
||||
solution.Report = report;
|
||||
solution.OrderId = battery.OrderId;
|
||||
solution.Shot = battery.OrderShots;
|
||||
solution.GunOrigin = gunOrigin;
|
||||
solution.Impact = impact;
|
||||
solution.AimTarget = impact + Vector(0, offset, 0);
|
||||
solution.SalvoOffset = salvoOffset;
|
||||
solution.Charge = charge;
|
||||
solution.FlightTime = flight;
|
||||
return solution;
|
||||
}
|
||||
}
|
||||
|
||||
modded class SCR_AIShootStaticArtillery
|
||||
{
|
||||
override ENodeResult EOnTaskSimulate(AIAgent owner, float dt)
|
||||
{
|
||||
IEntity gun;
|
||||
GetVariableIn(PORT_ARTILLERY_ENTITY, gun);
|
||||
ERR_MortarSystem system = ERR_MortarSystem.Get();
|
||||
ERR_MortarBattery battery;
|
||||
if (system)
|
||||
battery = system.FindGun(gun);
|
||||
if (battery && !system.AllowShot(battery))
|
||||
{
|
||||
battery.Trace("SHOOT NODE", "authorization rejected", battery.LastFailure);
|
||||
return ENodeResult.FAIL;
|
||||
}
|
||||
ENodeResult result = super.EOnTaskSimulate(owner, dt);
|
||||
if (battery)
|
||||
battery.Trace("SHOOT NODE", string.Format("native result=%1", result), "authorization passed; node result does not confirm a physical shot");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
modded class SCR_MortarMuzzleComponent
|
||||
{
|
||||
protected override void TransferShellToMortar()
|
||||
{
|
||||
ERR_MortarSystem system = ERR_MortarSystem.Get();
|
||||
ERR_MortarBattery battery;
|
||||
if (system && m_CharController && !EntityUtils.IsPlayer(m_CharController.GetOwner()))
|
||||
battery = system.FindGun(GetOwner());
|
||||
if (battery)
|
||||
{
|
||||
// The loading animation takes time. Recheck at the actual transfer,
|
||||
// before native inventory insertion triggers the physical projectile.
|
||||
if (!system.AllowShot(battery))
|
||||
{
|
||||
if (m_ShellComponent)
|
||||
{
|
||||
m_ShellComponent.GetOnShellUsed().Remove(TransferShellToMortar);
|
||||
m_ShellComponent.SetLoadedState(false);
|
||||
}
|
||||
CharacterAnimationComponent animation = m_CharController.GetAnimationComponent();
|
||||
if (animation && animation.GetCommandHandler())
|
||||
animation.GetCommandHandler().FinishItemUse(false);
|
||||
battery.Audit("ABORT", "final shell transfer rejected; " + battery.LastFailure);
|
||||
return;
|
||||
}
|
||||
battery.Released = true;
|
||||
battery.ReserveShellAirspace(GetGame().GetWorld().GetWorldTime());
|
||||
battery.Audit("SHELL COMMIT", string.Format("final safety passed; gunner=%1 predicted impact=%2 flight=%3s; invoking native transfer, awaiting projectile event", m_CharController.GetOwner(), battery.Impact, battery.FlightTime));
|
||||
}
|
||||
super.TransferShellToMortar();
|
||||
}
|
||||
|
||||
override void OnWeaponFired(IEntity effectEntity, BaseMuzzleComponent muzzle, IEntity projectileEntity)
|
||||
{
|
||||
IEntity firingCharacter;
|
||||
if (m_CharController) firingCharacter = m_CharController.GetOwner();
|
||||
super.OnWeaponFired(effectEntity, muzzle, projectileEntity);
|
||||
ERR_MortarSystem system = ERR_MortarSystem.Get();
|
||||
ERR_MortarBattery battery;
|
||||
if (system)
|
||||
battery = system.FindGun(GetOwner());
|
||||
if (!battery)
|
||||
return;
|
||||
bool playerGunner;
|
||||
if (firingCharacter) playerGunner = EntityUtils.IsPlayer(firingCharacter);
|
||||
battery.ObserveProjectile(projectileEntity, firingCharacter, playerGunner);
|
||||
}
|
||||
}
|
||||
|
||||
// The native tree normally re-aims only every five shots (and skips the first
|
||||
// re-aim). Each salvo shell needs its own spread point and fresh authorization.
|
||||
modded class SCR_AIStaticArtilleryBehavior
|
||||
{
|
||||
ERR_MortarBattery ERR_GetMortarBattery()
|
||||
{
|
||||
ERR_MortarSystem system = ERR_MortarSystem.Get();
|
||||
if (!system) return null;
|
||||
return system.FindGun(m_ArtilleryEntity.m_Value);
|
||||
}
|
||||
}
|
||||
|
||||
modded class SCR_AIStaticArtilleryBehavior_ShootingLogic
|
||||
{
|
||||
override ENodeResult EOnTaskSimulate(AIAgent owner, float dt)
|
||||
{
|
||||
ERR_MortarBattery battery;
|
||||
SCR_AIStaticArtilleryBehavior behavior;
|
||||
if (m_Utility)
|
||||
{
|
||||
behavior = SCR_AIStaticArtilleryBehavior.Cast(m_Utility.GetExecutedAction());
|
||||
if (behavior) battery = behavior.ERR_GetMortarBattery();
|
||||
}
|
||||
if (battery)
|
||||
{
|
||||
int gate = ERR_MortarSupplyRules.CycleGate(battery.CommandOpen, battery.OrderShots, behavior.GetShotCount(), ERR_MortarSalvo.ROUNDS, ERR_MortarSupplyState.READY);
|
||||
if (gate < 0) return ENodeResult.FAIL;
|
||||
if (gate == 0) return ENodeResult.RUNNING;
|
||||
// Native super increments the behavior counter. Wait BEFORE calling it,
|
||||
// so repeated pending ticks cannot count the same projectile twice.
|
||||
if (battery.OrderShots < ERR_MortarSalvo.ROUNDS)
|
||||
{
|
||||
float now = GetGame().GetWorld().GetWorldTime();
|
||||
ERR_MortarSupplyState supply = battery.SupplyState(now);
|
||||
if (supply == ERR_MortarSupplyState.PENDING) return ENodeResult.RUNNING;
|
||||
if (supply == ERR_MortarSupplyState.FAILED)
|
||||
{
|
||||
ERR_MortarSystem system = ERR_MortarSystem.Get();
|
||||
if (system) system.Withhold(battery, system.SupplyFailure(battery, now));
|
||||
return ENodeResult.FAIL;
|
||||
}
|
||||
}
|
||||
}
|
||||
ENodeResult result = super.EOnTaskSimulate(owner, dt);
|
||||
if (battery && result == ENodeResult.SUCCESS)
|
||||
{
|
||||
SetVariableOut(PORT_AIMING_NEEDED, true);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+347
@@ -0,0 +1,347 @@
|
||||
class ERR_MortarSupplyStatus
|
||||
{
|
||||
SCR_AIGroup Group;
|
||||
IEntity Gun;
|
||||
vector Position;
|
||||
float ObservedAt;
|
||||
int MessageId;
|
||||
int Shells;
|
||||
}
|
||||
class ERR_MortarLogisticsRules
|
||||
{
|
||||
static bool Fresh(float observedAt, float now) { return observedAt >= 0 && now >= observedAt && now - observedAt <= 180000; }
|
||||
static bool Need(int shells) { return shells >= 0 && shells <= 6; }
|
||||
static bool NewRequest(int message, int consumed, bool fresh) { return fresh && message > 0 && message > consumed; }
|
||||
static bool Near(float distance) { return distance >= 0 && distance <= 12; }
|
||||
static bool ActiveTrip(bool server, bool stopped, int trip, int current, bool fresh, bool ownershipValid) { return server && !stopped && trip > 0 && trip == current && fresh && ownershipValid; }
|
||||
}
|
||||
// One finite western-cache courier, owned and ticked exclusively by HQ.
|
||||
class ERR_MortarLogistics
|
||||
{
|
||||
protected ERR_HQ m_HQ;
|
||||
protected ERR_GameMode m_Mode;
|
||||
protected ERR_SiteRuntime m_Site;
|
||||
protected IEntity m_Cache;
|
||||
protected SCR_AIGroup m_Group;
|
||||
protected ERR_MortarBattery m_Battery;
|
||||
protected IEntity m_OriginalGun;
|
||||
protected SCR_AIGroup m_OriginalBatteryGroup;
|
||||
protected ref array<IEntity> m_BatteryMembers = {};
|
||||
protected ref array<IEntity> m_OriginalMembers = {};
|
||||
protected ref array<IEntity> m_Stock = {};
|
||||
protected ref array<IEntity> m_Cargo = {};
|
||||
protected ref array<IEntity> m_AttemptedPickup = {};
|
||||
protected ref array<IEntity> m_AttemptedUnload = {};
|
||||
protected ref array<ref ERR_MortarInitialItem> m_Initial = {};
|
||||
protected ref ERR_MortarSupplyStatus m_Report;
|
||||
protected ref ERR_HQRoute m_Order;
|
||||
protected ref ERR_MortarCourierTransfer m_Move;
|
||||
protected IEntity m_Courier;
|
||||
protected AIWaypoint m_Waypoint;
|
||||
protected AIWaypoint m_HoldWaypoint;
|
||||
protected float m_InitialDeadline;
|
||||
protected float m_NextReport;
|
||||
protected float m_LastDeliveryAt = -1;
|
||||
protected int m_ConsumedMessage;
|
||||
protected int m_Trip;
|
||||
protected int m_State; // 0 initial, 1 idle, 2 downlink, 3 pickup, 4 travel, 5 unload
|
||||
protected int m_Delivered;
|
||||
protected int m_CompletedTrips;
|
||||
protected bool m_Started;
|
||||
protected bool m_Stopped;
|
||||
protected string m_Reason;
|
||||
static const int INITIAL_SHELLS = 12;
|
||||
static const int TRIP_SHELLS = 6;
|
||||
void ERR_MortarLogistics(ERR_HQ hq, ERR_GameMode mode) { m_HQ = hq; m_Mode = mode; }
|
||||
int State() { return m_State; }
|
||||
int Trip() { return m_Trip; }
|
||||
int Delivered() { return m_Delivered; }
|
||||
int CompletedTrips() { return m_CompletedTrips; }
|
||||
IEntity Cache() { return m_Cache; }
|
||||
SCR_AIGroup Group() { return m_Group; }
|
||||
IEntity Courier() { return m_Courier; }
|
||||
ERR_MortarBattery Battery() { return m_Battery; }
|
||||
ERR_MortarSupplyStatus Report() { return m_Report; }
|
||||
bool Stopped() { return m_Stopped; }
|
||||
void InitialStock(out array<IEntity> items) { items.Copy(m_Stock); }
|
||||
protected void Audit(string kind, string reason)
|
||||
{
|
||||
int message; if (m_Report) message = m_Report.MessageId;
|
||||
m_Reason = reason;
|
||||
m_HQ.Journal().Write("COURIER " + kind, string.Format("trip=%1 state=%2 delivered=%3; %4", m_Trip, m_State, m_Delivered, reason), message);
|
||||
}
|
||||
protected InventoryStorageManagerComponent Inventory(IEntity entity)
|
||||
{
|
||||
if (!entity) return null;
|
||||
return InventoryStorageManagerComponent.Cast(entity.FindComponent(InventoryStorageManagerComponent));
|
||||
}
|
||||
protected bool Contains(IEntity owner, IEntity shell)
|
||||
{
|
||||
InventoryStorageManagerComponent inventory = Inventory(owner);
|
||||
if (!inventory || !shell) return false;
|
||||
array<IEntity> items = {}; ERR_Inventory.Items(inventory, items); return items.Contains(shell);
|
||||
}
|
||||
protected void Shells(IEntity owner, out array<IEntity> shells)
|
||||
{
|
||||
shells.Clear(); InventoryStorageManagerComponent inventory = Inventory(owner); if (!inventory) return;
|
||||
array<IEntity> items = {}; ERR_Inventory.Items(inventory, items);
|
||||
foreach (IEntity item : items)
|
||||
if (item && item.GetPrefabData() && item.GetPrefabData().GetPrefabName() == ERR_MortarBattery.SHELL) shells.Insert(item);
|
||||
}
|
||||
bool Prepare()
|
||||
{
|
||||
if (!Replication.IsServer() || m_Started || m_Stopped) return false;
|
||||
m_Started = true;
|
||||
ERR_ScenarioComponent scenario = ERR_ScenarioComponent.Cast(m_Mode.FindComponent(ERR_ScenarioComponent));
|
||||
if (!scenario || !scenario.Sites()) return false;
|
||||
m_Site = scenario.Sites().Find("everon-supply-2");
|
||||
if (!m_Site || !m_Site.Group || !m_Site.PatrolCycle || !scenario.Sites().Owns(m_Site.Container)) return false;
|
||||
m_Cache = m_Site.Container; m_Group = m_Site.Group;
|
||||
array<ref ERR_MortarBattery> batteries = {}; m_HQ.Mortars().Batteries(batteries);
|
||||
if (batteries.Count() != 1) return false;
|
||||
m_Battery = batteries[0]; m_OriginalGun = m_Battery.Gun; m_OriginalBatteryGroup = m_Battery.Group;
|
||||
InventoryStorageManagerComponent inventory = Inventory(m_Cache);
|
||||
if (!inventory || !inventory.CanInsertResource(ERR_MortarBattery.SHELL, EStoragePurpose.PURPOSE_DEPOSIT)) return false;
|
||||
array<IEntity> existing = {}; Shells(m_Cache, existing); if (!existing.IsEmpty()) return false;
|
||||
array<AIAgent> agents = {}; m_Group.GetAgents(agents);
|
||||
foreach (AIAgent agent : agents)
|
||||
{
|
||||
if (!agent || !ERR_Actors.CanFight(agent.GetControlledEntity())) return false;
|
||||
m_OriginalMembers.Insert(agent.GetControlledEntity());
|
||||
}
|
||||
if (m_OriginalMembers.Count() != m_Site.InitialGuardCount) return false;
|
||||
m_InitialDeadline = GetGame().GetWorld().GetWorldTime() + 15000;
|
||||
// SiteService owns this existing cache and its contents through rollback/world teardown.
|
||||
// This bounded startup allocation is never reopened, including partial failure/theft.
|
||||
for (int i = 0; i < INITIAL_SHELLS; i++)
|
||||
{
|
||||
IEntity shell = ERR_Actors.Spawn(ERR_MortarBattery.SHELL, m_Cache.GetOrigin());
|
||||
if (!shell) return false;
|
||||
// Keep the exact created identity; counts cannot prove an external item was
|
||||
// not substituted while an asynchronous insertion was pending.
|
||||
m_Mode.Own(shell); m_Stock.Insert(shell);
|
||||
ERR_MortarInitialItem operation = new ERR_MortarInitialItem(); m_Initial.Insert(operation);
|
||||
if (!inventory.TryInsertItem(shell, EStoragePurpose.PURPOSE_DEPOSIT, operation)) { operation.Rejected(); return false; }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
protected bool OriginalGroup()
|
||||
{
|
||||
if (!m_Site || m_Site.Group != m_Group || m_Site.Container != m_Cache || !m_Group || !m_Cache || m_Group.GetPlayerCount(true) > 0 || m_Group.GetFaction() != m_Mode.EnemyFaction()) return false;
|
||||
array<AIAgent> agents = {}; m_Group.GetAgents(agents); bool living;
|
||||
foreach (AIAgent agent : agents)
|
||||
{
|
||||
if (!agent || !agent.GetControlledEntity()) continue;
|
||||
IEntity actor = agent.GetControlledEntity();
|
||||
if (!m_OriginalMembers.Contains(actor) || ERR_Actors.Player(actor) || ERR_Actors.Group(actor) != m_Group || SCR_FactionManager.SGetFaction(actor) != m_Mode.EnemyFaction()) return false;
|
||||
if (ERR_Actors.CanFight(actor)) living = true;
|
||||
}
|
||||
return living;
|
||||
}
|
||||
protected bool BindBatteryMembers()
|
||||
{
|
||||
if (!m_Battery || !m_Battery.StartupActivated || m_Battery.Group != m_OriginalBatteryGroup || m_Battery.Gun != m_OriginalGun) return false;
|
||||
if (!m_BatteryMembers.IsEmpty()) return true;
|
||||
array<AIAgent> agents = {}; m_Battery.Group.GetAgents(agents);
|
||||
foreach (AIAgent agent : agents)
|
||||
if (agent && agent.GetControlledEntity() && !m_BatteryMembers.Contains(agent.GetControlledEntity())) m_BatteryMembers.Insert(agent.GetControlledEntity());
|
||||
return !m_BatteryMembers.IsEmpty();
|
||||
}
|
||||
bool TargetAvailable()
|
||||
{
|
||||
if (!m_Battery || m_Battery.Gun != m_OriginalGun || m_Battery.Group != m_OriginalBatteryGroup || !m_OriginalGun || !m_Battery.StartupActivated || m_Battery.Stopped() || !m_Battery.Operational() || m_HQ.Mortars().FindGun(m_OriginalGun) != m_Battery || m_BatteryMembers.IsEmpty()) return false;
|
||||
if (SCR_FactionManager.SGetFaction(m_OriginalGun) != m_Mode.EnemyFaction()) return false;
|
||||
SCR_AIVehicleUsageComponent usage = SCR_AIVehicleUsageComponent.Cast(m_OriginalGun.FindComponent(SCR_AIVehicleUsageComponent));
|
||||
if (!usage || !usage.GetTurretCompartmentSlot()) return false;
|
||||
IEntity occupant = usage.GetTurretCompartmentSlot().GetOccupant();
|
||||
if (!occupant) return true;
|
||||
return m_BatteryMembers.Contains(occupant) && ERR_Actors.CanFight(occupant) && !ERR_Actors.Player(occupant) && ERR_Actors.Group(occupant) == m_OriginalBatteryGroup && SCR_FactionManager.SGetFaction(occupant) == m_Mode.EnemyFaction();
|
||||
}
|
||||
bool CanSample(ERR_MortarBattery battery) { return battery == m_Battery && TargetAvailable(); }
|
||||
protected bool Bearer(IEntity actor)
|
||||
{
|
||||
return actor && m_OriginalMembers.Contains(actor) && ERR_Actors.Group(actor) == m_Group && ERR_Actors.CanFight(actor) && !ERR_Actors.Player(actor) && !CompartmentAccessComponent.GetVehicleIn(actor) && SCR_FactionManager.SGetFaction(actor) == m_Mode.EnemyFaction() && ERR_HQRadio.Capability(actor) != 2;
|
||||
}
|
||||
bool AcceptsStatus(ERR_MortarSupplyStatus report, float now)
|
||||
{
|
||||
return !m_Stopped && m_State == 1 && report && m_Battery && report.Group == m_Battery.Group && report.Gun == m_Battery.Gun && ERR_MortarLogisticsRules.Need(report.Shells) && ERR_MortarLogisticsRules.NewRequest(report.MessageId, m_ConsumedMessage, ERR_MortarLogisticsRules.Fresh(report.ObservedAt, now));
|
||||
}
|
||||
void Receive(ERR_MortarSupplyStatus report, float now)
|
||||
{
|
||||
if (!AcceptsStatus(report, now)) return;
|
||||
m_Report = report;
|
||||
Audit("RECEIVED", "immutable low-ammunition status delivered by native physical radio route");
|
||||
}
|
||||
protected bool Authorized(float now)
|
||||
{
|
||||
if (!m_Mode || !m_Mode.Active() || !m_Report || !m_Battery) return false;
|
||||
return ERR_MortarLogisticsRules.ActiveTrip(Replication.IsServer(), m_Stopped, m_Trip, m_HQ.LogisticsToken(m_Group), ERR_MortarLogisticsRules.Fresh(m_Report.ObservedAt, now), OriginalGroup()) && TargetAvailable() && m_Report.Gun == m_Battery.Gun && m_Report.Group == m_Battery.Group && vector.Distance(m_Report.Position, m_Battery.Gun.GetOrigin()) <= 5;
|
||||
}
|
||||
bool TransferAllowed(int trip, IEntity shell, IEntity from, IEntity to, float now)
|
||||
{
|
||||
if (trip != m_Trip || !Authorized(now) || !shell || !m_Stock.Contains(shell) || !Bearer(m_Courier)) return false;
|
||||
if (m_State == 3) return from == m_Cache && to == m_Courier && ERR_MortarLogisticsRules.Near(vector.Distance(from.GetOrigin(), to.GetOrigin()));
|
||||
if (m_State != 5 || from != m_Courier || !m_Cargo.Contains(shell) || !ERR_Actors.CanFight(to) || ERR_Actors.Player(to) || to == m_Battery.RadioOperator || !m_BatteryMembers.Contains(to) || ERR_Actors.Group(to) != m_Battery.Group || SCR_FactionManager.SGetFaction(to) != m_Mode.EnemyFaction()) return false;
|
||||
return !CompartmentAccessComponent.GetVehicleIn(to) && ERR_MortarLogisticsRules.Near(vector.Distance(from.GetOrigin(), to.GetOrigin()));
|
||||
}
|
||||
protected void ClearWaypoint()
|
||||
{
|
||||
if (m_Waypoint) { if (m_Group) m_Group.RemoveWaypoint(m_Waypoint); ERR_Actors.DeleteSpawned(m_Waypoint); }
|
||||
m_Waypoint = null;
|
||||
if (m_HoldWaypoint) { if (m_Group) m_Group.RemoveWaypoint(m_HoldWaypoint); ERR_Actors.DeleteSpawned(m_HoldWaypoint); }
|
||||
m_HoldWaypoint = null;
|
||||
}
|
||||
protected bool MoveTo(vector position)
|
||||
{
|
||||
ClearWaypoint();
|
||||
m_Waypoint = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.MoveWaypoint(), ERR_Actors.Ground(position)));
|
||||
if (!m_Waypoint) return false;
|
||||
// A completed Move alone would let the original patrol resume during the
|
||||
// asynchronous item operation. This owned, non-completing native Defend
|
||||
// keeps that queue below us until release; its original entries are untouched.
|
||||
m_HoldWaypoint = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.DefendWaypoint(), ERR_Actors.Ground(position)));
|
||||
if (!m_HoldWaypoint) { ClearWaypoint(); return false; }
|
||||
m_HoldWaypoint.SetCompletionRadius(8);
|
||||
m_Group.AddWaypointAt(m_HoldWaypoint, 0); m_Group.AddWaypointAt(m_Waypoint, 0); m_Group.ActivateAI(); return true;
|
||||
}
|
||||
protected void Finish(string reason)
|
||||
{
|
||||
Audit("END", reason);
|
||||
if (m_Move) m_Move.Invalidate();
|
||||
ClearWaypoint(); m_HQ.ReleaseLogistics(m_Group, m_Trip);
|
||||
m_Order = null; m_Report = null; m_Courier = null; m_Cargo.Clear(); m_State = 1;
|
||||
// Native requests cannot be canceled. A pending move remains held until its
|
||||
// physical outcome resolves; callbacks cannot authorize a later trip.
|
||||
m_NextReport = GetGame().GetWorld().GetWorldTime() + 60000;
|
||||
}
|
||||
protected bool BeginMove(IEntity shell, IEntity from, IEntity to, float now)
|
||||
{
|
||||
InventoryStorageManagerComponent destination = Inventory(to);
|
||||
BaseInventoryStorageComponent storage; if (destination) storage = destination.FindStorageForItem(shell, EStoragePurpose.PURPOSE_DEPOSIT);
|
||||
if (!storage) return false;
|
||||
m_Move = new ERR_MortarCourierTransfer(this, m_Trip, shell, from, to, storage, now);
|
||||
return m_Move.Begin(now);
|
||||
}
|
||||
protected bool PollMove(float now)
|
||||
{
|
||||
if (!m_Move) return true;
|
||||
int result = m_Move.Poll(now);
|
||||
if (result == 0) return false;
|
||||
if (result < 0) { Finish("native item operation rejected, expired or lost authority; physical items retained"); return false; }
|
||||
IEntity shell = m_Move.Shell(); m_Move = null;
|
||||
if (m_State == 3) { m_Cargo.Insert(shell); Audit("PICKUP", "exact depot shell confirmed in original bearer inventory"); }
|
||||
else if (m_State == 5) { m_Cargo.RemoveItem(shell); m_Delivered++; m_LastDeliveryAt = now; Audit("DELIVERED", "exact original shell confirmed in living mortar crew inventory"); }
|
||||
return true;
|
||||
}
|
||||
protected void Pickup(float now)
|
||||
{
|
||||
if (!m_Courier)
|
||||
{
|
||||
foreach (IEntity candidate : m_OriginalMembers)
|
||||
if (Bearer(candidate) && Inventory(candidate) && Inventory(candidate).CanInsertResource(ERR_MortarBattery.SHELL, EStoragePurpose.PURPOSE_DEPOSIT) && ERR_MortarLogisticsRules.Near(vector.Distance(candidate.GetOrigin(), m_Cache.GetOrigin()))) { m_Courier = candidate; break; }
|
||||
if (!m_Courier) return;
|
||||
}
|
||||
if (!Bearer(m_Courier)) { Finish("original bearer lost or captured"); return; }
|
||||
if (!PollMove(now)) return;
|
||||
if (m_Cargo.Count() < TRIP_SHELLS)
|
||||
{
|
||||
foreach (IEntity shell : m_Stock)
|
||||
{
|
||||
if (!shell || !Contains(m_Cache, shell) || m_AttemptedPickup.Contains(shell)) continue;
|
||||
InventoryStorageManagerComponent destination = Inventory(m_Courier);
|
||||
if (!destination || !destination.FindStorageForItem(shell, EStoragePurpose.PURPOSE_DEPOSIT)) break;
|
||||
m_AttemptedPickup.Insert(shell);
|
||||
if (!BeginMove(shell, m_Cache, m_Courier, now)) Finish("native pickup rejected; no second request for that shell");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (m_Cargo.IsEmpty()) { Finish("finite cache empty or bearer native storage full"); return; }
|
||||
m_State = 4;
|
||||
if (!MoveTo(m_Report.Position)) { Finish("native delivery waypoint unavailable"); return; }
|
||||
Audit("OUTBOUND", "original patrol walking with verified finite cargo to reported battery position");
|
||||
}
|
||||
protected void Unload(float now)
|
||||
{
|
||||
if (!PollMove(now)) return;
|
||||
if (m_Cargo.IsEmpty()) { m_CompletedTrips++; Finish("finite delivery complete; original patrol restored"); return; }
|
||||
array<AIAgent> agents = {}; m_Battery.Group.GetAgents(agents);
|
||||
foreach (IEntity shell : m_Cargo)
|
||||
{
|
||||
if (!shell || !Contains(m_Courier, shell)) { Finish("carried shell lost or stolen; no replacement"); return; }
|
||||
if (m_AttemptedUnload.Contains(shell)) { Finish("previous unload unresolved or failed; no retry"); return; }
|
||||
foreach (AIAgent agent : agents)
|
||||
{
|
||||
if (!agent) continue;
|
||||
IEntity recipient = agent.GetControlledEntity();
|
||||
if (!TransferAllowed(m_Trip, shell, m_Courier, recipient, now)) continue;
|
||||
InventoryStorageManagerComponent destination = Inventory(recipient);
|
||||
if (!destination || !destination.FindStorageForItem(shell, EStoragePurpose.PURPOSE_DEPOSIT)) continue;
|
||||
m_AttemptedUnload.Insert(shell);
|
||||
if (!BeginMove(shell, m_Courier, recipient, now)) Finish("native recipient move rejected; cargo remains physical");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
void Tick(float now)
|
||||
{
|
||||
if (m_Stopped || !m_Started || !Replication.IsServer() || !m_Mode.Active()) return;
|
||||
if (m_State == 0)
|
||||
{
|
||||
bool failed; foreach (ERR_MortarInitialItem operation : m_Initial) { if (!operation.Finished) { if (now < m_InitialDeadline) return; failed = true; } else if (!operation.Succeeded) failed = true; }
|
||||
if (!OriginalGroup()) failed = true;
|
||||
array<IEntity> shells = {}; Shells(m_Cache, shells);
|
||||
if (shells.Count() != INITIAL_SHELLS) { if (now < m_InitialDeadline && !failed) return; failed = true; }
|
||||
foreach (IEntity originalShell : m_Stock) if (!originalShell || !shells.Contains(originalShell)) failed = true;
|
||||
if (m_Stock.Count() != INITIAL_SHELLS) failed = true;
|
||||
if (failed) { Audit("STARTUP FAILED", "one-time native stock allocation incomplete; no refill"); Stop(); return; }
|
||||
m_State = 1; Audit("READY", "12 distinct original HE shells physically confirmed in existing western cache");
|
||||
}
|
||||
if (m_State == 1)
|
||||
{
|
||||
if (m_Move) { m_Move.Poll(now); if (m_Move.Unresolved()) return; m_Move = null; }
|
||||
if (!OriginalGroup() || !BindBatteryMembers() || !TargetAvailable()) return;
|
||||
bool available; foreach (IEntity stocked : m_Stock) if (Contains(m_Cache, stocked) && !m_AttemptedPickup.Contains(stocked)) available = true;
|
||||
if (!available) return;
|
||||
if (now >= m_NextReport && ERR_MortarLogisticsRules.Need(m_Battery.CountShells())) { if (m_HQ.QueueSupplyStatus(m_Battery, now)) m_NextReport = now + 60000; }
|
||||
if (!m_Report || !ERR_MortarLogisticsRules.NewRequest(m_Report.MessageId, m_ConsumedMessage, ERR_MortarLogisticsRules.Fresh(m_Report.ObservedAt, now))) return;
|
||||
int nextTrip = m_Trip + 1;
|
||||
if (!m_HQ.ReserveLogistics(m_Group, nextTrip)) return;
|
||||
m_Trip = nextTrip; m_ConsumedMessage = m_Report.MessageId;
|
||||
m_Order = m_HQ.Downlink(m_Group, "MORTAR SUPPLY", "collect finite western-cache HE; walk to reported battery", m_Report.MessageId);
|
||||
m_State = 2;
|
||||
if (!m_Order) { Finish("no native courier downlink"); return; }
|
||||
Audit("QUEUED", "original patrol reserved pending physical radio command delivery");
|
||||
}
|
||||
if (!Authorized(now)) { Finish("supply report expired or original ownership/gun/group changed"); return; }
|
||||
if (m_Courier && !Bearer(m_Courier)) { Finish("original bearer unavailable; surviving cargo retained"); return; }
|
||||
if (m_State == 2)
|
||||
{
|
||||
int delivered = m_Order.Advance(now, m_HQ.Journal());
|
||||
if (delivered == 0) return;
|
||||
if (delivered < 0) { Finish("courier command interrupted"); return; }
|
||||
m_Order = null; m_State = 3;
|
||||
if (!MoveTo(m_Cache.GetOrigin())) { Finish("native pickup waypoint unavailable"); return; }
|
||||
Audit("DISPATCH", "same original patrol receives delivered order and walks to finite cache");
|
||||
}
|
||||
if (m_State == 3) { Pickup(now); return; }
|
||||
if (m_State == 4)
|
||||
{
|
||||
if (!ERR_MortarLogisticsRules.Near(vector.Distance(m_Courier.GetOrigin(), m_Report.Position))) return;
|
||||
m_State = 5; Audit("ARRIVED", "physical bearer reached reported gun area; exact recipient checks still required");
|
||||
}
|
||||
if (m_State == 5) Unload(now);
|
||||
}
|
||||
void Stop()
|
||||
{
|
||||
if (m_Stopped) return;
|
||||
m_Stopped = true;
|
||||
if (m_Move) m_Move.Invalidate();
|
||||
ClearWaypoint(); m_HQ.ReleaseLogistics(m_Group, m_Trip); m_Order = null; m_Report = null;
|
||||
}
|
||||
#ifdef WORKBENCH
|
||||
ERR_HQRoute ProbeOrder() { return m_Order; }
|
||||
int ProbeSourceMessage() { return m_ConsumedMessage; }
|
||||
float ProbeLastDelivery() { return m_LastDeliveryAt; }
|
||||
#endif
|
||||
string Status() { return string.Format("courier state=%1 finite initial=%2 delivered=%3 stopped=%4 (%5)", m_State, m_Stock.Count(), m_Delivered, m_Stopped, m_Reason); }
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
// All target coordinates come from delivered HQ observations, never a player's
|
||||
// current transform. Road projection stops at an unknown junction/road end.
|
||||
class ERR_MortarPrediction
|
||||
{
|
||||
// Read immediately after a failed synchronous prediction; no extra world scan.
|
||||
static string FailureReason;
|
||||
// Includes observation uplink, HQ downlink and native aiming. All stages
|
||||
// retain the original observation time; radio delivery never renews it.
|
||||
static const float MAX_REPORT_AGE_MS = 60000;
|
||||
|
||||
static bool Fresh(ERR_HQReport report, float now)
|
||||
{
|
||||
return report && report.Target && !report.Distress && now >= report.ObservedAt && now - report.ObservedAt <= MAX_REPORT_AGE_MS && report.TravelMode != "aircraft";
|
||||
}
|
||||
|
||||
static bool Reject(string reason)
|
||||
{
|
||||
FailureReason = reason;
|
||||
return false;
|
||||
}
|
||||
|
||||
static vector ClosestOnSegment(vector point, vector start, vector end)
|
||||
{
|
||||
vector segment = end - start;
|
||||
segment[1] = 0;
|
||||
vector offset = point - start;
|
||||
offset[1] = 0;
|
||||
float lengthSq = segment.LengthSq();
|
||||
if (lengthSq < 0.01)
|
||||
return start;
|
||||
return vector.Lerp(start, end, Math.Clamp(vector.Dot(offset, segment) / lengthSq, 0, 1));
|
||||
}
|
||||
|
||||
static bool FollowRoad(array<vector> points, vector observed, vector velocity, float seconds, out vector impact)
|
||||
{
|
||||
if (!points || points.Count() < 2)
|
||||
return Reject("road polyline has fewer than two points");
|
||||
int segment = -1;
|
||||
float closest = 20;
|
||||
vector projected;
|
||||
for (int i = 0; i < points.Count() - 1; i++)
|
||||
{
|
||||
vector candidate = ClosestOnSegment(observed, points[i], points[i + 1]);
|
||||
float distance = vector.DistanceXZ(candidate, observed);
|
||||
if (distance < closest)
|
||||
{
|
||||
closest = distance;
|
||||
segment = i;
|
||||
projected = candidate;
|
||||
}
|
||||
}
|
||||
if (segment < 0)
|
||||
return Reject("observed vehicle is not within 20m of a road segment");
|
||||
vector forward = points[segment + 1] - points[segment];
|
||||
forward[1] = 0;
|
||||
forward.Normalize();
|
||||
vector travel = velocity;
|
||||
travel[1] = 0;
|
||||
float speed = travel.Length();
|
||||
travel.Normalize();
|
||||
float alignment = vector.Dot(forward, travel);
|
||||
if (Math.AbsFloat(alignment) < 0.7)
|
||||
return Reject("vehicle heading crosses road; alignment below 0.7");
|
||||
int step = 1;
|
||||
int next = segment + 1;
|
||||
if (alignment < 0)
|
||||
{
|
||||
step = -1;
|
||||
next = segment;
|
||||
}
|
||||
float remaining = speed * seconds;
|
||||
vector position = projected;
|
||||
while (next >= 0 && next < points.Count())
|
||||
{
|
||||
float length = vector.DistanceXZ(position, points[next]);
|
||||
if (length >= remaining && length > 0.01)
|
||||
{
|
||||
impact = vector.Lerp(position, points[next], remaining / length);
|
||||
return true;
|
||||
}
|
||||
remaining -= length;
|
||||
position = points[next];
|
||||
next += step;
|
||||
}
|
||||
return Reject("prediction reaches unknown road end/junction");
|
||||
}
|
||||
|
||||
static bool Predict(ERR_HQReport report, float now, float flightSeconds, out vector impact)
|
||||
{
|
||||
FailureReason = "";
|
||||
if (!report) return Reject("target report missing");
|
||||
if (report.Distress) return Reject("distress report cannot authorize fire");
|
||||
if (now < report.ObservedAt) return Reject("observation timestamp is in the future");
|
||||
if (now - report.ObservedAt > MAX_REPORT_AGE_MS) return Reject("observation older than 60s");
|
||||
float seconds = (now - report.ObservedAt) / 1000 + flightSeconds;
|
||||
if (seconds < 0 || seconds > 90)
|
||||
return Reject("observation age plus flight outside 0..90s");
|
||||
if (report.TravelMode == "aircraft") return Reject("aircraft target unsupported");
|
||||
if (report.TravelMode == "mixed travel") return Reject("mixed travel cannot be predicted");
|
||||
impact = report.Position;
|
||||
if (!report.HasVelocity || report.Velocity.Length() < 0.5)
|
||||
return true;
|
||||
if (report.Velocity.Length() * seconds > 1800)
|
||||
return Reject("predicted travel exceeds 1800m");
|
||||
bool vehicle = report.TravelMode == "vehicle" || report.TravelMode == "in vehicle";
|
||||
if (vehicle)
|
||||
{
|
||||
SCR_AIWorld world = SCR_AIWorld.Cast(GetGame().GetAIWorld());
|
||||
if (!world || !world.GetRoadNetworkManager())
|
||||
return Reject("road network unavailable");
|
||||
BaseRoad road;
|
||||
float roadDistance;
|
||||
world.GetRoadNetworkManager().GetClosestRoad(report.Position, road, roadDistance, skipNavlinks: true);
|
||||
if (!road) return Reject("no road near reported vehicle position");
|
||||
if (roadDistance > 20) return Reject("reported vehicle farther than 20m from road");
|
||||
array<vector> points = {};
|
||||
road.GetPoints(points);
|
||||
return FollowRoad(points, report.Position, report.Velocity, seconds, impact);
|
||||
}
|
||||
impact += report.Velocity * seconds;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool ThreatensFriendly(vector impact, vector position, vector velocity, float flightSeconds)
|
||||
{
|
||||
velocity[1] = 0;
|
||||
vector future = position + velocity * Math.Clamp(flightSeconds, 0, 90);
|
||||
// Protect the entire predicted path, not only where a soldier is now.
|
||||
return vector.DistanceXZ(impact, ClosestOnSegment(impact, position, future)) <= 100;
|
||||
}
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
enum ERR_MortarSupplyState { FAILED = -1, PENDING, READY }
|
||||
|
||||
class ERR_MortarSupplyRules
|
||||
{
|
||||
static int CycleGate(bool commandOpen, int confirmedShots, int behaviorShots, int limit, int supplyState)
|
||||
{
|
||||
if (!commandOpen) return -1;
|
||||
if (confirmedShots <= behaviorShots) return 0;
|
||||
if (confirmedShots < limit) return supplyState;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
class ERR_MortarInitialItem : ScriptedInventoryOperationCallback
|
||||
{
|
||||
bool Finished;
|
||||
bool Succeeded;
|
||||
protected void Finish(bool success) { if (!Finished) { Finished = true; Succeeded = success; } }
|
||||
protected override void OnComplete() { Finish(true); }
|
||||
protected override void OnFailed() { Finish(false); }
|
||||
protected override void OnAllComplete() { Finish(true); }
|
||||
protected override void OnAllFailed() { Finish(false); }
|
||||
void Rejected() { Finish(false); }
|
||||
}
|
||||
|
||||
// One finite allocation, after the ENTIRE original support crew exists. Failed
|
||||
// or stopped setup retains physical assets and retires only its pending spawn intent.
|
||||
class ERR_MortarReadiness
|
||||
{
|
||||
protected ERR_MortarBattery m_Battery;
|
||||
protected ref array<IEntity> m_Members = {};
|
||||
protected ref array<ref ERR_MortarInitialItem> m_Operations = {};
|
||||
protected bool m_Allocated;
|
||||
protected bool m_Ready;
|
||||
protected bool m_Stopped;
|
||||
protected bool m_Retired;
|
||||
protected int m_RequestedShells;
|
||||
void ERR_MortarReadiness(ERR_MortarBattery battery) { m_Battery = battery; }
|
||||
static ERR_MortarSupplyState Population(int physical, int gunners, int operators, bool complete, bool expired)
|
||||
{
|
||||
if (physical > 4 || gunners > 3 || operators > 1) return ERR_MortarSupplyState.FAILED;
|
||||
if (physical == 4 && gunners == 3 && operators == 1 && complete && !expired) return ERR_MortarSupplyState.READY;
|
||||
if (expired) return ERR_MortarSupplyState.FAILED;
|
||||
return ERR_MortarSupplyState.PENDING;
|
||||
}
|
||||
static bool AllocationValid(int shells) { return shells >= 0 && shells <= 9; }
|
||||
int RequestedShells() { return m_RequestedShells; }
|
||||
bool Ready() { return m_Ready && !m_Stopped; }
|
||||
void Stop()
|
||||
{
|
||||
if (m_Stopped) return;
|
||||
m_Stopped = true;
|
||||
if (m_Ready || m_Retired || !m_Battery || !m_Battery.Group || !Replication.IsServer()) return;
|
||||
m_Retired = true;
|
||||
// As in Core initial guard cleanup: stopping the current request alone does
|
||||
// not retire the native group's still-authored future expansion intent.
|
||||
RetireGroup(m_Battery.Group);
|
||||
}
|
||||
static void RetireGroup(SCR_AIGroup group)
|
||||
{
|
||||
if (!group || !Replication.IsServer()) return;
|
||||
group.SetSpawnImmediately(false);
|
||||
group.m_aUnitPrefabSlots = new array<ResourceName>();
|
||||
if (!GetGame()) return;
|
||||
ChimeraAIWorld world = ChimeraAIWorld.Cast(GetGame().GetAIWorld());
|
||||
if (world) world.PurgeSpawnRequestsForGroup(group);
|
||||
}
|
||||
|
||||
protected ERR_MortarSupplyState Fail(string reason)
|
||||
{
|
||||
m_Battery.SpawnFailed(reason);
|
||||
Stop();
|
||||
return ERR_MortarSupplyState.FAILED;
|
||||
}
|
||||
protected bool Allocate()
|
||||
{
|
||||
m_Allocated = true; // before native requests; never refill or retry a failure
|
||||
foreach (IEntity actor : m_Members)
|
||||
{
|
||||
if (actor == m_Battery.RadioOperator) continue;
|
||||
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(actor.FindComponent(InventoryStorageManagerComponent));
|
||||
int existing = m_Battery.InventoryShells(inventory);
|
||||
if (!AllocationValid(existing)) return false;
|
||||
if (ERR_HQRadio.Capability(actor) == 0 && !inventory.TrySpawnPrefabToStorage("{E1A5D4B878AA8980}Prefabs/Items/Equipment/Radios/Radio_R148.et"))
|
||||
m_Battery.Audit("RADIO EQUIP", "optional crew handset did not fit; dedicated manpack remains required");
|
||||
for (int i = existing; i < 9; i++)
|
||||
{
|
||||
ERR_MortarInitialItem operation = new ERR_MortarInitialItem();
|
||||
m_Operations.Insert(operation);
|
||||
m_RequestedShells++;
|
||||
if (!inventory.TrySpawnPrefabToStorage(ERR_MortarBattery.SHELL, purpose: EStoragePurpose.PURPOSE_DEPOSIT, cb: operation))
|
||||
{
|
||||
operation.Rejected();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
ERR_MortarSupplyState Poll(float now)
|
||||
{
|
||||
if (m_Stopped || !Replication.IsServer() || !m_Battery || m_Battery.Stopped()) return ERR_MortarSupplyState.FAILED;
|
||||
if (m_Ready) return ERR_MortarSupplyState.READY;
|
||||
SCR_AIGroup group = m_Battery.Group;
|
||||
ERR_GameMode mode = ERR_GameMode.Get();
|
||||
if (!mode || !group || !m_Battery.Gun || group.GetFaction() != mode.EnemyFaction() || group.GetPlayerCount(true) > 0) return Fail("initial support group/gun/faction changed");
|
||||
DamageManagerComponent damage = DamageManagerComponent.Cast(m_Battery.Gun.FindComponent(DamageManagerComponent));
|
||||
if (!damage || damage.IsDestroyed()) return Fail("initial support gun missing damage manager or destroyed");
|
||||
bool expired = now < m_Battery.SpawnedAt || now - m_Battery.SpawnedAt >= 90000;
|
||||
array<AIAgent> agents = {}; group.GetAgents(agents);
|
||||
array<IEntity> physical = {};
|
||||
int gunners, operators;
|
||||
IEntity operatorActor;
|
||||
foreach (AIAgent agent : agents)
|
||||
{
|
||||
if (!agent || !agent.GetControlledEntity()) continue;
|
||||
IEntity actor = agent.GetControlledEntity();
|
||||
if (physical.Contains(actor)) return Fail("initial support group reports duplicate physical actor");
|
||||
physical.Insert(actor);
|
||||
if (!ERR_Actors.CanFight(actor) || SCR_FactionManager.SGetFaction(actor) != mode.EnemyFaction() || !actor.GetPrefabData()) return Fail("initial support actor unavailable or has wrong faction/resource");
|
||||
ResourceName prefab = actor.GetPrefabData().GetPrefabName();
|
||||
if (prefab == ERR_MortarBattery.CREW) gunners++;
|
||||
else if (prefab == ERR_MortarBattery.RADIO_OPERATOR) { operators++; operatorActor = actor; }
|
||||
else return Fail("initial support actor does not belong to authored crew catalog");
|
||||
}
|
||||
ERR_MortarSupplyState population = Population(physical.Count(), gunners, operators, group.IsExpandComplete(), expired);
|
||||
if (population == ERR_MortarSupplyState.FAILED) return Fail("initial four-person support population incomplete/invalid within 90 seconds");
|
||||
if (population == ERR_MortarSupplyState.PENDING) return population;
|
||||
if (m_Allocated)
|
||||
{
|
||||
foreach (IEntity original : m_Members)
|
||||
if (!physical.Contains(original)) return Fail("initial support member replaced after finite allocation");
|
||||
}
|
||||
foreach (IEntity member : physical)
|
||||
{
|
||||
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(member.FindComponent(InventoryStorageManagerComponent));
|
||||
if (!inventory) return ERR_MortarSupplyState.PENDING;
|
||||
if (!m_Allocated && member != operatorActor && m_Battery.InventoryShells(inventory) < 9 && !inventory.CanInsertResource(ERR_MortarBattery.SHELL, EStoragePurpose.PURPOSE_DEPOSIT)) return ERR_MortarSupplyState.PENDING;
|
||||
}
|
||||
if (ERR_HQRadio.Capability(operatorActor) != 2) return ERR_MortarSupplyState.PENDING;
|
||||
// Preserve the existing earliest activation delay, but it is not readiness.
|
||||
if (now - m_Battery.SpawnedAt < 10000) return ERR_MortarSupplyState.PENDING;
|
||||
if (!m_Allocated)
|
||||
{
|
||||
if (!group.GetGroupUtilityComponent() || !group.GetGroupUtilityComponent().m_VehicleMgr) return ERR_MortarSupplyState.PENDING;
|
||||
SCR_AIVehicleUsageComponent usage = SCR_AIVehicleUsageComponent.Cast(m_Battery.Gun.FindComponent(SCR_AIVehicleUsageComponent));
|
||||
if (!usage || !group.GetGroupUtilityComponent().m_VehicleMgr.TryAddVehicle(usage)) return Fail("native support gun registration failed");
|
||||
m_Members.Copy(physical); m_Battery.RadioOperator = operatorActor;
|
||||
if (!Allocate()) return Fail("finite initial support shell allocation rejected; no retry");
|
||||
return ERR_MortarSupplyState.PENDING; // never accept mutation-frame readiness
|
||||
}
|
||||
foreach (ERR_MortarInitialItem operation : m_Operations)
|
||||
{
|
||||
if (!operation.Finished) return ERR_MortarSupplyState.PENDING;
|
||||
if (!operation.Succeeded) return Fail("finite initial support shell operation failed; no retry");
|
||||
}
|
||||
foreach (IEntity equipped : m_Members)
|
||||
{
|
||||
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(equipped.FindComponent(InventoryStorageManagerComponent));
|
||||
int shells = m_Battery.InventoryShells(inventory);
|
||||
if (equipped == operatorActor)
|
||||
{
|
||||
if (shells != 0) return Fail("dedicated initial manpack operator unexpectedly carries mortar shells");
|
||||
}
|
||||
else if (shells != 9) return ERR_MortarSupplyState.PENDING;
|
||||
}
|
||||
if (m_Battery.CountShells() != 27) return ERR_MortarSupplyState.PENDING;
|
||||
m_Ready = true;
|
||||
// One physical-ready handoff: HQ already owns/reserves this group, while
|
||||
// Intel must see its actual late-spawned members after finite stock settles.
|
||||
mode.RegisterGroup(group);
|
||||
group.ActivateAI();
|
||||
foreach (AIAgent readyAgent : agents)
|
||||
{
|
||||
if (readyAgent) { readyAgent.SetPermanentLOD(0); readyAgent.ActivateAI(); }
|
||||
}
|
||||
m_Battery.Audit("CREW READY", string.Format("four original physical members, one manpack, 27 confirmed HE; new shell requests=%1; allocation permanently closed", m_RequestedShells));
|
||||
return ERR_MortarSupplyState.READY;
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
// One newer observation in transit for one existing physical firing order.
|
||||
// The report's observed time survives both radio legs unchanged.
|
||||
class ERR_MortarRefresh
|
||||
{
|
||||
IEntity Gun;
|
||||
SCR_AIGroup Group;
|
||||
int OrderId;
|
||||
int RadioId;
|
||||
ref ERR_HQReport Report;
|
||||
ref ERR_HQRoute Route;
|
||||
|
||||
void ERR_MortarRefresh(ERR_MortarBattery battery, ERR_HQReport report, ERR_HQRoute route)
|
||||
{
|
||||
Gun = battery.Gun;
|
||||
Group = battery.Group;
|
||||
OrderId = battery.OrderId;
|
||||
RadioId = route.MessageId;
|
||||
Report = report.Snapshot();
|
||||
Route = route;
|
||||
}
|
||||
bool Owns(ERR_MortarBattery battery)
|
||||
{
|
||||
return battery && Gun && Group && Gun == battery.Gun && Group == battery.Group && OrderId == battery.OrderId;
|
||||
}
|
||||
bool DeliveredFor(ERR_MortarBattery battery, ERR_HQReport report)
|
||||
{
|
||||
return !Route && Report && Report == report && Owns(battery);
|
||||
}
|
||||
}
|
||||
|
||||
class ERR_MortarRefreshRules
|
||||
{
|
||||
static bool Newer(ERR_HQReport current, ERR_HQReport incoming, float now)
|
||||
{
|
||||
return current && incoming && current.Target && incoming.Target == current.Target && incoming.MessageId > 0 && incoming.MessageId != current.MessageId && incoming.ObservedAt > current.ObservedAt && ERR_MortarPrediction.Fresh(incoming, now);
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
// One finite response per delivered observation at this battery. Cancellation
|
||||
// does not turn the same radio message into new ammunition authorization.
|
||||
class ERR_MortarReportLedger
|
||||
{
|
||||
protected ref map<int, float> m_Attempts = new map<int, float>();
|
||||
|
||||
bool Available(int messageId, float observedAt, float now)
|
||||
{
|
||||
if (messageId <= 0 || observedAt < 0 || now < observedAt || now - observedAt > ERR_MortarPrediction.MAX_REPORT_AGE_MS) return false;
|
||||
for (int i = m_Attempts.Count() - 1; i >= 0; i--)
|
||||
if (now - m_Attempts.GetElement(i) > ERR_MortarPrediction.MAX_REPORT_AGE_MS) m_Attempts.Remove(m_Attempts.GetKey(i));
|
||||
return !m_Attempts.Contains(messageId) && m_Attempts.Count() < 128;
|
||||
}
|
||||
bool Claim(int messageId, float observedAt, float now)
|
||||
{
|
||||
if (!Available(messageId, observedAt, now)) return false;
|
||||
m_Attempts.Insert(messageId, observedAt);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
// One delivered HQ order drives a continuous native loading/firing cycle.
|
||||
class ERR_MortarSalvo
|
||||
{
|
||||
static const int ROUNDS = 8;
|
||||
static const float RADIUS_METRES = 40;
|
||||
static const float TIMEOUT_MS = 120000;
|
||||
|
||||
static vector Offset(int site, int order, int shot)
|
||||
{
|
||||
// Stable throughout one ballistic solve; successive shells cover the disk
|
||||
// instead of clustering on the same point or changing every iteration.
|
||||
float radius = RADIUS_METRES * Math.Sqrt((shot + 1.0) / ROUNDS);
|
||||
float angle = (site * 53 + order * 97 + shot * 137.5) * Math.DEG2RAD;
|
||||
return Vector(Math.Sin(angle) * radius, 0, Math.Cos(angle) * radius);
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
class ERR_MortarSupplyCallback : ScriptedInventoryOperationCallback
|
||||
{
|
||||
ERR_MortarSupplyTransfer Transfer;
|
||||
protected override void OnComplete() { if (Transfer) Transfer.Acknowledge(true); }
|
||||
protected override void OnFailed() { if (Transfer) Transfer.Acknowledge(false); }
|
||||
}
|
||||
|
||||
// Exact-item native transaction. Callbacks only record outcomes; the HQ tick
|
||||
// authorizes progress after checking actual inventory ownership and the order.
|
||||
class ERR_MortarSupplyTransfer
|
||||
{
|
||||
protected ERR_MortarBattery m_Battery;
|
||||
protected IEntity m_Gun;
|
||||
protected SCR_AIGroup m_Group;
|
||||
protected IEntity m_Donor;
|
||||
protected IEntity m_Gunner;
|
||||
protected IEntity m_Shell;
|
||||
protected InventoryStorageManagerComponent m_Source;
|
||||
protected InventoryStorageManagerComponent m_Destination;
|
||||
protected BaseInventoryStorageComponent m_Storage;
|
||||
protected ref ERR_MortarSupplyCallback m_Callback;
|
||||
protected int m_Order;
|
||||
protected int m_Acknowledgement;
|
||||
protected float m_Deadline;
|
||||
protected bool m_Accepted;
|
||||
protected bool m_Invalidated;
|
||||
protected bool m_Resolved;
|
||||
protected bool m_Ready;
|
||||
void ERR_MortarSupplyTransfer(ERR_MortarBattery battery, IEntity donor, IEntity gunner, IEntity shell, BaseInventoryStorageComponent storage, float now)
|
||||
{
|
||||
m_Battery = battery; m_Gun = battery.Gun; m_Group = battery.Group;
|
||||
m_Donor = donor; m_Gunner = gunner; m_Shell = shell; m_Storage = storage;
|
||||
m_Order = battery.OrderId; m_Deadline = now + 8000;
|
||||
m_Source = InventoryStorageManagerComponent.Cast(donor.FindComponent(InventoryStorageManagerComponent));
|
||||
m_Destination = InventoryStorageManagerComponent.Cast(gunner.FindComponent(InventoryStorageManagerComponent));
|
||||
}
|
||||
static bool Authority(bool valid, bool invalidated, int capturedOrder, int currentOrder)
|
||||
{
|
||||
return valid && !invalidated && capturedOrder == currentOrder;
|
||||
}
|
||||
static bool Proven(bool callbackComplete, bool sourceContains, bool destinationContains, bool exactSlot)
|
||||
{
|
||||
return callbackComplete && !sourceContains && destinationContains && exactSlot;
|
||||
}
|
||||
protected bool Contains(InventoryStorageManagerComponent inventory)
|
||||
{
|
||||
if (!inventory || !m_Shell) return false;
|
||||
array<IEntity> items = {}; ERR_Inventory.Items(inventory, items);
|
||||
return items.Contains(m_Shell);
|
||||
}
|
||||
protected bool Valid(float now)
|
||||
{
|
||||
if (!m_Battery || !Authority(m_Battery.SupplyAuthorized(now), m_Invalidated, m_Order, m_Battery.OrderId)) return false;
|
||||
if (m_Battery.Gun != m_Gun || m_Battery.Group != m_Group || !m_Donor || !m_Gunner || m_Donor == m_Gunner || !m_Shell || !m_Source || !m_Destination || !m_Storage) return false;
|
||||
if (!ERR_Actors.CanFight(m_Donor) || !ERR_Actors.CanFight(m_Gunner) || ERR_Actors.Group(m_Donor) != m_Group || ERR_Actors.Group(m_Gunner) != m_Group) return false;
|
||||
Faction faction = m_Group.GetFaction();
|
||||
if (SCR_FactionManager.SGetFaction(m_Donor) != faction || SCR_FactionManager.SGetFaction(m_Gunner) != faction || vector.Distance(m_Donor.GetOrigin(), m_Gunner.GetOrigin()) > 12) return false;
|
||||
SCR_AIVehicleUsageComponent usage = SCR_AIVehicleUsageComponent.Cast(m_Gun.FindComponent(SCR_AIVehicleUsageComponent));
|
||||
if (!usage || !usage.GetTurretCompartmentSlot()) return false;
|
||||
IEntity occupant = usage.GetTurretCompartmentSlot().GetOccupant();
|
||||
return !occupant || occupant == m_Gunner;
|
||||
}
|
||||
void Acknowledge(bool success)
|
||||
{
|
||||
if (m_Acknowledgement != 0) return;
|
||||
if (success) m_Acknowledgement = 1;
|
||||
else m_Acknowledgement = -1;
|
||||
}
|
||||
bool Begin(float now)
|
||||
{
|
||||
if (m_Callback || !Replication.IsServer() || !Valid(now) || !Contains(m_Source) || Contains(m_Destination)) return false;
|
||||
m_Callback = new ERR_MortarSupplyCallback(); m_Callback.Transfer = this;
|
||||
// Do not use callback.GetItem(): the native contract excludes Move operations.
|
||||
m_Accepted = m_Source.TryMoveItemToStorage(m_Shell, m_Storage, -1, m_Callback);
|
||||
if (!m_Accepted) { Acknowledge(false); m_Resolved = true; }
|
||||
return m_Accepted;
|
||||
}
|
||||
void Invalidate() { m_Invalidated = true; m_Ready = false; }
|
||||
bool Unresolved() { return m_Accepted && !m_Resolved; }
|
||||
int CapturedOrder() { return m_Order; }
|
||||
ERR_MortarSupplyState Poll(float now)
|
||||
{
|
||||
if (!Replication.IsServer()) return ERR_MortarSupplyState.FAILED;
|
||||
bool valid = Valid(now);
|
||||
bool sourceContains = Contains(m_Source);
|
||||
bool destinationContains = Contains(m_Destination);
|
||||
bool exactSlot;
|
||||
InventoryItemComponent item;
|
||||
if (m_Shell) item = InventoryItemComponent.Cast(m_Shell.FindComponent(InventoryItemComponent));
|
||||
if (item && item.GetParentSlot()) exactSlot = item.GetParentSlot().GetStorage() == m_Storage;
|
||||
bool proven = Proven(m_Acknowledgement == 1, sourceContains, destinationContains, exactSlot);
|
||||
if (proven || m_Acknowledgement < 0) m_Resolved = true;
|
||||
// A successful callback without physical destination proof is ambiguous.
|
||||
// Keep it reserved through timeout/cancellation; never start a second move.
|
||||
if (!valid || now >= m_Deadline)
|
||||
{
|
||||
m_Invalidated = true; m_Ready = false;
|
||||
return ERR_MortarSupplyState.FAILED;
|
||||
}
|
||||
if (proven)
|
||||
{
|
||||
m_Ready = true;
|
||||
return ERR_MortarSupplyState.READY;
|
||||
}
|
||||
if (m_Acknowledgement < 0 || !m_Accepted) return ERR_MortarSupplyState.FAILED;
|
||||
return ERR_MortarSupplyState.PENDING;
|
||||
}
|
||||
}
|
||||
+343
@@ -0,0 +1,343 @@
|
||||
// Owned and ticked by HQ. Initial assets are authored once; losses are never replaced.
|
||||
class ERR_MortarSystem
|
||||
{
|
||||
protected ERR_HQ m_HQ;
|
||||
protected ref array<ref ERR_MortarBattery> m_Batteries = {};
|
||||
protected ref ERR_SettlementSafety m_Settlements = new ERR_SettlementSafety();
|
||||
protected bool m_Prepared;
|
||||
protected bool m_Stopped;
|
||||
protected float m_NextDecision;
|
||||
protected float m_NextRefreshDecision;
|
||||
string SafetyReason;
|
||||
string SettlementReason;
|
||||
|
||||
void ERR_MortarSystem(ERR_HQ hq) { m_HQ = hq; }
|
||||
static ERR_MortarSystem Get()
|
||||
{
|
||||
ERR_HQ hq = ERR_HQ.Get();
|
||||
if (!hq || !Replication.IsServer()) return null;
|
||||
return hq.Mortars();
|
||||
}
|
||||
static bool NearPlayer(vector position)
|
||||
{
|
||||
array<IEntity> players = {};
|
||||
ERR_Actors.Players(players);
|
||||
foreach (IEntity player : players)
|
||||
if (vector.DistanceXZ(player.GetOrigin(), position) <= 1200) return true;
|
||||
return false;
|
||||
}
|
||||
bool Prepare()
|
||||
{
|
||||
if (!Replication.IsServer() || m_Prepared || m_Stopped) return false;
|
||||
m_Prepared = true;
|
||||
vector position = ERR_Actors.Ground("6000 0 7000");
|
||||
if (position[1] < 1) return false;
|
||||
IEntity gun = ERR_Actors.Spawn(ERR_MortarBattery.GUN, position);
|
||||
SCR_AIGroup group = SCR_AIGroup.Cast(ERR_Actors.Spawn(MortarGroup(), ERR_Actors.Ground(position + "0 0 4")));
|
||||
if (!gun || !group)
|
||||
{
|
||||
ERR_Actors.DeleteSpawned(gun);
|
||||
ERR_MortarReadiness.RetireGroup(group);
|
||||
ERR_Actors.DeleteSpawned(group);
|
||||
return false;
|
||||
}
|
||||
if (group.GetFaction() != ERR_GameMode.Get().EnemyFaction())
|
||||
{
|
||||
ERR_Actors.DeleteSpawned(gun);
|
||||
ERR_MortarReadiness.RetireGroup(group);
|
||||
ERR_Actors.DeleteSpawned(group);
|
||||
return false;
|
||||
}
|
||||
FactionAffiliationComponent affiliation = FactionAffiliationComponent.Cast(gun.FindComponent(FactionAffiliationComponent));
|
||||
if (affiliation) affiliation.SetAffiliatedFaction(ERR_GameMode.Get().EnemyFaction());
|
||||
ERR_MortarBattery battery = new ERR_MortarBattery();
|
||||
battery.Bind(1, group, gun, GetGame().GetWorld().GetWorldTime());
|
||||
m_Batteries.Insert(battery);
|
||||
ERR_GameMode mode = ERR_GameMode.Get();
|
||||
mode.Own(gun);
|
||||
// HQ owns/reserves the finite asset immediately. Publish its physical
|
||||
// membership to companion consumers only after readiness has completed.
|
||||
mode.Own(group);
|
||||
m_HQ.RegisterGroup(group);
|
||||
m_HQ.ReserveGroup(group);
|
||||
battery.Audit("DEPLOY", "authored gun and four-person crew; finite ammunition initialization pending");
|
||||
return true;
|
||||
}
|
||||
static ResourceName MortarGroup() { return "{B84C05941C4C81B8}Prefabs/ERR/MortarCrew.et"; }
|
||||
void Batteries(out array<ref ERR_MortarBattery> batteries)
|
||||
{
|
||||
batteries.Clear();
|
||||
foreach (ERR_MortarBattery battery : m_Batteries) batteries.Insert(battery);
|
||||
}
|
||||
ERR_MortarBattery FindGun(IEntity gun)
|
||||
{
|
||||
foreach (ERR_MortarBattery battery : m_Batteries)
|
||||
if (gun && battery.Gun == gun) return battery;
|
||||
return null;
|
||||
}
|
||||
ERR_MortarBattery FindGroup(SCR_AIGroup group)
|
||||
{
|
||||
foreach (ERR_MortarBattery battery : m_Batteries)
|
||||
if (group && battery.Group == group) return battery;
|
||||
return null;
|
||||
}
|
||||
ERR_HQReport Latest(ERR_MortarBattery battery, float now)
|
||||
{
|
||||
// Only the coordinates actually delivered to this battery are available.
|
||||
// A newer HQ contact cannot update a gun's knowledge without another downlink.
|
||||
if (!battery || !ERR_MortarPrediction.Fresh(battery.Report, now)) return null;
|
||||
return battery.Report;
|
||||
}
|
||||
string ContactFailure(ERR_MortarBattery battery)
|
||||
{
|
||||
if (!battery.Report) return "no delivered target report";
|
||||
return "delivered target report expired or invalid";
|
||||
}
|
||||
// Classify only after SupplyState has observed any accepted inventory move.
|
||||
// Report expiry must not be mislabeled as physical ammunition depletion.
|
||||
string SupplyFailure(ERR_MortarBattery battery, float now)
|
||||
{
|
||||
if (!Latest(battery, now)) return ContactFailure(battery);
|
||||
return "next native load lacks confirmed physical gunner ammunition";
|
||||
}
|
||||
bool OutsideSettlements(vector impact)
|
||||
{
|
||||
bool clear = m_Settlements.OutsideSettlements(impact);
|
||||
SettlementReason = m_Settlements.SettlementReason;
|
||||
return clear;
|
||||
}
|
||||
bool Safe(ERR_MortarBattery battery, vector impact, float flight)
|
||||
{
|
||||
SafetyReason = "clear";
|
||||
if (!battery.Operational()) { SafetyReason = battery.OperationalReason(); return false; }
|
||||
if (!OutsideSettlements(impact)) { SafetyReason = SettlementReason; return false; }
|
||||
if (SCR_TerrainHelper.GetTerrainY(impact) < 0) { SafetyReason = "impact below Everon sea level"; return false; }
|
||||
Faction faction = battery.Group.GetFaction();
|
||||
if (!faction) { SafetyReason = "battery faction missing"; return false; }
|
||||
array<IEntity> friendlies = {};
|
||||
m_HQ.PhysicalForces(friendlies);
|
||||
foreach (IEntity entity : friendlies)
|
||||
{
|
||||
Faction friendly = SCR_FactionManager.SGetFaction(entity);
|
||||
if (!friendly || faction.IsFactionEnemy(friendly) || !ERR_Actors.Alive(entity)) continue;
|
||||
vector velocity;
|
||||
IEntity moving = CompartmentAccessComponent.GetVehicleIn(entity);
|
||||
if (!moving) moving = entity;
|
||||
if (moving.GetPhysics()) velocity = moving.GetPhysics().GetVelocity();
|
||||
if (ERR_MortarPrediction.ThreatensFriendly(impact, entity.GetOrigin(), velocity, flight))
|
||||
{
|
||||
SafetyReason = "friendly physical force can enter the impact area";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
bool Withhold(ERR_MortarBattery battery, string reason, string detail = "")
|
||||
{
|
||||
battery.LastFailure = reason + "; " + detail;
|
||||
battery.Trace("WITHHELD", reason, detail);
|
||||
// Finish the activity from the next HQ tick, never from inside its own node.
|
||||
battery.Rejected = true;
|
||||
return false;
|
||||
}
|
||||
bool AllowShot(ERR_MortarBattery battery)
|
||||
{
|
||||
float now = GetGame().GetWorld().GetWorldTime();
|
||||
if (m_Stopped || !ERR_HQ.MissionActive()) return Withhold(battery, "mission inactive or HQ stopped");
|
||||
if (!battery.Operational()) return Withhold(battery, battery.OperationalReason());
|
||||
if (battery.RefreshRejected) return Withhold(battery, "target update failed; order awaiting cancellation");
|
||||
if (!battery.CommandOpen || battery.OrderShots >= ERR_MortarSalvo.ROUNDS) return Withhold(battery, "salvo complete or order closed");
|
||||
if (battery.Released) return Withhold(battery, "waiting for committed projectile");
|
||||
if (battery.AimedShot != battery.OrderShots || battery.AimedAt < 0 || now < battery.AimedAt) return Withhold(battery, "fresh aim solution required");
|
||||
ERR_HQReport report = Latest(battery, now);
|
||||
if (!report) return Withhold(battery, ContactFailure(battery));
|
||||
if (!battery.AimSolution || !battery.AimSolution.Matches(battery, report)) return Withhold(battery, "aim command does not match delivered order");
|
||||
if (battery.AimValidatedAt < battery.AimedAt || now < battery.AimValidatedAt) return Withhold(battery, "aim validation timestamp invalid");
|
||||
if (now - battery.AimValidatedAt > 10000)
|
||||
{
|
||||
if (!battery.AimSolver) return Withhold(battery, "original native aim solver no longer available");
|
||||
if (!battery.AimSolver.ERR_RevalidateAim(battery, this, report, now)) return false;
|
||||
}
|
||||
vector updated;
|
||||
if (!ERR_MortarPrediction.Predict(report, now, battery.FlightTime, updated)) return Withhold(battery, ERR_MortarPrediction.FailureReason);
|
||||
updated += battery.AimOffset;
|
||||
if (vector.DistanceXZ(updated, battery.Impact) > 60) return Withhold(battery, "predicted target left aimed impact area");
|
||||
if (!OutsideSettlements(updated)) return Withhold(battery, SettlementReason);
|
||||
if (!Safe(battery, battery.Impact, battery.FlightTime)) return Withhold(battery, SafetyReason);
|
||||
return true;
|
||||
}
|
||||
protected void AdvanceOrder(ERR_MortarBattery battery, float now)
|
||||
{
|
||||
if (battery.RadioOrder)
|
||||
{
|
||||
int deliveredState = battery.RadioOrder.Advance(now, m_HQ.Journal());
|
||||
if (deliveredState == 0) return;
|
||||
ERR_HQReport delivered = battery.PendingRadioReport;
|
||||
battery.RadioOrder = null;
|
||||
battery.PendingRadioReport = null;
|
||||
if (deliveredState < 0 || !ERR_MortarPrediction.Fresh(delivered, now) || !battery.Operational() || battery.Activity || !battery.CommandOpen)
|
||||
{
|
||||
battery.Cancel("radio delivery, report freshness or battery availability failed");
|
||||
battery.NextOrder = now + 10000;
|
||||
return;
|
||||
}
|
||||
battery.PendingSupplyReport = delivered;
|
||||
}
|
||||
if (!battery.PendingSupplyReport) return;
|
||||
if (!battery.CommandOpen || !ERR_MortarPrediction.Fresh(battery.PendingSupplyReport, now) || !battery.Operational() || battery.Activity)
|
||||
{
|
||||
battery.Cancel("delivered order expired or became unavailable while awaiting physical ammunition");
|
||||
battery.NextOrder = now + 10000;
|
||||
return;
|
||||
}
|
||||
ERR_MortarSupplyState supply = battery.SupplyState(now);
|
||||
if (supply == ERR_MortarSupplyState.PENDING) return;
|
||||
if (supply == ERR_MortarSupplyState.FAILED)
|
||||
{
|
||||
battery.Cancel("physical gunner ammunition unavailable or transfer failed");
|
||||
battery.NextOrder = now + 10000;
|
||||
return;
|
||||
}
|
||||
ERR_HQReport ready = battery.PendingSupplyReport;
|
||||
battery.PendingSupplyReport = null;
|
||||
battery.Order(ready, now);
|
||||
}
|
||||
|
||||
protected void AdvanceRefresh(ERR_MortarBattery battery, float now)
|
||||
{
|
||||
ERR_MortarRefresh update = battery.Refresh;
|
||||
if (!update) return;
|
||||
// A stale transaction must not cancel or modify a later order.
|
||||
if (update.OrderId != battery.OrderId || !battery.CommandOpen)
|
||||
{
|
||||
battery.Refresh = null;
|
||||
return;
|
||||
}
|
||||
if (!update.Owns(battery) || !battery.Operational() || !battery.Activity || !ERR_MortarRefreshRules.Newer(battery.Report, update.Report, now))
|
||||
{
|
||||
battery.RejectRefresh("target update ownership, observation freshness or activity changed");
|
||||
return;
|
||||
}
|
||||
if (update.Route)
|
||||
{
|
||||
int state = update.Route.Advance(now, m_HQ.Journal());
|
||||
if (state == 0) return;
|
||||
if (state < 0)
|
||||
{
|
||||
battery.RejectRefresh("new target observation failed physical HQ radio delivery");
|
||||
return;
|
||||
}
|
||||
update.Route = null;
|
||||
}
|
||||
// The original committed shell owns its report, geometry and airspace until
|
||||
// native OnWeaponFired confirms it. A delivered update cannot relabel it.
|
||||
if (battery.Released) return;
|
||||
if (battery.AimSolution)
|
||||
{
|
||||
if (!battery.AimSolver || !battery.AimSolver.ERR_AdoptReport(battery, this, update.Report, now))
|
||||
{
|
||||
battery.RejectRefresh("delivered update cannot preserve the original native aim command");
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No barrel command exists for the next shell yet. Its normal native aim
|
||||
// node must solve this report; receiving it is not an aim authorization.
|
||||
battery.Report = update.Report;
|
||||
}
|
||||
battery.Audit("UPDATE RECEIVED", string.Format("same order; update radio=M%1 accepted observation=M%2 observed=%3; shots=%4 original aim time=%5", update.RadioId, update.Report.MessageId, update.Report.ObservedAt, battery.OrderShots, battery.AimedAt));
|
||||
battery.Refresh = null;
|
||||
}
|
||||
|
||||
protected void QueueRefreshes(float now)
|
||||
{
|
||||
if (now < m_NextRefreshDecision) return;
|
||||
m_NextRefreshDecision = now + 5000;
|
||||
array<ref ERR_HQReport> reports = {};
|
||||
m_HQ.SupportContacts(reports, now);
|
||||
foreach (ERR_MortarBattery battery : m_Batteries)
|
||||
{
|
||||
ERR_HQReport newest;
|
||||
foreach (ERR_HQReport report : reports)
|
||||
{
|
||||
if (!battery.CanRefresh(report, now)) continue;
|
||||
if (!newest || report.ObservedAt > newest.ObservedAt) newest = report;
|
||||
}
|
||||
if (!newest) continue;
|
||||
ERR_HQRoute route = m_HQ.Downlink(battery.Group, "MORTAR UPDATE", newest.Description(now), newest.MessageId);
|
||||
if (!route)
|
||||
{
|
||||
battery.RejectRefresh("HQ has a newer observation but no physical downlink to the active battery");
|
||||
continue;
|
||||
}
|
||||
battery.BeginRefresh(newest, route, now);
|
||||
}
|
||||
}
|
||||
|
||||
void Tick(float now)
|
||||
{
|
||||
if (m_Stopped || !Replication.IsServer()) return;
|
||||
foreach (ERR_MortarBattery battery : m_Batteries)
|
||||
{
|
||||
if ((battery.Rejected || battery.RefreshRejected) && !battery.Released)
|
||||
{
|
||||
battery.Cancel("native aim or firing authorization rejected");
|
||||
battery.NextOrder = now + 10000;
|
||||
}
|
||||
battery.Rejected = false;
|
||||
battery.Update(now);
|
||||
AdvanceOrder(battery, now);
|
||||
AdvanceRefresh(battery, now);
|
||||
}
|
||||
QueueRefreshes(now);
|
||||
if (now < m_NextDecision) return;
|
||||
m_NextDecision = now + 10000;
|
||||
array<ref ERR_HQReport> reports = {};
|
||||
m_HQ.SupportContacts(reports, now);
|
||||
ERR_MortarBattery selected;
|
||||
ERR_HQReport target;
|
||||
float distance = float.MAX;
|
||||
foreach (ERR_MortarBattery battery : m_Batteries)
|
||||
{
|
||||
if (!battery.StartupActivated || !battery.Operational() || battery.Activity || battery.RadioOrder || battery.PendingSupplyReport || battery.SupplyPending() || now < battery.NextOrder || battery.CountShells() == 0) continue;
|
||||
foreach (ERR_HQReport report : reports)
|
||||
{
|
||||
if (!battery.CanRequest(report, now)) continue;
|
||||
float candidate = vector.DistanceXZ(battery.Position, report.Position);
|
||||
if (candidate < 350 || candidate > 4000 || candidate >= distance || !OutsideSettlements(report.Position)) continue;
|
||||
selected = battery;
|
||||
target = report;
|
||||
distance = candidate;
|
||||
}
|
||||
}
|
||||
if (!selected || !target) return;
|
||||
ERR_HQRoute route = m_HQ.Downlink(selected.Group, "MORTAR ORDER", target.Description(now), target.MessageId);
|
||||
if (!route) return;
|
||||
if (!selected.BeginRequest(target, now)) return;
|
||||
selected.RadioId = route.MessageId;
|
||||
selected.RadioOrder = route;
|
||||
selected.PendingRadioReport = target.Snapshot();
|
||||
m_NextDecision = now + 20000;
|
||||
}
|
||||
void AppendGuns(array<IEntity> entities)
|
||||
{
|
||||
foreach (ERR_MortarBattery battery : m_Batteries)
|
||||
if (battery.Gun && !entities.Contains(battery.Gun)) entities.Insert(battery.Gun);
|
||||
}
|
||||
string Status()
|
||||
{
|
||||
int ready, shells;
|
||||
foreach (ERR_MortarBattery battery : m_Batteries)
|
||||
{
|
||||
if (battery.StartupActivated && battery.Operational()) ready++;
|
||||
shells += battery.CountShells();
|
||||
}
|
||||
return string.Format("mortars %1/%2; HE shells %3", ready, m_Batteries.Count(), shells);
|
||||
}
|
||||
void Stop()
|
||||
{
|
||||
m_Stopped = true;
|
||||
foreach (ERR_MortarBattery battery : m_Batteries) battery.Stop();
|
||||
}
|
||||
}
|
||||
+366
@@ -0,0 +1,366 @@
|
||||
#ifdef WORKBENCH
|
||||
// Separate Chat fixture for the ACTUAL dormant AI controller. Core's original
|
||||
// powered-flight probe is disabled for this outcome and retains its own evidence.
|
||||
modded class SCR_CharacterControllerComponent
|
||||
{
|
||||
override void OnPrepareControls(IEntity owner, ActionManager am, float dt, bool player)
|
||||
{
|
||||
ERR_NativeAircraftControlProbe probe = ERR_NativeAircraftControlProbe.Current();
|
||||
if (probe && !player) probe.PrepareActions(this, owner, am, dt);
|
||||
super.OnPrepareControls(owner, am, dt, player);
|
||||
}
|
||||
}
|
||||
modded class SCR_HelicopterControllerComponent
|
||||
{
|
||||
protected override event void OnPrepareControls()
|
||||
{
|
||||
super.OnPrepareControls();
|
||||
ERR_NativeAircraftControlProbe probe = ERR_NativeAircraftControlProbe.Current();
|
||||
if (probe) probe.PrepareHelicopter(this);
|
||||
}
|
||||
}
|
||||
// Read-only native fixture observation; no production method or state override.
|
||||
modded class ERR_AircraftFlight
|
||||
{
|
||||
float ERR_NativeControlDeadline() { return m_Deadline; }
|
||||
}
|
||||
class ERR_NativeAircraftControlRules
|
||||
{
|
||||
static bool Terminal(bool cancellation, int state, int cancellationCount)
|
||||
{
|
||||
if (cancellation) return state == ERR_AircraftFlightState.CANCELLED && cancellationCount == 1;
|
||||
return state == ERR_AircraftFlightState.COMPLETE && cancellationCount == 0;
|
||||
}
|
||||
static bool CanCancel(int state, bool contact, float height)
|
||||
{
|
||||
return state == ERR_AircraftFlightState.CLIMB && !contact && height >= 5 && height <= 18;
|
||||
}
|
||||
static bool CancelProof(bool airborneHooks, bool landingHeld, bool sameDeadline, bool sameToken)
|
||||
{
|
||||
return airborneHooks && landingHeld && sameDeadline && sameToken;
|
||||
}
|
||||
static bool TranslateProof(bool proven, bool held, float requestedDistance, float displacement, float targetError)
|
||||
{
|
||||
return proven && held && requestedDistance >= 1.75 && requestedDistance <= 4.05 && displacement >= 1 && targetError >= 0 && targetError <= 0.75;
|
||||
}
|
||||
static bool PhysicalFinish(bool originalOccupancy, bool landed, bool engineOff, float fuelUsed, int wakes, int starts, int controllerStarts, int pilotHooks, int helicopterHooks)
|
||||
{
|
||||
if (!originalOccupancy || !landed || !engineOff) return false;
|
||||
return fuelUsed > 0.05 && wakes == 1 && starts == 1 && controllerStarts == 1 && pilotHooks > 100 && helicopterHooks > 100;
|
||||
}
|
||||
static bool CanDispose(bool context, bool noPlayers, bool sameFaction, bool groupUnchanged, bool pilotUnchanged)
|
||||
{
|
||||
return context && noPlayers && sameFaction && groupUnchanged && pilotUnchanged;
|
||||
}
|
||||
}
|
||||
class ERR_NativeAircraftControlProbe
|
||||
{
|
||||
protected static ERR_NativeAircraftControlProbe s_Current;
|
||||
protected ERR_GameMode m_Mode;
|
||||
protected Vehicle m_Aircraft;
|
||||
protected SCR_AIGroup m_Group;
|
||||
protected IEntity m_Pilot;
|
||||
protected PilotCompartmentSlot m_Seat;
|
||||
protected HelicopterControllerComponent m_Controller;
|
||||
protected VehicleHelicopterSimulation m_Simulation;
|
||||
protected FuelManagerComponent m_Fuel;
|
||||
protected ref ERR_AircraftFlightEnvelope m_Envelope;
|
||||
protected ref ERR_AircraftFlight m_Flight;
|
||||
protected float m_Deadline;
|
||||
protected float m_StageDeadline;
|
||||
protected float m_Stable = -1;
|
||||
protected float m_LastTick = -1;
|
||||
protected float m_NextLog;
|
||||
protected float m_InitialFuel;
|
||||
protected float m_ControlDeadline;
|
||||
protected float m_CancelAt = -1;
|
||||
protected float m_LandingStable = -1;
|
||||
protected int m_CancelCount;
|
||||
protected int m_CancelPilotHooks;
|
||||
protected int m_CancelHelicopterHooks;
|
||||
protected bool m_CancelFixture;
|
||||
protected bool m_TranslationFixture;
|
||||
protected bool m_TranslationObserved;
|
||||
protected bool m_TranslationHeld;
|
||||
protected vector m_TranslationOrigin;
|
||||
protected vector m_TranslationTarget;
|
||||
protected float m_TranslationStable = -1;
|
||||
protected bool m_ContinuedAirborneControl;
|
||||
protected bool m_LandingHeld;
|
||||
protected vector m_Pad;
|
||||
protected int m_Step;
|
||||
protected int m_Wakes;
|
||||
protected int m_ControllerStarts;
|
||||
protected int m_EngineStarts;
|
||||
protected int m_PilotHooks;
|
||||
protected int m_HelicopterHooks;
|
||||
protected bool m_LastEngine;
|
||||
protected bool m_AllocationRequested;
|
||||
protected bool m_PilotRequested;
|
||||
protected bool m_Stopped;
|
||||
protected bool m_Complete;
|
||||
static const int ORDER_TOKEN = 771001;
|
||||
static const ResourceName AIRCRAFT = "{DF5CCB7C0FF049F4}Prefabs/Vehicles/Helicopters/Mi8MT/Mi8MT_unarmed_transport.et";
|
||||
static const ResourceName GROUP = "{000CD338713F2B5A}Prefabs/AI/Groups/Group_Base.et";
|
||||
static const ResourceName PILOT = "{86E565387A634103}Prefabs/Characters/Campaign/Final/OPFOR/USSR_Army/Regular/Campaign_USSR_Player_MortarCrew.et";
|
||||
static bool Enabled()
|
||||
{
|
||||
string enabled, outcome;
|
||||
return Replication.IsServer() && System.GetCLIParam("errSmoke", enabled) && enabled == "1" && System.GetCLIParam("errSmokeOutcome", outcome) && (outcome == "aircraft-control" || outcome == "aircraft-cancel" || outcome == "aircraft-translate");
|
||||
}
|
||||
static ERR_NativeAircraftControlProbe Current() { return s_Current; }
|
||||
void ERR_NativeAircraftControlProbe(ERR_GameMode mode)
|
||||
{
|
||||
m_Mode = mode;
|
||||
if (!GetGame() || !GetGame().GetWorld() || !Enabled() || s_Current) { m_Stopped = true; return; }
|
||||
string outcome; System.GetCLIParam("errSmokeOutcome", outcome); m_CancelFixture = outcome == "aircraft-cancel"; m_TranslationFixture = outcome == "aircraft-translate";
|
||||
m_Deadline = GetGame().GetWorld().GetWorldTime() + 300000;
|
||||
s_Current = this;
|
||||
}
|
||||
protected bool Check(bool passed, string detail)
|
||||
{
|
||||
string outcome = "aircraft-control"; if (m_CancelFixture) outcome = "aircraft-cancel"; else if (m_TranslationFixture) outcome = "aircraft-translate";
|
||||
if (passed) { Print("ERR SMOKE PASS: " + outcome + " " + detail); return true; }
|
||||
Print("ERR SMOKE FAIL: " + outcome + " " + detail, LogLevel.ERROR); Stop(); return false;
|
||||
}
|
||||
protected bool NoPlayers()
|
||||
{
|
||||
if (!GetGame() || !GetGame().GetPlayerManager()) return false;
|
||||
if (m_Group && m_Group.GetPlayerCount(true) > 0) return false;
|
||||
array<int> players = {}; GetGame().GetPlayerManager().GetPlayers(players);
|
||||
foreach (int id : players)
|
||||
{
|
||||
IEntity actor = GetGame().GetPlayerManager().GetPlayerControlledEntity(id);
|
||||
if (actor && (actor == m_Pilot || actor == m_Aircraft || (m_Aircraft && CompartmentAccessComponent.GetVehicleIn(actor) == m_Aircraft))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
protected bool OriginalOccupancy()
|
||||
{
|
||||
if (!m_Pilot || !m_Group || !m_Aircraft || !m_Controller || !m_Seat || !NoPlayers()) return false;
|
||||
if (m_Group.GetAgentsCount() != 1 || ERR_Actors.Group(m_Pilot) != m_Group || !ERR_Actors.CanFight(m_Pilot)) return false;
|
||||
return m_Seat.GetOccupant() == m_Pilot && m_Controller.GetPilotCompartmentSlot() == m_Seat && CompartmentAccessComponent.GetVehicleIn(m_Pilot) == m_Aircraft;
|
||||
}
|
||||
void PrepareActions(CharacterControllerComponent controller, IEntity owner, ActionManager am, float dt)
|
||||
{
|
||||
if (m_Stopped || !m_Flight || owner != m_Pilot) return;
|
||||
m_Flight.PrepareActions(controller, owner, am, dt); m_PilotHooks++;
|
||||
}
|
||||
void PrepareHelicopter(HelicopterControllerComponent controller)
|
||||
{
|
||||
if (m_Stopped || !m_Flight || controller != m_Controller) return;
|
||||
m_Flight.PrepareHelicopter(controller); m_HelicopterHooks++;
|
||||
}
|
||||
protected bool FindPad(out vector result)
|
||||
{
|
||||
vector anchor = m_Mode.ExtractionVehicle().GetOrigin();
|
||||
float bestSlope = float.MAX; vector bestCandidate, refinementAnchor;
|
||||
int localCandidates = ERR_PoweredFlightRules.PAD_CANDIDATES + ERR_PoweredFlightRules.PAD_REFINEMENTS;
|
||||
for (int index = 0; index < localCandidates + ERR_PoweredFlightRules.AIRPORT_CANDIDATES; index++)
|
||||
{
|
||||
if (index == ERR_PoweredFlightRules.PAD_CANDIDATES)
|
||||
{
|
||||
refinementAnchor = anchor; if (bestSlope != float.MAX) refinementAnchor = bestCandidate;
|
||||
}
|
||||
vector candidate;
|
||||
if (index < ERR_PoweredFlightRules.PAD_CANDIDATES) candidate = ERR_Actors.Ground(anchor + ERR_PoweredFlightRules.PadOffset(index));
|
||||
else if (index < localCandidates) candidate = ERR_Actors.Ground(refinementAnchor + ERR_PoweredFlightRules.RefineOffset(index - ERR_PoweredFlightRules.PAD_CANDIDATES));
|
||||
else candidate = ERR_Actors.Ground("4900.953 0 11787.412" + ERR_PoweredFlightRules.AirportOffset(index - localCandidates));
|
||||
if (candidate[1] < 2) continue;
|
||||
float delta;
|
||||
for (int edge = 0; edge < 4; edge++)
|
||||
{
|
||||
float side = edge * Math.PI / 2;
|
||||
vector point = candidate + Vector(Math.Cos(side) * 14, 0, Math.Sin(side) * 14);
|
||||
delta = Math.Max(delta, Math.AbsFloat(SCR_TerrainHelper.GetTerrainY(point) - candidate[1]));
|
||||
}
|
||||
if (delta < bestSlope) { bestSlope = delta; bestCandidate = candidate; }
|
||||
if (delta > 0.8) continue;
|
||||
if (!SCR_WorldTools.TraceCilinderUtil(candidate + "0 2.5 0", 40, 4, TraceFlags.ENTS)) continue;
|
||||
TraceBox clearance = new TraceBox(); clearance.Start = candidate + "0 3 0"; clearance.End = candidate + "0 60 0";
|
||||
clearance.Mins = "-40 -1 -40"; clearance.Maxs = "40 1 40"; clearance.Flags = TraceFlags.ENTS | TraceFlags.WORLD;
|
||||
if (!(GetGame().GetWorld().TraceMove(clearance, null) >= 1)) continue;
|
||||
PrintFormat("ERR AIRCRAFT FIXTURE PAD: candidate=%1 tested=%2 terrainDelta=%3; no fallback", candidate, index + 1, delta);
|
||||
result = candidate; return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
protected bool ReadyComponents()
|
||||
{
|
||||
m_Controller = HelicopterControllerComponent.Cast(m_Aircraft.FindComponent(HelicopterControllerComponent));
|
||||
if (!m_Controller) return false;
|
||||
m_Simulation = m_Controller.GetSimulation();
|
||||
m_Fuel = FuelManagerComponent.Cast(m_Aircraft.FindComponent(FuelManagerComponent));
|
||||
if (!m_Simulation || !m_Simulation.IsValid() || !m_Fuel || !m_Fuel.HasFuel()) return false;
|
||||
BaseCompartmentManagerComponent manager = BaseCompartmentManagerComponent.Cast(m_Aircraft.FindComponent(BaseCompartmentManagerComponent));
|
||||
if (!manager) return false;
|
||||
array<BaseCompartmentSlot> slots = {}; manager.GetCompartments(slots);
|
||||
if (slots.Count() > 64) return false;
|
||||
foreach (BaseCompartmentSlot slot : slots)
|
||||
{
|
||||
PilotCompartmentSlot pilotSlot = PilotCompartmentSlot.Cast(slot);
|
||||
if (!pilotSlot || pilotSlot.GetOwner() != m_Aircraft || pilotSlot.GetVehicle() != m_Aircraft || pilotSlot.GetCompartmentName() != "PilotCompartment") continue;
|
||||
if (pilotSlot.GetType() != ECompartmentType.PILOT) continue;
|
||||
if (pilotSlot.GetOccupant() || pilotSlot.IsReserved() || pilotSlot.IsGetInLocked() || !pilotSlot.IsCompartmentAccessible()) continue;
|
||||
m_Seat = pilotSlot; return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
protected void Diagnostic(float now)
|
||||
{
|
||||
if (!m_Aircraft || !m_Controller || !m_Simulation || now < m_NextLog) return;
|
||||
m_NextLog = now + 2000;
|
||||
vector transform[4]; m_Aircraft.GetWorldTransform(transform);
|
||||
vector home = m_Pad; if (m_Envelope) home = m_Envelope.Home();
|
||||
int state = -1; string reason; if (m_Flight) { state = m_Flight.State(); reason = m_Flight.Reason(); }
|
||||
PrintFormat("ERR AIRCRAFT FIXTURE: step=%1 state=%2 height=%3 drift=%4 speed=%5 contact=%6 engine=%7 fuelUsed=%8 reason=%9", m_Step, state, transform[3][1] - home[1], vector.DistanceXZ(transform[3], home), m_Simulation.GetVelocity().Length(), m_Simulation.HasAnyGroundContact(), m_Controller.IsEngineOn(), m_InitialFuel - m_Fuel.GetTotalFuel(), reason);
|
||||
}
|
||||
int Tick(float now)
|
||||
{
|
||||
if (m_Complete) return 1;
|
||||
if (m_Stopped) return -1;
|
||||
if (!Enabled() || !m_Mode || m_Mode != ERR_GameMode.Get() || !m_Mode.Active() || now >= m_Deadline) { Check(false, "active exact mission and bounded300-second fixture"); return -1; }
|
||||
if (!NoPlayers()) { Check(false, "fixture remains unclaimed by any human"); return -1; }
|
||||
if (m_LastTick >= 0 && (now < m_LastTick || now - m_LastTick > 1500)) { m_Stable = -1; m_LandingStable = -1; m_TranslationStable = -1; }
|
||||
m_LastTick = now;
|
||||
if (m_Step == 0)
|
||||
{
|
||||
if (!m_Mode.ExtractionVehicle()) return 0;
|
||||
if (!Check(!m_AllocationRequested && FindPad(m_Pad), "independent qualified candidate found before one finite allocation")) return -1;
|
||||
m_AllocationRequested = true;
|
||||
m_Aircraft = Vehicle.Cast(ERR_Actors.Spawn(AIRCRAFT, m_Pad));
|
||||
m_Group = SCR_AIGroup.Cast(ERR_Actors.Spawn(GROUP, m_Pad + "20 0 0"));
|
||||
if (!Check(m_Aircraft && m_Group && m_Group.InitFactionKey("USSR") && m_Group.GetFaction() == m_Mode.EnemyFaction(), "one finite native Mi8 and memberless enemy group created")) return -1;
|
||||
m_Group.SetPermanentLOD(0); m_Group.ActivateAI();
|
||||
m_Step = 1; m_StageDeadline = now + 30000; return 0;
|
||||
}
|
||||
if (!m_Aircraft || !m_Group || !ERR_Actors.Alive(m_Aircraft)) { Check(false, "original airframe and group survive without replacement"); return -1; }
|
||||
if (m_Step == 1)
|
||||
{
|
||||
if (now >= m_StageDeadline) { Check(false, "real components and original pilot initialize within30seconds"); return -1; }
|
||||
if (!ReadyComponents()) return 0;
|
||||
if (!Check(!m_PilotRequested && m_Group.GetAgentsCount() == 0, "one original native pilot request only")) return -1;
|
||||
m_PilotRequested = true; AIGroup originalGroup = m_Group;
|
||||
m_Pilot = m_Seat.SpawnCharacterInCompartment(PILOT, originalGroup, GROUP);
|
||||
AIControlComponent control; if (m_Pilot) control = AIControlComponent.Cast(m_Pilot.FindComponent(AIControlComponent));
|
||||
AIAgent agent; if (control) agent = control.GetControlAIAgent();
|
||||
if (!Check(m_Pilot && originalGroup == m_Group && agent, "pilot seated by native mechanism in original group")) return -1;
|
||||
agent.SetPermanentLOD(0); agent.ActivateAI(); m_Step = 2; return 0;
|
||||
}
|
||||
Diagnostic(now);
|
||||
if (m_Step == 2)
|
||||
{
|
||||
if (now >= m_StageDeadline) { Check(false, "actual initial seating and native ground settling within30seconds"); return -1; }
|
||||
if (!OriginalOccupancy()) { m_Stable = -1; return 0; }
|
||||
Physics physics = m_Aircraft.GetPhysics();
|
||||
if (!physics || !physics.IsDynamic()) return 0;
|
||||
if (m_Wakes == 0) { m_Wakes = 1; physics.SetActive(ActiveState.ACTIVE); m_Stable = -1; return 0; }
|
||||
if (!m_Simulation.HasAnyGroundContact() || !ERR_CasualtyLoadingRules.AircraftReady(m_Aircraft)) { m_Stable = -1; return 0; }
|
||||
if (m_Stable < 0) m_Stable = now;
|
||||
if (now - m_Stable < 1000) return 0;
|
||||
if (!Check(!m_Controller.IsEngineOn(), "post-spawn qualification precedes native engine start")) return -1;
|
||||
m_Envelope = ERR_AircraftFlightEnvelope.Qualify(m_Mode, m_Aircraft, m_Pilot);
|
||||
if (!Check(m_Envelope != null, "actual post-spawn column and complete rotor/model footprint issue exact qualification")) return -1;
|
||||
m_InitialFuel = m_Fuel.GetTotalFuel();
|
||||
m_Flight = new ERR_AircraftFlight(); m_ControllerStarts++;
|
||||
m_ControlDeadline = Math.Min(m_Deadline, now + 240000);
|
||||
bool started;
|
||||
if (m_TranslationFixture) started = m_Flight.StartTranslation(m_Mode, m_Aircraft, m_Group, m_Pilot, m_Seat, m_Envelope, ORDER_TOKEN, m_ControlDeadline, m_Envelope.Home());
|
||||
else started = m_Flight.Start(m_Mode, m_Aircraft, m_Group, m_Pilot, m_Seat, m_Envelope, ORDER_TOKEN, m_ControlDeadline);
|
||||
if (!Check(started, "actual controller accepts one qualified original pilot/aircraft order")) return -1;
|
||||
m_Step = 3; return 0;
|
||||
}
|
||||
if (m_Step == 3)
|
||||
{
|
||||
if (!OriginalOccupancy()) { Check(false, "original pilot remains conscious and in its exact seat/group"); return -1; }
|
||||
bool engine = m_Controller.IsEngineOn(); if (engine && !m_LastEngine) m_EngineStarts++; m_LastEngine = engine;
|
||||
vector transform[4]; m_Aircraft.GetWorldTransform(transform);
|
||||
vector home = m_Envelope.Home(); float height = transform[3][1] - home[1];
|
||||
bool contact = m_Simulation.HasAnyGroundContact();
|
||||
bool landed = ERR_AircraftFlightRules.Landed(contact, height, m_Simulation.GetVelocity().Length(), m_Simulation.GetAngularVelocity().Length(), transform[1][1]);
|
||||
if (m_CancelFixture && m_CancelCount == 0 && ERR_NativeAircraftControlRules.CanCancel(m_Flight.State(), contact, height))
|
||||
{
|
||||
m_CancelCount = 1; m_CancelAt = now;
|
||||
m_CancelPilotHooks = m_PilotHooks; m_CancelHelicopterHooks = m_HelicopterHooks;
|
||||
m_Flight.Cancel("native fixture deliberate airborne cancellation");
|
||||
PrintFormat("ERR AIRCRAFT CANCEL: height=%1 contact=%2 state=%3 token=%4 deadline=%5 pilotHooks=%6 helicopterHooks=%7", height, contact, m_Flight.State(), m_Flight.OrderToken(), m_Flight.ERR_NativeControlDeadline(), m_PilotHooks, m_HelicopterHooks);
|
||||
if (!Check(m_Flight.State() == ERR_AircraftFlightState.LANDING && !m_Flight.Finished(), "one actual airborne Cancel enters retained LANDING control")) return -1;
|
||||
}
|
||||
if (m_CancelCount == 1)
|
||||
{
|
||||
if (m_Flight.ERR_NativeControlDeadline() != m_ControlDeadline || m_Flight.OrderToken() != ORDER_TOKEN) { Check(false, "cancel retains original overall deadline and exact order token"); return -1; }
|
||||
int state = m_Flight.State();
|
||||
if (state != ERR_AircraftFlightState.LANDING && state != ERR_AircraftFlightState.SHUTDOWN && state != ERR_AircraftFlightState.CANCELLED) { Check(false, "cancel retains landing or grounded shutdown states only"); return -1; }
|
||||
if (state == ERR_AircraftFlightState.LANDING && !contact && height > 1 && m_PilotHooks > m_CancelPilotHooks + 10 && m_HelicopterHooks > m_CancelHelicopterHooks + 10) m_ContinuedAirborneControl = true;
|
||||
if (!landed) m_LandingStable = -1;
|
||||
else if (m_LandingStable < 0) m_LandingStable = now;
|
||||
}
|
||||
if (m_TranslationFixture && m_Flight.State() == ERR_AircraftFlightState.TRANSLATING)
|
||||
{
|
||||
if (!m_TranslationObserved)
|
||||
{
|
||||
m_TranslationObserved = true; m_TranslationOrigin = m_Flight.TranslationOrigin(); m_TranslationTarget = m_Flight.TranslationTarget();
|
||||
if (!Check(vector.DistanceXZ(m_TranslationTarget, home) < 0.001, "pre-takeoff translation target remains the original qualified home")) return -1;
|
||||
}
|
||||
if (m_Flight.TranslationTarget() != m_TranslationTarget || m_Flight.TranslationOrigin() != m_TranslationOrigin || m_Flight.ERR_NativeControlDeadline() != m_ControlDeadline) { Check(false, "translation never retargets or renews its original deadline"); return -1; }
|
||||
vector horizontal = m_Simulation.GetVelocity(); horizontal[1] = 0;
|
||||
float requested = vector.DistanceXZ(m_TranslationOrigin, m_TranslationTarget);
|
||||
bool held = ERR_AircraftTranslationRules.Held(contact, height, m_Simulation.GetVelocity().Length(), m_Simulation.GetVelocity()[1], transform[1][1], horizontal.Length(), vector.DistanceXZ(transform[3], m_TranslationTarget), vector.DistanceXZ(transform[3], m_TranslationOrigin), requested);
|
||||
if (!held) m_TranslationStable = -1;
|
||||
else if (m_TranslationStable < 0) m_TranslationStable = now;
|
||||
if (m_TranslationStable >= 0 && now - m_TranslationStable >= 5000) m_TranslationHeld = true;
|
||||
}
|
||||
m_Flight.Tick();
|
||||
if (m_CancelCount == 1 && m_Flight.State() == ERR_AircraftFlightState.SHUTDOWN)
|
||||
{
|
||||
if (!Check(landed && m_LandingStable >= 0 && now - m_LandingStable >= 2000, "cancel reaches shutdown only after two observed stable grounded seconds")) return -1;
|
||||
m_LandingHeld = true;
|
||||
}
|
||||
if (!m_Flight.Finished()) return 0;
|
||||
PrintFormat("ERR AIRCRAFT FIXTURE RESULT: state=%1 token=%2 engineStarts=%3 controllerStarts=%4 wakes=%5 pilotHooks=%6 helicopterHooks=%7 reason=%8", m_Flight.State(), m_Flight.OrderToken(), m_EngineStarts, m_ControllerStarts, m_Wakes, m_PilotHooks, m_HelicopterHooks, m_Flight.Reason());
|
||||
bool complete = ERR_NativeAircraftControlRules.Terminal(m_CancelFixture, m_Flight.State(), m_CancelCount);
|
||||
complete = complete && ERR_NativeAircraftControlRules.PhysicalFinish(OriginalOccupancy(), landed, !m_Controller.IsEngineOn(), m_InitialFuel - m_Fuel.GetTotalFuel(), m_Wakes, m_EngineStarts, m_ControllerStarts, m_PilotHooks, m_HelicopterHooks);
|
||||
if (m_CancelFixture)
|
||||
{
|
||||
bool proof = ERR_NativeAircraftControlRules.CancelProof(m_ContinuedAirborneControl, m_LandingHeld, m_Flight.ERR_NativeControlDeadline() == m_ControlDeadline, m_Flight.OrderToken() == ORDER_TOKEN);
|
||||
PrintFormat("ERR AIRCRAFT CANCEL RESULT: count=%1 elapsed=%2 airborneHooks=%3 landingHeld=%4 deadline=%5 originalDeadline=%6", m_CancelCount, now - m_CancelAt, m_ContinuedAirborneControl, m_LandingHeld, m_Flight.ERR_NativeControlDeadline(), m_ControlDeadline);
|
||||
complete = complete && proof;
|
||||
}
|
||||
if (m_TranslationFixture)
|
||||
{
|
||||
float requestedDistance = vector.DistanceXZ(m_TranslationOrigin, m_TranslationTarget);
|
||||
float displacement = vector.DistanceXZ(transform[3], m_TranslationOrigin);
|
||||
float targetError = vector.DistanceXZ(transform[3], m_TranslationTarget);
|
||||
bool translated = ERR_NativeAircraftControlRules.TranslateProof(m_Flight.TranslationProven(), m_TranslationObserved && m_TranslationHeld, requestedDistance, displacement, targetError);
|
||||
PrintFormat("ERR AIRCRAFT TRANSLATION RESULT: proven=%1 held=%2 requested=%3 displacement=%4 targetError=%5 updates=%6", m_Flight.TranslationProven(), m_TranslationHeld, requestedDistance, displacement, targetError, m_Flight.TranslationUpdates());
|
||||
complete = complete && translated;
|
||||
}
|
||||
if (!Check(complete && m_Flight.OrderToken() == ORDER_TOKEN, "actual control order completes on original physical grounded aircraft with engine off, finite fuel use and exact pilot")) return -1;
|
||||
m_Complete = true; Stop(); return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
void Stop()
|
||||
{
|
||||
if (m_Stopped) return;
|
||||
m_Stopped = true;
|
||||
if (s_Current == this) s_Current = null;
|
||||
if (m_Flight && !m_Flight.Finished() && m_CancelCount == 0) m_Flight.Cancel("native fixture stopped");
|
||||
// End-of-test disposal is not successful flight or a production cancellation.
|
||||
// These assets are fixture-owned only, never registered with mission cleanup.
|
||||
// Release the controller before deleting disposable unclaimed physical assets.
|
||||
m_Flight = null;
|
||||
bool context = GetGame() && GetGame().GetWorld() && GetGame().GetPlayerManager() && m_Mode && m_Mode == ERR_GameMode.Get();
|
||||
bool faction = context && (!m_Group || (!m_PilotRequested && m_Group.GetAgentsCount() == 0 && !m_Group.GetFaction()) || m_Group.GetFaction() == m_Mode.EnemyFaction()) && (!m_Aircraft || SCR_FactionManager.SGetFaction(m_Aircraft) == m_Mode.EnemyFaction());
|
||||
bool groupUnchanged = context && (!m_Group || m_Group.GetAgentsCount() == 0 || (m_Group.GetAgentsCount() == 1 && m_Pilot && ERR_Actors.Group(m_Pilot) == m_Group));
|
||||
bool pilotUnchanged = context && (!m_Pilot || (m_Group && ERR_Actors.Group(m_Pilot) == m_Group && !ERR_Actors.Player(m_Pilot)));
|
||||
if (ERR_NativeAircraftControlRules.CanDispose(context, NoPlayers(), faction, groupUnchanged, pilotUnchanged))
|
||||
{
|
||||
ERR_Actors.DeleteSpawned(m_Group); ERR_Actors.DeleteSpawned(m_Pilot); ERR_Actors.DeleteSpawned(m_Aircraft);
|
||||
}
|
||||
else Print("ERR AIRCRAFT FIXTURE CLEANUP: ownership changed; captured entities preserved");
|
||||
m_Envelope = null;
|
||||
}
|
||||
void ~ERR_NativeAircraftControlProbe() { Stop(); }
|
||||
}
|
||||
#endif
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
#ifdef WORKBENCH
|
||||
class ERR_NativeCourierMoveCallback : ScriptedInventoryOperationCallback
|
||||
{
|
||||
int Result;
|
||||
protected override void OnComplete() { if (Result == 0) Result = 1; }
|
||||
protected override void OnFailed() { if (Result == 0) Result = -1; }
|
||||
}
|
||||
modded class ERR_HQ
|
||||
{
|
||||
override bool QueueSupplyStatus(ERR_MortarBattery battery, float now)
|
||||
{
|
||||
bool queued = super.QueueSupplyStatus(battery, now);
|
||||
ERR_NativeMortarCourierProbe probe = ERR_NativeMortarCourierProbe.Current();
|
||||
if (queued && probe)
|
||||
foreach (ERR_HQUnit unit : m_Units)
|
||||
if (unit.Group == battery.Group && unit.PendingSupplyStatus) probe.Uplink(unit.Uplink, unit.PendingSupplyStatus);
|
||||
return queued;
|
||||
}
|
||||
}
|
||||
class ERR_NativeMortarCourierProbe
|
||||
{
|
||||
protected static ERR_NativeMortarCourierProbe s_Current;
|
||||
protected ERR_GameMode m_Mode;
|
||||
protected ERR_MortarLogistics m_Service;
|
||||
protected ERR_MortarBattery m_Battery;
|
||||
protected ref array<IEntity> m_Original = {};
|
||||
protected ref array<IEntity> m_Crew = {};
|
||||
protected ref array<IEntity> m_Depot = {};
|
||||
protected ref array<IEntity> m_Forces = {};
|
||||
protected ref array<IEntity> m_Boxes = {};
|
||||
protected ref array<BaseInventoryStorageComponent> m_Stores = {};
|
||||
protected ref ERR_NativeCourierMoveCallback m_Callback;
|
||||
protected IEntity m_Moving;
|
||||
protected IEntity m_Source;
|
||||
protected BaseInventoryStorageComponent m_Destination;
|
||||
protected ref ERR_HQRoute m_Uplink;
|
||||
protected ref ERR_HQRoute m_Downlink;
|
||||
protected ref ERR_MortarSupplyStatus m_Status;
|
||||
protected float m_ObservedAt;
|
||||
protected int m_Message;
|
||||
protected int m_Index;
|
||||
protected int m_Stage;
|
||||
protected float m_Deadline;
|
||||
protected float m_MoveDeadline;
|
||||
protected bool m_Complete;
|
||||
protected bool m_Stopped;
|
||||
protected vector m_Departure;
|
||||
protected IEntity m_Bearer;
|
||||
protected AIWaypointCycle m_OriginalPatrolCycle;
|
||||
protected ref array<AIWaypoint> m_OriginalPatrolMoves = {};
|
||||
static const int STORAGE_CANDIDATES = 64;
|
||||
static const int STORAGE_COUNT = 5;
|
||||
// The compact 8x8 grid keeps 3m between centers; blocked points are skipped.
|
||||
static vector StorageOffset(int index)
|
||||
{
|
||||
if (index < 0 || index >= STORAGE_CANDIDATES) return vector.Zero;
|
||||
int column = Math.Mod(index, 8);
|
||||
int row = index / 8;
|
||||
return Vector(20 + column * 3, 0, 15 + row * 3);
|
||||
}
|
||||
static const ResourceName BOX = "{EBBC859CCD94D96D}Prefabs/Props/EmptyStorages/USSR_AmmoBox_empty.et";
|
||||
static ERR_NativeMortarCourierProbe Current() { return s_Current; }
|
||||
static bool Enabled()
|
||||
{
|
||||
string smoke, outcome;
|
||||
return Replication.IsServer() && System.GetCLIParam("errSmoke", smoke) && smoke == "1" && System.GetCLIParam("errSmokeOutcome", outcome) && outcome == "mortar-courier";
|
||||
}
|
||||
void ERR_NativeMortarCourierProbe(ERR_GameMode mode) { m_Mode = mode; if (Enabled()) s_Current = this; }
|
||||
protected bool Check(bool condition, string detail)
|
||||
{
|
||||
if (condition) { Print("ERR SMOKE PASS: mortar courier " + detail); return true; }
|
||||
Print("ERR SMOKE FAIL: mortar courier " + detail, LogLevel.ERROR); Stop(); return false;
|
||||
}
|
||||
protected bool Has(IEntity owner, IEntity shell)
|
||||
{
|
||||
if (!owner || !shell) return false;
|
||||
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(owner.FindComponent(InventoryStorageManagerComponent));
|
||||
if (!inventory) return false;
|
||||
array<IEntity> items = {}; ERR_Inventory.Items(inventory, items); return items.Contains(shell);
|
||||
}
|
||||
void Uplink(ERR_HQRoute route, ERR_MortarSupplyStatus report)
|
||||
{
|
||||
if (m_Stopped || !report || !m_Battery || report.Group != m_Battery.Group || m_Status) return;
|
||||
m_Uplink = route; m_Status = report; m_ObservedAt = report.ObservedAt; m_Message = report.MessageId;
|
||||
}
|
||||
protected bool OriginalStored()
|
||||
{
|
||||
foreach (IEntity shell : m_Original)
|
||||
{
|
||||
if (!shell) return false;
|
||||
int owners;
|
||||
foreach (BaseInventoryStorageComponent storage : m_Stores) if (storage && storage.Contains(shell)) owners++;
|
||||
foreach (IEntity actor : m_Crew) if (Has(actor, shell)) owners++;
|
||||
if (owners != 1) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
protected bool PreservedForces(ERR_HQ hq)
|
||||
{
|
||||
array<IEntity> forces = {}; hq.PhysicalForces(forces);
|
||||
if (forces.Count() != m_Forces.Count()) return false;
|
||||
foreach (IEntity original : m_Forces) if (!original || !forces.Contains(original)) return false;
|
||||
return true;
|
||||
}
|
||||
int Tick(float now)
|
||||
{
|
||||
if (m_Complete) return 1;
|
||||
if (m_Stopped || !Enabled()) return -1;
|
||||
if (m_Deadline == 0) m_Deadline = now + 360000;
|
||||
if (now > m_Deadline) { Check(false, "bounded native courier deadline"); return -1; }
|
||||
ERR_HQ hq = ERR_HQ.Get(); if (!hq) return 0;
|
||||
m_Service = hq.Logistics(); if (!m_Service) return 0;
|
||||
m_Battery = m_Service.Battery();
|
||||
if (m_Service.Stopped()) { Check(false, "production finite stock setup remains available"); return -1; }
|
||||
if (!m_Battery || !m_Battery.StartupActivated || m_Service.State() == 0) return 0;
|
||||
// No fire-controller hold, synthetic contact or routing bypass: this fixture
|
||||
// requires the actual local prisoner to remain inside the gun's minimum range.
|
||||
PlayerController controller = GetGame().GetPlayerController();
|
||||
IEntity player; if (controller) player = controller.GetControlledEntity();
|
||||
if (!player || !m_Mode.Players().CurrentActor(player) || vector.DistanceXZ(player.GetOrigin(), m_Battery.Gun.GetOrigin()) >= 300) { Check(false, "actual local prisoner remains below artillery minimum engagement range"); return -1; }
|
||||
if (m_Stage == 0)
|
||||
{
|
||||
m_Service.InitialStock(m_Depot); hq.PhysicalForces(m_Forces);
|
||||
ERR_ScenarioComponent scenario = ERR_ScenarioComponent.Cast(m_Mode.FindComponent(ERR_ScenarioComponent));
|
||||
ERR_SiteRuntime site = scenario.Sites().Find("everon-supply-2");
|
||||
if (!Check(site && site.Group == m_Service.Group() && site.PatrolCycle, "same original western cache patrol")) return -1;
|
||||
m_OriginalPatrolCycle = site.PatrolCycle; m_OriginalPatrolMoves.Copy(site.PatrolWaypoints);
|
||||
array<AIAgent> agents = {}; m_Battery.Group.GetAgents(agents);
|
||||
foreach (AIAgent agent : agents)
|
||||
{
|
||||
if (!agent || !agent.GetControlledEntity()) continue;
|
||||
IEntity actor = agent.GetControlledEntity(); m_Crew.Insert(actor);
|
||||
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(actor.FindComponent(InventoryStorageManagerComponent));
|
||||
array<IEntity> items = {}; ERR_Inventory.Items(inventory, items);
|
||||
foreach (IEntity item : items) if (item && item.GetPrefabData() && item.GetPrefabData().GetPrefabName() == ERR_MortarBattery.SHELL && !m_Original.Contains(item)) m_Original.Insert(item);
|
||||
}
|
||||
if (!Check(m_Original.Count() == 27 && m_Depot.Count() == 12 && m_Battery.TotalShots == 0, "27 original crew rounds and 12 distinct finite depot rounds")) return -1;
|
||||
foreach (IEntity depotShell : m_Depot) if (!Check(!m_Original.Contains(depotShell) && Has(m_Service.Cache(), depotShell), "depot round is a distinct original cache item")) return -1;
|
||||
int tested, lowGround, obstructed;
|
||||
for (int i = 0; i < STORAGE_CANDIDATES && m_Stores.Count() < STORAGE_COUNT; i++)
|
||||
{
|
||||
tested++;
|
||||
vector position = ERR_Actors.Ground(m_Battery.Gun.GetOrigin() + StorageOffset(i));
|
||||
if (position[1] <= 1) { lowGround++; continue; }
|
||||
if (!SCR_WorldTools.TraceCilinderUtil(position + "0 1 0", 1.3, 2, TraceFlags.ENTS | TraceFlags.WORLD)) { obstructed++; continue; }
|
||||
IEntity box = ERR_Actors.Spawn(BOX, position);
|
||||
if (!Check(box != null, "fixture-only empty box created; no new ammunition")) return -1;
|
||||
m_Mode.Own(box); m_Boxes.Insert(box);
|
||||
BaseInventoryStorageComponent storage = BaseInventoryStorageComponent.Cast(box.FindComponent(BaseInventoryStorageComponent));
|
||||
if (!Check(storage != null, "fixture box has real native storage")) return -1;
|
||||
m_Stores.Insert(storage);
|
||||
}
|
||||
Print(string.Format("ERR COURIER STORAGE: tested=%1/64 ready=%2/5 lowGround=%3 obstructed=%4", tested, m_Stores.Count(), lowGround, obstructed));
|
||||
if (!Check(m_Boxes.Count() == STORAGE_COUNT && m_Stores.Count() == STORAGE_COUNT, "five owned empty native stores ready before moving any original shell")) return -1;
|
||||
Print("ERR COURIER FIXTURE: original 27 shells will be physically sequestered in five fixture boxes, not fired/destroyed; no report insertion, teleport, AI hold or replacement ammunition");
|
||||
m_Stage = 1;
|
||||
}
|
||||
if (m_Stage == 1)
|
||||
{
|
||||
if (m_Callback)
|
||||
{
|
||||
if (m_Callback.Result < 0 || now >= m_MoveDeadline || !m_Moving) { Check(false, "native depletion move neither failed nor timed out/item lost"); return -1; }
|
||||
InventoryItemComponent item = InventoryItemComponent.Cast(m_Moving.FindComponent(InventoryItemComponent));
|
||||
if (m_Callback.Result == 0 || !item || !item.GetParentSlot() || item.GetParentSlot().GetStorage() != m_Destination || Has(m_Source, m_Moving) || !m_Destination.Contains(m_Moving)) return 0;
|
||||
m_Callback = null; m_Moving = null; m_Source = null; m_Destination = null; m_Index++;
|
||||
}
|
||||
if (m_Index < m_Original.Count())
|
||||
{
|
||||
m_Moving = m_Original[m_Index];
|
||||
foreach (IEntity owner : m_Crew) if (Has(owner, m_Moving)) m_Source = owner;
|
||||
if (!Check(m_Source != null, "original shell still belongs to original crew before depletion")) return -1;
|
||||
foreach (BaseInventoryStorageComponent candidate : m_Stores) if (candidate.PerformVolumeValidation(m_Moving)) { m_Destination = candidate; break; }
|
||||
if (!Check(m_Destination != null, "five native boxes have capacity for original shells")) return -1;
|
||||
InventoryStorageManagerComponent source = InventoryStorageManagerComponent.Cast(m_Source.FindComponent(InventoryStorageManagerComponent));
|
||||
m_Callback = new ERR_NativeCourierMoveCallback(); m_MoveDeadline = now + 8000;
|
||||
if (!Check(source.TryMoveItemToStorage(m_Moving, m_Destination, -1, m_Callback), "native depletion move accepted, awaiting exact ownership proof")) return -1;
|
||||
return 0;
|
||||
}
|
||||
if (!Check(OriginalStored() && m_Battery.CountShells() == 0, "all 27 original shells remain physical while real battery inventory is exhausted")) return -1;
|
||||
m_Stage = 2;
|
||||
}
|
||||
if (!OriginalStored() || !PreservedForces(hq)) { Check(false, "all original shells and all original physical forces preserved"); return -1; }
|
||||
if (!m_Downlink && m_Service.ProbeOrder()) m_Downlink = m_Service.ProbeOrder();
|
||||
if (!m_Bearer && m_Service.Courier()) { m_Bearer = m_Service.Courier(); m_Departure = m_Bearer.GetOrigin(); }
|
||||
if (m_Service.Delivered() < 1 || m_Service.CompletedTrips() < 1 || m_Service.State() != 1) return 0;
|
||||
AIWaypoint current = m_Service.Group().GetCurrentWaypoint();
|
||||
if (!current || (current != m_OriginalPatrolCycle && !m_OriginalPatrolMoves.Contains(current))) return 0;
|
||||
if (!Check(m_Service.CompletedTrips() == 1 && hq.LogisticsToken(m_Service.Group()) == 0, "complete physical trip released HQ reservation and restored original native patrol")) return -1;
|
||||
if (!Check(m_Status && m_Service.ProbeSourceMessage() == m_Message && m_Status.MessageId == m_Message && m_Status.ObservedAt == m_ObservedAt && ERR_MortarLogisticsRules.Fresh(m_ObservedAt, m_Service.ProbeLastDelivery()), "same immutable originally delivered supply status authorizes delivery")) return -1;
|
||||
if (!Check(m_Uplink && !m_Uplink.Failed && !m_Uplink.Sending && m_Uplink.Cursor >= m_Uplink.Nodes.Count(), "actual low-ammo radio uplink reached HQ")) return -1;
|
||||
if (!Check(m_Downlink && !m_Downlink.Failed && !m_Downlink.Sending && m_Downlink.Cursor == m_Downlink.Nodes.Count() - 1, "actual courier downlink completed before item delivery")) return -1;
|
||||
if (!Check(m_Bearer && ERR_Actors.Group(m_Bearer) == m_Service.Group() && vector.DistanceXZ(m_Departure, m_Bearer.GetOrigin()) > 100, "same original bearer physically walked more than 100m")) return -1;
|
||||
bool found; foreach (IEntity deliveredShell : m_Depot) foreach (IEntity recipient : m_Crew) if (Has(recipient, deliveredShell)) found = true;
|
||||
if (!Check(found && m_Battery.CountShells() > 0 && m_Battery.TotalShots == 0, "previously empty battery now owns an exact original depot shell; no synthetic rounds or shots")) return -1;
|
||||
foreach (IEntity retained : m_Depot)
|
||||
{
|
||||
int owners;
|
||||
if (Has(m_Service.Cache(), retained)) owners++;
|
||||
if (Has(m_Bearer, retained)) owners++;
|
||||
foreach (IEntity crew : m_Crew) if (Has(crew, retained)) owners++;
|
||||
if (!Check(retained && owners == 1, "each of 12 initial depot shells has exactly one physical owner")) return -1;
|
||||
}
|
||||
m_Complete = true; Stop(); return 1;
|
||||
}
|
||||
void Stop()
|
||||
{
|
||||
if (m_Stopped) return;
|
||||
m_Stopped = true; if (s_Current == this) s_Current = null;
|
||||
// Boxes with borrowed originals remain owned by normal mission cleanup.
|
||||
// No manual restoration/deletion can race the production/native moves.
|
||||
}
|
||||
}
|
||||
#endif
|
||||
+334
@@ -0,0 +1,334 @@
|
||||
#ifdef WORKBENCH
|
||||
// Explicit synthetic observation only. Delivery, HQ scheduling and native fire
|
||||
// are unchanged; this helper never writes directly into the HQ contact list.
|
||||
modded class ERR_HQ
|
||||
{
|
||||
int ERR_QueueMortarFixtureReport(SCR_AIGroup source, IEntity target, vector position, float now)
|
||||
{
|
||||
if (!ERR_NativeMortarFiringProbe.Enabled() || !target || !m_Contacts.IsEmpty()) return 0;
|
||||
foreach (ERR_HQUnit unit : m_Units)
|
||||
{
|
||||
if (unit.Group != source || !unit.ReservedForSupport || !unit.Radio || unit.Uplink || unit.PendingReport) continue;
|
||||
ERR_HQRoute route = m_Network.Route(unit.Radio);
|
||||
if (!route || route.Nodes.IsEmpty()) return 0;
|
||||
ERR_HQReport report = new ERR_HQReport();
|
||||
report.MessageId = m_Journal.Message(); report.Target = target; report.Source = source;
|
||||
report.Position = position; report.ObservedAt = now; report.DetailsObservedAt = now;
|
||||
report.HasVelocity = true; report.Velocity = vector.Zero;
|
||||
report.TravelMode = "on foot"; report.SeenEscapists = 1; report.SeenWeapons = "fixture observation";
|
||||
route.MessageId = report.MessageId; route.Payload = "EXPLICIT SYNTHETIC OBSERVATION: " + report.Description(now);
|
||||
unit.PendingReport = report; unit.Uplink = route;
|
||||
m_Journal.Write("FIXTURE REPORT", route.Payload, report.MessageId);
|
||||
return report.MessageId;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
void ERR_RemoveMortarFixtureReport(IEntity target)
|
||||
{
|
||||
if (!target) return;
|
||||
for (int i = m_Contacts.Count() - 1; i >= 0; i--)
|
||||
if (m_Contacts[i].Target == target) m_Contacts.Remove(i);
|
||||
foreach (ERR_HQUnit unit : m_Units)
|
||||
{
|
||||
if (unit.PendingReport && unit.PendingReport.Target == target) { unit.PendingReport = null; unit.Uplink = null; }
|
||||
if ((unit.PendingOrder && unit.PendingOrder.Target == target) || (unit.Assignment && unit.Assignment.Target == target)) unit.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Production sets Released immediately before reserving shell airspace, then
|
||||
// invokes native inventory transfer. Observe that existing commit boundary so
|
||||
// even a synchronous native projectile has a prior recorded authorization.
|
||||
modded class ERR_MortarBattery
|
||||
{
|
||||
override void ReserveShellAirspace(float now)
|
||||
{
|
||||
super.ReserveShellAirspace(now);
|
||||
ERR_NativeMortarFiringProbe probe = ERR_NativeMortarFiringProbe.Active();
|
||||
if (probe) probe.NativeCommit(this, now);
|
||||
}
|
||||
override void Cancel(string reason = "cancel requested")
|
||||
{
|
||||
ERR_NativeMortarFiringProbe probe = ERR_NativeMortarFiringProbe.Active();
|
||||
if (probe) probe.NativeClosing(this, reason, LastFailure);
|
||||
super.Cancel(reason);
|
||||
}
|
||||
}
|
||||
|
||||
class ERR_NativeMortarEvidenceRules
|
||||
{
|
||||
static bool AimFresh(float commandedAt, float validatedAt, float now)
|
||||
{
|
||||
return commandedAt >= 0 && now >= commandedAt && validatedAt >= commandedAt && now >= validatedAt && now - validatedAt <= 10000;
|
||||
}
|
||||
static bool CommitFresh(float observed, float expectedObserved, float committed)
|
||||
{
|
||||
return observed == expectedObserved && observed >= 0 && committed >= observed && committed - observed <= ERR_MortarPrediction.MAX_REPORT_AGE_MS;
|
||||
}
|
||||
static bool EventBound(bool pending, int order, int currentOrder, int shot, int currentShot, float committed, float emitted)
|
||||
{
|
||||
// The fixture has its own overall deadline. A committed shell does not gain
|
||||
// a second authorization expiry while native physical transfer completes.
|
||||
return pending && order == currentOrder && shot == currentShot && emitted >= committed;
|
||||
}
|
||||
static bool ExpiredClosure(float observed, float closed, string failure)
|
||||
{
|
||||
return observed >= 0 && closed > observed + ERR_MortarPrediction.MAX_REPORT_AGE_MS && (failure.Contains("delivered target report expired") || failure.Contains("observation older than 60s"));
|
||||
}
|
||||
}
|
||||
|
||||
// Observe only the actual native event. Node SUCCESS, accepted inventory moves
|
||||
// and committed shell loading are never counted as physical projectiles.
|
||||
modded class SCR_MortarMuzzleComponent
|
||||
{
|
||||
override void OnWeaponFired(IEntity effectEntity, BaseMuzzleComponent muzzle, IEntity projectileEntity)
|
||||
{
|
||||
ERR_NativeMortarFiringProbe probe = ERR_NativeMortarFiringProbe.Active();
|
||||
if (probe) probe.NativeProjectile(GetOwner(), projectileEntity);
|
||||
super.OnWeaponFired(effectEntity, muzzle, projectileEntity);
|
||||
}
|
||||
}
|
||||
|
||||
class ERR_NativeMortarFiringProbe
|
||||
{
|
||||
protected static ERR_NativeMortarFiringProbe s_Active;
|
||||
protected ERR_GameMode m_Mode;
|
||||
protected ERR_MortarBattery m_Battery;
|
||||
protected IEntity m_Gun;
|
||||
protected SCR_AIGroup m_Group;
|
||||
protected IEntity m_Target;
|
||||
protected vector m_Impact;
|
||||
protected ref array<IEntity> m_Crew = {};
|
||||
protected ref array<IEntity> m_InitialShells = {};
|
||||
protected ref array<IEntity> m_Projectiles = {};
|
||||
protected int m_Source;
|
||||
protected int m_Order;
|
||||
protected int m_Step;
|
||||
protected int m_Events;
|
||||
protected int m_StoppedShells;
|
||||
protected float m_Deadline;
|
||||
protected float m_NextDiagnostic;
|
||||
protected float m_StopUntil;
|
||||
protected float m_ObservedAt;
|
||||
protected bool m_WaitForExpiry;
|
||||
protected int m_FinalEvents;
|
||||
protected bool m_CommitPending;
|
||||
protected float m_CommittedAt;
|
||||
protected int m_CommitOrder;
|
||||
protected int m_CommitShot;
|
||||
protected int m_Commits;
|
||||
protected bool m_ClosureSeen;
|
||||
protected bool m_ClosureExpired;
|
||||
protected bool m_ClosureComplete;
|
||||
protected bool m_Unsafe;
|
||||
protected bool m_BadProjectile;
|
||||
protected bool m_SawUplink;
|
||||
protected bool m_SawDownlink;
|
||||
protected bool m_SawActivity;
|
||||
protected bool m_Stopped;
|
||||
protected string m_BadReason;
|
||||
|
||||
static bool Enabled()
|
||||
{
|
||||
string enabled, outcome;
|
||||
return Replication.IsServer() && System.GetCLIParam("errSmoke", enabled) && enabled == "1" && System.GetCLIParam("errSmokeOutcome", outcome) && (outcome == "mortar-fire" || outcome == "mortar-expiry");
|
||||
}
|
||||
static ERR_NativeMortarFiringProbe Active() { return s_Active; }
|
||||
void ERR_NativeMortarFiringProbe(ERR_GameMode mode)
|
||||
{
|
||||
m_Mode = mode;
|
||||
string outcome; System.GetCLIParam("errSmokeOutcome", outcome); m_WaitForExpiry = outcome == "mortar-expiry";
|
||||
m_Deadline = GetGame().GetWorld().GetWorldTime() + 180000;
|
||||
if (Enabled() && !s_Active) s_Active = this;
|
||||
}
|
||||
protected bool Check(bool condition, string detail)
|
||||
{
|
||||
if (condition) { Print("ERR SMOKE PASS: mortar firing " + detail); return true; }
|
||||
Print("ERR SMOKE FAIL: mortar firing " + detail, LogLevel.ERROR); Stop(); return false;
|
||||
}
|
||||
protected void Shells(out array<IEntity> shells)
|
||||
{
|
||||
shells.Clear();
|
||||
foreach (IEntity actor : m_Crew)
|
||||
{
|
||||
if (!actor) continue;
|
||||
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(actor.FindComponent(InventoryStorageManagerComponent));
|
||||
array<IEntity> items = {}; ERR_Inventory.Items(inventory, items);
|
||||
foreach (IEntity item : items)
|
||||
if (item && item.GetPrefabData() && item.GetPrefabData().GetPrefabName() == ERR_MortarBattery.SHELL && !shells.Contains(item)) shells.Insert(item);
|
||||
}
|
||||
}
|
||||
protected bool CheckNoOccupants(IEntity entity)
|
||||
{
|
||||
if (ChimeraCharacter.Cast(entity) || Vehicle.Cast(entity)) { m_Unsafe = true; return false; }
|
||||
return true;
|
||||
}
|
||||
protected bool EmptyImpact(vector position)
|
||||
{
|
||||
m_Unsafe = false;
|
||||
GetGame().GetWorld().QueryEntitiesByAABB(position - "200 5000 200", position + "200 5000 200", CheckNoOccupants, null, EQueryEntitiesFlags.ALL);
|
||||
return !m_Unsafe;
|
||||
}
|
||||
protected bool SelectImpact(ERR_MortarSystem system)
|
||||
{
|
||||
// Bounded candidate survey: no clearance assumption from coordinates alone.
|
||||
array<vector> offsets = {"-800 0 -600", "-600 0 -800", "0 0 -1000", "600 0 -800", "-1000 0 0", "800 0 -600", "-800 0 600", "0 0 1000"};
|
||||
foreach (vector offset : offsets)
|
||||
{
|
||||
vector candidate = ERR_Actors.Ground(m_Battery.Position + offset);
|
||||
if (candidate[1] < 1 || !EmptyImpact(candidate) || !system.Safe(m_Battery, candidate, 75)) continue;
|
||||
bool clear = true;
|
||||
for (int shot = 0; shot < ERR_MortarSalvo.ROUNDS; shot++)
|
||||
{
|
||||
vector spread = ERR_Actors.Ground(candidate + ERR_MortarSalvo.Offset(m_Battery.SiteId, m_Battery.OrderId + 1, shot));
|
||||
if (!system.Safe(m_Battery, spread, 75)) { clear = false; break; }
|
||||
}
|
||||
if (!clear) continue;
|
||||
m_Impact = candidate;
|
||||
PrintFormat("ERR MORTAR FIXTURE IMPACT: position=%1 batteryDistance=%2; no characters/vehicles in 400m square; production settlement/friendly exclusions passed", candidate, vector.DistanceXZ(candidate, m_Battery.Position));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
protected void Diagnose(float now)
|
||||
{
|
||||
if (now < m_NextDiagnostic) return;
|
||||
m_NextDiagnostic = now + 5000;
|
||||
if (!m_Battery) { Print("ERR MORTAR FIXTURE: waiting for one authored battery"); return; }
|
||||
PrintFormat("ERR MORTAR FIXTURE: step=%1 startup=%2 source=M%3 observedEvents=%4 ready=%5 failure=%6", m_Step, m_Battery.StartupActivated, m_Source, m_Events, m_Battery.Operational(), m_Battery.SpawnFailure);
|
||||
m_Battery.SiteStatus(now);
|
||||
}
|
||||
void NativeCommit(ERR_MortarBattery battery, float now)
|
||||
{
|
||||
if (m_Stopped || battery != m_Battery) return;
|
||||
if ((m_Step != 1 && m_Step != 3) || m_CommitPending || !battery.CommandOpen || !battery.Released || battery.SourceId != m_Source || battery.OrderId != m_Order || !battery.Report || battery.Report.MessageId != m_Source || !ERR_NativeMortarEvidenceRules.CommitFresh(battery.Report.ObservedAt, m_ObservedAt, now) || !ERR_NativeMortarEvidenceRules.AimFresh(battery.AimedAt, battery.AimValidatedAt, now) || battery.FlightTime <= 0)
|
||||
{ m_BadProjectile = true; m_BadReason = "physical commit lacks immutable fresh report/current order/native aim, or duplicates pending commit"; return; }
|
||||
m_CommitPending = true; m_CommittedAt = now; m_CommitOrder = battery.OrderId; m_CommitShot = battery.OrderShots; m_Commits++;
|
||||
PrintFormat("ERR MORTAR FIXTURE COMMIT: count=%1 order=%2 shot=%3 source=M%4 observedAge=%5; recorded before native inventory transfer", m_Commits, m_CommitOrder, m_CommitShot, m_Source, now - m_ObservedAt);
|
||||
}
|
||||
void NativeClosing(ERR_MortarBattery battery, string reason, string failure)
|
||||
{
|
||||
if (m_Stopped || !m_WaitForExpiry || battery != m_Battery || !battery.CommandOpen || battery.SourceId != m_Source || m_Source <= 0) return;
|
||||
float now = GetGame().GetWorld().GetWorldTime();
|
||||
m_ClosureSeen = true;
|
||||
m_ClosureComplete = battery.OrderShots == ERR_MortarSalvo.ROUNDS && m_Events == ERR_MortarSalvo.ROUNDS;
|
||||
m_ClosureExpired = ERR_NativeMortarEvidenceRules.ExpiredClosure(m_ObservedAt, now, failure);
|
||||
PrintFormat("ERR MORTAR FIXTURE CLOSURE: order=%1 observedAge=%2 reason=%3 lastFailure=%4 complete=%5 expiredWithheld=%6", battery.OrderId, now - m_ObservedAt, reason, failure, m_ClosureComplete, m_ClosureExpired);
|
||||
if (battery.Stopped() || battery.OrderId != m_Order || (!m_ClosureExpired && !m_ClosureComplete))
|
||||
{ m_BadProjectile = true; m_BadReason = "expiry evidence interrupted by Stop or unrelated/incomplete order closure"; }
|
||||
}
|
||||
void NativeProjectile(IEntity gun, IEntity projectile)
|
||||
{
|
||||
if (m_Stopped || !m_Battery || gun != m_Gun) return;
|
||||
if (!projectile) { m_BadProjectile = true; m_BadReason = "native fired event has no physical projectile"; return; }
|
||||
if (m_Projectiles.Contains(projectile)) { m_BadProjectile = true; m_BadReason = "same physical projectile observed twice"; return; }
|
||||
m_Projectiles.Insert(projectile); m_Events++;
|
||||
float now = GetGame().GetWorld().GetWorldTime();
|
||||
if ((m_Step != 1 && m_Step != 3) || !m_Battery.Released || m_Battery.SourceId != m_Source || !ERR_NativeMortarEvidenceRules.EventBound(m_CommitPending, m_CommitOrder, m_Battery.OrderId, m_CommitShot, m_Battery.OrderShots, m_CommittedAt, now))
|
||||
{ m_BadProjectile = true; m_BadReason = "projectile lacks the recorded fresh physical commit for its current order/shot"; }
|
||||
m_CommitPending = false;
|
||||
PrintFormat("ERR MORTAR FIXTURE PROJECTILE: event=%1 entity=%2 origin=%3 order=%4 source=M%5 aimAge=%6 flight=%7; impact is not measured", m_Events, projectile, projectile.GetOrigin(), m_Battery.OrderId, m_Battery.SourceId, now - m_Battery.AimedAt, m_Battery.FlightTime);
|
||||
}
|
||||
int Tick(float now)
|
||||
{
|
||||
if (m_Stopped) return -1;
|
||||
if (!Enabled() || s_Active != this || !m_Mode || !m_Mode.Active()) { Check(false, "explicit active fixture context retained"); return -1; }
|
||||
ERR_HQ hq = ERR_HQ.Get();
|
||||
ERR_MortarSystem system;
|
||||
if (hq) system = hq.Mortars();
|
||||
if (!system) { Check(false, "real HQ mortar service available"); return -1; }
|
||||
if (!m_Battery)
|
||||
{
|
||||
array<IEntity> guns = {}; system.AppendGuns(guns);
|
||||
if (guns.Count() == 1) { m_Gun = guns[0]; m_Battery = system.FindGun(m_Gun); }
|
||||
}
|
||||
Diagnose(now);
|
||||
if (now >= m_Deadline) { Check(false, "bounded deadline; inspect readiness/route/activity/aim diagnostics"); return -1; }
|
||||
if (m_BadProjectile) { Check(false, m_BadReason); return -1; }
|
||||
if (!m_Battery) return 0;
|
||||
if (!m_Battery.SpawnFailure.IsEmpty()) { Check(false, "battery startup failed: " + m_Battery.SpawnFailure); return -1; }
|
||||
if (m_Step == 0)
|
||||
{
|
||||
if (!m_Battery.StartupActivated || !m_Battery.Operational() || !hq.MortarConnected(m_Battery.Group)) return 0;
|
||||
m_Group = m_Battery.Group;
|
||||
if (!Check(m_Group && m_Group.IsExpandComplete() && m_Group.GetAgentsCount() == 4 && m_Battery.TotalShots == 0 && m_Battery.OrderId == 0 && !m_Battery.CommandOpen && !m_Battery.Activity && !m_Battery.RadioOrder, "untasked original gun and complete four-person crew start without shots")) return -1;
|
||||
array<AIAgent> agents = {}; m_Group.GetAgents(agents);
|
||||
foreach (AIAgent agent : agents)
|
||||
{
|
||||
IEntity actor; if (agent) actor = agent.GetControlledEntity();
|
||||
if (!Check(ERR_Actors.CanFight(actor) && !m_Crew.Contains(actor), "initial physical crew member is conscious and unique")) return -1;
|
||||
m_Crew.Insert(actor);
|
||||
}
|
||||
if (!Check(m_Battery.RadioOperator && ERR_HQRadio.Capability(m_Battery.RadioOperator) == 2, "dedicated actual manpack supplies the radio route")) return -1;
|
||||
Shells(m_InitialShells);
|
||||
if (!Check(m_InitialShells.Count() == 27 && m_Battery.CountShells() == 27, "exactly 27 distinct physical initial HE shells")) return -1;
|
||||
if (!Check(SelectImpact(system), "bounded candidate survey finds an unoccupied production-safe impact area")) return -1;
|
||||
m_Target = ERR_Actors.Spawn(ERR_Resources.IntelFolder(), m_Impact);
|
||||
if (!Check(m_Target != null, "fixture-owned inert report identity exists; no enemy perception is claimed")) return -1;
|
||||
m_Source = hq.ERR_QueueMortarFixtureReport(m_Group, m_Target, m_Impact, now);
|
||||
if (!Check(m_Source > 0, "synthetic observation enters actual existing radio uplink once")) return -1;
|
||||
m_ObservedAt = now; m_SawUplink = true; m_Deadline = now + 90000; m_Step = 1; return 0;
|
||||
}
|
||||
if (m_WaitForExpiry && m_Battery.Stopped()) { Check(false, "battery must remain running throughout natural expiry observation"); return -1; }
|
||||
if (m_Battery.Report && (m_Battery.Report.MessageId != m_Source || m_Battery.Report.ObservedAt != m_ObservedAt)) { Check(false, "delivered report identity and original observation timestamp remain immutable"); return -1; }
|
||||
if (m_Battery.Gun != m_Gun || m_Battery.Group != m_Group) { Check(false, "original gun and crew identity retained"); return -1; }
|
||||
if (!EmptyImpact(m_Impact)) { Check(false, "impact exclusion stays unoccupied before native firing/cancellation"); return -1; }
|
||||
array<IEntity> remaining = {}; Shells(remaining);
|
||||
foreach (IEntity item : remaining)
|
||||
if (!m_InitialShells.Contains(item)) { Check(false, "no replacement HE shell appears after initialization"); return -1; }
|
||||
if (m_Step == 1)
|
||||
{
|
||||
if (m_Battery.RadioOrder && m_Battery.SourceId == m_Source) { m_SawDownlink = true; m_Order = m_Battery.OrderId; }
|
||||
if (m_Battery.Activity && m_Battery.Report && m_Battery.Report.MessageId == m_Source) m_SawActivity = true;
|
||||
if (m_Battery.OrderId > 1 || (m_Battery.SourceId != 0 && m_Battery.SourceId != m_Source)) { Check(false, "one unchanged fixture source creates only one HQ order"); return -1; }
|
||||
if (m_Events == 0) return 0;
|
||||
if (!Check(m_Events == 1 && m_Battery.TotalShots == 1 && m_Battery.OrderShots == 1 && !m_Battery.Released && m_SawUplink && m_SawDownlink && m_SawActivity && m_Order == 1, "one physical projectile follows real uplink/downlink, native activity and fresh aim")) return -1;
|
||||
if (!Check(remaining.Count() == 26 && m_Battery.CountShells() == 26, "one fired projectile consumes one of 27 original inventory shells")) return -1;
|
||||
if (m_WaitForExpiry)
|
||||
{
|
||||
m_Step = 3; m_Deadline = m_ObservedAt + ERR_MortarPrediction.MAX_REPORT_AGE_MS + 50000;
|
||||
return 0;
|
||||
}
|
||||
m_FinalEvents = m_Events;
|
||||
// Cancel on this later fixture tick, never reentrantly inside the firing node.
|
||||
hq.ERR_RemoveMortarFixtureReport(m_Target);
|
||||
system.Stop(); system.Stop();
|
||||
if (!Check(!m_Battery.CommandOpen && !m_Battery.Activity && !m_Battery.RadioOrder, "explicit support Stop closes native order and route idempotently")) return -1;
|
||||
m_StoppedShells = remaining.Count(); m_StopUntil = now + 12000; m_Deadline = m_StopUntil + 2000; m_Step = 2; return 0;
|
||||
}
|
||||
if (m_Step == 3)
|
||||
{
|
||||
if (m_Battery.OrderId != m_Order || m_Battery.SourceId != m_Source) { Check(false, "unchanged delivered observation received a second order"); return -1; }
|
||||
if (m_Events > ERR_MortarSalvo.ROUNDS || m_Battery.TotalShots > ERR_MortarSalvo.ROUNDS) { Check(false, "single observation cannot exceed finite salvo"); return -1; }
|
||||
if (now < m_ObservedAt + ERR_MortarPrediction.MAX_REPORT_AGE_MS + 25000) return 0;
|
||||
if (!Check(m_ClosureSeen && (m_ClosureExpired || m_ClosureComplete) && !m_CommitPending && m_Commits == m_Events && m_Events >= 1 && m_Battery.TotalShots == m_Events && m_Battery.OrderShots == m_Events && remaining.Count() == 27 - m_Events && !m_Battery.CommandOpen && !m_Battery.Activity && !m_Battery.RadioOrder && !m_Battery.Report, "explicit expired-withheld closure or complete finite salvo, with only fresh commits and observed physical shells consumed")) return -1;
|
||||
m_FinalEvents = m_Events;
|
||||
// Keep HQ running and the expired contact present throughout this next wait.
|
||||
m_StoppedShells = remaining.Count(); m_StopUntil = now + 12000; m_Deadline = m_StopUntil + 2000; m_Step = 4;
|
||||
return 0;
|
||||
}
|
||||
if (now < m_StopUntil) return 0;
|
||||
if (!Check(m_Battery.TotalShots == m_FinalEvents && m_Events == m_FinalEvents && m_Battery.OrderId == m_Order && remaining.Count() == m_StoppedShells && !m_Battery.CommandOpen && !m_Battery.Activity && !m_Battery.RadioOrder, "closed order issues no duplicate order, projectile or replenishment over 12 seconds")) return -1;
|
||||
array<AIAgent> finalCrew = {}; m_Group.GetAgents(finalCrew);
|
||||
if (!Check(finalCrew.Count() == 4 && m_Battery.Gun == m_Gun, "original group and gun retain four crew")) return -1;
|
||||
foreach (AIAgent finalAgent : finalCrew)
|
||||
if (!Check(finalAgent && m_Crew.Contains(finalAgent.GetControlledEntity()) && ERR_Actors.CanFight(finalAgent.GetControlledEntity()), "final conscious crew entity belongs to original physical roster")) return -1;
|
||||
if (m_WaitForExpiry) { hq.ERR_RemoveMortarFixtureReport(m_Target); system.Stop(); system.Stop(); }
|
||||
Stop(); return 1;
|
||||
}
|
||||
void Stop()
|
||||
{
|
||||
if (m_Stopped) return;
|
||||
m_Stopped = true;
|
||||
ERR_HQ hq = ERR_HQ.Get();
|
||||
if (hq)
|
||||
{
|
||||
hq.ERR_RemoveMortarFixtureReport(m_Target);
|
||||
if (m_Battery && m_Battery.SourceId == m_Source && m_Source > 0) m_Battery.Cancel("mortar firing fixture cleanup");
|
||||
}
|
||||
ERR_Actors.DeleteSpawned(m_Target); m_Target = null;
|
||||
if (s_Active == this) s_Active = null;
|
||||
}
|
||||
void ~ERR_NativeMortarFiringProbe() { Stop(); }
|
||||
}
|
||||
#endif
|
||||
+476
@@ -0,0 +1,476 @@
|
||||
#ifdef WORKBENCH
|
||||
// Explicit synthetic observation only. Delivery, HQ scheduling and native fire
|
||||
// are unchanged; this helper never writes directly into the HQ contact list.
|
||||
modded class ERR_HQ
|
||||
{
|
||||
int ERR_QueueMortarRefreshReport(SCR_AIGroup source, IEntity radio, IEntity target, vector position, out float observedAt)
|
||||
{
|
||||
observedAt = -1;
|
||||
if (!ERR_NativeMortarSalvoProbe.RefreshEnabled() || !target || !radio || ERR_HQRadio.Capability(radio) != 2) return 0;
|
||||
foreach (ERR_HQUnit unit : m_Units)
|
||||
{
|
||||
if (unit.Group != source || !unit.ReservedForSupport || unit.Radio != radio || unit.Uplink || unit.PendingReport || unit.PendingSupplyStatus) continue;
|
||||
ERR_HQRoute route = m_Network.Route(radio);
|
||||
if (!route || route.Nodes.IsEmpty()) return 0;
|
||||
observedAt = GetGame().GetWorld().GetWorldTime();
|
||||
ERR_HQReport report = new ERR_HQReport();
|
||||
report.MessageId = m_Journal.Message(); report.Target = target; report.Source = source;
|
||||
report.Position = position; report.ObservedAt = observedAt; report.DetailsObservedAt = observedAt;
|
||||
report.HasVelocity = true; report.Velocity = vector.Zero;
|
||||
report.TravelMode = "on foot"; report.SeenEscapists = 1; report.SeenWeapons = "fixture observation";
|
||||
route.MessageId = report.MessageId; route.Payload = "NEW EXPLICIT SYNTHETIC OBSERVATION: " + report.Description(observedAt);
|
||||
unit.PendingReport = report; unit.Uplink = route;
|
||||
m_Journal.Write("FIXTURE REPORT", route.Payload, report.MessageId);
|
||||
return report.MessageId;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
int ERR_QueueMortarSalvoReport(SCR_AIGroup source, IEntity target, vector position, float now)
|
||||
{
|
||||
if (!ERR_NativeMortarSalvoProbe.Enabled() || !target || !m_Contacts.IsEmpty()) return 0;
|
||||
foreach (ERR_HQUnit unit : m_Units)
|
||||
{
|
||||
if (unit.Group != source || !unit.ReservedForSupport || !unit.Radio || unit.Uplink || unit.PendingReport) continue;
|
||||
ERR_HQRoute route = m_Network.Route(unit.Radio);
|
||||
if (!route || route.Nodes.IsEmpty()) return 0;
|
||||
ERR_HQReport report = new ERR_HQReport();
|
||||
report.MessageId = m_Journal.Message(); report.Target = target; report.Source = source;
|
||||
report.Position = position; report.ObservedAt = now; report.DetailsObservedAt = now;
|
||||
report.HasVelocity = true; report.Velocity = vector.Zero;
|
||||
report.TravelMode = "on foot"; report.SeenEscapists = 1; report.SeenWeapons = "fixture observation";
|
||||
route.MessageId = report.MessageId; route.Payload = "EXPLICIT SYNTHETIC OBSERVATION: " + report.Description(now);
|
||||
unit.PendingReport = report; unit.Uplink = route;
|
||||
m_Journal.Write("FIXTURE REPORT", route.Payload, report.MessageId);
|
||||
return report.MessageId;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
void ERR_RemoveMortarSalvoReport(IEntity target)
|
||||
{
|
||||
if (!target) return;
|
||||
for (int i = m_Contacts.Count() - 1; i >= 0; i--)
|
||||
if (m_Contacts[i].Target == target) m_Contacts.Remove(i);
|
||||
foreach (ERR_HQUnit unit : m_Units)
|
||||
{
|
||||
if (unit.PendingReport && unit.PendingReport.Target == target) { unit.PendingReport = null; unit.Uplink = null; }
|
||||
if ((unit.PendingOrder && unit.PendingOrder.Target == target) || (unit.Assignment && unit.Assignment.Target == target)) unit.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Production sets Released immediately before reserving shell airspace, then
|
||||
// invokes native inventory transfer. Observe that existing commit boundary so
|
||||
// even a synchronous native projectile has a prior recorded authorization.
|
||||
modded class ERR_MortarBattery
|
||||
{
|
||||
override void Audit(string kind, string detail)
|
||||
{
|
||||
super.Audit(kind, detail);
|
||||
ERR_NativeMortarSalvoProbe probe = ERR_NativeMortarSalvoProbe.Active();
|
||||
if (probe) probe.NativeAudit(this, kind);
|
||||
}
|
||||
override void ReserveShellAirspace(float now)
|
||||
{
|
||||
super.ReserveShellAirspace(now);
|
||||
ERR_NativeMortarSalvoProbe probe = ERR_NativeMortarSalvoProbe.Active();
|
||||
if (probe) probe.NativeCommit(this, now);
|
||||
}
|
||||
override void Cancel(string reason = "cancel requested")
|
||||
{
|
||||
ERR_NativeMortarSalvoProbe probe = ERR_NativeMortarSalvoProbe.Active();
|
||||
if (probe) probe.NativeClosing(this, reason, LastFailure);
|
||||
super.Cancel(reason);
|
||||
}
|
||||
}
|
||||
|
||||
// Exact completion is separate from native activity success or accepted loading.
|
||||
class ERR_NativeMortarSalvoRules
|
||||
{
|
||||
static bool Complete(int events, int commits, int total, int orderShots, int shells, bool pending)
|
||||
{
|
||||
return events == ERR_MortarSalvo.ROUNDS && commits == events && total == events && orderShots == events && shells == 27 - events && !pending;
|
||||
}
|
||||
static bool QueueUpdate(bool accepted, bool pending, int shots, float observedAt, float orderedAt, float now)
|
||||
{
|
||||
return accepted && !pending && shots < ERR_MortarSalvo.ROUNDS / 2 && now >= observedAt + 20000 && orderedAt >= 0 && now >= orderedAt && now - orderedAt < 90000;
|
||||
}
|
||||
}
|
||||
|
||||
class ERR_NativeMortarRefreshRecord
|
||||
{
|
||||
int MessageId;
|
||||
float ObservedAt;
|
||||
int DownlinkId;
|
||||
bool Accepted;
|
||||
ref ERR_HQReport Delivered;
|
||||
|
||||
bool Matches(ERR_HQReport report, IEntity target, SCR_AIGroup group, vector position)
|
||||
{
|
||||
if (!report || report.MessageId != MessageId || report.ObservedAt != ObservedAt || report.Target != target || report.Source != group) return false;
|
||||
return vector.Distance(report.Position, position) < 0.01 && report.HasVelocity && report.Velocity.Length() < 0.001 && !report.Distress;
|
||||
}
|
||||
}
|
||||
|
||||
// Observe only the actual native event. Node SUCCESS, accepted inventory moves
|
||||
// and committed shell loading are never counted as physical projectiles.
|
||||
modded class SCR_MortarMuzzleComponent
|
||||
{
|
||||
override void OnWeaponFired(IEntity effectEntity, BaseMuzzleComponent muzzle, IEntity projectileEntity)
|
||||
{
|
||||
ERR_NativeMortarSalvoProbe probe = ERR_NativeMortarSalvoProbe.Active();
|
||||
if (probe) probe.NativeProjectile(GetOwner(), projectileEntity);
|
||||
super.OnWeaponFired(effectEntity, muzzle, projectileEntity);
|
||||
}
|
||||
}
|
||||
|
||||
class ERR_NativeMortarSalvoProbe
|
||||
{
|
||||
protected static ERR_NativeMortarSalvoProbe s_Active;
|
||||
protected ERR_GameMode m_Mode;
|
||||
protected ERR_MortarBattery m_Battery;
|
||||
protected IEntity m_Gun;
|
||||
protected SCR_AIGroup m_Group;
|
||||
protected IEntity m_Target;
|
||||
protected vector m_Impact;
|
||||
protected ref array<IEntity> m_Crew = {};
|
||||
protected ref array<IEntity> m_InitialShells = {};
|
||||
protected ref array<IEntity> m_Projectiles = {};
|
||||
protected int m_Source;
|
||||
protected int m_Order;
|
||||
protected int m_Step;
|
||||
protected int m_Events;
|
||||
protected int m_StoppedShells;
|
||||
protected float m_Deadline;
|
||||
protected float m_NextDiagnostic;
|
||||
protected float m_StopUntil;
|
||||
protected float m_ObservedAt;
|
||||
protected ref ERR_HQReport m_Delivered;
|
||||
protected bool m_RefreshCase;
|
||||
protected IEntity m_OriginalRTO;
|
||||
protected SCR_AIStaticArtilleryActivity m_OriginalActivity;
|
||||
protected ref array<ref ERR_NativeMortarRefreshRecord> m_Observations = {};
|
||||
protected int m_UpdatesAccepted;
|
||||
protected int m_UpdatedCommits;
|
||||
protected int m_CommitSource;
|
||||
protected float m_CommitObservedAt;
|
||||
protected bool m_CommitPending;
|
||||
protected float m_CommittedAt;
|
||||
protected int m_CommitOrder;
|
||||
protected int m_CommitShot;
|
||||
protected int m_Commits;
|
||||
protected bool m_ClosureSeen;
|
||||
protected bool m_ClosureComplete;
|
||||
protected bool m_Unsafe;
|
||||
protected bool m_BadProjectile;
|
||||
protected bool m_SawUplink;
|
||||
protected bool m_SawDownlink;
|
||||
protected bool m_SawActivity;
|
||||
protected bool m_Stopped;
|
||||
protected string m_BadReason;
|
||||
|
||||
static bool Enabled()
|
||||
{
|
||||
string enabled, outcome;
|
||||
return Replication.IsServer() && System.GetCLIParam("errSmoke", enabled) && enabled == "1" && System.GetCLIParam("errSmokeOutcome", outcome) && (outcome == "mortar-salvo" || outcome == "mortar-refresh");
|
||||
}
|
||||
static bool RefreshEnabled()
|
||||
{
|
||||
string outcome;
|
||||
return Enabled() && System.GetCLIParam("errSmokeOutcome", outcome) && outcome == "mortar-refresh";
|
||||
}
|
||||
static ERR_NativeMortarSalvoProbe Active() { return s_Active; }
|
||||
void ERR_NativeMortarSalvoProbe(ERR_GameMode mode)
|
||||
{
|
||||
m_Mode = mode;
|
||||
m_RefreshCase = RefreshEnabled();
|
||||
m_Deadline = GetGame().GetWorld().GetWorldTime() + 180000;
|
||||
if (Enabled() && !s_Active) s_Active = this;
|
||||
}
|
||||
protected bool Check(bool condition, string detail)
|
||||
{
|
||||
if (condition) { Print("ERR SMOKE PASS: mortar salvo " + detail); return true; }
|
||||
Print("ERR SMOKE FAIL: mortar salvo " + detail, LogLevel.ERROR); Stop(); return false;
|
||||
}
|
||||
protected void Shells(out array<IEntity> shells)
|
||||
{
|
||||
shells.Clear();
|
||||
foreach (IEntity actor : m_Crew)
|
||||
{
|
||||
if (!actor) continue;
|
||||
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(actor.FindComponent(InventoryStorageManagerComponent));
|
||||
array<IEntity> items = {}; ERR_Inventory.Items(inventory, items);
|
||||
foreach (IEntity item : items)
|
||||
if (item && item.GetPrefabData() && item.GetPrefabData().GetPrefabName() == ERR_MortarBattery.SHELL && !shells.Contains(item)) shells.Insert(item);
|
||||
}
|
||||
}
|
||||
protected bool CheckNoOccupants(IEntity entity)
|
||||
{
|
||||
if (ChimeraCharacter.Cast(entity) || Vehicle.Cast(entity)) { m_Unsafe = true; return false; }
|
||||
return true;
|
||||
}
|
||||
protected bool EmptyImpact(vector position)
|
||||
{
|
||||
m_Unsafe = false;
|
||||
GetGame().GetWorld().QueryEntitiesByAABB(position - "200 5000 200", position + "200 5000 200", CheckNoOccupants, null, EQueryEntitiesFlags.ALL);
|
||||
return !m_Unsafe;
|
||||
}
|
||||
protected bool SelectImpact(ERR_MortarSystem system)
|
||||
{
|
||||
// Bounded candidate survey: no clearance assumption from coordinates alone.
|
||||
array<vector> offsets = {"-800 0 -600", "-600 0 -800", "0 0 -1000", "600 0 -800", "-1000 0 0", "800 0 -600", "-800 0 600", "0 0 1000"};
|
||||
foreach (vector offset : offsets)
|
||||
{
|
||||
vector candidate = ERR_Actors.Ground(m_Battery.Position + offset);
|
||||
if (candidate[1] < 1 || !EmptyImpact(candidate) || !system.Safe(m_Battery, candidate, 75)) continue;
|
||||
bool clear = true;
|
||||
for (int shot = 0; shot < ERR_MortarSalvo.ROUNDS; shot++)
|
||||
{
|
||||
vector spread = ERR_Actors.Ground(candidate + ERR_MortarSalvo.Offset(m_Battery.SiteId, m_Battery.OrderId + 1, shot));
|
||||
if (!system.Safe(m_Battery, spread, 75)) { clear = false; break; }
|
||||
}
|
||||
if (!clear) continue;
|
||||
m_Impact = candidate;
|
||||
PrintFormat("ERR MORTAR SALVO IMPACT: position=%1 batteryDistance=%2; no characters/vehicles in 400m square; production settlement/friendly exclusions passed", candidate, vector.DistanceXZ(candidate, m_Battery.Position));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
protected void Diagnose(float now)
|
||||
{
|
||||
if (now < m_NextDiagnostic) return;
|
||||
m_NextDiagnostic = now + 5000;
|
||||
if (!m_Battery) { Print("ERR MORTAR SALVO: waiting for one authored battery"); return; }
|
||||
PrintFormat("ERR MORTAR SALVO: step=%1 startup=%2 source=M%3 observedEvents=%4 ready=%5 failure=%6", m_Step, m_Battery.StartupActivated, m_Source, m_Events, m_Battery.Operational(), m_Battery.SpawnFailure);
|
||||
m_Battery.SiteStatus(now);
|
||||
}
|
||||
protected ERR_NativeMortarRefreshRecord FindObservation(int messageId)
|
||||
{
|
||||
foreach (ERR_NativeMortarRefreshRecord observation : m_Observations)
|
||||
if (observation.MessageId == messageId) return observation;
|
||||
return null;
|
||||
}
|
||||
protected bool AddObservation(int messageId, float observedAt)
|
||||
{
|
||||
if (messageId <= 0 || observedAt < 0 || FindObservation(messageId) || m_Observations.Count() >= 8) return false;
|
||||
if (!m_Observations.IsEmpty() && observedAt <= m_Observations[m_Observations.Count() - 1].ObservedAt) return false;
|
||||
ERR_NativeMortarRefreshRecord record = new ERR_NativeMortarRefreshRecord();
|
||||
record.MessageId = messageId; record.ObservedAt = observedAt;
|
||||
m_Observations.Insert(record);
|
||||
PrintFormat("ERR MORTAR REFRESH OBSERVATION: source=M%1 actualObservedAt=%2 originalRTO=%3 synthetic stationary inert target; awaiting actual uplink and HQ downlink", messageId, observedAt, m_OriginalRTO);
|
||||
return true;
|
||||
}
|
||||
protected bool ReportKnown(ERR_HQReport report, bool accepted = true)
|
||||
{
|
||||
if (!report) return false;
|
||||
if (!m_RefreshCase) return report.MessageId == m_Source && report.ObservedAt == m_ObservedAt;
|
||||
ERR_NativeMortarRefreshRecord record = FindObservation(report.MessageId);
|
||||
return record && (!accepted || record.Accepted) && record.Matches(report, m_Target, m_Group, m_Impact);
|
||||
}
|
||||
protected bool AllAccepted()
|
||||
{
|
||||
if (!m_RefreshCase) return true;
|
||||
if (m_Observations.Count() < 2 || m_UpdatesAccepted != m_Observations.Count() - 1 || m_UpdatedCommits < 1) return false;
|
||||
foreach (ERR_NativeMortarRefreshRecord record : m_Observations)
|
||||
if (!record.Accepted || !record.Delivered || record.DownlinkId <= 0) return false;
|
||||
return true;
|
||||
}
|
||||
protected bool NoReportReplay(float now)
|
||||
{
|
||||
if (!m_RefreshCase) return m_Delivered && !m_Battery.CanRequest(m_Delivered, now);
|
||||
if (!AllAccepted()) return false;
|
||||
foreach (ERR_NativeMortarRefreshRecord record : m_Observations)
|
||||
if (m_Battery.CanRequest(record.Delivered, now)) return false;
|
||||
return true;
|
||||
}
|
||||
// Observational only: never stop/cancel inside production's current call.
|
||||
void NativeAudit(ERR_MortarBattery battery, string kind)
|
||||
{
|
||||
if (!m_RefreshCase || m_Stopped || battery != m_Battery || m_Source <= 0) return;
|
||||
if (kind == "AIM")
|
||||
{
|
||||
if (m_OriginalActivity || !battery.Activity || !m_SawDownlink || battery.SourceId != m_Source || battery.OrderId != m_Order || !ReportKnown(battery.Report, false))
|
||||
{ m_BadProjectile = true; m_BadReason = "initial native activity does not match recorded report/downlink or was recreated"; return; }
|
||||
m_OriginalActivity = battery.Activity; m_SawActivity = true;
|
||||
ERR_NativeMortarRefreshRecord initial = FindObservation(m_Source);
|
||||
initial.Accepted = true; initial.Delivered = battery.Report.Snapshot();
|
||||
m_Delivered = initial.Delivered;
|
||||
return;
|
||||
}
|
||||
if (kind != "UPDATE QUEUED" && kind != "UPDATE RECEIVED") return;
|
||||
ERR_MortarRefresh update = battery.Refresh;
|
||||
if (!update || !update.Owns(battery) || battery.OrderId != m_Order || battery.Activity != m_OriginalActivity || !ReportKnown(update.Report, false))
|
||||
{ m_BadProjectile = true; m_BadReason = "update lost recorded observation or original gun/group/order/activity"; return; }
|
||||
ERR_NativeMortarRefreshRecord record = FindObservation(update.Report.MessageId);
|
||||
if (kind == "UPDATE QUEUED")
|
||||
{
|
||||
if (record.DownlinkId != 0 || record.Accepted || !update.Route || !update.Route.Downlink || update.Route.Nodes.IsEmpty() || update.Route.MessageId != update.RadioId)
|
||||
{ m_BadProjectile = true; m_BadReason = "refresh downlink was missing, duplicated or already accepted"; return; }
|
||||
record.DownlinkId = update.RadioId;
|
||||
return;
|
||||
}
|
||||
if (record.Accepted || record.DownlinkId != update.RadioId || update.Route || battery.Released || battery.Report != update.Report || !ReportKnown(battery.Report, false))
|
||||
{ m_BadProjectile = true; m_BadReason = "refresh acceptance lacks its completed route or overwrites committed shell"; return; }
|
||||
record.Accepted = true; record.Delivered = battery.Report.Snapshot(); m_UpdatesAccepted++;
|
||||
m_Delivered = record.Delivered;
|
||||
PrintFormat("ERR MORTAR REFRESH ACCEPTED: source=M%1 observedAt=%2 downlink=M%3 sameOrder=%4 originalActivity=%5", record.MessageId, record.ObservedAt, record.DownlinkId, battery.OrderId, battery.Activity == m_OriginalActivity);
|
||||
}
|
||||
void NativeCommit(ERR_MortarBattery battery, float now)
|
||||
{
|
||||
if (m_Stopped || battery != m_Battery) return;
|
||||
if (m_RefreshCase && (!m_OriginalActivity || battery.Activity != m_OriginalActivity))
|
||||
{ m_BadProjectile = true; m_BadReason = "refreshed commit requires the original unchanged native activity"; return; }
|
||||
if (m_Commits >= ERR_MortarSalvo.ROUNDS || battery.OrderShots != m_Commits)
|
||||
{ m_BadProjectile = true; m_BadReason = "commit count or physical shot sequence exceeds configured finite salvo"; return; }
|
||||
if (m_Step != 1 || m_CommitPending || !battery.CommandOpen || !battery.Released || battery.SourceId != m_Source || battery.OrderId != m_Order || !ReportKnown(battery.Report) || !ERR_NativeMortarEvidenceRules.CommitFresh(battery.Report.ObservedAt, battery.Report.ObservedAt, now) || !ERR_NativeMortarEvidenceRules.AimFresh(battery.AimedAt, battery.AimValidatedAt, now) || battery.FlightTime <= 0)
|
||||
{ m_BadProjectile = true; m_BadReason = "physical commit lacks immutable fresh report/current order/native aim, or duplicates pending commit"; return; }
|
||||
m_CommitPending = true; m_CommittedAt = now; m_CommitOrder = battery.OrderId; m_CommitShot = battery.OrderShots; m_Commits++;
|
||||
m_CommitSource = battery.Report.MessageId; m_CommitObservedAt = battery.Report.ObservedAt;
|
||||
if (m_RefreshCase && m_CommitSource != m_Source) m_UpdatedCommits++;
|
||||
PrintFormat("ERR MORTAR SALVO COMMIT: count=%1 order=%2 shot=%3 acceptedSource=M%4 observedAge=%5 commandAge=%6 validationAge=%7; recorded before native inventory transfer", m_Commits, m_CommitOrder, m_CommitShot, m_CommitSource, now - m_CommitObservedAt, now - battery.AimedAt, now - battery.AimValidatedAt);
|
||||
}
|
||||
void NativeClosing(ERR_MortarBattery battery, string reason, string failure)
|
||||
{
|
||||
if (m_Stopped || battery != m_Battery || !battery.CommandOpen || battery.SourceId != m_Source || m_Source <= 0) return;
|
||||
m_ClosureSeen = true;
|
||||
m_ClosureComplete = battery.OrderShots == ERR_MortarSalvo.ROUNDS && m_Events == ERR_MortarSalvo.ROUNDS && !m_CommitPending && m_Commits == m_Events;
|
||||
PrintFormat("ERR MORTAR SALVO CLOSURE: order=%1 reason=%2 lastFailure=%3 events=%4 commits=%5 complete=%6", battery.OrderId, reason, failure, m_Events, m_Commits, m_ClosureComplete);
|
||||
if (battery.Stopped() || battery.OrderId != m_Order || !m_ClosureComplete || !AllAccepted() || reason != "native artillery activity COMPLETED (shot count is separate)")
|
||||
{ m_BadProjectile = true; m_BadReason = "full salvo requires natural native completion; partial expiry, rejection, timeout or Stop is not success"; }
|
||||
if (m_RefreshCase && (battery.Activity != m_OriginalActivity || battery.Refresh || battery.RefreshRejected))
|
||||
{ m_BadProjectile = true; m_BadReason = "natural completion cannot leave a refresh pending or replace original activity"; }
|
||||
}
|
||||
void NativeProjectile(IEntity gun, IEntity projectile)
|
||||
{
|
||||
if (m_Stopped || !m_Battery || gun != m_Gun) return;
|
||||
if (!projectile) { m_BadProjectile = true; m_BadReason = "native fired event has no physical projectile"; return; }
|
||||
if (m_Projectiles.Contains(projectile)) { m_BadProjectile = true; m_BadReason = "same physical projectile observed twice"; return; }
|
||||
m_Projectiles.Insert(projectile); m_Events++;
|
||||
float now = GetGame().GetWorld().GetWorldTime();
|
||||
if (m_Step != 1 || !m_Battery.Released || m_Battery.SourceId != m_Source || !ERR_NativeMortarEvidenceRules.EventBound(m_CommitPending, m_CommitOrder, m_Battery.OrderId, m_CommitShot, m_Battery.OrderShots, m_CommittedAt, now))
|
||||
{ m_BadProjectile = true; m_BadReason = "projectile lacks the recorded fresh physical commit for its current order/shot"; }
|
||||
if (!m_Battery.Report || m_Battery.Report.MessageId != m_CommitSource || m_Battery.Report.ObservedAt != m_CommitObservedAt)
|
||||
{ m_BadProjectile = true; m_BadReason = "committed shell was relabelled with another report before native emission"; }
|
||||
m_CommitPending = false;
|
||||
PrintFormat("ERR MORTAR SALVO PROJECTILE: event=%1 entity=%2 origin=%3 order=%4 acceptedSource=M%5 aimAge=%6 flight=%7; impact is not measured", m_Events, projectile, projectile.GetOrigin(), m_Battery.OrderId, m_CommitSource, now - m_Battery.AimedAt, m_Battery.FlightTime);
|
||||
}
|
||||
int Tick(float now)
|
||||
{
|
||||
if (m_Stopped) return -1;
|
||||
if (!Enabled() || s_Active != this || !m_Mode || !m_Mode.Active()) { Check(false, "explicit active fixture context retained"); return -1; }
|
||||
ERR_HQ hq = ERR_HQ.Get();
|
||||
ERR_MortarSystem system;
|
||||
if (hq) system = hq.Mortars();
|
||||
if (!system) { Check(false, "real HQ mortar service available"); return -1; }
|
||||
if (!m_Battery)
|
||||
{
|
||||
array<IEntity> guns = {}; system.AppendGuns(guns);
|
||||
if (guns.Count() == 1) { m_Gun = guns[0]; m_Battery = system.FindGun(m_Gun); if (m_Battery) m_Group = m_Battery.Group; }
|
||||
}
|
||||
Diagnose(now);
|
||||
if (now >= m_Deadline) { Check(false, "bounded deadline; inspect readiness/route/activity/aim diagnostics"); return -1; }
|
||||
if (m_BadProjectile) { Check(false, m_BadReason); return -1; }
|
||||
if (!m_Battery) return 0;
|
||||
if (!m_Battery.SpawnFailure.IsEmpty()) { Check(false, "battery startup failed: " + m_Battery.SpawnFailure); return -1; }
|
||||
if (m_Step == 0)
|
||||
{
|
||||
if (!m_Battery.StartupActivated || !m_Battery.Operational() || !hq.MortarConnected(m_Battery.Group)) return 0;
|
||||
m_Group = m_Battery.Group;
|
||||
if (!Check(m_Group && m_Group.IsExpandComplete() && m_Group.GetAgentsCount() == 4 && m_Battery.TotalShots == 0 && m_Battery.OrderId == 0 && !m_Battery.CommandOpen && !m_Battery.Activity && !m_Battery.RadioOrder, "untasked original gun and complete four-person crew start without shots")) return -1;
|
||||
array<AIAgent> agents = {}; m_Group.GetAgents(agents);
|
||||
foreach (AIAgent agent : agents)
|
||||
{
|
||||
IEntity actor; if (agent) actor = agent.GetControlledEntity();
|
||||
if (!Check(ERR_Actors.CanFight(actor) && !m_Crew.Contains(actor), "initial physical crew member is conscious and unique")) return -1;
|
||||
m_Crew.Insert(actor);
|
||||
}
|
||||
if (!Check(m_Battery.RadioOperator && ERR_HQRadio.Capability(m_Battery.RadioOperator) == 2, "dedicated actual manpack supplies the radio route")) return -1;
|
||||
m_OriginalRTO = m_Battery.RadioOperator;
|
||||
Shells(m_InitialShells);
|
||||
if (!Check(m_InitialShells.Count() == 27 && m_Battery.CountShells() == 27, "exactly 27 distinct physical initial HE shells")) return -1;
|
||||
if (!Check(SelectImpact(system), "bounded candidate survey finds an unoccupied production-safe impact area")) return -1;
|
||||
m_Target = ERR_Actors.Spawn(ERR_Resources.IntelFolder(), m_Impact);
|
||||
if (!Check(m_Target != null, "fixture-owned inert report identity exists; no enemy perception is claimed")) return -1;
|
||||
if (m_RefreshCase) m_Source = hq.ERR_QueueMortarRefreshReport(m_Group, m_OriginalRTO, m_Target, m_Impact, m_ObservedAt);
|
||||
else { m_Source = hq.ERR_QueueMortarSalvoReport(m_Group, m_Target, m_Impact, now); m_ObservedAt = now; }
|
||||
if (!Check(m_Source > 0, "synthetic observation enters actual existing radio uplink once")) return -1;
|
||||
if (m_RefreshCase && !Check(AddObservation(m_Source, m_ObservedAt), "first actual-clock observation recorded without replacement")) return -1;
|
||||
m_SawUplink = true; m_Deadline = now + ERR_MortarSalvo.TIMEOUT_MS + 35000; m_Step = 1; return 0;
|
||||
}
|
||||
if (m_Battery.Stopped()) { Check(false, "battery must remain running throughout full salvo and replay observation"); return -1; }
|
||||
if (m_Battery.Report && !ReportKnown(m_Battery.Report)) { Check(false, "delivered report identity and original observation timestamp remain immutable and accepted"); return -1; }
|
||||
if (m_Battery.Gun != m_Gun || m_Battery.Group != m_Group) { Check(false, "original gun and crew identity retained"); return -1; }
|
||||
if (m_RefreshCase && (m_Battery.RadioOperator != m_OriginalRTO || (m_OriginalActivity && m_Battery.Activity && m_Battery.Activity != m_OriginalActivity)))
|
||||
{ Check(false, "refreshes preserve original RTO and native activity"); return -1; }
|
||||
if (!EmptyImpact(m_Impact)) { Check(false, "impact exclusion stays unoccupied before native firing/cancellation"); return -1; }
|
||||
array<IEntity> remaining = {}; Shells(remaining);
|
||||
foreach (IEntity item : remaining)
|
||||
if (!m_InitialShells.Contains(item)) { Check(false, "no replacement HE shell appears after initialization"); return -1; }
|
||||
if (m_Battery.OrderId > 1 || (m_Battery.SourceId != 0 && m_Battery.SourceId != m_Source)) { Check(false, "one unchanged fixture source creates only one HQ order"); return -1; }
|
||||
if (m_Events > ERR_MortarSalvo.ROUNDS || m_Battery.TotalShots > ERR_MortarSalvo.ROUNDS) { Check(false, "physical event count never exceeds configured salvo"); return -1; }
|
||||
if (m_Step == 1)
|
||||
{
|
||||
if (m_Battery.RadioOrder && m_Battery.SourceId == m_Source)
|
||||
{
|
||||
m_SawDownlink = true; m_Order = m_Battery.OrderId;
|
||||
if (m_RefreshCase)
|
||||
{
|
||||
ERR_NativeMortarRefreshRecord first = FindObservation(m_Source);
|
||||
if (!first || first.DownlinkId == 0)
|
||||
{
|
||||
if (!Check(first && m_Battery.RadioOrder.Downlink && !m_Battery.RadioOrder.Nodes.IsEmpty(), "initial order traverses actual nonempty HQ downlink")) return -1;
|
||||
first.DownlinkId = m_Battery.RadioOrder.MessageId;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (m_Battery.Activity && m_Battery.Report && m_Battery.Report.MessageId == m_Source)
|
||||
{
|
||||
m_SawActivity = true;
|
||||
if (!m_Delivered) m_Delivered = m_Battery.Report.Snapshot();
|
||||
}
|
||||
if (m_RefreshCase && m_Battery.CommandOpen && m_Battery.Activity && !m_Observations.IsEmpty())
|
||||
{
|
||||
ERR_NativeMortarRefreshRecord latest = m_Observations[m_Observations.Count() - 1];
|
||||
if (ERR_NativeMortarSalvoRules.QueueUpdate(latest.Accepted, m_Battery.Refresh != null, m_Battery.OrderShots, latest.ObservedAt, m_Battery.OrderedAt, now))
|
||||
{
|
||||
float observed;
|
||||
int messageId = hq.ERR_QueueMortarRefreshReport(m_Group, m_OriginalRTO, m_Target, m_Impact, observed);
|
||||
if (!Check(messageId > 0 && AddObservation(messageId, observed), "next distinct actual-clock synthetic observation queued only after previous acceptance")) return -1;
|
||||
}
|
||||
}
|
||||
if (m_Battery.CommandOpen || m_Battery.Activity) return 0;
|
||||
if (!m_ClosureSeen) return 0;
|
||||
if (!Check(m_ClosureComplete && m_SawUplink && m_SawDownlink && m_SawActivity && m_Order == 1, "one real uplink/downlink and native activity naturally complete the entire configured salvo")) return -1;
|
||||
if (m_RefreshCase && !Check(AllAccepted(), "every queued update was accepted under the same order before closure and at least one updated report authorized a commit")) return -1;
|
||||
if (!Check(ERR_NativeMortarSalvoRules.Complete(m_Events, m_Commits, m_Battery.TotalShots, m_Battery.OrderShots, remaining.Count(), m_CommitPending) && m_Battery.CountShells() == remaining.Count(), "exact configured native projectiles equal fresh commits and missing original inventory shells")) return -1;
|
||||
if (!Check(NoReportReplay(now), "all delivered messages are ineligible for a second request after completion")) return -1;
|
||||
if (!Check(!m_Battery.RadioOrder && !m_Battery.Report && !m_Battery.PendingRadioReport && !m_Battery.PendingSupplyReport && !m_Battery.SupplyPending() && !m_Battery.Released && !m_Battery.Refresh, "natural completion clears command, routes, loading and projectile reservation")) return -1;
|
||||
// Keep the unchanged HQ contact, battery and scheduler alive. This exceeds
|
||||
// the normal 20-second post-dispatch decision interval without forging time.
|
||||
m_StoppedShells = remaining.Count(); m_StopUntil = now + 25000; m_Deadline = m_StopUntil + 2000; m_Step = 2;
|
||||
return 0;
|
||||
}
|
||||
if (m_Battery.CommandOpen || m_Battery.Activity || m_Battery.RadioOrder || m_Battery.Report || m_Battery.PendingRadioReport || m_Battery.PendingSupplyReport || m_Battery.SupplyPending() || m_Battery.Released)
|
||||
{ Check(false, "completed observation must not reopen command, activity, route or physical loading"); return -1; }
|
||||
if (!ERR_NativeMortarSalvoRules.Complete(m_Events, m_Commits, m_Battery.TotalShots, m_Battery.OrderShots, remaining.Count(), m_CommitPending) || m_Battery.OrderId != m_Order || remaining.Count() != m_StoppedShells)
|
||||
{ Check(false, "closed order cannot add shots, orders or replacement ammunition during replay window"); return -1; }
|
||||
if (now < m_StopUntil) return 0;
|
||||
if (!Check(NoReportReplay(now), "all claimed contacts produce no second request, projectile or replenishment over 25 seconds")) return -1;
|
||||
array<AIAgent> finalCrew = {}; m_Group.GetAgents(finalCrew);
|
||||
if (!Check(finalCrew.Count() == 4 && m_Battery.Gun == m_Gun, "original group and gun retain four crew")) return -1;
|
||||
foreach (AIAgent finalAgent : finalCrew)
|
||||
if (!Check(finalAgent && m_Crew.Contains(finalAgent.GetControlledEntity()) && ERR_Actors.CanFight(finalAgent.GetControlledEntity()), "final conscious crew entity belongs to original physical roster")) return -1;
|
||||
Stop(); return 1;
|
||||
}
|
||||
void Stop()
|
||||
{
|
||||
if (m_Stopped) return;
|
||||
m_Stopped = true;
|
||||
ERR_HQ hq = ERR_HQ.Get();
|
||||
if (hq) hq.ERR_RemoveMortarSalvoReport(m_Target);
|
||||
if (m_Battery && m_Battery.Gun == m_Gun && m_Battery.Group == m_Group) m_Battery.Stop();
|
||||
ERR_Actors.DeleteSpawned(m_Target); m_Target = null;
|
||||
if (s_Active == this) s_Active = null;
|
||||
}
|
||||
void ~ERR_NativeMortarSalvoProbe() { Stop(); }
|
||||
}
|
||||
#endif
|
||||
+383
@@ -0,0 +1,383 @@
|
||||
#ifdef WORKBENCH
|
||||
// Mechanism-only fixture: radio delivery and automatic artillery scheduling are
|
||||
// explicitly bypassed. All ammunition and inventory operations remain physical.
|
||||
modded class ERR_MortarSystem
|
||||
{
|
||||
protected ERR_NativeMortarTransferProbe m_ERRTransferFixture;
|
||||
bool ERR_HoldForTransfer(ERR_NativeMortarTransferProbe owner)
|
||||
{
|
||||
if (!ERR_NativeMortarTransferProbe.Enabled() || m_Stopped || m_ERRTransferFixture || !owner) return false;
|
||||
m_ERRTransferFixture = owner; return true;
|
||||
}
|
||||
void ERR_ReleaseTransferHold(ERR_NativeMortarTransferProbe owner)
|
||||
{
|
||||
if (m_ERRTransferFixture == owner) m_ERRTransferFixture = null;
|
||||
}
|
||||
override void Tick(float now)
|
||||
{
|
||||
if (m_ERRTransferFixture) return;
|
||||
super.Tick(now);
|
||||
}
|
||||
ERR_MortarBattery ERR_TransferBattery()
|
||||
{
|
||||
if (m_Batteries.Count() != 1) return null;
|
||||
return m_Batteries[0];
|
||||
}
|
||||
}
|
||||
modded class ERR_MortarBattery
|
||||
{
|
||||
ERR_MortarSupplyTransfer ERR_TransferForProbe() { return m_Transfer; }
|
||||
int ERR_TransferAttempts() { return m_AttemptedShells.Count(); }
|
||||
}
|
||||
modded class ERR_MortarSupplyTransfer
|
||||
{
|
||||
int ERR_CallbackState() { return m_Acknowledgement; }
|
||||
IEntity ERR_TransferredShell() { return m_Shell; }
|
||||
IEntity ERR_TransferRecipient() { return m_Gunner; }
|
||||
bool ERR_RequestAccepted() { return m_Accepted; }
|
||||
}
|
||||
class ERR_NativeMortarMoveCallback : ScriptedInventoryOperationCallback
|
||||
{
|
||||
int Result;
|
||||
protected override void OnComplete() { if (Result == 0) Result = 1; }
|
||||
protected override void OnFailed() { if (Result == 0) Result = -1; }
|
||||
}
|
||||
class ERR_NativeMortarShellOwner
|
||||
{
|
||||
IEntity Shell;
|
||||
IEntity OriginalOwner;
|
||||
}
|
||||
|
||||
class ERR_NativeMortarTransferProbe
|
||||
{
|
||||
protected ERR_GameMode m_Mode;
|
||||
protected ERR_MortarSystem m_System;
|
||||
protected ERR_MortarBattery m_Battery;
|
||||
protected IEntity m_Gunner;
|
||||
protected BaseCompartmentSlot m_Seat;
|
||||
protected ref array<IEntity> m_Crew = {};
|
||||
protected ref array<IEntity> m_Boxes = {};
|
||||
protected ref array<BaseInventoryStorageComponent> m_Stores = {};
|
||||
protected ref array<ref ERR_NativeMortarShellOwner> m_Original = {};
|
||||
protected ref ERR_NativeMortarMoveCallback m_MoveCallback;
|
||||
protected IEntity m_MoveShell;
|
||||
protected BaseInventoryStorageComponent m_MoveDestination;
|
||||
protected float m_MoveDeadline;
|
||||
protected ref ERR_MortarSupplyTransfer m_ObservedTransfer;
|
||||
protected IEntity m_FirstShell;
|
||||
protected IEntity m_SecondShell;
|
||||
protected int m_AttemptsAtStart;
|
||||
protected int m_ShotsAtStart;
|
||||
protected int m_CallbackAtCancel;
|
||||
protected int m_CancelOrder;
|
||||
protected int m_Step;
|
||||
protected float m_Deadline;
|
||||
protected float m_StepDeadline;
|
||||
protected float m_Wait;
|
||||
protected bool m_Held;
|
||||
protected bool m_Stopped;
|
||||
protected bool m_Complete;
|
||||
static const ResourceName STORE = "{EBBC859CCD94D96D}Prefabs/Props/EmptyStorages/USSR_AmmoBox_empty.et";
|
||||
static bool Enabled()
|
||||
{
|
||||
string enabled, outcome;
|
||||
return Replication.IsServer() && System.GetCLIParam("errSmoke", enabled) && enabled == "1" && System.GetCLIParam("errSmokeOutcome", outcome) && outcome == "mortar-transfer";
|
||||
}
|
||||
void ERR_NativeMortarTransferProbe(ERR_GameMode mode)
|
||||
{
|
||||
m_Mode = mode; m_Deadline = GetGame().GetWorld().GetWorldTime() + 180000;
|
||||
}
|
||||
protected bool Check(bool condition, string detail)
|
||||
{
|
||||
if (condition) { Print("ERR SMOKE PASS: mortar transfer " + detail); return true; }
|
||||
Print("ERR SMOKE FAIL: mortar transfer " + detail, LogLevel.ERROR); Stop(); return false;
|
||||
}
|
||||
protected bool IsShell(IEntity item)
|
||||
{
|
||||
return item && item.GetPrefabData() && item.GetPrefabData().GetPrefabName() == ERR_MortarBattery.SHELL;
|
||||
}
|
||||
protected InventoryStorageManagerComponent Inventory(IEntity actor)
|
||||
{
|
||||
if (!actor) return null;
|
||||
return InventoryStorageManagerComponent.Cast(actor.FindComponent(InventoryStorageManagerComponent));
|
||||
}
|
||||
protected bool Has(IEntity actor, IEntity item)
|
||||
{
|
||||
array<IEntity> items = {}; ERR_Inventory.Items(Inventory(actor), items);
|
||||
return item && items.Contains(item);
|
||||
}
|
||||
protected IEntity CrewOwner(IEntity item)
|
||||
{
|
||||
foreach (IEntity actor : m_Crew) if (Has(actor, item)) return actor;
|
||||
return null;
|
||||
}
|
||||
protected IEntity OriginalOwner(IEntity item)
|
||||
{
|
||||
foreach (ERR_NativeMortarShellOwner entry : m_Original) if (entry.Shell == item) return entry.OriginalOwner;
|
||||
return null;
|
||||
}
|
||||
protected bool InStore(IEntity item)
|
||||
{
|
||||
foreach (BaseInventoryStorageComponent storage : m_Stores) if (storage && storage.Contains(item)) return true;
|
||||
return false;
|
||||
}
|
||||
static bool Conservation(int originals, int actual, bool allOriginal, bool singleOwner)
|
||||
{
|
||||
return originals == 27 && actual == originals && allOriginal && singleOwner;
|
||||
}
|
||||
protected bool Conserved()
|
||||
{
|
||||
array<IEntity> actual = {};
|
||||
foreach (IEntity actor : m_Crew)
|
||||
{
|
||||
array<IEntity> items = {}; ERR_Inventory.Items(Inventory(actor), items);
|
||||
foreach (IEntity item : items) if (IsShell(item) && !actual.Contains(item)) actual.Insert(item);
|
||||
}
|
||||
foreach (BaseInventoryStorageComponent storage : m_Stores)
|
||||
{
|
||||
if (!storage) return false;
|
||||
array<IEntity> items = {}; storage.GetAll(items);
|
||||
foreach (IEntity item : items) if (IsShell(item) && !actual.Contains(item)) actual.Insert(item);
|
||||
}
|
||||
bool allOriginal = true;
|
||||
bool singleOwner = true;
|
||||
foreach (ERR_NativeMortarShellOwner entry : m_Original)
|
||||
{
|
||||
if (!entry.Shell || !actual.Contains(entry.Shell)) allOriginal = false;
|
||||
int owners;
|
||||
foreach (IEntity actor : m_Crew) if (Has(actor, entry.Shell)) owners++;
|
||||
foreach (BaseInventoryStorageComponent storage : m_Stores) if (storage && storage.Contains(entry.Shell)) owners++;
|
||||
if (owners != 1) singleOwner = false;
|
||||
}
|
||||
return Conservation(m_Original.Count(), actual.Count(), allOriginal, singleOwner);
|
||||
}
|
||||
protected IEntity FirstGunnerShell()
|
||||
{
|
||||
array<IEntity> items = {}; ERR_Inventory.Items(Inventory(m_Gunner), items);
|
||||
foreach (IEntity item : items) if (IsShell(item)) return item;
|
||||
return null;
|
||||
}
|
||||
protected bool BeginMove(IEntity shell, BaseInventoryStorageComponent destination, float now)
|
||||
{
|
||||
if (!shell || !destination || m_MoveCallback) return false;
|
||||
IEntity current = CrewOwner(shell);
|
||||
// A vanilla empty box has storage but no inventory manager. The original
|
||||
// recipient manager performs its explicit pickup from the external storage.
|
||||
if (!current) current = OriginalOwner(shell);
|
||||
InventoryStorageManagerComponent manager = Inventory(current);
|
||||
if (!manager) return false;
|
||||
m_MoveShell = shell; m_MoveDestination = destination;
|
||||
m_MoveCallback = new ERR_NativeMortarMoveCallback(); m_MoveDeadline = now + 8000;
|
||||
if (!manager.TryMoveItemToStorage(shell, destination, -1, m_MoveCallback)) return false;
|
||||
return true;
|
||||
}
|
||||
protected int MoveResult(float now)
|
||||
{
|
||||
if (!m_MoveCallback) return 1;
|
||||
if (m_MoveCallback.Result < 0 || now >= m_MoveDeadline) return -1;
|
||||
if (m_MoveCallback.Result == 0 || !m_MoveShell || !m_MoveDestination || !m_MoveDestination.Contains(m_MoveShell)) return 0;
|
||||
InventoryItemComponent item = InventoryItemComponent.Cast(m_MoveShell.FindComponent(InventoryItemComponent));
|
||||
if (!item || !item.GetParentSlot() || item.GetParentSlot().GetStorage() != m_MoveDestination) return 0;
|
||||
m_MoveCallback = null; m_MoveShell = null; m_MoveDestination = null;
|
||||
return 1;
|
||||
}
|
||||
protected bool Stash(IEntity shell, float now)
|
||||
{
|
||||
foreach (BaseInventoryStorageComponent storage : m_Stores)
|
||||
if (storage && storage.GetEstimatedCountFitForItem(shell) > 0) return BeginMove(shell, storage, now);
|
||||
return false;
|
||||
}
|
||||
protected void StageOrder(float now)
|
||||
{
|
||||
ERR_HQReport report = new ERR_HQReport();
|
||||
report.Target = GetGame().GetPlayerController().GetControlledEntity();
|
||||
report.Source = m_Battery.Group;
|
||||
report.Position = m_Battery.Gun.GetOrigin() + "700 0 0";
|
||||
report.ObservedAt = now; report.DetailsObservedAt = now;
|
||||
report.TravelMode = "on foot"; report.MessageId = 900001 + m_Battery.OrderId;
|
||||
m_Battery.BeginRequest(report, now);
|
||||
m_Battery.PendingSupplyReport = report.Snapshot();
|
||||
PrintFormat("ERR MORTAR TRANSFER FIXTURE: synthetic supply order=%1; radio bypassed; scheduler held; no artillery activity authorized", m_Battery.OrderId);
|
||||
}
|
||||
protected bool NoFire()
|
||||
{
|
||||
return m_Battery && m_Battery.TotalShots == m_ShotsAtStart && !m_Battery.Activity && !m_Battery.RadioOrder && !m_Battery.Released;
|
||||
}
|
||||
void Stop()
|
||||
{
|
||||
if (m_Stopped) return;
|
||||
m_Stopped = true;
|
||||
if (m_Battery) m_Battery.Cancel("native transfer fixture stopped");
|
||||
if (m_System)
|
||||
{
|
||||
m_System.Stop(); // do not resume automatic fire while test items may be stored
|
||||
if (m_Held) m_System.ERR_ReleaseTransferHold(this);
|
||||
}
|
||||
m_Held = false;
|
||||
// Never delete a box containing borrowed shells or an outstanding operation.
|
||||
// Failure preserves it for diagnosis/normal round-owned cleanup.
|
||||
if (!m_MoveCallback)
|
||||
{
|
||||
foreach (IEntity box : m_Boxes)
|
||||
{
|
||||
if (!box) continue;
|
||||
BaseInventoryStorageComponent storage = BaseInventoryStorageComponent.Cast(box.FindComponent(BaseInventoryStorageComponent));
|
||||
array<IEntity> items = {}; if (storage) storage.GetAll(items);
|
||||
if (storage && items.IsEmpty()) ERR_Actors.DeleteSpawned(box);
|
||||
}
|
||||
}
|
||||
}
|
||||
int Tick(float now)
|
||||
{
|
||||
if (m_Complete) return 1;
|
||||
if (m_Stopped) return -1;
|
||||
if (!Enabled() || now > m_Deadline) { Check(false, "enabled server fixture and bounded 180-second deadline"); return -1; }
|
||||
if (now < m_Wait) return 0;
|
||||
if (m_MoveCallback)
|
||||
{
|
||||
int moved = MoveResult(now);
|
||||
if (moved < 0) { Check(false, "fixture inventory move completed with exact destination before deadline; borrowed shells retained"); return -1; }
|
||||
if (moved == 0) return 0;
|
||||
if (!Check(Conserved(), "all original 27 shell identities conserved after fixture move")) return -1;
|
||||
}
|
||||
if (m_Step == 0)
|
||||
{
|
||||
m_System = ERR_MortarSystem.Get();
|
||||
if (!m_System) return 0;
|
||||
m_Battery = m_System.ERR_TransferBattery();
|
||||
if (!m_Battery || !m_Battery.StartupActivated) return 0;
|
||||
if (!Check(m_Battery.Operational() && m_Battery.CountShells() == 27 && !m_Battery.Activity && !m_Battery.RadioOrder && !m_Battery.CommandOpen, "actual initialized idle battery has finite 27 shells")) return -1;
|
||||
if (!Check(m_System.ERR_HoldForTransfer(this), "opt-in mechanism fixture holds automatic mortar scheduler")) return -1;
|
||||
m_Held = true; m_AttemptsAtStart = m_Battery.ERR_TransferAttempts(); m_ShotsAtStart = m_Battery.TotalShots;
|
||||
array<AIAgent> agents = {}; m_Battery.Group.GetAgents(agents);
|
||||
foreach (AIAgent agent : agents)
|
||||
{
|
||||
if (!agent || !ERR_Actors.CanFight(agent.GetControlledEntity())) continue;
|
||||
IEntity actor = agent.GetControlledEntity(); if (m_Crew.Contains(actor)) continue;
|
||||
m_Crew.Insert(actor);
|
||||
array<IEntity> items = {}; ERR_Inventory.Items(Inventory(actor), items);
|
||||
foreach (IEntity item : items)
|
||||
{
|
||||
if (!IsShell(item)) continue;
|
||||
ERR_NativeMortarShellOwner entry = new ERR_NativeMortarShellOwner(); entry.Shell = item; entry.OriginalOwner = actor; m_Original.Insert(entry);
|
||||
}
|
||||
if (!m_Gunner && actor != m_Battery.RadioOperator) m_Gunner = actor;
|
||||
}
|
||||
if (!Check(m_Crew.Count() == 4 && m_Original.Count() == 27 && m_Gunner != null, "four real crew and original shell-owner identities captured")) return -1;
|
||||
SCR_AIVehicleUsageComponent usage = SCR_AIVehicleUsageComponent.Cast(m_Battery.Gun.FindComponent(SCR_AIVehicleUsageComponent));
|
||||
if (usage) m_Seat = usage.GetTurretCompartmentSlot();
|
||||
if (m_Seat && m_Seat.GetOccupant())
|
||||
{
|
||||
IEntity occupant = m_Seat.GetOccupant();
|
||||
if (!Check(m_Crew.Contains(occupant) && occupant != m_Battery.RadioOperator && ERR_Actors.CanFight(occupant), "existing native seat occupant is original fighting gunner")) return -1;
|
||||
m_Gunner = occupant;
|
||||
}
|
||||
else
|
||||
{
|
||||
CompartmentAccessComponent access = CompartmentAccessComponent.Cast(m_Gunner.FindComponent(CompartmentAccessComponent));
|
||||
if (!Check(m_Seat && access && access.GetInVehicle(m_Battery.Gun, m_Seat, true, -1, ECloseDoorAfterActions.INVALID, false), "native seating request selects actual original crew gunner")) return -1;
|
||||
}
|
||||
if (!Check(m_Battery.InventoryShells(Inventory(m_Gunner)) == 9, "selected actual gunner owns its original nine physical shells")) return -1;
|
||||
for (int i = 0; i < 2; i++)
|
||||
{
|
||||
IEntity box = ERR_Actors.Spawn(STORE, ERR_Actors.Ground(m_Battery.Gun.GetOrigin() + Vector(3 + i * 2, 0, 3)));
|
||||
if (!Check(box != null, "fixture-owned empty vanilla storage created")) return -1;
|
||||
m_Boxes.Insert(box); m_Mode.Own(box);
|
||||
BaseInventoryStorageComponent storage = BaseInventoryStorageComponent.Cast(box.FindComponent(BaseInventoryStorageComponent));
|
||||
if (!Check(storage != null, "real vanilla storage component available without attaching it to gunner inventory")) return -1;
|
||||
m_Stores.Insert(storage);
|
||||
}
|
||||
m_Step = 1; m_StepDeadline = now + 8000; m_Wait = now + 500; return 0;
|
||||
}
|
||||
if (!NoFire()) { Check(false, "no artillery activity, committed load or physical projectile during inventory mechanism test"); return -1; }
|
||||
if (m_Step == 1)
|
||||
{
|
||||
if (m_Seat.GetOccupant() != m_Gunner)
|
||||
{
|
||||
if (now >= m_StepDeadline) { Check(false, "actual original gunner occupies native mortar seat"); return -1; }
|
||||
return 0;
|
||||
}
|
||||
if (!Check(Conserved(), "27 original shells have exactly one observed owner each")) return -1;
|
||||
m_Step = 2;
|
||||
}
|
||||
if (m_Step == 2 || m_Step == 5)
|
||||
{
|
||||
IEntity shell = FirstGunnerShell();
|
||||
if (shell)
|
||||
{
|
||||
if (!Check(Stash(shell, now), "one existing gunner shell move to vanilla store accepted; no shell minted")) return -1;
|
||||
return 0;
|
||||
}
|
||||
if (!Check(m_Battery.InventoryShells(Inventory(m_Gunner)) == 0 && Conserved(), "actual gunner empty while all 27 original shells remain in crew or fixture storage")) return -1;
|
||||
StageOrder(now);
|
||||
ERR_MortarSupplyState started = m_Battery.SupplyState(now);
|
||||
m_ObservedTransfer = m_Battery.ERR_TransferForProbe();
|
||||
if (!Check(started == ERR_MortarSupplyState.PENDING && m_ObservedTransfer && m_ObservedTransfer.ERR_RequestAccepted() && m_ObservedTransfer.ERR_TransferRecipient() == m_Gunner && m_ObservedTransfer.CapturedOrder() == m_Battery.OrderId, "production exact-shell request accepted once with captured current order and real gunner")) return -1;
|
||||
if (m_Step == 2)
|
||||
{
|
||||
m_FirstShell = m_ObservedTransfer.ERR_TransferredShell();
|
||||
m_Step = 3;
|
||||
}
|
||||
else
|
||||
{
|
||||
m_SecondShell = m_ObservedTransfer.ERR_TransferredShell(); m_CallbackAtCancel = m_ObservedTransfer.ERR_CallbackState(); m_CancelOrder = m_Battery.OrderId;
|
||||
m_Battery.Cancel("native fixture cancels authorization immediately after accepted inventory request");
|
||||
if (!Check(m_SecondShell && m_SecondShell != m_FirstShell && !m_Battery.CommandOpen && m_Battery.SupplyState(now) == ERR_MortarSupplyState.FAILED, "canceled current order cannot report supply READY or issue another request")) return -1;
|
||||
PrintFormat("ERR MORTAR TRANSFER CANCEL: callbackBeforeCancel=%1 order=%2; accepted native move is not retracted", m_CallbackAtCancel, m_CancelOrder);
|
||||
m_Step = 6;
|
||||
}
|
||||
m_StepDeadline = now + 8000; m_Wait = now + 250; return 0;
|
||||
}
|
||||
if (m_Step == 3)
|
||||
{
|
||||
ERR_MortarSupplyState state = m_Battery.SupplyState(now);
|
||||
if (state == ERR_MortarSupplyState.FAILED || now >= m_StepDeadline) { Check(false, "production transfer completes within its bounded deadline"); return -1; }
|
||||
if (state == ERR_MortarSupplyState.PENDING) return 0;
|
||||
if (!Check(m_ObservedTransfer.ERR_CallbackState() == 1 && Has(m_Gunner, m_FirstShell) && !Has(OriginalOwner(m_FirstShell), m_FirstShell) && Conserved() && m_Battery.ERR_TransferAttempts() == m_AttemptsAtStart + 1, "successful native callback and exact donor-to-gunner ownership, one attempt, original 27 conserved")) return -1;
|
||||
if (!Check(m_Battery.SupplyState(now) == ERR_MortarSupplyState.READY && m_Battery.ERR_TransferAttempts() == m_AttemptsAtStart + 1, "repeated supply check reuses actual inventory without another move")) return -1;
|
||||
m_Battery.Cancel("native success subcase finished; no artillery activity requested");
|
||||
m_Step = 5; return 0;
|
||||
}
|
||||
if (m_Step == 6)
|
||||
{
|
||||
if (m_Battery.SupplyState(now) != ERR_MortarSupplyState.FAILED || m_Battery.OrderId != m_CancelOrder || m_Battery.ERR_TransferAttempts() != m_AttemptsAtStart + 2) { Check(false, "canceled order/generation remains failed with exactly two total move attempts"); return -1; }
|
||||
if (m_ObservedTransfer.ERR_CallbackState() == 0 || m_ObservedTransfer.Unresolved())
|
||||
{
|
||||
if (now >= m_StepDeadline) { Check(false, "accepted canceled operation reached definite native outcome; no ambiguous retry"); return -1; }
|
||||
return 0;
|
||||
}
|
||||
if (!Check(m_ObservedTransfer.ERR_CallbackState() == 1 && Has(m_Gunner, m_SecondShell) && Conserved(), "canceled accepted native move has definite physical outcome with exactly two attempts and original 27 conserved")) return -1;
|
||||
if (m_CallbackAtCancel == 0) Print("ERR SMOKE PASS: mortar transfer actual native completion observed after authorization cancellation");
|
||||
else Print("ERR MORTAR TRANSFER LIMIT: native callback completed synchronously before cancellation; late callback timing not exercised");
|
||||
m_Step = 7; return 0;
|
||||
}
|
||||
if (m_Step == 7)
|
||||
{
|
||||
// Return foreign shells first, leaving room for each original nine-round kit.
|
||||
IEntity misplaced;
|
||||
foreach (ERR_NativeMortarShellOwner entry : m_Original)
|
||||
if (Has(m_Gunner, entry.Shell) && entry.OriginalOwner != m_Gunner) { misplaced = entry.Shell; break; }
|
||||
if (!misplaced)
|
||||
foreach (ERR_NativeMortarShellOwner entry : m_Original) if (InStore(entry.Shell)) { misplaced = entry.Shell; break; }
|
||||
if (misplaced)
|
||||
{
|
||||
InventoryStorageManagerComponent recipient = Inventory(OriginalOwner(misplaced));
|
||||
BaseInventoryStorageComponent destination;
|
||||
if (recipient) destination = recipient.FindStorageForItem(misplaced, EStoragePurpose.PURPOSE_DEPOSIT);
|
||||
if (!Check(destination && BeginMove(misplaced, destination, now), "one borrowed shell return to its original crew owner accepted")) return -1;
|
||||
return 0;
|
||||
}
|
||||
foreach (ERR_NativeMortarShellOwner entry : m_Original)
|
||||
if (!Check(Has(entry.OriginalOwner, entry.Shell), "original shell identity returned to original crew inventory")) return -1;
|
||||
if (!Check(Conserved() && m_Battery.CountShells() == 27 && NoFire(), "all original ammunition restored; no minted/deleted shell or shot")) return -1;
|
||||
foreach (BaseInventoryStorageComponent storage : m_Stores)
|
||||
{
|
||||
array<IEntity> items = {}; storage.GetAll(items);
|
||||
if (!Check(items.IsEmpty(), "borrowed-ammunition fixture storage empty before deletion")) return -1;
|
||||
}
|
||||
Stop(); m_Complete = true; return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
#ifdef WORKBENCH
|
||||
// Test access only: trigger the real delivered-order method on an existing
|
||||
// registered squad. Radio transit is intentionally not simulated by this test.
|
||||
modded class ERR_HQ
|
||||
{
|
||||
bool ERR_TestPatrolSearch(SCR_AIGroup group, out AIWaypoint search)
|
||||
{
|
||||
foreach (ERR_HQUnit unit : m_Units)
|
||||
{
|
||||
if (unit.Group != group) continue;
|
||||
if (unit.Assignment || unit.PendingOrder || unit.Waypoint || !unit.Eligible(m_Mode)) return false;
|
||||
ERR_HQReport order = new ERR_HQReport();
|
||||
order.Position = group.GetOrigin() + "40 0 0";
|
||||
order.ObservedAt = GetGame().GetWorld().GetWorldTime();
|
||||
order.MessageId = m_Journal.Message();
|
||||
unit.PendingOrder = order;
|
||||
DeliverOrder(unit, order.ObservedAt);
|
||||
search = unit.Waypoint;
|
||||
return search && unit.Assignment == order;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
bool ERR_TestPatrolRelease(SCR_AIGroup group, AIWaypoint search)
|
||||
{
|
||||
foreach (ERR_HQUnit unit : m_Units)
|
||||
{
|
||||
if (unit.Group != group || unit.Waypoint != search) continue;
|
||||
unit.Release();
|
||||
return !unit.Assignment && !unit.PendingOrder && !unit.Waypoint;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class ERR_NativePatrolHQProbe
|
||||
{
|
||||
protected ERR_GameMode m_Mode;
|
||||
protected ERR_SiteRuntime m_Site;
|
||||
protected AIWaypoint m_Search;
|
||||
protected ref array<AIWaypoint> m_Baseline = {};
|
||||
protected int m_Step;
|
||||
protected float m_Wait;
|
||||
void ERR_NativePatrolHQProbe(ERR_GameMode mode) { m_Mode = mode; }
|
||||
protected bool Check(bool condition, string detail)
|
||||
{
|
||||
if (condition) { Print("ERR SMOKE PASS: patrol HQ " + detail); return true; }
|
||||
Print("ERR SMOKE FAIL: patrol HQ " + detail, LogLevel.ERROR); return false;
|
||||
}
|
||||
int Tick(float now)
|
||||
{
|
||||
if (now < m_Wait) return 0;
|
||||
ERR_HQ hq = ERR_HQ.Get();
|
||||
if (!Check(hq != null, "real HQ available")) return -1;
|
||||
if (m_Step == 0)
|
||||
{
|
||||
m_Site = m_Mode.Sites().Find("everon-outpost-1");
|
||||
if (!Check(m_Site && m_Site.Group && m_Site.PatrolCycle, "existing outpost route available")) return -1;
|
||||
m_Site.Group.GetWaypoints(m_Baseline);
|
||||
if (!Check(!m_Baseline.IsEmpty() && hq.ERR_TestPatrolSearch(m_Site.Group, m_Search), "real delivered-order method assigns existing squad")) return -1;
|
||||
m_Step = 1; m_Wait = now + 750; return 0;
|
||||
}
|
||||
if (m_Step == 1)
|
||||
{
|
||||
array<AIWaypoint> queue = {};
|
||||
m_Site.Group.GetWaypoints(queue);
|
||||
if (!Check(m_Search && !queue.IsEmpty() && queue[0] == m_Search && m_Site.Group.GetCurrentWaypoint() == m_Search, "Search preempts native patrol")) return -1;
|
||||
foreach (AIWaypoint waypoint : m_Baseline)
|
||||
if (!Check(queue.Contains(waypoint), "preempted baseline route remains queued")) return -1;
|
||||
if (!Check(hq.ERR_TestPatrolRelease(m_Site.Group, m_Search), "real HQ release removes only its assignment")) return -1;
|
||||
m_Step = 2; m_Wait = now + 750; return 0;
|
||||
}
|
||||
if (m_Step == 2)
|
||||
{
|
||||
AIWaypoint current = m_Site.Group.GetCurrentWaypoint();
|
||||
if (!Check(current && (current == m_Site.PatrolCycle || m_Site.PatrolWaypoints.Contains(current)), "native patrol resumes after Search release")) return -1;
|
||||
m_Step = 3; return 1;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
void Stop()
|
||||
{
|
||||
ERR_HQ hq = ERR_HQ.Get();
|
||||
if (hq && m_Site && m_Search) hq.ERR_TestPatrolRelease(m_Site.Group, m_Search);
|
||||
}
|
||||
void ~ERR_NativePatrolHQProbe() { Stop(); }
|
||||
}
|
||||
#endif
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
// Vanilla named settlements supply exclusion areas; no original mission location system.
|
||||
// Shared town/village exclusion for mortar impacts, roadblocks and mine sites.
|
||||
// Fixed distance from mapped settlement centres, independent of mission
|
||||
// activation radii. Scattered buildings and props are not settlements.
|
||||
class ERR_SettlementSafety
|
||||
{
|
||||
static const float RADIUS = 200;
|
||||
string SettlementReason;
|
||||
protected vector m_SafeImpact;
|
||||
protected bool m_SettlementNear;
|
||||
protected vector m_LastSettlementImpact;
|
||||
protected float m_LastSettlementCheck = -1;
|
||||
protected bool m_LastSettlementClear;
|
||||
|
||||
bool OutsideSettlements(vector impact)
|
||||
{
|
||||
float now = GetGame().GetWorld().GetWorldTime();
|
||||
if (m_LastSettlementCheck >= 0 && now >= m_LastSettlementCheck && now - m_LastSettlementCheck < 1000 && vector.DistanceXZ(impact, m_LastSettlementImpact) == 0)
|
||||
return m_LastSettlementClear;
|
||||
m_LastSettlementCheck = now;
|
||||
m_LastSettlementImpact = impact;
|
||||
m_LastSettlementClear = false;
|
||||
m_SafeImpact = impact;
|
||||
m_SettlementNear = false;
|
||||
SettlementReason = "clear";
|
||||
// Also cover named native map settlements, independent of whether the
|
||||
// mission placed a garrison marker there. Generic building entities also
|
||||
// include deer stands, bridge pillars, rubble and electrical cabinets.
|
||||
// Broad vertical bounds keep the 200m distance horizontal on hills.
|
||||
GetGame().GetWorld().QueryEntitiesByAABB(impact - Vector(RADIUS, 5000, RADIUS), impact + Vector(RADIUS, 5000, RADIUS), CheckSettlement, FilterSettlement, EQueryEntitiesFlags.ALL);
|
||||
m_LastSettlementClear = !m_SettlementNear;
|
||||
return m_LastSettlementClear;
|
||||
}
|
||||
|
||||
protected bool FilterSettlement(IEntity entity)
|
||||
{
|
||||
return entity && entity.FindComponent(MapDescriptorComponent);
|
||||
}
|
||||
|
||||
protected bool CheckSettlement(IEntity entity)
|
||||
{
|
||||
float radius;
|
||||
MapDescriptorComponent descriptor = MapDescriptorComponent.Cast(entity.FindComponent(MapDescriptorComponent));
|
||||
if (descriptor)
|
||||
{
|
||||
int type = descriptor.GetBaseType();
|
||||
if (type == EMapDescriptorType.MDT_NAME_CITY)
|
||||
radius = RADIUS;
|
||||
else if (type == EMapDescriptorType.MDT_NAME_TOWN)
|
||||
radius = RADIUS;
|
||||
else if (type == EMapDescriptorType.MDT_NAME_VILLAGE || type == EMapDescriptorType.MDT_NAME_SETTLEMENT)
|
||||
radius = RADIUS;
|
||||
}
|
||||
if (radius > 0 && vector.DistanceXZ(m_SafeImpact, entity.GetOrigin()) <= radius)
|
||||
{
|
||||
m_SettlementNear = true;
|
||||
SettlementReason = string.Format("named map settlement entity=%1 position=%2 distance=%3m exclusion=%4m", entity, entity.GetOrigin(), vector.DistanceXZ(m_SafeImpact, entity.GetOrigin()), radius);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
modded class ERR_ScenarioComponent
|
||||
{
|
||||
protected override bool PrepareAdditionalForces()
|
||||
{
|
||||
if (!super.PrepareAdditionalForces()) return false;
|
||||
ERR_HQ hq = ERR_HQ.Get();
|
||||
return hq && hq.PrepareSupport();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user