// 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; vector Position; vector Impact; vector AimOffset; int AimedShot = -1; float FlightTime; float AimedAt = -1; 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 (!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 ""; } void BeginRequest(ERR_HQReport report, float now) { 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))); } 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; 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 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; array items = {}; inventory.GetItems(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; } protected int InventoryShells(InventoryStorageManagerComponent inventory) { if (!inventory) return 0; int count; array items = {}; inventory.GetItems(items); foreach (IEntity item : items) { if (item && item.GetPrefabData() && item.GetPrefabData().GetPrefabName() == SHELL) count++; } return count; } protected void Equip(IEntity soldier) { 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))); } bool SupplyGunner() { if (!Operational()) return SupplyFailed(OperationalReason()); 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 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"); InventoryStorageManagerComponent destination = InventoryStorageManagerComponent.Cast(gunner.FindComponent(InventoryStorageManagerComponent)); if (!destination) return SupplyFailed("gunner inventory missing"); if (InventoryShells(destination) > 0) return true; int nearbyDonors, donorShells, storageRejected, moveRejected; 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 = {}; inventory.GetItems(items); foreach (IEntity item : items) { if (!item || !item.GetPrefabData() || item.GetPrefabData().GetPrefabName() != SHELL) continue; 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++; } } 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)); } 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; Report = null; AimedAt = -1; 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 (!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 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 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)); 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) { 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))); } }