// Owned and ticked by HQ. Initial assets are authored once; losses are never replaced. class ERR_MortarSystem { protected ERR_HQ m_HQ; protected ref array m_Batteries = {}; protected ref ERR_SettlementSafety m_Settlements = new ERR_SettlementSafety(); protected bool m_Prepared; protected bool m_Stopped; protected float m_NextDecision; protected float m_NextRefreshDecision; string SafetyReason; string SettlementReason; void ERR_MortarSystem(ERR_HQ hq) { m_HQ = hq; } static ERR_MortarSystem Get() { ERR_HQ hq = ERR_HQ.Get(); if (!hq || !Replication.IsServer()) return null; return hq.Mortars(); } static bool NearPlayer(vector position) { array players = {}; ERR_Actors.Players(players); foreach (IEntity player : players) if (vector.DistanceXZ(player.GetOrigin(), position) <= 1200) return true; return false; } bool Prepare() { if (!Replication.IsServer() || m_Prepared || m_Stopped) return false; m_Prepared = true; vector position = ERR_Actors.Ground("6000 0 7000"); if (position[1] < 1) return false; IEntity gun = ERR_Actors.Spawn(ERR_MortarBattery.GUN, position); SCR_AIGroup group = SCR_AIGroup.Cast(ERR_Actors.Spawn(MortarGroup(), ERR_Actors.Ground(position + "0 0 4"))); if (!gun || !group) { ERR_Actors.DeleteSpawned(gun); ERR_MortarReadiness.RetireGroup(group); ERR_Actors.DeleteSpawned(group); return false; } if (group.GetFaction() != ERR_GameMode.Get().EnemyFaction()) { ERR_Actors.DeleteSpawned(gun); ERR_MortarReadiness.RetireGroup(group); ERR_Actors.DeleteSpawned(group); return false; } FactionAffiliationComponent affiliation = FactionAffiliationComponent.Cast(gun.FindComponent(FactionAffiliationComponent)); if (affiliation) affiliation.SetAffiliatedFaction(ERR_GameMode.Get().EnemyFaction()); ERR_MortarBattery battery = new ERR_MortarBattery(); battery.Bind(1, group, gun, GetGame().GetWorld().GetWorldTime()); m_Batteries.Insert(battery); ERR_GameMode mode = ERR_GameMode.Get(); mode.Own(gun); mode.RegisterGroup(group); m_HQ.ReserveGroup(group); battery.Audit("DEPLOY", "authored gun and four-person crew; finite ammunition initialization pending"); return true; } static ResourceName MortarGroup() { return "{B84C05941C4C81B8}Prefabs/ERR/MortarCrew.et"; } void Batteries(out array batteries) { batteries.Clear(); foreach (ERR_MortarBattery battery : m_Batteries) batteries.Insert(battery); } ERR_MortarBattery FindGun(IEntity gun) { foreach (ERR_MortarBattery battery : m_Batteries) if (gun && battery.Gun == gun) return battery; return null; } ERR_MortarBattery FindGroup(SCR_AIGroup group) { foreach (ERR_MortarBattery battery : m_Batteries) if (group && battery.Group == group) return battery; return null; } ERR_HQReport Latest(ERR_MortarBattery battery, float now) { // Only the coordinates actually delivered to this battery are available. // A newer HQ contact cannot update a gun's knowledge without another downlink. if (!battery || !ERR_MortarPrediction.Fresh(battery.Report, now)) return null; return battery.Report; } string ContactFailure(ERR_MortarBattery battery) { if (!battery.Report) return "no delivered target report"; return "delivered target report expired or invalid"; } // Classify only after SupplyState has observed any accepted inventory move. // Report expiry must not be mislabeled as physical ammunition depletion. string SupplyFailure(ERR_MortarBattery battery, float now) { if (!Latest(battery, now)) return ContactFailure(battery); return "next native load lacks confirmed physical gunner ammunition"; } bool OutsideSettlements(vector impact) { bool clear = m_Settlements.OutsideSettlements(impact); SettlementReason = m_Settlements.SettlementReason; return clear; } bool Safe(ERR_MortarBattery battery, vector impact, float flight) { SafetyReason = "clear"; if (!battery.Operational()) { SafetyReason = battery.OperationalReason(); return false; } if (!OutsideSettlements(impact)) { SafetyReason = SettlementReason; return false; } if (SCR_TerrainHelper.GetTerrainY(impact) < 0) { SafetyReason = "impact below Everon sea level"; return false; } Faction faction = battery.Group.GetFaction(); if (!faction) { SafetyReason = "battery faction missing"; return false; } array friendlies = {}; m_HQ.PhysicalForces(friendlies); foreach (IEntity entity : friendlies) { Faction friendly = SCR_FactionManager.SGetFaction(entity); if (!friendly || faction.IsFactionEnemy(friendly) || !ERR_Actors.Alive(entity)) continue; vector velocity; IEntity moving = CompartmentAccessComponent.GetVehicleIn(entity); if (!moving) moving = entity; if (moving.GetPhysics()) velocity = moving.GetPhysics().GetVelocity(); if (ERR_MortarPrediction.ThreatensFriendly(impact, entity.GetOrigin(), velocity, flight)) { SafetyReason = "friendly physical force can enter the impact area"; return false; } } return true; } bool Withhold(ERR_MortarBattery battery, string reason, string detail = "") { battery.LastFailure = reason + "; " + detail; battery.Trace("WITHHELD", reason, detail); // Finish the activity from the next HQ tick, never from inside its own node. battery.Rejected = true; return false; } bool AllowShot(ERR_MortarBattery battery) { float now = GetGame().GetWorld().GetWorldTime(); if (m_Stopped || !ERR_HQ.MissionActive()) return Withhold(battery, "mission inactive or HQ stopped"); if (!battery.Operational()) return Withhold(battery, battery.OperationalReason()); if (battery.RefreshRejected) return Withhold(battery, "target update failed; order awaiting cancellation"); if (!battery.CommandOpen || battery.OrderShots >= ERR_MortarSalvo.ROUNDS) return Withhold(battery, "salvo complete or order closed"); if (battery.Released) return Withhold(battery, "waiting for committed projectile"); if (battery.AimedShot != battery.OrderShots || battery.AimedAt < 0 || now < battery.AimedAt) return Withhold(battery, "fresh aim solution required"); ERR_HQReport report = Latest(battery, now); if (!report) return Withhold(battery, ContactFailure(battery)); if (!battery.AimSolution || !battery.AimSolution.Matches(battery, report)) return Withhold(battery, "aim command does not match delivered order"); if (battery.AimValidatedAt < battery.AimedAt || now < battery.AimValidatedAt) return Withhold(battery, "aim validation timestamp invalid"); if (now - battery.AimValidatedAt > 10000) { if (!battery.AimSolver) return Withhold(battery, "original native aim solver no longer available"); if (!battery.AimSolver.ERR_RevalidateAim(battery, this, report, now)) return false; } vector updated; if (!ERR_MortarPrediction.Predict(report, now, battery.FlightTime, updated)) return Withhold(battery, ERR_MortarPrediction.FailureReason); updated += battery.AimOffset; if (vector.DistanceXZ(updated, battery.Impact) > 60) return Withhold(battery, "predicted target left aimed impact area"); if (!OutsideSettlements(updated)) return Withhold(battery, SettlementReason); if (!Safe(battery, battery.Impact, battery.FlightTime)) return Withhold(battery, SafetyReason); return true; } protected void AdvanceOrder(ERR_MortarBattery battery, float now) { if (battery.RadioOrder) { int deliveredState = battery.RadioOrder.Advance(now, m_HQ.Journal()); if (deliveredState == 0) return; ERR_HQReport delivered = battery.PendingRadioReport; battery.RadioOrder = null; battery.PendingRadioReport = null; if (deliveredState < 0 || !ERR_MortarPrediction.Fresh(delivered, now) || !battery.Operational() || battery.Activity || !battery.CommandOpen) { battery.Cancel("radio delivery, report freshness or battery availability failed"); battery.NextOrder = now + 10000; return; } battery.PendingSupplyReport = delivered; } if (!battery.PendingSupplyReport) return; if (!battery.CommandOpen || !ERR_MortarPrediction.Fresh(battery.PendingSupplyReport, now) || !battery.Operational() || battery.Activity) { battery.Cancel("delivered order expired or became unavailable while awaiting physical ammunition"); battery.NextOrder = now + 10000; return; } ERR_MortarSupplyState supply = battery.SupplyState(now); if (supply == ERR_MortarSupplyState.PENDING) return; if (supply == ERR_MortarSupplyState.FAILED) { battery.Cancel("physical gunner ammunition unavailable or transfer failed"); battery.NextOrder = now + 10000; return; } ERR_HQReport ready = battery.PendingSupplyReport; battery.PendingSupplyReport = null; battery.Order(ready, now); } protected void AdvanceRefresh(ERR_MortarBattery battery, float now) { ERR_MortarRefresh update = battery.Refresh; if (!update) return; // A stale transaction must not cancel or modify a later order. if (update.OrderId != battery.OrderId || !battery.CommandOpen) { battery.Refresh = null; return; } if (!update.Owns(battery) || !battery.Operational() || !battery.Activity || !ERR_MortarRefreshRules.Newer(battery.Report, update.Report, now)) { battery.RejectRefresh("target update ownership, observation freshness or activity changed"); return; } if (update.Route) { int state = update.Route.Advance(now, m_HQ.Journal()); if (state == 0) return; if (state < 0) { battery.RejectRefresh("new target observation failed physical HQ radio delivery"); return; } update.Route = null; } // The original committed shell owns its report, geometry and airspace until // native OnWeaponFired confirms it. A delivered update cannot relabel it. if (battery.Released) return; if (battery.AimSolution) { if (!battery.AimSolver || !battery.AimSolver.ERR_AdoptReport(battery, this, update.Report, now)) { battery.RejectRefresh("delivered update cannot preserve the original native aim command"); return; } } else { // No barrel command exists for the next shell yet. Its normal native aim // node must solve this report; receiving it is not an aim authorization. battery.Report = update.Report; } battery.Audit("UPDATE RECEIVED", string.Format("same order; update radio=M%1 accepted observation=M%2 observed=%3; shots=%4 original aim time=%5", update.RadioId, update.Report.MessageId, update.Report.ObservedAt, battery.OrderShots, battery.AimedAt)); battery.Refresh = null; } protected void QueueRefreshes(float now) { if (now < m_NextRefreshDecision) return; m_NextRefreshDecision = now + 5000; array reports = {}; m_HQ.SupportContacts(reports, now); foreach (ERR_MortarBattery battery : m_Batteries) { ERR_HQReport newest; foreach (ERR_HQReport report : reports) { if (!battery.CanRefresh(report, now)) continue; if (!newest || report.ObservedAt > newest.ObservedAt) newest = report; } if (!newest) continue; ERR_HQRoute route = m_HQ.Downlink(battery.Group, "MORTAR UPDATE", newest.Description(now), newest.MessageId); if (!route) { battery.RejectRefresh("HQ has a newer observation but no physical downlink to the active battery"); continue; } battery.BeginRefresh(newest, route, now); } } void Tick(float now) { if (m_Stopped || !Replication.IsServer()) return; foreach (ERR_MortarBattery battery : m_Batteries) { if ((battery.Rejected || battery.RefreshRejected) && !battery.Released) { battery.Cancel("native aim or firing authorization rejected"); battery.NextOrder = now + 10000; } battery.Rejected = false; battery.Update(now); AdvanceOrder(battery, now); AdvanceRefresh(battery, now); } QueueRefreshes(now); if (now < m_NextDecision) return; m_NextDecision = now + 10000; array reports = {}; m_HQ.SupportContacts(reports, now); ERR_MortarBattery selected; ERR_HQReport target; float distance = float.MAX; foreach (ERR_MortarBattery battery : m_Batteries) { if (!battery.StartupActivated || !battery.Operational() || battery.Activity || battery.RadioOrder || battery.PendingSupplyReport || battery.SupplyPending() || now < battery.NextOrder || battery.CountShells() == 0) continue; foreach (ERR_HQReport report : reports) { if (!battery.CanRequest(report, now)) continue; float candidate = vector.DistanceXZ(battery.Position, report.Position); if (candidate < 350 || candidate > 4000 || candidate >= distance || !OutsideSettlements(report.Position)) continue; selected = battery; target = report; distance = candidate; } } if (!selected || !target) return; ERR_HQRoute route = m_HQ.Downlink(selected.Group, "MORTAR ORDER", target.Description(now), target.MessageId); if (!route) return; if (!selected.BeginRequest(target, now)) return; selected.RadioId = route.MessageId; selected.RadioOrder = route; selected.PendingRadioReport = target.Snapshot(); m_NextDecision = now + 20000; } void AppendGuns(array entities) { foreach (ERR_MortarBattery battery : m_Batteries) if (battery.Gun && !entities.Contains(battery.Gun)) entities.Insert(battery.Gun); } string Status() { int ready, shells; foreach (ERR_MortarBattery battery : m_Batteries) { if (battery.StartupActivated && battery.Operational()) ready++; shells += battery.CountShells(); } return string.Format("mortars %1/%2; HE shells %3", ready, m_Batteries.Count(), shells); } void Stop() { m_Stopped = true; foreach (ERR_MortarBattery battery : m_Batteries) battery.Stop(); } }