// Adapted from the user-owned AI body's visual discovery rules only. // Candidate casualty identities are local perception work, never delivered HQ knowledge. class ERR_HQBodyCandidate { IEntity Corpse; ref ERR_HQStatusSampler Roster; string SourceId; int Slot = -1; int Strength; bool Confirmed; float ReportAfter; } class ERR_HQBodyScan { SCR_AIGroup Group; float NextScan; float LastAsked = -1; int Cursor; } class ERR_HQBodyRules { static bool InView(float distance, float facing) { return ERR_HQMemoryRules.Finite(distance) && ERR_HQMemoryRules.Finite(facing) && distance >= 0 && distance <= 60 && (distance <= 12 || facing >= 0.5); } static bool Clear(float fraction, bool hitBody) { return ERR_HQMemoryRules.Finite(fraction) && fraction >= 0 && (fraction >= 1 || hitBody); } } class ERR_HQBodyObserver { protected ref array m_Roster = {}; protected ref array m_Bodies = {}; protected ref array m_Groups = {}; protected float m_NextBudget; protected float m_Now = -1; protected int m_Traces; protected int m_RosterCursor; protected bool m_CapLogged; protected bool OwnedPhysical(SCR_AIGroup group) { ERR_GameMode mode = ERR_GameMode.Get(); if (!mode || !group || group.GetFaction() != mode.EnemyFaction()) return false; ERR_ForceRecord force = mode.Forces().FindGroup(group); return force && force.Physical && !force.Eliminated; } protected bool Dead(IEntity actor) { ChimeraCharacter character = ChimeraCharacter.Cast(actor); return character && character.GetCharacterController() && character.GetCharacterController().GetLifeState() == ECharacterLifeState.DEAD; } protected void Cap() { if (m_CapLogged) return; m_CapLogged = true; Print("Rereintroduced [body observer]: finite local registry limit reached; no discovery work added."); } protected void Prune() { for (int i = m_Bodies.Count() - 1; i >= 0; i--) if (!m_Bodies[i].Corpse || m_Bodies[i].Confirmed || !Dead(m_Bodies[i].Corpse)) m_Bodies.Remove(i); for (int i = m_Roster.Count() - 1; i >= 0; i--) if (!m_Roster[i].Corpse) m_Roster.Remove(i); for (int i = m_Groups.Count() - 1; i >= 0; i--) if (!m_Groups[i].Group) m_Groups.Remove(i); } void RegisterGroup(SCR_AIGroup group, ERR_HQStatusSampler sampler) { if (!Replication.IsServer() || !OwnedPhysical(group) || !sampler || !sampler.OwnsRoster(group)) return; Prune(); bool known; foreach (ERR_HQBodyScan scan : m_Groups) if (scan.Group == group) known = true; if (!known) { if (m_Groups.Count() >= 128) { Cap(); return; } ERR_HQBodyScan created = new ERR_HQBodyScan(); created.Group = group; m_Groups.Insert(created); } // Slot identity comes only from the immutable sampler bind, never current agent order. for (int member = 0; member < sampler.InitialStrength(); member++) { IEntity actor = sampler.InitialMember(member); if (!sampler.CanAttribute(member, actor)) continue; bool found; foreach (ERR_HQBodyCandidate entry : m_Roster) if (entry.Corpse == actor) found = true; if (found) continue; if (m_Roster.Count() >= 128) { Cap(); return; } ERR_HQBodyCandidate candidate = new ERR_HQBodyCandidate(); candidate.Corpse = actor; candidate.Roster = sampler; candidate.SourceId = sampler.InitialSourceId(); candidate.Slot = member; candidate.Strength = sampler.InitialStrength(); m_Roster.Insert(candidate); } } void TickBudget(float now) { if (!Replication.IsServer() || !ERR_HQMemoryRules.Finite(now) || now < 0 || now < m_Now) return; m_Now = now; if (now < m_NextBudget) return; m_NextBudget = now + 5000; m_Traces = 12; Prune(); // No position reads, messages or callbacks here: death only makes a local LOS candidate. // Rotate the finite candidate window so sixteen remote corpses cannot hide later losses. m_Bodies.Clear(); int total = m_Roster.Count(); for (int visited = 0; visited < total; visited++) { if (m_Bodies.Count() >= 16) { Cap(); break; } if (m_RosterCursor >= total) m_RosterCursor = 0; ERR_HQBodyCandidate candidate = m_Roster[m_RosterCursor++]; if (!candidate.Corpse || candidate.Confirmed || m_Bodies.Contains(candidate) || !Dead(candidate.Corpse)) continue; m_Bodies.Insert(candidate); } } protected bool Visible(IEntity observer, IEntity body, out vector observedPosition) { if (m_Traces <= 0 || !observer || !body || !ERR_Actors.CanFight(observer) || CompartmentAccessComponent.GetVehicleIn(observer)) return false; vector bodyPosition = body.GetOrigin(); vector observerPosition = observer.GetOrigin(); vector delta = bodyPosition - observerPosition; float distance = delta.Length(); delta[1] = 0; vector transform[4]; observer.GetWorldTransform(transform); vector forward = transform[2]; forward[1] = 0; float facing; if (delta.Length() > 0.01 && forward.Length() > 0.01) facing = vector.Dot(forward.Normalized(), delta.Normalized()); if (!ERR_HQBodyRules.InView(distance, facing)) return false; TraceParam trace = new TraceParam(); trace.Start = observerPosition + "0 1.5 0"; trace.End = bodyPosition + "0 0.3 0"; trace.Exclude = observer; trace.Flags = TraceFlags.ENTS | TraceFlags.WORLD; m_Traces--; float fraction = GetGame().GetWorld().TraceMove(trace, null); if (!ERR_HQBodyRules.Clear(fraction, trace.TraceEnt == body)) return false; observedPosition = bodyPosition; return true; } protected ERR_HQBodyScan OldestDue(float now) { ERR_HQBodyScan selected; foreach (ERR_HQBodyScan scan : m_Groups) { // Recently requesting groups share turns; inactive callers cannot reserve work forever. if (!scan.Group || now < scan.NextScan || scan.LastAsked < 0 || now - scan.LastAsked > 5000) continue; if (!OwnedPhysical(scan.Group) || scan.Group.GetPlayerCount(true) > 0) continue; if (!selected || scan.NextScan < selected.NextScan) selected = scan; } return selected; } bool Observe(SCR_AIGroup group, float now, out ERR_HQReport report) { report = null; if (!Replication.IsServer() || !ERR_HQMemoryRules.Finite(now) || now < m_Now || now < 0) return false; TickBudget(now); if (!OwnedPhysical(group) || group.GetPlayerCount(true) > 0 || m_Bodies.IsEmpty()) return false; ERR_HQBodyScan state; foreach (ERR_HQBodyScan scan : m_Groups) if (scan.Group == group) state = scan; if (!state) return false; state.LastAsked = now; if (now < state.NextScan || m_Traces <= 0 || OldestDue(now) != state) return false; state.NextScan = now + 15000; array agents = {}; group.GetAgents(agents); int count = m_Bodies.Count(); for (int visited = 0; visited < count; visited++) { if (state.Cursor >= count) state.Cursor = 0; ERR_HQBodyCandidate candidate = m_Bodies[state.Cursor++]; if (!candidate.Corpse || candidate.Confirmed || now < candidate.ReportAfter || !Dead(candidate.Corpse)) continue; if (!candidate.Roster || !candidate.Roster.CanAttribute(candidate.Slot, candidate.Corpse)) continue; for (int member = 0; member < agents.Count() && member < 32; member++) { if (m_Traces <= 0) return false; AIAgent agent = agents[member]; if (!agent) continue; vector position; if (!Visible(agent.GetControlledEntity(), candidate.Corpse, position)) continue; report = new ERR_HQReport(); report.Target = candidate.Corpse; report.Source = group; report.Position = position; report.ObservedAt = now; report.TravelMode = "body"; report.CasualtySourceId = candidate.SourceId; report.CasualtySlot = candidate.Slot; report.CasualtyStrength = candidate.Strength; // Local throttling only. A lost radio call can be retried after fresh LOS. candidate.ReportAfter = now + 15000; return true; } } return false; } // Call only after the radio-delivered immutable body attribution was accepted by HQ. void ConfirmDelivered(IEntity body) { if (!Replication.IsServer() || !body) return; foreach (ERR_HQBodyCandidate candidate : m_Roster) if (candidate.Corpse == body) candidate.Confirmed = true; } void Stop() { m_Roster.Clear(); m_Bodies.Clear(); m_Groups.Clear(); m_Traces = 0; m_Now = -1; m_NextBudget = 0; m_RosterCursor = 0; m_CapLogged = false; } }