331 lines
19 KiB
C
331 lines
19 KiB
C
#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
|