461 lines
16 KiB
C
461 lines
16 KiB
C
// 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<string, int> SearchCounts = new map<string, int>();
|
|
ref map<string, ref ERR_MortarTraceState> Traces = new map<string, ref ERR_MortarTraceState>();
|
|
|
|
void Audit(string kind, string detail)
|
|
{
|
|
ERR_HQ.Event("MORTAR " + kind, string.Format("site=%1 order=%2 radio=M%3 source=M%4 position=%5; %6", SiteId, OrderId, RadioId, SourceId, Position, detail), RadioId);
|
|
}
|
|
|
|
void Trace(string phase, string reason, string detail)
|
|
{
|
|
ERR_MortarTraceState state;
|
|
if (!Traces.Find(phase, state))
|
|
{
|
|
state = new ERR_MortarTraceState();
|
|
Traces.Insert(phase, state);
|
|
}
|
|
string previous = state.LastReason;
|
|
if (!state.Due(reason, GetGame().GetWorld().GetWorldTime()))
|
|
return;
|
|
Audit(phase, string.Format("reason=%1; %2; previous=%3 repeated polls omitted=%4", reason, detail, previous, state.Suppressed));
|
|
state.Suppressed = 0;
|
|
}
|
|
|
|
void FlushTraces()
|
|
{
|
|
foreach (string phase, ERR_MortarTraceState state : Traces)
|
|
{
|
|
if (state.Suppressed > 0)
|
|
Audit("REPEATS", string.Format("phase=%1 reason=%2 repeated polls omitted=%3", phase, state.LastReason, state.Suppressed));
|
|
}
|
|
Traces.Clear();
|
|
}
|
|
|
|
string OperationalReason()
|
|
{
|
|
if (!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<AIAgent> crew = {};
|
|
Group.GetAgents(crew);
|
|
agents = crew.Count();
|
|
foreach (AIAgent agent : crew)
|
|
{
|
|
if (agent && ERR_Actors.CanFight(agent.GetControlledEntity())) living++;
|
|
}
|
|
}
|
|
if (Activity) actionState = Activity.GetActionState();
|
|
bool connected;
|
|
ERR_HQ hq = ERR_HQ.Get();
|
|
if (hq && Group) connected = hq.MortarConnected(Group);
|
|
Audit("SITE STATUS", string.Format("claimed=%1 startup=%2 operational=%3 crew agents=%4 fighting=%5 inventory HE=%6 radio connected=%7 cooldown remaining=%8s", Claimed, StartupActivated, Operational(), agents, living, CountShells(), connected, Math.Max(0, NextOrder - now) / 1000));
|
|
Audit("ORDER STATUS", string.Format("open=%1 route pending=%2 native action=%3 shell committed=%4 order shots=%5 total shots=%6 age=%7s last failure=%8", CommandOpen, RadioOrder != null, actionState, Released, OrderShots, TotalShots, Math.Max(0, now - OrderedAt) / 1000, LastFailure));
|
|
if (!SpawnFailure.IsEmpty()) Audit("PARTIAL DEPLOYMENT", SpawnFailure);
|
|
if (!Operational()) Trace("SITE BLOCK", OperationalReason(), "spawn failure=" + SpawnFailure);
|
|
}
|
|
|
|
bool Operational()
|
|
{
|
|
return OperationalReason().IsEmpty();
|
|
}
|
|
|
|
int CountShells()
|
|
{
|
|
if (!Group)
|
|
return 0;
|
|
int count;
|
|
array<IEntity> items = {};
|
|
array<AIAgent> agents = {};
|
|
Group.GetAgents(agents);
|
|
foreach (AIAgent agent : agents)
|
|
{
|
|
if (!agent || !ERR_Actors.CanFight(agent.GetControlledEntity()))
|
|
continue;
|
|
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(agent.GetControlledEntity().FindComponent(InventoryStorageManagerComponent));
|
|
if (!inventory)
|
|
continue;
|
|
ERR_Inventory.AppendUnique(inventory, items);
|
|
}
|
|
foreach (IEntity item : items)
|
|
if (item && item.GetPrefabData() && item.GetPrefabData().GetPrefabName() == SHELL) count++;
|
|
return count;
|
|
}
|
|
|
|
void Bind(int siteId, SCR_AIGroup group, IEntity gun, float now)
|
|
{
|
|
SiteId = siteId;
|
|
Group = group;
|
|
Gun = gun;
|
|
Position = gun.GetOrigin();
|
|
SpawnedAt = now;
|
|
Claimed = true;
|
|
}
|
|
|
|
|
|
protected int InventoryShells(InventoryStorageManagerComponent inventory)
|
|
{
|
|
if (!inventory)
|
|
return 0;
|
|
int count;
|
|
array<IEntity> items = {};
|
|
ERR_Inventory.Items(inventory, items);
|
|
foreach (IEntity item : items)
|
|
{
|
|
if (item && item.GetPrefabData() && item.GetPrefabData().GetPrefabName() == SHELL)
|
|
count++;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
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<AIAgent> agents = {};
|
|
Group.GetAgents(agents);
|
|
float nearest = float.MAX;
|
|
if (!gunner)
|
|
{
|
|
foreach (AIAgent candidate : agents)
|
|
{
|
|
if (!candidate || candidate.GetControlledEntity() == RadioOperator || !ERR_Actors.CanFight(candidate.GetControlledEntity()))
|
|
continue;
|
|
float distance = vector.DistanceXZ(Gun.GetOrigin(), candidate.GetControlledEntity().GetOrigin());
|
|
if (distance < nearest)
|
|
{
|
|
nearest = distance;
|
|
gunner = candidate.GetControlledEntity();
|
|
}
|
|
}
|
|
}
|
|
if (!gunner) return 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;
|
|
array<IEntity> consideredShells = {};
|
|
foreach (AIAgent bearer : agents)
|
|
{
|
|
if (!bearer || !ERR_Actors.CanFight(bearer.GetControlledEntity()))
|
|
continue;
|
|
IEntity donor = bearer.GetControlledEntity();
|
|
if (donor == gunner || vector.DistanceXZ(donor.GetOrigin(), gunner.GetOrigin()) > 12)
|
|
continue;
|
|
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(donor.FindComponent(InventoryStorageManagerComponent));
|
|
if (!inventory)
|
|
continue;
|
|
nearbyDonors++;
|
|
array<IEntity> items = {};
|
|
ERR_Inventory.Items(inventory, items);
|
|
foreach (IEntity item : items)
|
|
{
|
|
if (!item || !item.GetPrefabData() || item.GetPrefabData().GetPrefabName() != SHELL)
|
|
continue;
|
|
if (consideredShells.Contains(item)) 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++;
|
|
}
|
|
}
|
|
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<AIAgent> agents = {};
|
|
Group.GetAgents(agents);
|
|
foreach (AIAgent agent : agents)
|
|
{
|
|
if (!agent || EntityUtils.IsPlayer(agent.GetControlledEntity()))
|
|
continue;
|
|
if (agent.GetPermanentLOD() != lod)
|
|
agent.SetPermanentLOD(lod);
|
|
if (active && !agent.IsAIActivated() && ERR_Actors.CanFight(agent.GetControlledEntity()))
|
|
agent.ActivateAI();
|
|
}
|
|
}
|
|
|
|
void Update(float now)
|
|
{
|
|
if (!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));
|
|
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)));
|
|
}
|
|
}
|