Advance standalone admission, finite artillery and native validation

This commit is contained in:
2026-09-19 09:02:31 +02:00
parent af5146a317
commit 40a474934c
532 changed files with 56456 additions and 199 deletions
@@ -0,0 +1,31 @@
// 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;
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;
}
}
+87 -73
View File
@@ -33,12 +33,20 @@ class ERR_MortarBattery
ref ERR_HQReport Report;
ref ERR_HQRoute RadioOrder;
ref ERR_HQReport PendingRadioReport;
ref ERR_HQReport PendingSupplyReport;
protected ref ERR_MortarReadiness m_Readiness;
protected ref ERR_MortarSupplyTransfer m_Transfer;
protected ref array<IEntity> m_AttemptedShells = {};
protected bool m_Stopped;
protected ref ERR_MortarReportLedger m_ReportLedger = new ERR_MortarReportLedger();
vector Position;
vector Impact;
vector AimOffset;
int AimedShot = -1;
float FlightTime;
float AimedAt = -1;
SCR_AIGetArtilleryAimDistanceCompensation AimSolver;
ref ERR_MortarAimSolution AimSolution;
float AirspaceUntil;
vector AirspaceImpact;
float AirspaceRadius = 600;
@@ -94,6 +102,7 @@ class ERR_MortarBattery
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";
@@ -105,8 +114,13 @@ class ERR_MortarBattery
return "";
}
void BeginRequest(ERR_HQReport report, float now)
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++;
@@ -118,6 +132,7 @@ class ERR_MortarBattery
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;
}
void ObserveProjectile(IEntity projectileEntity, IEntity firingCharacter, bool playerGunner)
@@ -134,6 +149,8 @@ class ERR_MortarBattery
// Only a physical projectile unlocks the next load. It must be aimed again.
Released = false;
AimedAt = -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
@@ -234,10 +251,11 @@ class ERR_MortarBattery
Position = gun.GetOrigin();
SpawnedAt = now;
Claimed = true;
m_Readiness = new ERR_MortarReadiness(this);
}
protected int InventoryShells(InventoryStorageManagerComponent inventory)
int InventoryShells(InventoryStorageManagerComponent inventory)
{
if (!inventory)
return 0;
@@ -252,36 +270,41 @@ class ERR_MortarBattery
return count;
}
protected void Equip(IEntity soldier)
bool Stopped() { return m_Stopped; }
bool SupplyPending() { return m_Transfer && m_Transfer.Unresolved(); }
bool SupplyAuthorized(float now)
{
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(soldier.FindComponent(InventoryStorageManagerComponent));
if (!inventory)
{
Audit("EQUIP FAILED", string.Format("crew=%1 inventory missing", soldier));
return;
}
if (soldier == RadioOperator)
{
int capability = ERR_HQRadio.Capability(soldier);
Audit("RADIO EQUIP", string.Format("operator=%1 capability=%2 (2=physical manpack); next HQ radio index verifies route", soldier, capability));
return;
}
if (ERR_HQRadio.Capability(soldier) == 0 && !inventory.TrySpawnPrefabToStorage("{E1A5D4B878AA8980}Prefabs/Items/Equipment/Radios/Radio_R148.et"))
Print("Rereintroduced AI [mortar]: crew radio did not fit; HQ handoff required", LogLevel.WARNING);
// Native mortar-crew backpacks carry three EMPTY three-round boxes.
// Fill them once. No infinite ammunition or replacement after casualties.
for (int i = InventoryShells(inventory); i < 9; i++)
{
if (!inventory.TrySpawnPrefabToStorage(SHELL, purpose: EStoragePurpose.PURPOSE_DEPOSIT))
break;
}
Audit("EQUIP", string.Format("crew=%1 inventory HE=%2 requested=9 radio capability=%3 (0=none)", soldier, InventoryShells(inventory), ERR_HQRadio.Capability(soldier)));
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();
}
bool SupplyGunner()
ERR_MortarSupplyState SupplyState(float now)
{
if (!Operational())
return SupplyFailed(OperationalReason());
// 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())
@@ -303,15 +326,15 @@ class ERR_MortarBattery
}
}
}
if (!gunner) return SupplyFailed("no fighting gunner found");
if (ERR_Actors.Group(gunner) != Group) return SupplyFailed("turret occupant belongs to another group");
if (!ERR_Actors.CanFight(gunner)) return SupplyFailed("gunner cannot fight");
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 SupplyFailed("gunner inventory missing");
return SupplyStateFailed("gunner inventory missing");
if (InventoryShells(destination) > 0)
return true;
int nearbyDonors, donorShells, storageRejected, moveRejected;
return ERR_MortarSupplyState.READY;
int nearbyDonors, donorShells, storageRejected;
array<IEntity> consideredShells = {};
foreach (AIAgent bearer : agents)
{
@@ -330,20 +353,27 @@ class ERR_MortarBattery
{
if (!item || !item.GetPrefabData() || item.GetPrefabData().GetPrefabName() != SHELL)
continue;
if (consideredShells.Contains(item)) 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; }
if (inventory.TryMoveItemToStorage(item, storage))
{
Audit("AMMO TRANSFER", string.Format("request accepted donor=%1 gunner=%2 shell=%3; inventory move is not a fired shot", donor, gunner, item));
return true;
}
moveRejected++;
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 SupplyFailed(string.Format("gunner empty; fighting donors with inventory within 12m=%1 donor HE=%2 destination storage rejected=%3 native move rejected=%4", nearbyDonors, donorShells, storageRejected, moveRejected));
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)
@@ -363,8 +393,12 @@ class ERR_MortarBattery
CommandOpen = false;
RadioOrder = null;
PendingRadioReport = null;
PendingSupplyReport = null;
if (m_Transfer) m_Transfer.Invalidate();
Report = null;
AimedAt = -1;
AimSolver = null;
AimSolution = null;
if (Activity)
Activity.Complete();
Activity = null;
@@ -399,42 +433,21 @@ class ERR_MortarBattery
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 (!StartupActivated && now - SpawnedAt >= 10000)
{
SCR_AIVehicleUsageComponent usage = SCR_AIVehicleUsageComponent.Cast(Gun.FindComponent(SCR_AIVehicleUsageComponent));
if (!usage || !Group.GetGroupUtilityComponent().m_VehicleMgr || !Group.GetGroupUtilityComponent().m_VehicleMgr.TryAddVehicle(usage))
{
SpawnFailure = "native gun registration failed";
Cancel(SpawnFailure);
return;
}
array<AIAgent> initialCrew = {};
Group.GetAgents(initialCrew);
foreach (AIAgent initialAgent : initialCrew)
{
IEntity initialActor = initialAgent.GetControlledEntity();
if (initialActor && initialActor.GetPrefabData() && initialActor.GetPrefabData().GetPrefabName() == RADIO_OPERATOR) RadioOperator = initialActor;
}
StartupActivated = true;
Audit("CREW ACTIVATE", "startup delay elapsed; activating and equipping crew");
Group.ActivateAI();
array<AIAgent> agents = {};
Group.GetAgents(agents);
foreach (AIAgent agent : agents)
{
if (agent && ERR_Actors.CanFight(agent.GetControlledEntity()))
{
agent.SetPermanentLOD(0);
agent.ActivateAI();
Equip(agent.GetControlledEntity());
}
}
}
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));
@@ -448,6 +461,7 @@ class ERR_MortarBattery
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;
+84 -28
View File
@@ -36,6 +36,8 @@ modded class SCR_AIGetArtilleryAimDistanceCompensation
if (!battery)
return super.EOnTaskSimulate(owner, dt);
battery.AimedAt = -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; }
@@ -43,13 +45,56 @@ modded class SCR_AIGetArtilleryAimDistanceCompensation
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.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.AimedAt = 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;
}
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 ENodeResult.FAIL; }
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 ENodeResult.FAIL; }
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 ENodeResult.FAIL; }
if (!source) { system.Withhold(battery, "shell resource has no entity source", ammo); return null; }
array<float> coefficients = {};
GetAmmoInitialSpeedCoefficients(ammo, coefficients);
if (coefficients.IsEmpty())
@@ -66,13 +111,13 @@ modded class SCR_AIGetArtilleryAimDistanceCompensation
// 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 ENodeResult.FAIL; }
if (!system.OutsideSettlements(impact)) { system.Withhold(battery, "aim target inside settlement exclusion", system.SettlementReason); return ENodeResult.FAIL; }
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(gun.GetOrigin(), impact);
float altitude = impact[1] - gun.GetOrigin()[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 ENodeResult.FAIL; }
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++)
@@ -86,25 +131,26 @@ modded class SCR_AIGetArtilleryAimDistanceCompensation
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 ENodeResult.FAIL; }
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 ENodeResult.FAIL; }
if (!system.Safe(battery, impact, flight + 2)) { system.Withhold(battery, "aim impact safety rejected", system.SafetyReason); return ENodeResult.FAIL; }
battery.Impact = impact;
battery.AimOffset = salvoOffset;
battery.AimedShot = battery.OrderShots;
battery.FlightTime = flight;
battery.AimedAt = now;
battery.LastFailure = "";
battery.Trace("AIM SOLUTION", "ready", string.Format("predicted impact=%1 flight=%2s charge index=%3 aim height offset=%4m latest source=M%5", impact, flight, charge, offset, report.MessageId));
vector aim = impact + Vector(0, offset, 0);
SetVariableOut(PORT_AIM_POS, aim);
SetVariableOut(PORT_INIT_SPEED_ID, charge);
return ENodeResult.SUCCESS;
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;
}
}
@@ -204,17 +250,27 @@ modded class SCR_AIStaticArtilleryBehavior_ShootingLogic
}
if (battery)
{
if (!battery.CommandOpen) return ENodeResult.FAIL;
// Loading can outlast the native pause. Count confirmed shells only.
if (battery.OrderShots <= behavior.GetShotCount()) return ENodeResult.RUNNING;
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)
{
ERR_MortarSupplyState supply = battery.SupplyState(GetGame().GetWorld().GetWorldTime());
if (supply == ERR_MortarSupplyState.PENDING) return ENodeResult.RUNNING;
if (supply == ERR_MortarSupplyState.FAILED)
{
ERR_MortarSystem system = ERR_MortarSystem.Get();
if (system) system.Withhold(battery, "next native load lacks confirmed physical gunner ammunition");
return ENodeResult.FAIL;
}
}
}
ENodeResult result = super.EOnTaskSimulate(owner, dt);
if (battery && result == ENodeResult.SUCCESS)
{
SetVariableOut(PORT_AIMING_NEEDED, true);
// Transfer existing nearby crew ammunition before the next native load.
if (battery.CommandOpen && battery.OrderShots < ERR_MortarSalvo.ROUNDS)
battery.SupplyGunner();
}
return result;
}
@@ -0,0 +1,177 @@
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;
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;
}
}
@@ -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;
}
}
@@ -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;
}
}
+44 -14
View File
@@ -36,12 +36,14 @@ class ERR_MortarSystem
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;
}
@@ -125,13 +127,19 @@ class ERR_MortarSystem
bool AllowShot(ERR_MortarBattery battery)
{
float now = GetGame().GetWorld().GetWorldTime();
if (!ERR_HQ.MissionActive()) return Withhold(battery, "mission inactive");
if (m_Stopped || !ERR_HQ.MissionActive()) return Withhold(battery, "mission inactive or HQ stopped");
if (!battery.Operational()) return Withhold(battery, battery.OperationalReason());
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 > 10000) return Withhold(battery, "fresh aim solution required");
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 (now - battery.AimedAt > 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;
@@ -142,20 +150,41 @@ class ERR_MortarSystem
}
protected void AdvanceOrder(ERR_MortarBattery battery, float now)
{
if (!battery.RadioOrder) return;
int state = battery.RadioOrder.Advance(now, m_HQ.Journal());
if (state == 0) return;
ERR_HQReport delivered = battery.PendingRadioReport;
battery.RadioOrder = null;
battery.PendingRadioReport = null;
if (state < 0 || !ERR_MortarPrediction.Fresh(delivered, now) || !battery.Operational() || battery.Activity || !battery.SupplyGunner())
if (battery.RadioOrder)
{
battery.Cancel("radio delivery, report freshness or gunner availability failed");
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;
}
battery.Order(delivered, now);
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);
}
void Tick(float now)
{
if (m_Stopped || !Replication.IsServer()) return;
@@ -179,9 +208,10 @@ class ERR_MortarSystem
float distance = float.MAX;
foreach (ERR_MortarBattery battery : m_Batteries)
{
if (!battery.StartupActivated || !battery.Operational() || battery.Activity || battery.RadioOrder || now < battery.NextOrder || battery.CountShells() == 0) continue;
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;
@@ -192,7 +222,7 @@ class ERR_MortarSystem
if (!selected || !target) return;
ERR_HQRoute route = m_HQ.Downlink(selected.Group, "MORTAR ORDER", target.Description(now), target.MessageId);
if (!route) return;
selected.BeginRequest(target, now);
if (!selected.BeginRequest(target, now)) return;
selected.RadioId = route.MessageId;
selected.RadioOrder = route;
selected.PendingRadioReport = target.Snapshot();
@@ -216,6 +246,6 @@ class ERR_MortarSystem
void Stop()
{
m_Stopped = true;
foreach (ERR_MortarBattery battery : m_Batteries) battery.Cancel("HQ stopped");
foreach (ERR_MortarBattery battery : m_Batteries) battery.Stop();
}
}
@@ -0,0 +1,330 @@
#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 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) || battery.AimedAt < 0 || now < battery.AimedAt || now - battery.AimedAt > 10000 || 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
@@ -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
@@ -0,0 +1,284 @@
#ifdef WORKBENCH
// Read-only evidence hooks. Every production method delegates unchanged.
modded class ERR_HQ
{
void ERR_ResponseUnits(out array<ERR_HQUnit> units)
{
units.Clear();
if (!ERR_NativeHQResponseProof.Enabled()) return;
foreach (ERR_HQUnit unit : m_Units) units.Insert(unit);
}
protected override void Observe(ERR_HQUnit unit, float now)
{
ERR_HQReport previous = unit.PendingReport;
super.Observe(unit, now);
ERR_NativeHQResponseProof proof = ERR_NativeHQResponseProof.Active();
if (proof && unit.PendingReport && unit.PendingReport != previous) proof.Observed(this, unit, now);
}
protected override void Accept(ERR_HQReport report, float now)
{
super.Accept(report, now);
ERR_NativeHQResponseProof proof = ERR_NativeHQResponseProof.Active();
if (!proof || !report) return;
foreach (ERR_HQReport accepted : m_Contacts)
if (accepted.MessageId == report.MessageId) { proof.Accepted(this, accepted, now); break; }
}
protected override void DeliverOrder(ERR_HQUnit unit, float now)
{
ERR_HQReport order = unit.PendingOrder;
ERR_HQRoute route = unit.Downlink;
super.DeliverOrder(unit, now);
ERR_NativeHQResponseProof proof = ERR_NativeHQResponseProof.Active();
if (proof && order && route && unit.Assignment == order && unit.Waypoint) proof.Delivered(this, unit, route, now);
}
}
modded class ERR_HQJournal
{
override void Write(string kind, string message, int messageId = 0)
{
super.Write(kind, message, messageId);
ERR_NativeHQResponseProof proof = ERR_NativeHQResponseProof.Active();
if (proof) proof.JournalEvent(this, kind, message, messageId);
}
}
modded class SCR_AIGroupUtilityComponent
{
IEntity ERR_ResponseVisualObserver(ERR_HQReport report, float now)
{
foreach (SCR_AIInfoComponent info : m_aInfoComponents)
{
if (!info || !info.m_Perception) continue;
AIAgent agent = AIAgent.Cast(info.GetOwner());
if (!agent || !ERR_Actors.CanFight(agent.GetControlledEntity())) continue;
array<BaseTarget> targets = {}; info.m_Perception.GetTargetsList(targets, ETargetCategory.ENEMY);
foreach (BaseTarget target : targets)
{
ERR_HQVisualContact sample = ERR_HQObservation.Capture(target, now);
if (sample && sample.Entity == report.Target && sample.Escapist && Math.AbsFloat(sample.ObservedAt - report.ObservedAt) < 1 && vector.Distance(sample.Position, report.Position) < 0.1) return agent.GetControlledEntity();
}
}
return null;
}
}
class ERR_NativeResponseUnit
{
ERR_HQUnit Unit;
SCR_AIGroup Group;
IEntity Radio;
ref array<IEntity> Actors = {};
}
class ERR_NativeResponseJournal
{
bool Report;
bool Transmit;
bool HQReceive;
bool HQTransmit;
bool UnitReceive;
bool Delivered;
}
class ERR_NativeResponseLineage
{
ref ERR_HQReport Report;
ERR_HQUnit Source;
IEntity Observer;
IEntity SourceRadio;
ref ERR_HQRoute Uplink;
bool Accepted;
float AcceptedAt;
ERR_HQUnit Recipient;
IEntity RecipientRadio;
int DeliveryId;
AIWaypoint Waypoint;
IEntity MovingActor;
vector DeliveredOrigin;
float DeliveredAt;
}
class ERR_NativeHQResponseProof
{
protected static ERR_NativeHQResponseProof s_Active;
protected ERR_GameMode m_Mode;
protected ERR_HQ m_HQ;
protected IEntity m_Player;
protected SCR_PlayerController m_Controller;
protected ERR_PlayerLife m_Life;
protected ERR_HQUnit m_Source;
protected DamageManagerComponent m_Damage;
protected bool m_DamageWasEnabled;
protected bool m_Protected;
protected bool m_Started;
protected bool m_Stopped;
protected bool m_Failed;
protected string m_Failure;
protected float m_Deadline;
protected float m_NextDiagnostic;
protected ref array<ref ERR_NativeResponseUnit> m_Units = {};
protected ref array<IEntity> m_Forces = {};
protected ref array<ref ERR_NativeResponseLineage> m_Reports = {};
protected ref map<int, ref ERR_NativeResponseJournal> m_Journal = new map<int, ref ERR_NativeResponseJournal>();
static bool Enabled()
{
string enabled, outcome;
return Replication.IsServer() && System.GetCLIParam("errSmoke", enabled) && enabled == "1" && System.GetCLIParam("errSmokeOutcome", outcome) && outcome == "hq-response";
}
static ERR_NativeHQResponseProof Active() { return s_Active; }
void ERR_NativeHQResponseProof(ERR_GameMode mode, IEntity player)
{
m_Mode = mode; m_Player = player; m_HQ = ERR_HQ.Get(); m_Deadline = GetGame().GetWorld().GetWorldTime() + 140000;
if (Enabled() && !s_Active) s_Active = this;
}
protected bool Fail(string reason) { m_Failed = true; m_Failure = reason; return false; }
protected bool Check(bool condition, string detail)
{
if (condition) { Print("ERR SMOKE PASS: HQ response " + detail); return true; }
Print("ERR SMOKE FAIL: HQ response " + detail, LogLevel.ERROR); Stop(); return false;
}
protected ERR_NativeResponseUnit Original(ERR_HQUnit unit)
{
foreach (ERR_NativeResponseUnit original : m_Units) if (original.Unit == unit && original.Group == unit.Group) return original;
return null;
}
protected ERR_NativeResponseLineage Report(int id)
{
foreach (ERR_NativeResponseLineage report : m_Reports) if (report.Report.MessageId == id) return report;
return null;
}
protected bool Canonical()
{
return m_Player && m_Controller && m_Life && m_Mode.Players().CurrentActor(m_Player) == m_Life && m_Controller == GetGame().GetPlayerController() && m_Controller.GetControlledEntity() == m_Player && m_Life.Actor == m_Player && m_Life.Connected && m_Life.UsedLife;
}
protected bool UnchangedForces()
{
array<ERR_HQUnit> current = {}; m_HQ.ERR_ResponseUnits(current);
if (current.Count() != m_Units.Count()) return false;
foreach (ERR_HQUnit unit : current)
{
ERR_NativeResponseUnit original = Original(unit);
if (!original) return false;
array<AIAgent> agents = {}; unit.Group.GetAgents(agents);
if (agents.Count() != original.Actors.Count()) return false;
foreach (AIAgent agent : agents) if (!agent || !original.Actors.Contains(agent.GetControlledEntity())) return false;
}
array<IEntity> forces = {}; m_HQ.PhysicalForces(forces);
if (forces.Count() != m_Forces.Count()) return false;
foreach (IEntity entity : forces) if (!m_Forces.Contains(entity)) return false;
return true;
}
protected bool Arrange()
{
array<ERR_HQUnit> units = {}; m_HQ.ERR_ResponseUnits(units);
foreach (ERR_HQUnit unit : units)
{
if (!unit.Group || !unit.Utility || !unit.Radio || ERR_HQRadio.Capability(unit.Radio) != 2) return false;
array<AIAgent> agents = {}; unit.Group.GetAgents(agents);
if (agents.IsEmpty() || !unit.Group.IsExpandComplete()) return false;
ERR_NativeResponseUnit original = new ERR_NativeResponseUnit(); original.Unit = unit; original.Group = unit.Group; original.Radio = unit.Radio;
foreach (AIAgent agent : agents)
{
if (!agent || !ERR_Actors.CanFight(agent.GetControlledEntity())) return false;
original.Actors.Insert(agent.GetControlledEntity());
}
m_Units.Insert(original);
if (!m_Source && !unit.ReservedForSupport) m_Source = unit;
}
if (!m_Source || m_Units.Count() < 2) return false;
m_HQ.PhysicalForces(m_Forces);
m_Controller = SCR_PlayerController.Cast(GetGame().GetPlayerController()); m_Life = m_Mode.Players().CurrentActor(m_Player);
if (!Canonical()) return false;
m_Damage = DamageManagerComponent.Cast(m_Player.FindComponent(DamageManagerComponent));
if (!m_Damage) return false;
m_DamageWasEnabled = m_Damage.IsDamageHandlingEnabled(); m_Damage.EnableDamageHandling(false); m_Protected = true;
// Preserve the established fixture arrangement: move only the real prisoner.
// Native perception must discover him; neither targets nor reports are seeded.
vector position = ERR_Actors.Ground(m_Source.Group.GetOrigin() + "0 0 20");
vector transform[4]; m_Player.GetWorldTransform(transform); transform[3] = position;
ChimeraCharacter.Cast(m_Player).Teleport(transform);
m_Started = true;
PrintFormat("ERR HQ RESPONSE ARRANGEMENT: canonical prisoner moved to=%1 near original source group=%2; temporary player damage protection=%3; no AI/report/radio cadence changes", position, m_Source.Id, m_Protected);
return true;
}
void JournalEvent(ERR_HQJournal journal, string kind, string message, int messageId)
{
if (!m_Started || m_Stopped || !m_HQ || journal != m_HQ.Journal() || messageId <= 0) return;
ERR_NativeResponseJournal entry = m_Journal.Get(messageId);
if (!entry)
{
if (m_Journal.Count() >= 128) { Fail("bounded journal evidence map exhausted"); return; }
entry = new ERR_NativeResponseJournal(); m_Journal.Insert(messageId, entry);
}
if (kind == "REPORT") entry.Report = true;
if (kind == "TRANSMIT") { entry.Transmit = true; if (message.StartsWith("HQ -> ")) entry.HQTransmit = true; }
if (kind == "RECEIVE") { if (message.StartsWith("HQ received from ")) entry.HQReceive = true; else if (message.StartsWith("unit ")) entry.UnitReceive = true; }
if (kind == "ORDER DELIVERED") entry.Delivered = true;
}
void Observed(ERR_HQ hq, ERR_HQUnit unit, float now)
{
if (!m_Started || m_Stopped || hq != m_HQ || unit != m_Source || unit.PendingReport.Target != m_Player) return;
ERR_HQReport report = unit.PendingReport;
ERR_NativeResponseUnit original = Original(unit);
IEntity observer = unit.Utility.ERR_ResponseVisualObserver(report, now);
if (!Canonical() || !original || !original.Actors.Contains(observer) || !unit.Uplink || unit.Uplink.Nodes.IsEmpty() || unit.Uplink.Nodes[0].Carrier != unit.Radio || unit.Uplink.MessageId != report.MessageId || unit.Radio != original.Radio || !original.Actors.Contains(unit.Radio) || report.Source != original.Group || report.ObservedAt > now || Report(report.MessageId)) { Fail("real observed report lacks canonical original observer/radio/source lineage"); return; }
if (m_Reports.Count() >= 32) { Fail("bounded report evidence exhausted"); return; }
ERR_NativeResponseLineage proof = new ERR_NativeResponseLineage(); proof.Report = report.Snapshot(); proof.Source = unit; proof.Observer = observer; proof.SourceRadio = unit.Radio; proof.Uplink = unit.Uplink; m_Reports.Insert(proof);
PrintFormat("ERR HQ RESPONSE SIGHTING: source=%1 report=M%2 observer=%3 radio=%4 target=%5 observedAt=%6; actual native visual sample", unit.Id, report.MessageId, observer, unit.Radio, m_Player, report.ObservedAt);
}
void Accepted(ERR_HQ hq, ERR_HQReport report, float now)
{
if (!m_Started || m_Stopped || hq != m_HQ) return;
ERR_NativeResponseLineage proof = Report(report.MessageId); if (!proof) return;
if (report.Target != m_Player || report.Source != proof.Report.Source || report.ObservedAt != proof.Report.ObservedAt || vector.Distance(report.Position, proof.Report.Position) > 0.01 || !proof.Uplink || proof.Uplink.Failed || proof.Uplink.Sending || proof.Uplink.Cursor < proof.Uplink.Nodes.Count()) { Fail("accepted report lacks completed actual uplink or immutable observed time"); return; }
proof.Accepted = true; proof.AcceptedAt = now;
}
void Delivered(ERR_HQ hq, ERR_HQUnit unit, ERR_HQRoute route, float now)
{
if (!m_Started || m_Stopped || hq != m_HQ || !unit.Assignment) return;
ERR_NativeResponseLineage proof = Report(unit.Assignment.MessageId); if (!proof || proof.Recipient) return;
ERR_NativeResponseUnit original = Original(unit);
if (!proof.Accepted || !original || unit == proof.Source || !unit.Assignment || unit.Assignment.Target != m_Player || unit.Assignment.Source != proof.Report.Source || unit.Assignment.ObservedAt != proof.Report.ObservedAt || vector.Distance(unit.Assignment.Position, proof.Report.Position) > 0.01 || unit.Radio != original.Radio || !original.Actors.Contains(unit.Radio) || !route.Downlink || route.Nodes.IsEmpty() || route.Nodes[route.Nodes.Count() - 1].Carrier != unit.Radio || route.Failed || route.Sending || route.Cursor != route.Nodes.Count() - 1 || !unit.Waypoint) { Fail("delivered response lacks immutable accepted report and original physical recipient/downlink"); return; }
proof.Recipient = unit; proof.RecipientRadio = unit.Radio; proof.DeliveryId = route.MessageId; proof.Waypoint = unit.Waypoint; proof.DeliveredAt = now;
foreach (IEntity actor : original.Actors) if (ERR_Actors.CanFight(actor)) { proof.MovingActor = actor; break; }
if (proof.MovingActor) proof.DeliveredOrigin = proof.MovingActor.GetOrigin();
PrintFormat("ERR HQ RESPONSE DELIVERED: report=M%1 downlink=M%2 source=%3 recipient=%4 radio=%5 waypoint=%6 observedAt=%7", proof.Report.MessageId, route.MessageId, proof.Source.Id, unit.Id, unit.Radio, unit.Waypoint, unit.Assignment.ObservedAt);
}
int Tick(float now)
{
if (m_Stopped) return -1;
if (!Enabled() || s_Active != this || !m_Mode || !m_Mode.Active() || !m_HQ || now > m_Deadline || m_Failed) { Check(false, "bounded active evidence context: " + m_Failure); return -1; }
if (!m_Started)
{
if (!m_HQ.ERR_ProbeSquadsReady()) return 0;
array<IEntity> guns = {}; m_HQ.Mortars().AppendGuns(guns);
foreach (IEntity gun : guns) { ERR_MortarBattery battery = m_HQ.Mortars().FindGun(gun); if (!battery || !battery.StartupActivated || !battery.Operational() || !m_HQ.MortarConnected(battery.Group)) return 0; }
if (!Check(Arrange(), "original physical force, RTOs and canonical prisoner captured before arranged sighting")) return -1;
return 0;
}
if (!Canonical() || !UnchangedForces()) { Check(false, "canonical player and all original physical group/actor identities retained without added force"); return -1; }
if (now >= m_NextDiagnostic)
{
PrintFormat("ERR HQ RESPONSE PROOF: observed source reports=%1; %2", m_Reports.Count(), m_HQ.ERR_ProbePerception()); m_NextDiagnostic = now + 10000;
}
foreach (ERR_NativeResponseLineage proof : m_Reports)
{
if (!proof.Recipient || !proof.MovingActor) continue;
if (proof.Recipient.Waypoint != proof.Waypoint || !proof.Recipient.Assignment || proof.Recipient.Assignment.MessageId != proof.Report.MessageId) continue;
ERR_NativeResponseJournal uplink = m_Journal.Get(proof.Report.MessageId);
ERR_NativeResponseJournal downlink = m_Journal.Get(proof.DeliveryId);
if (!uplink || !downlink || !uplink.Report || !uplink.Transmit || !uplink.HQReceive || !downlink.HQTransmit || !downlink.UnitReceive || !downlink.Delivered) continue;
if (proof.Recipient.Group.GetCurrentWaypoint() != proof.Waypoint || vector.DistanceXZ(proof.MovingActor.GetOrigin(), proof.DeliveredOrigin) < 3) continue;
if (vector.DistanceXZ(proof.MovingActor.GetOrigin(), proof.Report.Position) >= vector.DistanceXZ(proof.DeliveredOrigin, proof.Report.Position) - 1) continue;
if (!Check(proof.SourceRadio && proof.RecipientRadio && m_HQ.ERR_ProbeUniqueAssignments(), "actual sighting/report/uplink/HQ receipt/downlink journal lineage reaches distinct original infantry with bounded unique waypoints")) return -1;
if (!Check(UnchangedForces(), "original recipient physically moves toward its native Search waypoint with no replacement or additional force")) return -1;
Stop(); return 1;
}
return 0;
}
void Stop()
{
if (m_Stopped) return;
m_Stopped = true;
if (m_Protected && m_Damage) m_Damage.EnableDamageHandling(m_DamageWasEnabled);
m_Protected = false;
if (s_Active == this) s_Active = null;
}
void ~ERR_NativeHQResponseProof() { Stop(); }
}
#endif
+62 -18
View File
@@ -17,8 +17,13 @@ modded class ERR_GameMode
protected bool m_ERRTestRadioNetwork;
protected bool m_ERRTestHQResponse;
protected bool m_ERRTestMortar;
protected bool m_ERRTestMortarFire;
protected bool m_ERRTestMortarTransfer;
protected ref ERR_NativeMortarTransferProbe m_ERRMortarTransfer;
protected ref ERR_NativeMortarFiringProbe m_ERRMortarFire;
protected float m_ERRNextProbeLog;
protected ref ERR_NativeRadioProbe m_ERRRadioProbe;
protected ref ERR_NativeHQResponseProof m_ERRHQResponseProof;
protected ref array<IEntity> m_ERRCacheFolders = {};
protected override void EOnInit(IEntity owner)
@@ -31,9 +36,13 @@ modded class ERR_GameMode
m_ERRTestRadioNetwork = outcome == "radio-network";
m_ERRTestHQResponse = outcome == "hq-response";
m_ERRTestMortar = outcome == "mortar-inventory";
m_ERRTestMortarFire = outcome == "mortar-fire" || outcome == "mortar-expiry";
m_ERRTestMortarTransfer = outcome == "mortar-transfer";
m_ERRTestPatrolHQ = outcome == "patrol-hq";
m_ERRTestPlayerDeath = outcome == "player-death" || m_ERRTestRadioNetwork || m_ERRTestHQResponse || m_ERRTestMortar;
m_ERRSmokeDeadline = GetWorld().GetWorldTime() + 150000;
if (m_ERRTestMortarTransfer) m_ERRSmokeDeadline = GetWorld().GetWorldTime() + 330000;
if (m_ERRTestMortarFire) m_ERRSmokeDeadline = GetWorld().GetWorldTime() + 330000;
if (m_ERRTestPatrolHQ) m_ERRSmokeDeadline = GetWorld().GetWorldTime() + 420000;
OnStop().Insert(ERR_StopHQPatrol);
OnPhaseChanged().Insert(ERR_SmokePhase);
@@ -52,6 +61,9 @@ modded class ERR_GameMode
}
protected void ERR_StopHQPatrol()
{
if (m_ERRHQResponseProof) m_ERRHQResponseProof.Stop();
if (m_ERRMortarTransfer) m_ERRMortarTransfer.Stop();
if (m_ERRMortarFire) m_ERRMortarFire.Stop();
if (m_ERRHQPatrolMovement) m_ERRHQPatrolMovement.Stop();
if (m_ERRHQPatrolOrder) m_ERRHQPatrolOrder.Stop();
}
@@ -87,6 +99,24 @@ modded class ERR_GameMode
protected void ERR_SmokePhase(ERR_Phase previous, ERR_Phase current)
{
if (m_ERRSmokeDone || !ERR_MissionRules.Terminal(current)) return;
if (m_ERRTestMortarTransfer)
{
if (!ERR_Check(current == ERR_Phase.DEFEAT && m_ERRSmokeStep == 161, "native exact shell transfers and original-owner restoration complete before explicit reset")) return;
m_ERRSmokeDone = true;
GetGame().GetCallqueue().Remove(ERR_SmokeTick);
ERR_StopHQPatrol();
Print("ERR SMOKE COMPLETE: native exact mortar inventory transfer, accepted-operation cancellation and original-owner restoration; radio bypassed; fixture reset requested");
return;
}
if (m_ERRTestMortarFire)
{
if (!ERR_Check(current == ERR_Phase.DEFEAT && m_ERRSmokeStep == 151, "native mortar first shot and stop complete before explicit reset")) return;
m_ERRSmokeDone = true;
GetGame().GetCallqueue().Remove(ERR_SmokeTick);
ERR_StopHQPatrol();
Print("ERR SMOKE COMPLETE: native HQ radio mortar projectile and configured cancellation/expiry; fixture reset requested");
return;
}
if (m_ERRTestPatrolHQ)
{
if (!ERR_Check(current == ERR_Phase.DEFEAT && m_ERRSmokeStep == 133, "HQ patrol preemption and physical resumption before explicit reset")) return;
@@ -149,6 +179,20 @@ modded class ERR_GameMode
if (m_ERRSmokeDone) return;
if (now > m_ERRSmokeDeadline) { ERR_Check(false, "timeout at step " + m_ERRSmokeStep.ToString()); return; }
if (now < m_ERRSmokeWait) return;
if (m_ERRSmokeStep == 160)
{
int result = m_ERRMortarTransfer.Tick(now);
if (result < 0) { ERR_Check(false, "native mortar exact-shell transfer fixture"); return; }
if (result == 1) { m_ERRSmokeStep = 161; Transition(ERR_Phase.DEFEAT); }
return;
}
if (m_ERRSmokeStep == 150)
{
int result = m_ERRMortarFire.Tick(now);
if (result < 0) { ERR_Check(false, "native mortar firing probe failed; see diagnostics"); return; }
if (result == 1) { m_ERRSmokeStep = 151; Transition(ERR_Phase.DEFEAT); }
return;
}
if (m_ERRSmokeStep == 130 || m_ERRSmokeStep == 132)
{
int result = m_ERRHQPatrolMovement.Tick(now);
@@ -181,6 +225,20 @@ modded class ERR_GameMode
m_ERRSmokeStep = 130;
return;
}
if (m_ERRTestMortarTransfer)
{
DamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(DamageManagerComponent)).EnableDamageHandling(false);
m_ERRMortarTransfer = new ERR_NativeMortarTransferProbe(this);
m_ERRSmokeStep = 160;
return;
}
if (m_ERRTestMortarFire)
{
DamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(DamageManagerComponent)).EnableDamageHandling(false);
m_ERRMortarFire = new ERR_NativeMortarFiringProbe(this);
m_ERRSmokeStep = 150;
return;
}
if (m_ERRTestMortar)
{
DamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(DamageManagerComponent)).EnableDamageHandling(false);
@@ -189,7 +247,7 @@ modded class ERR_GameMode
}
if (m_ERRTestHQResponse)
{
DamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(DamageManagerComponent)).EnableDamageHandling(false);
m_ERRHQResponseProof = new ERR_NativeHQResponseProof(this, m_ERRSmokePlayer);
m_ERRSmokeStep = 110;
return;
}
@@ -343,23 +401,9 @@ modded class ERR_GameMode
}
else if (m_ERRSmokeStep == 110)
{
ERR_HQ hq = ERR_HQ.Cast(Command());
if (!hq || !hq.ERR_ProbeSquadsReady()) return;
ERR_MovePlayer(hq.ERR_ProbeResponseOrigin());
m_ERRSmokeStep = 111;
m_ERRSmokeWait = now + 1000;
}
else if (m_ERRSmokeStep == 111)
{
ERR_HQ hq = ERR_HQ.Cast(Command());
if (now >= m_ERRNextProbeLog)
{
Print("ERR HQ PROBE: " + hq.ERR_ProbePerception());
m_ERRNextProbeLog = now + 10000;
}
if (!hq.ERR_ProbeDeliveredResponse()) return;
if (!ERR_Check(true, "native visual contact reaches HQ and orders a different existing squad")) return;
if (!ERR_Check(hq.ERR_ProbeUniqueAssignments(), "HQ uses distinct waypoints within the existing force and order limits")) return;
int result = m_ERRHQResponseProof.Tick(now);
if (result < 0) { ERR_Check(false, "native HQ response evidence failed; inspect report lineage diagnostics"); return; }
if (result == 0) return;
m_ERRSmokeStep = 9;
SCR_CharacterDamageManagerComponent damage = SCR_CharacterDamageManagerComponent.Cast(m_ERRSmokePlayer.FindComponent(SCR_CharacterDamageManagerComponent));
damage.EnableDamageHandling(true);
@@ -1,3 +1,26 @@
// Discovery markers are authored objective information, not player annotations.
// Native local insertion overwrites owner ID before OnCreateMarker; clear it at
// that boundary so native edit/drag cannot move a discovered objective.
class ERR_DiscoveryMarker : SCR_MapMarkerBase
{
void ERR_DiscoveryMarker() { SetCanBeRemovedByOwner(false); }
override void OnCreateMarker(bool skipProfanityFilter = false)
{
SetMarkerOwnerID(-1);
// Text is scenario-authored, never user input. Avoid delayed UGC callbacks
// overwriting a newer discovery label after the location becomes identified.
super.OnCreateMarker(true);
}
override void SetCustomText(string text)
{
super.SetCustomText(text);
// Native SetCustomText updates data only; an already-open map needs its
// existing widget updated too. Closed maps use the saved text on creation.
SCR_MapMarkerWidgetComponent widget = GetMarkerComponent();
if (GetRootWidget() && widget) widget.SetText(text, true);
}
}
// Read replicated discovery locally. A server-wide marker list never leaks
// locations which have not been recovered through gameplay.
modded class ERR_LocationComponent
@@ -17,15 +40,6 @@ modded class ERR_LocationComponent
if (!GetGame().GetPlayerController() || Knowledge() <= 0 || (Knowledge() == m_ERRShown && Label() == m_ERRShownLabel)) return;
SCR_MapMarkerManagerComponent manager = SCR_MapMarkerManagerComponent.GetInstance();
if (!manager || !manager.GetMarkerConfig()) return;
if (!m_ERRMarker)
{
m_ERRMarker = new SCR_MapMarkerBase();
m_ERRMarker.SetType(SCR_EMapMarkerType.PLACED_CUSTOM);
vector p = Position();
m_ERRMarker.SetWorldPos(Math.Round(p[0]), Math.Round(p[2]));
m_ERRMarker.SetMarkerConfigID(0);
manager.InsertStaticMarker(m_ERRMarker, true);
}
string label = "Unknown location";
if (Knowledge() == 2)
{
@@ -36,6 +50,16 @@ modded class ERR_LocationComponent
else if (Kind() == ERR_LocationKind.PRISON) label = "Prison";
else label = Id();
}
if (!m_ERRMarker)
{
m_ERRMarker = new ERR_DiscoveryMarker();
m_ERRMarker.SetType(SCR_EMapMarkerType.PLACED_CUSTOM);
vector p = Position();
m_ERRMarker.SetWorldPos(Math.Round(p[0]), Math.Round(p[2]));
m_ERRMarker.SetMarkerConfigID(0);
m_ERRMarker.SetCustomText(label);
manager.InsertStaticMarker(m_ERRMarker, true);
}
m_ERRMarker.SetCustomText(label);
m_ERRShown = Knowledge();
m_ERRShownLabel = Label();
@@ -0,0 +1,107 @@
// A round-local roster identity is not a Bohemia/platform identity. Only the
// real native admission callback may select it for the sole offline controller.
class ERR_LocalAdmissionRules
{
static bool Context(bool server, int sessionMode, int count, bool connected, bool sameLocal, bool sameManager, int connection, int controllerId)
{
return server && sessionMode == RplMode.None && count == 1 && connected && sameLocal && sameManager && connection > 0 && controllerId == connection;
}
}
class ERR_LocalAdmission
{
protected ERR_GameMode m_Mode;
protected SCR_PlayerController m_Controller;
protected int m_Connection;
protected UUID m_Key;
protected bool m_Selected;
protected bool m_OfflineMode;
protected bool m_LocalKey;
protected bool m_Failed;
protected bool m_Stopped;
protected bool m_RefusalLogged;
void ERR_LocalAdmission(ERR_GameMode mode) { m_Mode = mode; m_OfflineMode = RplSession.Mode() == RplMode.None; }
bool OfflineMode() { return m_OfflineMode; }
bool Selected() { return m_Selected; }
bool LocalKey() { return m_LocalKey; }
protected bool Context(ERR_GameMode mode, int connection, SCR_PlayerController controller)
{
if (!GetGame()) return false;
PlayerManager manager = GetGame().GetPlayerManager();
bool ownedMode = mode && mode == m_Mode && mode == ERR_GameMode.Get();
int count = -1;
bool connected, sameManager;
if (manager)
{
count = manager.GetPlayerCount();
connected = manager.IsPlayerConnected(connection);
sameManager = controller && manager.GetPlayerController(connection) == controller;
}
bool sameLocal = controller && controller == GetGame().GetPlayerController();
int controllerId;
if (controller) controllerId = controller.GetPlayerId();
bool valid = ownedMode && manager && controller && ERR_LocalAdmissionRules.Context(Replication.IsServer(), RplSession.Mode(), count, connected, sameLocal, sameManager, connection, controllerId);
if (!valid && !m_RefusalLogged)
{
string project;
if (System.GetCLIParam("errOrdinaryScenario", project) && (project == "core" || project == "chat"))
{
m_RefusalLogged = true;
PrintFormat("ERR ORDINARY LOCAL CONTEXT: server=%1 sessionMode=%2 count=%3 connected=%4 sameLocal=%5 sameManager=%6 positiveMatchingId=%7 ownedMode=%8 controllerPresent=%9", Replication.IsServer(), RplSession.Mode(), count, connected, sameLocal, sameManager, connection > 0 && controllerId == connection, ownedMode, controller != null);
}
}
return valid;
}
UUID Resolve(ERR_GameMode mode, int connection, SCR_PlayerController controller, UUID nativeIdentity)
{
// This helper is inert for modes created as Listen/Dedicated, even after Stop.
if (!m_OfflineMode) return nativeIdentity;
if (m_Failed || m_Stopped) return UUID.NULL_UUID;
if (m_Selected)
{
// A late backend value or name change never grants another local life.
if (mode != m_Mode || connection != m_Connection || controller != m_Controller || !Validate()) { m_Failed = true; return UUID.NULL_UUID; }
return m_Key;
}
if (!Context(mode, connection, controller)) { m_Failed = true; return UUID.NULL_UUID; }
m_Controller = controller; m_Connection = connection; m_Key = nativeIdentity;
m_Selected = true;
if (m_Key.IsNull()) { m_Key = UUID.GenV4(); m_LocalKey = true; }
if (m_Key.IsNull()) { m_Failed = true; return UUID.NULL_UUID; }
if (m_LocalKey) Print("Rereintroduced [admission]: sole offline controller uses a generated local-round roster key; backend identity and permissions unchanged.");
return m_Key;
}
bool Validate()
{
if (!m_OfflineMode) return true;
if (m_Failed || m_Stopped) return false;
if (!m_Selected) return true;
if (!Context(m_Mode, m_Connection, m_Controller)) m_Failed = true;
return !m_Failed;
}
bool Disconnected(int connection)
{
if (!m_Selected || m_Stopped || connection != m_Connection) return false;
m_Failed = true;
return true;
}
void Stop()
{
m_Stopped = true;
m_Controller = null;
m_Key = UUID.NULL_UUID;
// Keep selection/failure latched: this object cannot admit a replacement.
}
}
modded class ERR_GameMode
{
void ERR_FailLocalAdmission()
{
if (!Replication.IsServer()) return;
string reason = "The offline player context changed. Start a new scenario to establish a fresh session.";
if (Phase() == ERR_Phase.PREPARING) { FailPreparation(reason); return; }
if (!Active()) return;
m_EndReason = reason;
Transition(ERR_Phase.DEFEAT);
}
}
@@ -17,11 +17,14 @@ modded class ERR_GameMode
protected ref ERR_NativeCasualtyServiceProbe m_ERRCasualtyServiceProbe;
protected ref ERR_NativePrisonerProbe m_ERRPrisonerProbe;
protected ref ERR_NativeGuardCancelProbe m_ERRGuardCancelProbe;
protected ref ERR_NativeLocalAdmissionProbe m_ERRLocalAdmissionProbe;
protected ref ERR_NativePoweredFlightProbe m_ERRPoweredFlightProbe;
protected ref ERR_NativeObjectiveLossProbe m_ERRObjectiveLossProbe;
protected ref ERR_NativeMedicalProbe m_ERRBaseMedicalProbe;
protected ref ERR_NativeCasualtyLoadProbe m_ERRCasualtyLoadProbe;
protected ref ERR_NativeEnvironmentProbe m_ERRBaseEnvironmentProbe;
protected bool m_ERRCatalogChecked;
protected bool m_ERRDiscoveryMapChecked;
protected ref ERR_NativePatrolProbe m_ERRPatrolEast;
protected ref ERR_NativePatrolProbe m_ERRPatrolWest;
protected int m_ERRBaseStep;
@@ -79,12 +82,15 @@ modded class ERR_GameMode
}
protected void ERR_StopHealthProbes()
{
ERR_NativeDiscoveryMapProbe.Stop();
if (m_ERRPatrolEast) m_ERRPatrolEast.Stop();
if (m_ERRPatrolWest) m_ERRPatrolWest.Stop();
if (m_ERRBaseEnvironmentRestartProbe) m_ERRBaseEnvironmentRestartProbe.Stop();
if (m_ERRBaseEnvironmentProbe) m_ERRBaseEnvironmentProbe.Stop();
if (m_ERRPrisonerProbe) m_ERRPrisonerProbe.Stop();
if (m_ERRGuardCancelProbe) m_ERRGuardCancelProbe.Stop();
if (m_ERRLocalAdmissionProbe) m_ERRLocalAdmissionProbe.Stop();
if (m_ERRPoweredFlightProbe) m_ERRPoweredFlightProbe.Stop();
if (m_ERRCasualtyServiceProbe) m_ERRCasualtyServiceProbe.Stop();
if (m_ERRObjectiveLossProbe) m_ERRObjectiveLossProbe.Stop();
if (m_ERRBaseMedicalProbe) m_ERRBaseMedicalProbe.Stop();
@@ -156,6 +162,20 @@ modded class ERR_GameMode
{
if (!ERR_MissionRules.Terminal(current)) return;
GetGame().GetCallqueue().Remove(ERR_BaseTick);
if (m_ERRExtractionCase == "powered-flight")
{
if (!ERR_BaseCheck(current == ERR_Phase.DEFEAT && m_ERRBaseStep == 1802, "native powered-flight fixture finished before explicit test reset")) return;
ERR_StopHealthProbes();
Print("ERR SMOKE COMPLETE: native pilot-input powered takeoff, hover, contact landing and shutdown; isolated mechanism fixture reset");
return;
}
if (m_ERRExtractionCase == "local-admission")
{
// Completion is emitted by the calling tick after synchronous teardown and
// its post-transition invariants; a terminal event alone is insufficient.
ERR_BaseCheck(current == ERR_Phase.DEFEAT && m_ERRBaseStep == 1700, "local admission fixture received its production fail-closed transition");
return;
}
if (m_ERRExtractionCase == "early-helicopter-loss")
{
if (!ERR_BaseCheck(current == ERR_Phase.DEFEAT && m_ERRBaseStep == 201 && Players().HasSurvivor() && Extraction().State() == ERR_ExtractionState.LOST, "lost helicopter ends prison phase despite a living prisoner")) return;
@@ -285,6 +305,25 @@ modded class ERR_GameMode
Print("ERR SMOKE COMPLETE: expected startup-failure");
return;
}
if (m_ERRBaseStep == 1800)
{
int result = m_ERRPoweredFlightProbe.Tick(now);
if (result < 0) { ERR_BaseCheck(false, "native powered-flight feasibility fixture"); return; }
if (result == 1) { m_ERRBaseStep = 1802; Transition(ERR_Phase.DEFEAT); }
return;
}
if (m_ERRBaseStep == 1700)
{
int result = m_ERRLocalAdmissionProbe.Tick(now);
if (result < 0) { ERR_StopHealthProbes(); GetGame().GetCallqueue().Remove(ERR_BaseTick); return; }
if (result == 1)
{
m_ERRBaseStep = 1702;
GetGame().GetCallqueue().Remove(ERR_BaseTick);
Print("ERR SMOKE COMPLETE: native local admission callback replay and fail-closed method injection; not multiplayer evidence");
}
return;
}
if (m_ERRBaseStep == 1400)
{
int result = m_ERRCasualtyServiceProbe.Tick(now);
@@ -423,6 +462,25 @@ modded class ERR_GameMode
if (!ERR_NativeFactionCatalogProbe.Run(this)) { GetGame().GetCallqueue().Remove(ERR_BaseTick); return; }
m_ERRCatalogChecked = true;
}
if (!m_ERRDiscoveryMapChecked)
{
if (!ERR_NativeDiscoveryMapProbe.Run()) { GetGame().GetCallqueue().Remove(ERR_BaseTick); return; }
m_ERRDiscoveryMapChecked = true;
}
if (m_ERRExtractionCase == "powered-flight")
{
DamageManagerComponent.Cast(m_ERRBasePlayer.FindComponent(DamageManagerComponent)).EnableDamageHandling(false);
m_ERRPoweredFlightProbe = new ERR_NativePoweredFlightProbe(this);
m_ERRBaseDeadline = now + 250000;
m_ERRBaseStep = 1800;
return;
}
if (m_ERRExtractionCase == "local-admission")
{
m_ERRLocalAdmissionProbe = new ERR_NativeLocalAdmissionProbe(this);
m_ERRBaseStep = 1700;
return;
}
if (m_ERRExtractionCase == "casualty-service")
{
m_ERRCasualtyServiceProbe = new ERR_NativeCasualtyServiceProbe(this);
@@ -0,0 +1,140 @@
#ifdef WORKBENCH
// Opt-in hidden native-widget comparison; no discovery, inventory or mission
// state is changed, and no map menu or player input is fabricated.
// Separate owned subclasses preserve the vanilla base RPC serializer identity.
// Their small widget adapters are repeated to compare the unchanged native
// setter against the real successor subclass without modding a replicated type.
class ERR_NativePlainMapMarker : SCR_MapMarkerBase
{
bool ERR_ProbeAttachMapWidget()
{
ERR_ProbeDetachMapWidget();
SCR_MapMarkerManagerComponent manager = SCR_MapMarkerManagerComponent.GetInstance();
if (!manager || !manager.GetMarkerConfig()) return false;
m_ConfigEntry = manager.GetMarkerConfig().GetMarkerEntryConfigByType(GetType());
if (!m_ConfigEntry) return false;
m_wRoot = GetGame().GetWorkspace().CreateWidgets(m_ConfigEntry.GetMarkerLayout());
if (!m_wRoot) return false;
m_wRoot.SetVisible(false);
m_MarkerWidgetComp = SCR_MapMarkerWidgetComponent.Cast(m_wRoot.FindHandler(SCR_MapMarkerWidgetComponent));
if (!m_MarkerWidgetComp) return false;
m_MarkerWidgetComp.SetMarkerObject(this);
m_ConfigEntry.InitClientSettings(this, m_MarkerWidgetComp, true);
return m_wRoot.FindAnyWidget("MarkerText") != null;
}
string ERR_ProbeMapText()
{
if (!m_wRoot) return "";
TextWidget text = TextWidget.Cast(m_wRoot.FindAnyWidget("MarkerText"));
if (!text) return "";
return text.GetText();
}
void ERR_ProbeDetachMapWidget()
{
// Remove only this probe object's native subscriptions/widgets.
SCR_MapEntity.GetOnMapClose().Remove(OnMapClosed);
SCR_MapEntity.GetOnLayerChanged().Remove(OnMapLayerChanged);
if (m_wRoot) m_wRoot.RemoveFromHierarchy();
m_wRoot = null;
m_MarkerWidgetComp = null;
}
}
class ERR_NativeObjectiveMapMarker : ERR_DiscoveryMarker
{
bool ERR_ProbeAttachMapWidget()
{
ERR_ProbeDetachMapWidget();
SCR_MapMarkerManagerComponent manager = SCR_MapMarkerManagerComponent.GetInstance();
if (!manager || !manager.GetMarkerConfig()) return false;
m_ConfigEntry = manager.GetMarkerConfig().GetMarkerEntryConfigByType(GetType());
if (!m_ConfigEntry) return false;
m_wRoot = GetGame().GetWorkspace().CreateWidgets(m_ConfigEntry.GetMarkerLayout());
if (!m_wRoot) return false;
m_wRoot.SetVisible(false);
m_MarkerWidgetComp = SCR_MapMarkerWidgetComponent.Cast(m_wRoot.FindHandler(SCR_MapMarkerWidgetComponent));
if (!m_MarkerWidgetComp) return false;
m_MarkerWidgetComp.SetMarkerObject(this);
m_ConfigEntry.InitClientSettings(this, m_MarkerWidgetComp, true);
return m_wRoot.FindAnyWidget("MarkerText") != null;
}
string ERR_ProbeMapText()
{
if (!m_wRoot) return "";
TextWidget text = TextWidget.Cast(m_wRoot.FindAnyWidget("MarkerText"));
if (!text) return "";
return text.GetText();
}
void ERR_ProbeDetachMapWidget()
{
// Remove only this probe object's native subscriptions/widgets.
SCR_MapEntity.GetOnMapClose().Remove(OnMapClosed);
SCR_MapEntity.GetOnLayerChanged().Remove(OnMapLayerChanged);
if (m_wRoot) m_wRoot.RemoveFromHierarchy();
m_wRoot = null;
m_MarkerWidgetComp = null;
}
}
class ERR_NativeDiscoveryMapProbe
{
protected static ref ERR_NativePlainMapMarker s_Plain;
protected static ref ERR_NativeObjectiveMapMarker s_Objective;
static void Stop()
{
SCR_MapMarkerManagerComponent manager = SCR_MapMarkerManagerComponent.GetInstance();
if (s_Plain)
{
s_Plain.ERR_ProbeDetachMapWidget();
if (manager) manager.RemoveStaticMarker(s_Plain);
}
if (s_Objective)
{
s_Objective.ERR_ProbeDetachMapWidget();
if (manager) manager.RemoveStaticMarker(s_Objective);
}
s_Plain = null;
s_Objective = null;
}
protected static bool Check(bool success, string detail)
{
if (success) { Print("ERR SMOKE PASS: discovery map " + detail); return true; }
Print("ERR SMOKE FAIL: discovery map " + detail, LogLevel.ERROR);
Stop();
return false;
}
// Invoke once after ordinary fixture admission and native map manager init.
static bool Run()
{
Stop();
SCR_MapMarkerManagerComponent manager = SCR_MapMarkerManagerComponent.GetInstance();
if (!Check(manager && manager.GetMarkerConfig() && GetGame().GetPlayerController(), "native local controller and marker manager available")) return false;
int originalCount = manager.GetStaticMarkers().Count();
s_Plain = new ERR_NativePlainMapMarker();
s_Plain.SetType(SCR_EMapMarkerType.PLACED_CUSTOM);
s_Plain.SetMarkerConfigID(0);
s_Plain.SetCustomText("Unknown location");
manager.InsertStaticMarker(s_Plain, true);
if (!Check(s_Plain.ERR_ProbeAttachMapWidget() && s_Plain.ERR_ProbeMapText() == "Unknown location", "native baseline label rendered in actual hidden marker layout")) return false;
s_Plain.SetCustomText("Radio station");
if (!Check(s_Plain.GetCustomText() == "Radio station" && s_Plain.ERR_ProbeMapText() == "Unknown location", "native data-only setter reproduces stale already-open label")) return false;
if (!Check(s_Plain.CanBeRemovedByOwner() && SCR_MapMarkersUI.IsOwnedMarker(s_Plain), "plain local marker exposes native delete/edit/drag eligibility")) return false;
s_Objective = new ERR_NativeObjectiveMapMarker();
s_Objective.SetType(SCR_EMapMarkerType.PLACED_CUSTOM);
s_Objective.SetMarkerConfigID(0);
s_Objective.SetCustomText("Unknown location");
manager.InsertStaticMarker(s_Objective, true);
if (!Check(s_Objective.GetMarkerOwnerID() == -1 && !SCR_MapMarkersUI.IsOwnedMarker(s_Objective) && !s_Objective.CanBeRemovedByOwner(), "local insertion retains objective identity and disables native edit/drag/delete eligibility")) return false;
if (!Check(s_Objective.ERR_ProbeAttachMapWidget() && s_Objective.ERR_ProbeMapText() == "Unknown location", "objective first label rendered through actual native layout")) return false;
Widget originalWidget = s_Objective.GetRootWidget();
s_Objective.SetCustomText("Radio station");
if (!Check(s_Objective.GetRootWidget() == originalWidget && s_Objective.ERR_ProbeMapText() == "Radio station", "identification refreshes existing native widget without recreating marker")) return false;
if (!Check(manager.GetStaticMarkers().Count() == originalCount + 2, "label change creates no additional marker")) return false;
s_Objective.ERR_ProbeDetachMapWidget();
s_Objective.SetCustomText("Initial helicopter pickup site");
if (!Check(!s_Objective.GetRootWidget() && s_Objective.GetCustomText() == "Initial helicopter pickup site", "closed widget accepts data-only update safely")) return false;
if (!Check(s_Objective.ERR_ProbeAttachMapWidget() && s_Objective.ERR_ProbeMapText() == "Initial helicopter pickup site", "new native widget renders latest saved label")) return false;
Stop();
return Check(manager.GetStaticMarkers().Count() == originalCount, "owned probe widgets and markers removed; mission markers preserved");
}
}
#endif
@@ -0,0 +1,129 @@
#ifdef WORKBENCH
[BaseContainerProps(category: "Respawn")]
modded class ERR_SpawnLogic
{
ERR_LocalAdmission ERR_LocalProbeHelper()
{
if (!ERR_NativeLocalAdmissionProbe.Enabled()) return null;
return m_LocalAdmission;
}
bool ERR_LocalProbeIdle(int connection)
{
return ERR_NativeLocalAdmissionProbe.Enabled() && !m_Attempts.Contains(connection) && !m_Retries.Contains(connection) && !m_Pending.Contains(connection) && !m_AuthorizedPositions.Contains(connection) && m_SpawnTickets.IsEmpty();
}
bool ERR_LocalProbeClosed()
{
return ERR_NativeLocalAdmissionProbe.Enabled() && m_AdmissionsStopped && m_LocalAdmissionFailed;
}
bool ERR_LocalProbeRefuseController(int connection)
{
if (!ERR_NativeLocalAdmissionProbe.Enabled() || !m_LocalAdmission || !m_LocalAdmission.OfflineMode() || !m_LocalAdmission.Selected() || !m_Identities.Contains(connection)) return false;
// Deliberately invalid METHOD ARGUMENT, not a fabricated native disconnect or
// replacement controller. The real controller and backend remain untouched.
UUID rejected = m_LocalAdmission.Resolve(ERR_GameMode.Get(), connection, null, m_Identities.Get(connection));
if (!rejected.IsNull()) return false;
return !CheckLocalAdmissionContext();
}
}
class ERR_NativeLocalAdmissionProbe
{
protected ERR_GameMode m_Mode;
protected SCR_PlayerController m_Controller;
protected IEntity m_Actor;
protected ERR_SpawnLogic m_Logic;
protected ERR_LocalAdmission m_Helper;
protected ERR_PlayerLife m_Life;
protected UUID m_Key;
protected int m_Connection;
protected int m_Participants;
protected int m_Changes;
protected int m_Step;
protected float m_Deadline;
protected float m_Wait;
protected bool m_Listening;
protected bool m_Stopped;
static bool Enabled()
{
string enabled, outcome;
return Replication.IsServer() && System.GetCLIParam("errBaseSmoke", enabled) && enabled == "1" && System.GetCLIParam("errSmokeOutcome", outcome) && outcome == "local-admission";
}
void ERR_NativeLocalAdmissionProbe(ERR_GameMode mode)
{
m_Mode = mode; m_Deadline = GetGame().GetWorld().GetWorldTime() + 15000;
}
protected bool Check(bool condition, string detail)
{
if (condition) { Print("ERR SMOKE PASS: local admission " + detail); return true; }
Print("ERR SMOKE FAIL: local admission " + detail, LogLevel.ERROR); Stop(); return false;
}
protected void Changed(int connection) { m_Changes++; }
protected int Participants()
{
array<ref ERR_PlayerLife> lives = {}; m_Mode.Players().Participants(lives); return lives.Count();
}
protected bool Unchanged()
{
return m_Controller && m_Actor && m_Life && m_Mode.Players().Get(m_Connection) == m_Life && m_Mode.Players().CurrentActor(m_Actor) == m_Life && m_Life.AccountId == m_Key && m_Life.Connected && m_Life.UsedLife && m_Life.Actor == m_Actor && m_Controller.GetControlledEntity() == m_Actor && GetGame().GetPlayerManager().GetPlayerController(m_Connection) == m_Controller && Participants() == m_Participants && !m_Mode.Players().CanEnter(m_Connection, ERR_Phase.PRISON) && m_Logic.ERR_LocalProbeIdle(m_Connection);
}
protected bool DisposableHelpers()
{
ERR_LocalAdmission disconnected = new ERR_LocalAdmission(m_Mode);
if (!Check(disconnected.Resolve(m_Mode, m_Connection, m_Controller, m_Key) == m_Key && disconnected.Validate() && !disconnected.LocalKey(), "disposable helper accepts actual sole native controller using existing round key")) return false;
// Opaque alternative input only: no backend read/write, roster bind or login.
UUID alternative = "00e77500-0000-0000-0000-000000000001";
if (!Check(disconnected.Resolve(m_Mode, m_Connection, m_Controller, alternative) == m_Key && disconnected.Resolve(m_Mode, m_Connection, m_Controller, UUID.NULL_UUID) == m_Key, "late opaque identity input and null input retain the selected key")) return false;
if (!Check(!disconnected.Disconnected(m_Connection + 1) && disconnected.Validate(), "unrelated disconnect argument does not close helper")) return false;
if (!Check(disconnected.Disconnected(m_Connection) && !disconnected.Validate() && disconnected.Resolve(m_Mode, m_Connection, m_Controller, m_Key).IsNull(), "matching disconnect argument latches disposable helper closed")) return false;
disconnected.Stop();
if (!Check(!disconnected.Validate() && disconnected.Resolve(m_Mode, m_Connection, m_Controller, m_Key).IsNull(), "stopped disposable helper cannot reopen with the genuine controller")) return false;
ERR_LocalAdmission invalid = new ERR_LocalAdmission(m_Mode);
if (!Check(invalid.Resolve(m_Mode, m_Connection, null, m_Key).IsNull() && invalid.Resolve(m_Mode, m_Connection, m_Controller, m_Key).IsNull() && !invalid.Selected(), "invalid controller argument before selection fails closed without selecting another life")) return false;
invalid.Stop();
Print("ERR LOCAL ADMISSION LIMIT: disposable method-argument fault injection; no native disconnect or replacement occurred; Listen/Dedicated pass-through remains source-policy coverage, not multiplayer evidence");
return true;
}
void Stop()
{
if (m_Stopped) return;
m_Stopped = true;
if (m_Listening && m_Mode) m_Mode.Players().OnChanged().Remove(Changed);
m_Listening = false;
}
int Tick(float now)
{
if (m_Stopped) return -1;
if (!Enabled() || now > m_Deadline) { Check(false, "explicit enabled fixture completes within fifteen seconds"); return -1; }
if (now < m_Wait) return 0;
if (m_Step == 0)
{
m_Controller = SCR_PlayerController.Cast(GetGame().GetPlayerController());
SCR_RespawnSystemComponent respawn = SCR_RespawnSystemComponent.GetInstance();
if (respawn) m_Logic = ERR_SpawnLogic.Cast(respawn.GetSpawnLogic());
if (!Check(RplSession.Mode() == RplMode.None && m_Controller && m_Logic && GetGame().GetPlayerManager().GetPlayerCount() == 1, "actual standalone session has one native local controller and successor spawn logic")) return -1;
m_Connection = m_Controller.GetPlayerId(); m_Actor = m_Controller.GetControlledEntity();
m_Life = m_Mode.Players().Get(m_Connection); m_Helper = m_Logic.ERR_LocalProbeHelper();
if (!Check(m_Actor && m_Life && m_Helper && m_Helper.OfflineMode() && m_Helper.Selected() && m_Helper.Validate(), "real native admission already created the local controlled prisoner and selected helper")) return -1;
m_Key = m_Life.AccountId; m_Participants = Participants();
if (!Check(!m_Key.IsNull() && Unchanged(), "existing prisoner has one spent life and no pending spawn metadata")) return -1;
if (!DisposableHelpers()) return -1;
m_Mode.Players().OnChanged().Insert(Changed); m_Listening = true;
Print("ERR LOCAL ADMISSION REPLAY: invoking existing callback twice for already admitted real controller; this does not represent new native authentication/audits");
m_Logic.OnPlayerAuditSuccess_S(m_Connection);
m_Logic.OnPlayerAuditSuccess_S(m_Connection);
if (!Check(Unchanged() && m_Changes == 0, "replayed callbacks preserve exact roster object, used life, key, actor and no roster-change event")) return -1;
m_Step = 1; m_Wait = now + 1500; return 0;
}
if (m_Step == 1)
{
if (!Check(Unchanged() && m_Changes == 0 && m_Helper.Validate(), "native update interval after replay creates no replacement actor, life or pending request")) return -1;
if (!Check(m_Logic.ERR_LocalProbeRefuseController(m_Connection), "live helper rejects invalid controller argument through production context guard")) return -1;
// The guard has synchronously entered the real terminal transition and
// stopped services. No second callback, login, UUID or actor is introduced.
if (!Check(m_Mode.Phase() == ERR_Phase.DEFEAT && m_Mode.EndReason().Contains("offline player context changed") && m_Logic.ERR_LocalProbeClosed() && !m_Helper.Validate() && Unchanged(), "production failure closes admission and ends round while preserving original used life and controlled actor")) return -1;
Stop(); m_Step = 2; return 1;
}
return 0;
}
}
#endif
@@ -0,0 +1,321 @@
#ifdef WORKBENCH
// New native-input feasibility experiment. No original Escapists flight code.
// Only the exact fixture-owned AI pilot and helicopter receive these hooks.
modded class SCR_CharacterControllerComponent
{
override void OnPrepareControls(IEntity owner, ActionManager am, float dt, bool player)
{
super.OnPrepareControls(owner, am, dt, player);
ERR_NativePoweredFlightProbe probe = ERR_NativePoweredFlightProbe.Current();
if (probe && !player) probe.PreparePilot(this, owner, dt);
}
}
modded class SCR_HelicopterControllerComponent
{
protected override event void OnPrepareControls()
{
super.OnPrepareControls();
ERR_NativePoweredFlightProbe probe = ERR_NativePoweredFlightProbe.Current();
if (probe) probe.PrepareHelicopter(this);
}
}
class ERR_PoweredFlightRules
{
static bool Authority(bool server, bool enabled, bool stopped, bool ownedPilot, bool ownedSeat, bool playerPresent, bool alive, bool fueled)
{
return server && enabled && !stopped && ownedPilot && ownedSeat && !playerPresent && alive && fueled;
}
static float ClampInput(float value, float limit = 1)
{
return Math.Clamp(value, -limit, limit);
}
static float DesiredVertical(float height, bool landing)
{
float target = 8;
if (landing) target = 0;
return Math.Clamp((target - height) * 0.6, -0.8, 1.5);
}
static bool Hover(float height, float speed, float vertical, float upright, float drift)
{
return height >= 7 && height <= 9 && speed < 1.5 && Math.AbsFloat(vertical) < 0.5 && upright > 0.98 && drift < 8;
}
static bool Landed(bool contact, float height, float speed, float angular, float upright)
{
return contact && height < 1 && height > -1 && speed < 0.5 && angular < 0.1 && upright > 0.98;
}
}
class ERR_NativePoweredFlightProbe
{
protected static ERR_NativePoweredFlightProbe s_Current;
protected ERR_GameMode m_Mode;
protected Vehicle m_Aircraft;
protected SCR_AIGroup m_Group;
protected IEntity m_Pilot;
protected HelicopterControllerComponent m_Controller;
protected CharacterControllerComponent m_Character;
protected CharacterInputContext m_Input;
protected VehicleHelicopterSimulation m_Simulation;
protected FuelManagerComponent m_Fuel;
protected PilotCompartmentSlot m_Seat;
protected CollectiveMode m_OriginalMode;
protected vector m_Home;
protected float m_Heading;
protected float m_Deadline;
protected float m_StageDeadline;
protected float m_Stable = -1;
protected float m_PreviousTick = -1;
protected float m_NextLog;
protected float m_InitialFuel;
protected float m_Collective = -1;
protected float m_LastControl;
protected int m_Stage;
protected int m_Wakes;
protected int m_StartRequests;
protected int m_ControlUpdates;
protected int m_HelicopterUpdates;
protected bool m_Bound;
protected bool m_ModeSaved;
protected bool m_Stopped;
protected bool m_Complete;
protected bool m_ReadbackOK = true;
static const ResourceName AIRCRAFT = "{DF5CCB7C0FF049F4}Prefabs/Vehicles/Helicopters/Mi8MT/Mi8MT_unarmed_transport.et";
static const ResourceName GROUP = "{8DE0C0830FE0C33D}Prefabs/Groups/OPFOR/Group_USSR_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("errBaseSmoke", enabled) && enabled == "1" && System.GetCLIParam("errSmokeOutcome", outcome) && outcome == "powered-flight";
}
static ERR_NativePoweredFlightProbe Current() { return s_Current; }
void ERR_NativePoweredFlightProbe(ERR_GameMode mode)
{
m_Mode = mode; m_Deadline = GetGame().GetWorld().GetWorldTime() + 240000;
if (Enabled() && !s_Current) s_Current = this;
else m_Stopped = true;
}
protected bool Check(bool condition, string detail)
{
if (condition) { Print("ERR SMOKE PASS: powered-flight " + detail); return true; }
Print("ERR SMOKE FAIL: powered-flight " + detail, LogLevel.ERROR); Stop(); return false;
}
protected bool PlayerPresent()
{
if (!m_Aircraft || !m_Group || m_Group.GetPlayerCount(true) > 0) return true;
array<int> ids = {}; GetGame().GetPlayerManager().GetPlayers(ids);
foreach (int id : ids)
{
IEntity actor = GetGame().GetPlayerManager().GetPlayerControlledEntity(id);
if (actor && (actor == m_Pilot || CompartmentAccessComponent.GetVehicleIn(actor) == m_Aircraft)) return true;
}
return false;
}
protected bool OwnPilot()
{
return m_Pilot && m_Group && ERR_Actors.Group(m_Pilot) == m_Group && !ERR_Actors.Player(m_Pilot);
}
protected bool PoweredAuthority()
{
bool occupied = m_Seat && m_Seat.GetOccupant() == m_Pilot && m_Controller && m_Controller.GetPilotCompartmentSlot() == m_Seat && CompartmentAccessComponent.GetVehicleIn(m_Pilot) == m_Aircraft;
bool alive = ERR_Actors.Alive(m_Aircraft) && ERR_Actors.CanFight(m_Pilot);
SCR_VehicleDamageManagerComponent damage;
if (m_Aircraft) damage = SCR_VehicleDamageManagerComponent.Cast(m_Aircraft.FindComponent(SCR_VehicleDamageManagerComponent));
alive = alive && damage && damage.GetEngineFunctional() && damage.GetMovementDamage() < 1 && !damage.IsOnFire();
if (m_Simulation)
for (int rotor = 0; rotor < m_Simulation.RotorCount(); rotor++) if (m_Simulation.RotorGetState(rotor) == RotorState.DESTROYED) alive = false;
return ERR_PoweredFlightRules.Authority(Replication.IsServer(), Enabled(), m_Stopped, OwnPilot(), occupied, PlayerPresent(), alive, m_Fuel && m_Fuel.HasFuel());
}
void PrepareHelicopter(HelicopterControllerComponent controller)
{
if (!m_Bound || controller != m_Controller || !PoweredAuthority()) return;
// Native SCR controller normally reapplies device preferences every update.
// Override only this owned fixture controller, never global/user settings.
controller.SetCollectiveMode(CollectiveMode.Direct); m_HelicopterUpdates++;
}
void PreparePilot(CharacterControllerComponent controller, IEntity owner, float dt)
{
if (!m_Bound || controller != m_Character || owner != m_Pilot || !PoweredAuthority() || !m_Input || !m_Simulation || dt <= 0 || dt > 0.2) return;
vector transform[4]; m_Simulation.GetWorldTransform(transform);
vector velocity = m_Simulation.GetVelocity();
vector angular = m_Simulation.GetAngularVelocityLocal();
float height = transform[3][1] - m_Home[1];
float forward, aside, torque;
float collective = -1;
if (m_Stage >= 4 && m_Stage <= 6 && m_Controller.IsEngineOn())
{
float desiredVertical = ERR_PoweredFlightRules.DesiredVertical(height, m_Stage == 6);
float error = desiredVertical - velocity[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);
vector offset = m_Home - transform[3]; offset[1] = 0;
vector acceleration = offset * 0.08 - velocity * 0.3; acceleration[1] = 0;
// Small local cyclic corrections counter drift/tilt through native inputs.
// Sign/gain and native Direct-mode response remain experimental evidence.
forward = ERR_PoweredFlightRules.ClampInput((vector.Dot(acceleration, transform[2]) / 9.81 + transform[2][1]) * 0.8 - angular[0] * 0.3, 0.15);
aside = ERR_PoweredFlightRules.ClampInput((vector.Dot(acceleration, transform[0]) / 9.81 + transform[0][1]) * 0.8 + angular[2] * 0.3, 0.15);
float headingError = m_Heading - m_Aircraft.GetAngles()[0];
while (headingError > 180) headingError -= 360;
while (headingError < -180) headingError += 360;
torque = ERR_PoweredFlightRules.ClampInput(headingError * 0.01 - angular[1] * 0.3, 0.3);
if (m_Stage == 6 && ERR_PoweredFlightRules.Landed(m_Simulation.HasAnyGroundContact(), height, velocity.Length(), angular.Length(), transform[1][1])) collective = -1;
}
m_Input.SetVehicleThrottle(1);
m_Input.SetVehicleCollective(collective);
m_Input.SetVehicleCyclicForward(forward);
m_Input.SetVehicleCyclicAside(aside);
m_Input.SetVehicleAntiTorque(torque);
m_ReadbackOK = m_ReadbackOK && Math.AbsFloat(m_Input.GetVehicleCollective() - collective) < 0.001 && Math.AbsFloat(m_Input.GetVehicleCyclicForward() - forward) < 0.001 && Math.AbsFloat(m_Input.GetVehicleCyclicAside() - aside) < 0.001 && Math.AbsFloat(m_Input.GetVehicleAntiTorque() - torque) < 0.001;
m_ControlUpdates++; m_LastControl = GetGame().GetWorld().GetWorldTime();
}
protected bool FindPad(out vector result)
{
vector anchor = m_Mode.ExtractionVehicle().GetOrigin();
for (int index = 0; index < 12; index++)
{
float angle = index * Math.PI / 6;
vector candidate = ERR_Actors.Ground(anchor + Vector(Math.Cos(angle) * 120, 0, Math.Sin(angle) * 120));
if (candidate[1] < 2) continue;
bool flat = true;
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);
if (Math.AbsFloat(SCR_TerrainHelper.GetTerrainY(point) - candidate[1]) > 0.8) flat = false;
}
if (!flat || !SCR_WorldTools.TraceCilinderUtil(candidate + "0 2.5 0", 15, 4, TraceFlags.ENTS)) continue;
TraceBox clearance = new TraceBox(); clearance.Start = candidate + "0 3 0"; clearance.End = candidate + "0 35 0";
clearance.Mins = "-15 -1 -15"; clearance.Maxs = "15 1 15"; clearance.Flags = TraceFlags.ENTS | TraceFlags.WORLD;
if (GetGame().GetWorld().TraceMove(clearance, null) < 1) continue;
result = candidate; return true;
}
return false;
}
protected void Diagnostic(float now)
{
if (!m_Simulation || !m_Aircraft || now < m_NextLog) return;
m_NextLog = now + 2000;
vector transform[4]; m_Simulation.GetWorldTransform(transform);
PrintFormat("ERR POWERED FLIGHT: stage=%1 height=%2 drift=%3 speed=%4 vertical=%5 up=%6 groundContact=%7 fuel=%8 engine=%9", m_Stage, transform[3][1] - m_Home[1], vector.DistanceXZ(transform[3], m_Home), m_Simulation.GetVelocity().Length(), m_Simulation.GetVelocity()[1], transform[1][1], m_Simulation.HasAnyGroundContact(), m_Fuel.GetTotalFuel(), m_Controller.IsEngineOn());
PrintFormat("ERR POWERED INPUT: pilotHooks=%1 vehicleHooks=%2 readback=%3 collective=%4 cyclicF=%5 cyclicR=%6 antiTorque=%7 rotorRPM=%8 targetRPM=%9", m_ControlUpdates, m_HelicopterUpdates, m_ReadbackOK, m_Input.GetVehicleCollective(), m_Input.GetVehicleCyclicForward(), m_Input.GetVehicleCyclicAside(), m_Input.GetVehicleAntiTorque(), m_Simulation.RotorGetRPM(0), m_Simulation.RotorGetRPMTarget(0));
}
void Stop()
{
if (m_Stopped) return;
// Input cleanup is confined to the same unclaimed test pilot/aircraft.
bool ours = m_Bound && OwnPilot() && !PlayerPresent();
m_Stopped = true; m_Bound = false;
if (s_Current == this) s_Current = null;
if (ours && m_Input)
{
m_Input.SetVehicleCollective(0); m_Input.SetVehicleThrottle(0);
m_Input.SetVehicleCyclicForward(0); m_Input.SetVehicleCyclicAside(0); m_Input.SetVehicleAntiTorque(0);
if (m_Controller && m_ModeSaved) m_Controller.SetCollectiveMode(m_OriginalMode);
}
if (m_Group && m_Group.GetPlayerCount(true) == 0) m_Group.SetPermanentLOD(-1);
// Keep real airborne/failed test actors for diagnosis and mode-owned cleanup.
// Stop never repairs, stabilizes, teleports, refuels or replaces them.
}
int Tick(float now)
{
if (m_Complete) return 1;
if (m_Stopped) return -1;
if (!Enabled() || !m_Mode || now >= m_Deadline || ERR_MissionRules.Terminal(m_Mode.Phase())) { Check(false, "server fixture active within 240-second bound"); return -1; }
if (m_PreviousTick >= 0 && (now < m_PreviousTick || now - m_PreviousTick > 1500)) m_Stable = -1;
m_PreviousTick = now;
if (m_Stage == 0)
{
if (!m_Mode.ExtractionVehicle()) return 0;
vector pad;
if (!Check(FindPad(pad), "separate clear flat test pad found; extraction vehicle untouched")) return -1;
m_Aircraft = Vehicle.Cast(ERR_Actors.Spawn(AIRCRAFT, pad));
m_Group = SCR_AIGroup.Cast(ERR_Actors.Spawn(GROUP, pad + "20 0 0"));
m_Mode.Own(m_Aircraft); m_Mode.Own(m_Group);
if (!Check(m_Aircraft && m_Group, "one stock unarmed airframe and own empty group created once")) return -1;
m_Group.SetPermanentLOD(0); m_Group.ActivateAI();
m_Controller = HelicopterControllerComponent.Cast(m_Aircraft.FindComponent(HelicopterControllerComponent));
if (m_Controller) { m_Simulation = m_Controller.GetSimulation(); m_Seat = m_Controller.GetPilotCompartmentSlot(); }
m_Fuel = FuelManagerComponent.Cast(m_Aircraft.FindComponent(FuelManagerComponent));
if (!Check(m_Simulation && m_Seat && !m_Seat.GetOccupant() && m_Fuel && m_Fuel.HasFuel(), "native flight simulation, actual active pilot seat and finite fuel present")) return -1;
AIGroup nativeGroup = m_Group;
m_Pilot = m_Seat.SpawnCharacterInCompartment(PILOT, nativeGroup, GROUP);
if (!Check(m_Pilot && nativeGroup == m_Group, "one existing vanilla character spawned through native pilot-seat mechanism")) return -1;
m_Mode.Own(m_Pilot);
m_Stage = 1; m_StageDeadline = now + 30000; return 0;
}
if (m_Stage == 1)
{
Physics physics = m_Aircraft.GetPhysics();
if (physics && physics.IsDynamic() && m_Simulation.IsValid() && m_Wakes == 0) { m_Wakes = 1; physics.SetActive(ActiveState.ACTIVE); m_Stable = -1; return 0; }
if (now >= m_StageDeadline) { Check(false, "initial native seating and ground settling complete in 30 seconds"); return -1; }
if (!m_Wakes || !OwnPilot() || m_Seat.GetOccupant() != m_Pilot || !ERR_CasualtyLoadingRules.AircraftReady(m_Aircraft)) { m_Stable = -1; return 0; }
if (m_Stable < 0) m_Stable = now;
if (now - m_Stable < 1000) return 0;
m_Character = CharacterControllerComponent.Cast(m_Pilot.FindComponent(CharacterControllerComponent));
if (m_Character) m_Input = m_Character.GetInputContext();
if (!Check(m_Input && m_Group.GetAgentsCount() == 1 && PoweredAuthority(), "one living native AI pilot physically occupies its own fueled helicopter")) return -1;
m_Home = m_Aircraft.GetOrigin(); m_Heading = m_Aircraft.GetAngles()[0];
m_OriginalMode = m_Controller.GetCollectiveMode(); m_ModeSaved = true;
m_InitialFuel = m_Fuel.GetTotalFuel(); m_Bound = true;
m_Stage = 2; m_StageDeadline = now + 5000; return 0;
}
Diagnostic(now);
if (!PoweredAuthority() || !m_ReadbackOK) { Check(false, "current owned conscious pilot, fuel, rotors and exact input readback remain valid"); return -1; }
if (m_Stage == 2)
{
if (now >= m_StageDeadline) { Check(false, "native AI input and helicopter preparation hooks execute before engine start"); return -1; }
if (m_ControlUpdates < 3 || m_HelicopterUpdates < 3) return 0;
if (!Check(m_Controller.GetCollectiveMode() == CollectiveMode.Direct && m_Input.GetVehicleCollective() == -1, "owned native input hooks apply minimum collective in Direct mode")) return -1;
m_StartRequests = 1;
bool started = m_Controller.StartEngine();
PrintFormat("ERR POWERED ENGINE REQUEST: StartEngine returned=%1; normal procedure, no force-start or simulation EngineStart", started);
m_Stage = 3; m_StageDeadline = now + 65000; return 0;
}
if (now - m_LastControl > 1000) { Check(false, "native control callback remains live during test flight"); return -1; }
vector transform[4]; m_Simulation.GetWorldTransform(transform);
float height = transform[3][1] - m_Home[1];
float speed = m_Simulation.GetVelocity().Length();
float drift = vector.DistanceXZ(transform[3], m_Home);
if (height > 18 || height < -3 || drift > 22 || transform[1][1] < 0.85) { Check(false, "physical test stays inside height, drift and tilt safety envelope"); return -1; }
if (m_Stage == 3)
{
if (now >= m_StageDeadline) { Check(false, "normal engine startup and native rotor RPM complete within 65 seconds"); return -1; }
float targetRPM = m_Simulation.RotorGetRPMTarget(0);
if (!m_Controller.IsEngineOn() || targetRPM <= 0 || m_Simulation.RotorGetRPM(0) < targetRPM * 0.9 || m_Fuel.GetTotalFuel() >= m_InitialFuel) return 0;
if (!Check(m_StartRequests == 1 && m_Fuel.GetTotalFuel() < m_InitialFuel, "single normal engine start spins physical rotor and consumes native fuel")) return -1;
m_Collective = -0.8; m_Stage = 4; m_StageDeadline = now + 65000; m_Stable = -1; return 0;
}
if (m_Stage >= 4 && m_Stage <= 6 && !m_Controller.IsEngineOn()) { Check(false, "powered flight retains real running engine"); return -1; }
if (m_Stage == 4 || m_Stage == 5)
{
bool hover = !m_Simulation.HasAnyGroundContact() && ERR_PoweredFlightRules.Hover(height, speed, m_Simulation.GetVelocity()[1], transform[1][1], drift);
if (!hover) m_Stable = -1;
if (hover && m_Stable < 0) m_Stable = now;
if (now >= m_StageDeadline) { Check(false, "actual powered takeoff and five-second stable hover reached in 65 seconds"); return -1; }
if (m_Stable >= 0 && now - m_Stable >= 5000)
{
if (!Check(m_ControlUpdates > 100 && m_Fuel.GetTotalFuel() < m_InitialFuel - 0.05, "actual 7–9 metre powered hover stable five seconds with native fuel consumption")) return -1;
m_Stage = 6; m_StageDeadline = now + 65000; m_Stable = -1;
}
return 0;
}
if (m_Stage == 6)
{
bool landed = ERR_PoweredFlightRules.Landed(m_Simulation.HasAnyGroundContact(), height, speed, m_Simulation.GetAngularVelocity().Length(), transform[1][1]);
if (!landed) m_Stable = -1;
if (landed && m_Stable < 0) m_Stable = now;
if (now >= m_StageDeadline) { Check(false, "actual native contact landing reached within 65 seconds"); return -1; }
if (m_Stable < 0 || now - m_Stable < 2000) return 0;
if (!Check(drift < 8 && m_Fuel.GetTotalFuel() < m_InitialFuel - 0.05 && m_Wakes == 1 && m_StartRequests == 1, "same physical aircraft and living AI pilot land with stable ground contact after powered hover")) return -1;
m_Controller.StopEngine(false);
m_Stage = 7; m_StageDeadline = now + 15000; return 0;
}
if (m_Stage == 7)
{
if (now >= m_StageDeadline) { Check(false, "normal engine shutdown completed on actual ground"); return -1; }
if (m_Controller.IsEngineOn()) return 0;
if (!Check(ERR_PoweredFlightRules.Landed(m_Simulation.HasAnyGroundContact(), height, speed, m_Simulation.GetAngularVelocity().Length(), transform[1][1]) && PoweredAuthority(), "native engine off after contact landing; no transform/velocity/health/fuel correction")) return -1;
Stop(); m_Complete = true; return 1;
}
return 0;
}
}
#endif
@@ -67,7 +67,7 @@ modded class SCR_BaseItemSupportStationComponent
ownerName = owner.GetName();
if (owner.GetPrefabData()) prefab = owner.GetPrefabData().GetPrefabName();
}
SCR_EntityCatalogManagerComponent catalog = SCR_EntityCatalogManagerComponent.GetInstance();
bool globalCatalogPresent = SCR_EntityCatalogManagerComponent.GetInstance() != null;
SCR_BaseGameMode baseMode;
if (GetGame()) baseMode = SCR_BaseGameMode.Cast(GetGame().GetGameMode());
int gameState = -1;
@@ -80,7 +80,7 @@ modded class SCR_BaseItemSupportStationComponent
ERR_GameMode mode = ERR_GameMode.Cast(baseMode);
if (mode) phase = mode.Phase();
}
string detail = string.Format("ERR STATION TEARDOWN DIAG: n=%1 owner=%2 prefab=%3 cachedCatalog=%4 globalCatalog=%5", s_ERRStationTeardownLines, ownerName, prefab, m_EntityCatalogManager != null, catalog != null);
string detail = string.Format("ERR STATION TEARDOWN DIAG: n=%1 owner=%2 prefab=%3 cachedCatalog=%4 globalCatalog=%5", s_ERRStationTeardownLines, ownerName, prefab, m_EntityCatalogManager != null, globalCatalogPresent);
detail += string.Format(" modePresent=%1 gameState=%2 errPhase=%3 endData=%4 call=OnDelete/InitValidSetup", baseMode != null, gameState, phase, hasEndData);
detail += string.Format(" initSeen=%1 initMode=%2 initMainWorld=%3 initSiteOwned=%4 initInInventory=%5 initParentPrefab=%6", m_ERRStationInitSeen, m_ERRStationInitModePresent, m_ERRStationInitMainWorld, m_ERRStationInitSiteOwned, m_ERRStationInitInInventory, m_ERRStationInitParentPrefab);
bool deleteMainWorld;
@@ -111,4 +111,20 @@ modded class ArmaReforgerScripted
super.OnBeforeWorldCleanup();
}
}
// Observe normal finalized admission; this never creates a body or identity.
modded class ERR_GameMode
{
override void PlayerSpawned(int id, IEntity actor)
{
super.PlayerSpawned(id, actor);
string project;
if (!System.GetCLIParam("errOrdinaryScenario", project) || (project != "core" && project != "chat")) return;
PlayerController local = GetGame().GetPlayerController();
if (!local || local.GetPlayerId() != id || local.GetControlledEntity() != actor || !ERR_Actors.Alive(actor)) return;
if (!Players().CurrentActor(actor) || Phase() != ERR_Phase.PRISON) return;
PrintFormat("ERR ORDINARY: local participant verified project=%1 phase=prison", project);
}
}
#endif
+55 -4
View File
@@ -10,6 +10,8 @@ class ERR_SpawnLogic : SCR_SpawnLogic
protected ref array<ref ERR_SpawnTicket> m_SpawnTickets = {};
protected ERR_GameMode m_LifecycleMode;
protected bool m_AdmissionsStopped;
protected ref ERR_LocalAdmission m_LocalAdmission;
protected bool m_LocalAdmissionFailed;
protected void BindLifecycle(ERR_GameMode mode)
{
@@ -18,11 +20,17 @@ class ERR_SpawnLogic : SCR_SpawnLogic
{
m_LifecycleMode.OnPhaseChanged().Remove(AdmissionPhaseChanged);
m_LifecycleMode.OnStop().Remove(StopAdmissions);
m_LifecycleMode.OnTick().Remove(CheckLocalAdmissionTick);
}
if (m_LocalAdmission && m_LocalAdmission.OfflineMode()) { StopAdmissions(); m_Identities.Clear(); }
if (m_LocalAdmission) m_LocalAdmission.Stop();
m_LocalAdmission = new ERR_LocalAdmission(mode);
m_LocalAdmissionFailed = false;
m_AdmissionsStopped = false;
m_LifecycleMode = mode;
mode.OnPhaseChanged().Insert(AdmissionPhaseChanged);
mode.OnStop().Insert(StopAdmissions);
mode.OnTick().Insert(CheckLocalAdmissionTick);
}
override void OnPlayerAuditSuccess_S(int playerId)
@@ -40,20 +48,47 @@ class ERR_SpawnLogic : SCR_SpawnLogic
ERR_GameMode mode = ERR_GameMode.Get();
BindLifecycle(mode);
SCR_PlayerController resultController = SCR_PlayerController.Cast(GetGame().GetPlayerManager().GetPlayerController(playerId));
if (resultController) resultController.ERR_ResetEscapeResult();
if (resultController && (!m_LocalAdmission || !m_LocalAdmission.OfflineMode())) resultController.ERR_ResetEscapeResult();
#ifndef WORKBENCH
string diagnosticProject;
if (System.GetCLIParam("errOrdinaryScenario", diagnosticProject))
if (System.GetCLIParam("errOrdinaryScenario", diagnosticProject) && (diagnosticProject == "core" || diagnosticProject == "chat"))
{
PrintFormat("ERR ORDINARY ADMISSION: server=%1 sessionMode=%2 modePresent=%3 controllerPresent=%4 identityPresent=%5 nameLength=%6", Replication.IsServer(), RplSession.Mode(), mode != null, resultController != null, !identity.IsNull(), GetGame().GetPlayerManager().GetPlayerName(playerId).Length());
// Read cached native identity metadata only. Never print identity/name bytes,
// override cached backend identity or change its native utility.
string rawIdentity;
int rawLength = -1;
BackendApi backend = GetGame().GetBackendApi();
if (backend)
{
rawIdentity = backend.GetPlayerIdentityId(playerId);
rawLength = rawIdentity.Length();
}
UUID rawUUID = rawIdentity;
PrintFormat("ERR ORDINARY IDENTITY: rawLength=%1 rawEmpty=%2 rawNull=%3 rawFormatValid=%4 rawNullSentinel=%5 helperLength=%6 helperFormatValid=%7 dedicated=%8", rawLength, rawIdentity.IsEmpty(), rawUUID.IsNull(), UUID.IsUUID(rawIdentity), rawIdentity == UUID.NULL_UUID, identity.Length(), UUID.IsUUID(identity), RplSession.Mode() == RplMode.Dedicated);
}
#endif
if (m_LocalAdmission) identity = m_LocalAdmission.Resolve(mode, playerId, resultController, identity);
if (!CheckLocalAdmissionContext()) return;
// Repeated real callbacks retain the existing attempt/body/life and result.
if (m_LocalAdmission && m_LocalAdmission.Selected() && m_Identities.Contains(playerId))
{
if (m_Identities.Get(playerId) != identity) { m_LocalAdmission.Disconnected(playerId); CheckLocalAdmissionContext(); return; }
if (resultController) resultController.ERR_PublishEscapeResult(mode.Players(), mode.Extraction());
return;
}
if (!mode || !mode.Players().Bind(playerId, identity))
{
Print("Rereintroduced: player identity could not be bound; spawn withheld", LogLevel.ERROR);
return;
}
if (resultController) resultController.ERR_PublishEscapeResult(mode.Players(), mode.Extraction());
if (resultController)
{
if (m_LocalAdmission && m_LocalAdmission.OfflineMode()) resultController.ERR_ResetEscapeResult();
resultController.ERR_PublishEscapeResult(mode.Players(), mode.Extraction());
}
m_Identities.Set(playerId, identity);
if (ResolveReconnection(playerId))
if ((!m_LocalAdmission || !m_LocalAdmission.LocalKey()) && ResolveReconnection(playerId))
{
mode.PlayerSpawned(playerId, GetGame().GetPlayerManager().GetPlayerControlledEntity(playerId));
SCR_PlayerController.Cast(GetGame().GetPlayerManager().GetPlayerController(playerId)).ERR_SetObserver(ERR_ObserverReason.NONE);
@@ -68,6 +103,7 @@ class ERR_SpawnLogic : SCR_SpawnLogic
ERR_GameMode mode = ERR_GameMode.Get();
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerManager().GetPlayerController(playerId));
if (!controller || !mode || m_AdmissionsStopped || !m_Identities.Contains(playerId) || !m_Attempts.Contains(playerId) || m_Pending.Contains(playerId)) return;
if (!CheckLocalAdmissionContext()) return;
if (controller.GetControlledEntity()) return;
if (ERR_MissionRules.Terminal(mode.Phase())) return;
if (!mode.PreparationError().IsEmpty()) { Observe(playerId, ERR_ObserverReason.SPAWN_UNAVAILABLE); return; }
@@ -150,6 +186,7 @@ class ERR_SpawnLogic : SCR_SpawnLogic
{
ERR_GameMode mode = ERR_GameMode.Get();
if (!Replication.IsServer() || m_AdmissionsStopped || !mode || !ticket || ticket.Mode != mode || !ticket.Actor || !ticket.Request || !ticket.Controller) return false;
if (!CheckLocalAdmissionContext()) return false;
ERR_PlayerLife life = mode.Players().Get(ticket.ConnectionId);
if (!life || !m_Identities.Contains(ticket.ConnectionId)) return false;
PlayerController controller = GetGame().GetPlayerManager().GetPlayerController(ticket.ConnectionId);
@@ -197,10 +234,21 @@ class ERR_SpawnLogic : SCR_SpawnLogic
CancelAdmission(playerId, reason);
}
}
protected bool CheckLocalAdmissionContext()
{
if (!m_LocalAdmission || m_LocalAdmission.Validate()) return true;
if (m_LocalAdmissionFailed || m_AdmissionsStopped) return false;
m_LocalAdmissionFailed = true;
StopAdmissions();
if (m_LifecycleMode) m_LifecycleMode.ERR_FailLocalAdmission();
return false;
}
protected void CheckLocalAdmissionTick(float now) { CheckLocalAdmissionContext(); }
protected void StopAdmissions()
{
if (!Replication.IsServer()) return;
m_AdmissionsStopped = true;
if (m_LocalAdmission) m_LocalAdmission.Stop();
array<int> pending = {};
foreach (int id, vector position : m_AuthorizedPositions) pending.Insert(id);
foreach (ERR_SpawnTicket ticket : m_SpawnTickets)
@@ -277,6 +325,7 @@ class ERR_SpawnLogic : SCR_SpawnLogic
}
override void OnPlayerDisconnected_S(int playerId, KickCauseCode cause, int timeout)
{
if (m_LocalAdmission && m_LocalAdmission.Disconnected(playerId)) CheckLocalAdmissionContext();
CancelAdmission(playerId, ERR_ObserverReason.NONE);
m_Identities.Remove(playerId);
ERR_GameMode mode = ERR_GameMode.Get();
@@ -287,6 +336,7 @@ class ERR_SpawnLogic : SCR_SpawnLogic
{
ERR_GameMode mode = ERR_GameMode.Get();
if (!Replication.IsServer() || m_AdmissionsStopped || !mode || !data || !m_Identities.Contains(playerId) || !mode.Players().CanEnter(playerId, mode.Phase())) return false;
if (!CheckLocalAdmissionContext()) return false;
ERR_PlayerLife life = mode.Players().Get(playerId);
if (!life || life.AccountId != m_Identities.Get(playerId)) return false;
if (!m_Pending.Contains(playerId) || !m_AuthorizedPositions.Contains(playerId) || data.GetPrefab() != mode.Factions().PrisonerPrefab) return false;
@@ -300,6 +350,7 @@ class ERR_SpawnLogic : SCR_SpawnLogic
{
m_LifecycleMode.OnPhaseChanged().Remove(AdmissionPhaseChanged);
m_LifecycleMode.OnStop().Remove(StopAdmissions);
m_LifecycleMode.OnTick().Remove(CheckLocalAdmissionTick);
}
GetGame().GetCallqueue().Remove(RetrySpawn);
GetGame().GetCallqueue().Remove(ObserveAfterLoss);