475 lines
17 KiB
C
475 lines
17 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;
|
|
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;
|
|
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 (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<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;
|
|
m_Readiness = new ERR_MortarReadiness(this);
|
|
}
|
|
|
|
|
|
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;
|
|
}
|
|
|
|
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<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 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<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) || 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<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 (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)));
|
|
}
|
|
}
|