// Migrated from the independently authored AI extension; successor-owned physical groups. // Each site/phase has its own repeat limiter. Changed reasons and lifecycle // events are immediate; only identical polling results are summarized. class ERR_MortarTraceState { string LastReason; float NextLog; int Suppressed; bool Due(string reason, float now) { if (reason != LastReason || now >= NextLog) { LastReason = reason; NextLog = now + 30000; return true; } Suppressed++; return false; } } class ERR_MortarBattery { static const ResourceName GUN = "{D1FFE458E8AC4BDB}Prefabs/Weapons/Mortars/2B14/Mortar_2B14.et"; static const ResourceName SHELL = "{98EC9C526AFBA282}Prefabs/Weapons/Ammo/Ammo_Shell_82mm_HE_O832DU.et"; static const ResourceName CREW = "{86E565387A634103}Prefabs/Characters/Campaign/Final/OPFOR/USSR_Army/Regular/Campaign_USSR_Player_MortarCrew.et"; static const ResourceName RADIO_OPERATOR = "{612F43A4D5AE765F}Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_RTO.et"; IEntity RadioOperator; IEntity Gun; SCR_AIGroup Group; ref SCR_AIStaticArtilleryActivity Activity; ref ERR_HQReport Report; ref ERR_HQRoute RadioOrder; ref ERR_HQReport PendingRadioReport; ref ERR_HQReport PendingSupplyReport; protected ref ERR_MortarReadiness m_Readiness; protected ref ERR_MortarSupplyTransfer m_Transfer; protected ref array 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; float OrderedAt; float NextOrder; float SpawnedAt; bool StartupActivated; bool Released; bool Rejected; bool Claimed; // keeps the world slot occupied after the battery is destroyed int SiteId; int OrderId; int RadioId; int SourceId; int OrderShots; int TotalShots; bool CommandOpen; string LastFailure; string SpawnFailure; vector LastCandidate; ref map SearchCounts = new map(); ref map Traces = new map(); void Audit(string kind, string detail) { ERR_HQ.Event("MORTAR " + kind, string.Format("site=%1 order=%2 radio=M%3 source=M%4 position=%5; %6", SiteId, OrderId, RadioId, SourceId, Position, detail), RadioId); } void Trace(string phase, string reason, string detail) { ERR_MortarTraceState state; if (!Traces.Find(phase, state)) { state = new ERR_MortarTraceState(); Traces.Insert(phase, state); } string previous = state.LastReason; if (!state.Due(reason, GetGame().GetWorld().GetWorldTime())) return; Audit(phase, string.Format("reason=%1; %2; previous=%3 repeated polls omitted=%4", reason, detail, previous, state.Suppressed)); state.Suppressed = 0; } void FlushTraces() { foreach (string phase, ERR_MortarTraceState state : Traces) { if (state.Suppressed > 0) Audit("REPEATS", string.Format("phase=%1 reason=%2 repeated polls omitted=%3", phase, state.LastReason, state.Suppressed)); } Traces.Clear(); } string OperationalReason() { if (m_Stopped) return "battery stopped"; if (!Gun) return "gun missing"; if (!SpawnFailure.IsEmpty()) return SpawnFailure; if (!Group || !Group.GetGroupUtilityComponent()) return "crew group missing"; if (Group.GetFaction() != ERR_GameMode.Get().EnemyFaction()) return "crew changed faction"; if (Group.GetPlayerCount(true) > 0) return "player in crew group"; if (Group.GetAgentsCount() == 0) return "crew group empty"; DamageManagerComponent damage = DamageManagerComponent.Cast(Gun.FindComponent(DamageManagerComponent)); if (damage && damage.IsDestroyed()) return "gun destroyed"; return ""; } bool CanRequest(ERR_HQReport report, float now) { return !m_Stopped && ERR_MortarPrediction.Fresh(report, now) && m_ReportLedger.Available(report.MessageId, report.ObservedAt, now); } bool BeginRequest(ERR_HQReport report, float now) { if (!CanRequest(report, now) || !m_ReportLedger.Claim(report.MessageId, report.ObservedAt, now)) return false; Cancel("superseded by new HQ order"); FlushTraces(); OrderId++; RadioId = 0; SourceId = report.MessageId; OrderShots = 0; Released = false; CommandOpen = true; OrderedAt = now; LastFailure = ""; Audit("COMMAND", string.Format("requested rounds=%1 HE radius=%2m; %3", ERR_MortarSalvo.ROUNDS, ERR_MortarSalvo.RADIUS_METRES, report.Description(now))); return true; } void ObserveProjectile(IEntity projectileEntity, IEntity firingCharacter, bool playerGunner) { if (!projectileEntity) { Audit("FIRE CALLBACK", "native OnWeaponFired had no projectile; no confirmed shot counted"); return; } TotalShots++; if (CommandOpen && Released && !playerGunner) { OrderShots++; // Only a physical projectile unlocks the next load. It must be aimed again. Released = false; AimedAt = -1; AimSolver = null; AimSolution = null; Audit("FIRED", string.Format("physical projectile=%1 origin=%2 order shots=%3 total shots=%4 predicted impact=%5 predicted flight=%6s gunner=%7 (impact not observed)", projectileEntity, projectileEntity.GetOrigin(), OrderShots, TotalShots, Impact, FlightTime, firingCharacter)); } else Audit("FIRED UNASSIGNED", string.Format("physical projectile=%1 origin=%2 player gunner=%3 order open=%4 committed=%5 total shots=%6; no active HQ order attribution", projectileEntity, projectileEntity.GetOrigin(), playerGunner, CommandOpen, Released, TotalShots)); } void ReserveShellAirspace(float now) { // Enclose every still-airborne shell, including an earlier, longer flight. // A later round must not move/shrink the existing exclusion or deadline. if (now >= AirspaceUntil) { AirspaceImpact = Impact; AirspaceRadius = 600; } AirspaceRadius = Math.Max(AirspaceRadius, vector.DistanceXZ(AirspaceImpact, Impact) + 600); AirspaceUntil = Math.Max(AirspaceUntil, now + (FlightTime + 10) * 1000); } bool SpawnFailed(string reason) { SpawnFailure = reason; Audit("DEPLOY FAILED", string.Format("reason=%1 claimed=%2 gun=%3 group=%4", reason, Claimed, Gun, Group)); return false; } void SearchResult(vector candidate, string reason) { LastCandidate = candidate; int count; SearchCounts.Find(reason, count); SearchCounts.Set(reason, count + 1); } void SiteStatus(float now) { if (!SearchCounts.IsEmpty()) { string counts; foreach (string reason, int count : SearchCounts) counts += string.Format("%1=%2; ", reason, count); Audit("SITE SEARCH", string.Format("candidate counts since last summary: %1 last candidate=%2", counts, LastCandidate)); SearchCounts.Clear(); } int agents, living; int actionState = -1; if (Group) { array crew = {}; Group.GetAgents(crew); agents = crew.Count(); foreach (AIAgent agent : crew) { if (agent && ERR_Actors.CanFight(agent.GetControlledEntity())) living++; } } if (Activity) actionState = Activity.GetActionState(); bool connected; ERR_HQ hq = ERR_HQ.Get(); if (hq && Group) connected = hq.MortarConnected(Group); Audit("SITE STATUS", string.Format("claimed=%1 startup=%2 operational=%3 crew agents=%4 fighting=%5 inventory HE=%6 radio connected=%7 cooldown remaining=%8s", Claimed, StartupActivated, Operational(), agents, living, CountShells(), connected, Math.Max(0, NextOrder - now) / 1000)); Audit("ORDER STATUS", string.Format("open=%1 route pending=%2 native action=%3 shell committed=%4 order shots=%5 total shots=%6 age=%7s last failure=%8", CommandOpen, RadioOrder != null, actionState, Released, OrderShots, TotalShots, Math.Max(0, now - OrderedAt) / 1000, LastFailure)); if (!SpawnFailure.IsEmpty()) Audit("PARTIAL DEPLOYMENT", SpawnFailure); if (!Operational()) Trace("SITE BLOCK", OperationalReason(), "spawn failure=" + SpawnFailure); } bool Operational() { return OperationalReason().IsEmpty(); } int CountShells() { if (!Group) return 0; int count; array items = {}; array agents = {}; Group.GetAgents(agents); foreach (AIAgent agent : agents) { if (!agent || !ERR_Actors.CanFight(agent.GetControlledEntity())) continue; InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(agent.GetControlledEntity().FindComponent(InventoryStorageManagerComponent)); if (!inventory) continue; ERR_Inventory.AppendUnique(inventory, items); } foreach (IEntity item : items) if (item && item.GetPrefabData() && item.GetPrefabData().GetPrefabName() == SHELL) count++; return count; } void Bind(int siteId, SCR_AIGroup group, IEntity gun, float now) { SiteId = siteId; Group = group; Gun = gun; Position = gun.GetOrigin(); SpawnedAt = now; Claimed = true; m_Readiness = new ERR_MortarReadiness(this); } int InventoryShells(InventoryStorageManagerComponent inventory) { if (!inventory) return 0; int count; array items = {}; ERR_Inventory.Items(inventory, items); foreach (IEntity item : items) { if (item && item.GetPrefabData() && item.GetPrefabData().GetPrefabName() == SHELL) count++; } return count; } bool Stopped() { return m_Stopped; } bool SupplyPending() { return m_Transfer && m_Transfer.Unresolved(); } bool SupplyAuthorized(float now) { if (!Replication.IsServer() || m_Stopped || !StartupActivated || !Operational() || !CommandOpen || !ERR_HQ.MissionActive()) return false; ERR_MortarSystem system = ERR_MortarSystem.Get(); if (!system || system.FindGun(Gun) != this) return false; ERR_HQReport suppliedReport = Report; if (!suppliedReport) suppliedReport = PendingSupplyReport; return ERR_MortarPrediction.Fresh(suppliedReport, now); } void Stop() { if (m_Stopped) return; m_Stopped = true; Cancel("HQ stopped"); if (m_Readiness) m_Readiness.Stop(); } ERR_MortarSupplyState SupplyState(float now) { // Observe an existing native operation even after its order was canceled. // No second request may race an unresolved transfer on this battery. if (m_Transfer) { ERR_MortarSupplyState observed = m_Transfer.Poll(now); if (m_Transfer.CapturedOrder() == OrderId) { if (observed == ERR_MortarSupplyState.READY) m_Transfer = null; return observed; } if (m_Transfer.Unresolved()) return ERR_MortarSupplyState.FAILED; m_Transfer = null; } if (!SupplyAuthorized(now)) return SupplyStateFailed("current HQ order, crew or report is unavailable"); SCR_AIVehicleUsageComponent usage = SCR_AIVehicleUsageComponent.Cast(Gun.FindComponent(SCR_AIVehicleUsageComponent)); IEntity gunner; if (usage && usage.GetTurretCompartmentSlot()) gunner = usage.GetTurretCompartmentSlot().GetOccupant(); array agents = {}; Group.GetAgents(agents); float nearest = float.MAX; if (!gunner) { foreach (AIAgent candidate : agents) { if (!candidate || candidate.GetControlledEntity() == RadioOperator || !ERR_Actors.CanFight(candidate.GetControlledEntity())) continue; float distance = vector.DistanceXZ(Gun.GetOrigin(), candidate.GetControlledEntity().GetOrigin()); if (distance < nearest) { nearest = distance; gunner = candidate.GetControlledEntity(); } } } if (!gunner) return SupplyStateFailed("no fighting gunner found"); if (ERR_Actors.Group(gunner) != Group) return SupplyStateFailed("turret occupant belongs to another group"); if (!ERR_Actors.CanFight(gunner)) return SupplyStateFailed("gunner cannot fight"); InventoryStorageManagerComponent destination = InventoryStorageManagerComponent.Cast(gunner.FindComponent(InventoryStorageManagerComponent)); if (!destination) return SupplyStateFailed("gunner inventory missing"); if (InventoryShells(destination) > 0) return ERR_MortarSupplyState.READY; int nearbyDonors, donorShells, storageRejected; array consideredShells = {}; foreach (AIAgent bearer : agents) { if (!bearer || !ERR_Actors.CanFight(bearer.GetControlledEntity())) continue; IEntity donor = bearer.GetControlledEntity(); if (donor == gunner || vector.DistanceXZ(donor.GetOrigin(), gunner.GetOrigin()) > 12) continue; InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(donor.FindComponent(InventoryStorageManagerComponent)); if (!inventory) continue; nearbyDonors++; array items = {}; ERR_Inventory.Items(inventory, items); foreach (IEntity item : items) { if (!item || !item.GetPrefabData() || item.GetPrefabData().GetPrefabName() != SHELL) continue; if (consideredShells.Contains(item) || m_AttemptedShells.Contains(item)) continue; consideredShells.Insert(item); donorShells++; BaseInventoryStorageComponent storage = destination.FindStorageForItem(item, EStoragePurpose.PURPOSE_DEPOSIT); if (!storage) { storageRejected++; continue; } m_AttemptedShells.Insert(item); // even rejected/ambiguous requests are never retried m_Transfer = new ERR_MortarSupplyTransfer(this, donor, gunner, item, storage, now); if (!m_Transfer.Begin(now)) return SupplyStateFailed("exact shell transfer request rejected; no retry"); Audit("AMMO TRANSFER", string.Format("one exact-item move pending donor=%1 gunner=%2 shell=%3 order=%4; awaiting callback AND physical destination", donor, gunner, item, OrderId)); return ERR_MortarSupplyState.PENDING; } } return SupplyStateFailed(string.Format("gunner empty; fighting donors within 12m=%1 unattempted HE=%2 destination storage rejected=%3", nearbyDonors, donorShells, storageRejected)); } // Compatibility for existing native callers; request acceptance is NOT ready. bool SupplyGunner() { return SupplyState(GetGame().GetWorld().GetWorldTime()) == ERR_MortarSupplyState.READY; } protected ERR_MortarSupplyState SupplyStateFailed(string reason) { SupplyFailed(reason); return ERR_MortarSupplyState.FAILED; } bool SupplyFailed(string reason) { LastFailure = reason; Audit("AMMO FAILED", reason); return false; } void Cancel(string reason = "cancel requested") { if (CommandOpen) { FlushTraces(); Audit("ORDER END", string.Format("reason=%1 observed shots=%2 shell committed=%3 elapsed=%4s last failure=%5", reason, OrderShots, Released, (GetGame().GetWorld().GetWorldTime() - OrderedAt) / 1000, LastFailure)); } CommandOpen = false; 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; } // Idle distant batteries still report through HQ's scheduler. Their native // AI needs full detail while firing or when players can interact with them. protected void UpdateCrewDetail(float now, bool firing = false) { if (!Group || Group.GetPlayerCount(true) > 0) return; bool active = firing || Activity != null || ERR_MortarSystem.NearPlayer(Position); int lod = -1; if (active) lod = 0; if (Group.GetPermanentLOD() != lod) Group.SetPermanentLOD(lod); if (active && !Group.IsAIActivated()) Group.ActivateAI(); array agents = {}; Group.GetAgents(agents); foreach (AIAgent agent : agents) { if (!agent || EntityUtils.IsPlayer(agent.GetControlledEntity())) continue; if (agent.GetPermanentLOD() != lod) agent.SetPermanentLOD(lod); if (active && !agent.IsAIActivated() && ERR_Actors.CanFight(agent.GetControlledEntity())) agent.ActivateAI(); } } void Update(float now) { if (m_Stopped) return; if (!StartupActivated) { if (!SpawnFailure.IsEmpty()) return; if (!m_Readiness) { SpawnFailed("initial support readiness owner missing"); return; } ERR_MortarSupplyState startup = m_Readiness.Poll(now); if (startup != ERR_MortarSupplyState.READY) return; StartupActivated = true; } if (m_Transfer) m_Transfer.Poll(now); if (!Operational()) { Cancel(OperationalReason()); return; } if (Activity) { Trace("ACTIVITY", string.Format("native state=%1", Activity.GetActionState()), string.Format("age=%1s aim age=%2s committed=%3 shots=%4", (now - OrderedAt) / 1000, (now - AimedAt) / 1000, Released, OrderShots)); if (Activity.GetActionState() == EAIActionState.FAILED) Cancel("native artillery activity FAILED"); else if (Activity.GetActionState() == EAIActionState.COMPLETED) Cancel("native artillery activity COMPLETED (shot count is separate)"); else if (now - OrderedAt > ERR_MortarSalvo.TIMEOUT_MS) Cancel("120s artillery salvo timeout"); } if (StartupActivated) UpdateCrewDetail(now); } void Order(ERR_HQReport report, float now) { if (m_Stopped || !StartupActivated || !Operational() || !CommandOpen || Activity || !ERR_MortarPrediction.Fresh(report, now)) return; Report = report.Snapshot(); OrderedAt = now; NextOrder = now + 30000; Released = false; UpdateCrewDetail(now, true); Activity = new SCR_AIStaticArtilleryActivity(Group.GetGroupUtilityComponent(), null, report.Position, SCR_EAIArtilleryAmmoType.HIGH_EXPLOSIVE, ERR_MortarSalvo.ROUNDS); Group.GetGroupUtilityComponent().AddAction(Activity); Audit("AIM", string.Format("native activity created; latest source=M%1 observed=%2", report.MessageId, report.Description(now))); } }