Reconcile radio-delivered body and status casualties by original roster identity

This commit is contained in:
2026-09-20 03:09:16 +02:00
parent a2aab17171
commit 9b6596eb3e
44 changed files with 6042 additions and 86 deletions
+5 -2
View File
@@ -296,7 +296,7 @@ class ERR_HQ : ERR_CommandService
if (!record || !record.Physical || record.Eliminated) return;
unit.ForceSourceId = record.SourceId;
if (!unit.StatusSampler.Bound()) unit.StatusSampler.Bind(unit.Group, record.SourceId, record.InitialStrength, m_Mode.EnemyFaction());
m_Bodies.RegisterGroup(unit.Group);
if (unit.StatusSampler.Bound()) m_Bodies.RegisterGroup(unit.Group, unit.StatusSampler);
}
override void RegisterGroup(SCR_AIGroup group)
{
@@ -541,9 +541,12 @@ class ERR_HQ : ERR_CommandService
protected void Accept(ERR_HQReport report, float now)
{
if (!report || !ERR_MissionRules.ContactFresh(report.ObservedAt, now)) return;
// Delivered scalar body evidence survives native corpse cleanup during radio transit.
// Memory still applies its own entity-identity rules; casualty accounting needs no live target.
if (ERR_HQMemoryRules.Kind(report) == ERR_HQMemoryKind.BODY && m_Watch.IngestBody(report, now))
m_Bodies.ConfirmDelivered(report.Target);
if (!m_Memory.Ingest(report, now)) return;
if (ERR_HQMemoryRules.Kind(report) == ERR_HQMemoryKind.SIGHTING) m_Coverage.Forget(report.Position);
if (ERR_HQMemoryRules.Kind(report) == ERR_HQMemoryKind.BODY) m_Bodies.ConfirmDelivered(report.Target);
m_Journal.Write("BELIEF", string.Format("delivered report M%1 retained; tracks=%2", report.MessageId, m_Memory.Count()), report.MessageId);
// A reported corpse or distress call can justify investigation, never fire.
if (ERR_HQMemoryRules.Kind(report) != ERR_HQMemoryKind.SIGHTING) return;
@@ -3,6 +3,10 @@
class ERR_HQBodyCandidate
{
IEntity Corpse;
ref ERR_HQStatusSampler Roster;
string SourceId;
int Slot = -1;
int Strength;
bool Confirmed;
float ReportAfter;
}
@@ -67,9 +71,9 @@ class ERR_HQBodyObserver
for (int i = m_Groups.Count() - 1; i >= 0; i--)
if (!m_Groups[i].Group) m_Groups.Remove(i);
}
void RegisterGroup(SCR_AIGroup group)
void RegisterGroup(SCR_AIGroup group, ERR_HQStatusSampler sampler)
{
if (!Replication.IsServer() || !OwnedPhysical(group)) return;
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;
@@ -79,17 +83,18 @@ class ERR_HQBodyObserver
ERR_HQBodyScan created = new ERR_HQBodyScan(); created.Group = group;
m_Groups.Insert(created);
}
array<AIAgent> agents = {}; group.GetAgents(agents);
for (int member = 0; member < agents.Count() && member < 32; member++)
// Slot identity comes only from the immutable sampler bind, never current agent order.
for (int member = 0; member < sampler.InitialStrength(); member++)
{
AIAgent agent = agents[member];
if (!agent || !agent.GetControlledEntity()) continue;
IEntity actor = agent.GetControlledEntity();
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);
}
}
@@ -171,6 +176,7 @@ class ERR_HQBodyObserver
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;
@@ -181,6 +187,8 @@ class ERR_HQBodyObserver
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;
@@ -188,7 +196,7 @@ class ERR_HQBodyObserver
}
return false;
}
// Call only after a radio-delivered body snapshot was accepted by HQ memory.
// Call only after the radio-delivered immutable body attribution was accepted by HQ.
void ConfirmDelivered(IEntity body)
{
if (!Replication.IsServer() || !body) return;
@@ -0,0 +1,54 @@
// Delivered initial-roster slot identities only. No native actor/position access.
// One instance belongs to one immutable formation SourceId in the watch roster.
class ERR_HQDeliveredCasualties
{
protected int m_Strength;
protected ref array<int> m_StatusSlots = {};
protected ref array<int> m_KnownSlots = {};
bool Bind(int strength)
{
if (strength < 1 || strength > 32) return false;
if (m_Strength != 0) return m_Strength == strength;
m_Strength = strength;
return true;
}
int Count() { return m_KnownSlots.Count(); }
int StatusCount() { return m_StatusSlots.Count(); }
bool Closed() { return m_Strength > 0 && Count() == m_Strength; }
bool Known(int slot) { return m_KnownSlots.Contains(slot); }
protected bool ValidSlot(int slot) { return m_Strength > 0 && slot >= 0 && slot < m_Strength; }
bool AcceptBody(int slot)
{
if (!ValidSlot(slot)) return false;
if (!m_KnownSlots.Contains(slot)) m_KnownSlots.Insert(slot);
return true; // A repeated valid report is idempotent, including a status-known casualty.
}
bool AcceptStatus(array<int> slots, int reportedLosses, int reportedLiving)
{
if (m_Strength <= 0 || !slots || reportedLosses < 0 || reportedLosses > m_Strength
|| reportedLiving < 0 || reportedLiving > m_Strength - reportedLosses || slots.Count() != reportedLosses)
return false;
array<int> unique = {};
foreach (int slot : slots)
{
if (!ValidSlot(slot) || unique.Contains(slot)) return false;
unique.Insert(slot);
}
// A later local cumulative status cannot resurrect a previously status-confirmed member.
// Body-only knowledge is intentionally not required in an uninformed caller's status.
foreach (int previous : m_StatusSlots) if (!unique.Contains(previous)) return false;
m_StatusSlots.Clear();
foreach (int slot : unique)
{
m_StatusSlots.Insert(slot);
if (!m_KnownSlots.Contains(slot)) m_KnownSlots.Insert(slot);
}
return true;
}
int LivingUpperBound(int lastReportedLiving)
{
if (Closed()) return 0;
if (lastReportedLiving < 0 || lastReportedLiving > m_Strength) return -1;
return Math.Min(lastReportedLiving, m_Strength - Count());
}
}
@@ -51,6 +51,10 @@ class ERR_HQReport
bool Distress;
int SeenEscapists = -1;
string TravelMode = "unknown";
// Original formation identity captured by the local observer, only used after radio delivery.
string CasualtySourceId;
int CasualtySlot = -1;
int CasualtyStrength;
string SeenWeapons = "unknown";
float DetailsObservedAt = -1;
@@ -68,6 +72,9 @@ class ERR_HQReport
copy.HasVelocity = HasVelocity;
copy.SeenEscapists = SeenEscapists;
copy.TravelMode = TravelMode;
copy.CasualtySourceId = CasualtySourceId;
copy.CasualtySlot = CasualtySlot;
copy.CasualtyStrength = CasualtyStrength;
copy.SeenWeapons = SeenWeapons;
copy.DetailsObservedAt = DetailsObservedAt;
return copy;
@@ -14,6 +14,30 @@ class ERR_HQStatusSampler
protected ref array<bool> m_Released = {};
protected ref array<AIAgent> m_Agents = {};
bool Bound() { return m_Bound; }
string InitialSourceId() { return m_SourceId; }
int InitialStrength() { return m_Strength; }
bool OwnsRoster(SCR_AIGroup group) { return m_Bound && group && group == m_Group; }
IEntity InitialMember(int slot)
{
if (!m_Bound || slot < 0 || slot >= m_Members.Count()) return null;
return m_Members[slot];
}
// Attribution retains the original slot even when native death detaches its agent.
// A known transfer/player conversion permanently releases that slot, never rebinds it.
bool CanAttribute(int slot, IEntity actor)
{
if (!Replication.IsServer() || !actor || InitialMember(slot) != actor || m_Released[slot]) return false;
if (ERR_Actors.Player(actor) || SCR_FactionManager.SGetFaction(actor) != m_Faction)
{ m_Released[slot] = true; return false; }
DamageManagerComponent damage = DamageManagerComponent.Cast(actor.FindComponent(DamageManagerComponent));
if (!damage) return false;
SCR_AIGroup actorGroup = ERR_Actors.Group(actor);
// A retained foreign group proves transfer even if death preceded this tick.
// A null group on a destroyed actor remains normal native corpse detachment.
if ((actorGroup && actorGroup != m_Group) || (!damage.IsDestroyed() && actorGroup != m_Group))
{ m_Released[slot] = true; return false; }
return true;
}
protected bool Eligible()
{
return Replication.IsServer() && m_Bound && m_Group && m_Faction
@@ -59,10 +83,12 @@ class ERR_HQStatusSampler
{ m_Released[i] = true; continue; }
DamageManagerComponent damage = DamageManagerComponent.Cast(actor.FindComponent(DamageManagerComponent));
if (!damage) continue;
SCR_AIGroup actorGroup = ERR_Actors.Group(actor);
if (actorGroup && actorGroup != m_Group) { m_Released[i] = true; continue; }
if (damage.IsDestroyed()) { m_Dead[i] = true; m_Deaths++; continue; }
// A living member who left this group is no longer ours to account for.
// Native death may detach a corpse, so group identity is checked after death.
if (ERR_Actors.Group(actor) != m_Group) m_Released[i] = true;
// A living detached member is no longer ours. A destroyed detached actor
// was handled above without mistaking native corpse cleanup for transfer.
if (actorGroup != m_Group) m_Released[i] = true;
}
return true;
}
@@ -79,6 +105,8 @@ class ERR_HQStatusSampler
ERR_HQStatusReport report = new ERR_HQStatusReport();
report.SourceId = m_SourceId; report.MessageId = messageId; report.Position = position;
report.Strength = m_Strength; report.ConfirmedLosses = m_Deaths; report.ObservedAt = now;
for (int slot = 0; slot < m_Dead.Count(); slot++)
if (m_Dead[slot]) report.DeathSlots.Insert(slot);
for (int i = 0; i < m_Members.Count(); i++)
{
if (m_Dead[i] || m_Released[i]) continue;
+64 -34
View File
@@ -10,6 +10,7 @@ class ERR_HQStatusReport
int Count;
int Fit;
int ConfirmedLosses;
ref array<int> DeathSlots = {};
float ObservedAt = -1;
}
@@ -58,9 +59,11 @@ class ERR_HQWatchEntry
int MessageId;
vector Position;
int Strength;
int Count;
int Fit;
int Count = -1;
int Fit = -1;
int ConfirmedLosses;
ref ERR_HQDeliveredCasualties Casualties = new ERR_HQDeliveredCasualties();
float LastBodyDeliveredAt = -1;
float ObservedAt = -1;
float LastDeliveredAt = -1;
float AskedAt = -1;
@@ -84,44 +87,71 @@ class ERR_HQWatch
}
int ConfirmedLosses() { return m_ConfirmedLosses; }
int SquadLimit(int baseline) { return ERR_HQWatchRules.SquadLimit(baseline, m_ConfirmedLosses); }
protected ERR_HQWatchEntry Create(string sourceId, int strength)
{
// Closed identities remain tombstones: replays cannot recreate a formation.
if (m_Entries.Count() >= 128)
{
if (!m_CapLogged) Print("Rereintroduced [watch]: 128-formation check-in cap reached.");
m_CapLogged = true;
return null;
}
ERR_HQWatchEntry entry = new ERR_HQWatchEntry();
entry.SourceId = sourceId; entry.Strength = strength;
entry.Casualties.Bind(strength);
m_Entries.Insert(entry); m_BySource.Set(sourceId, entry);
return entry;
}
protected void ApplyLosses(ERR_HQWatchEntry entry)
{
int known = entry.Casualties.Count();
m_ConfirmedLosses += known - entry.ConfirmedLosses;
entry.ConfirmedLosses = known;
entry.Count = entry.Casualties.LivingUpperBound(entry.Count);
if (entry.Count >= 0) entry.Fit = Math.Min(entry.Fit, entry.Count);
entry.Closed = entry.Casualties.Closed();
if (entry.Closed) entry.Fit = 0;
}
bool Ingest(ERR_HQStatusReport report, float now)
{
if (!ERR_HQWatchRules.Valid(report, now)) return false;
ERR_HQWatchEntry entry = Find(report.SourceId);
if (entry)
if (entry && (report.MessageId <= entry.MessageId || report.ObservedAt <= entry.ObservedAt
|| now < entry.LastDeliveredAt || report.Strength != entry.Strength)) return false;
if (!entry)
{
if (report.MessageId <= entry.MessageId || report.ObservedAt <= entry.ObservedAt
|| now < entry.LastDeliveredAt || report.Strength != entry.Strength
|| report.ConfirmedLosses < entry.ConfirmedLosses) return false;
// Validate slot payload before allocation: malformed snapshots cannot consume the cap.
ERR_HQDeliveredCasualties validation = new ERR_HQDeliveredCasualties();
if (!validation.Bind(report.Strength)
|| !validation.AcceptStatus(report.DeathSlots, report.ConfirmedLosses, report.Count)) return false;
entry = Create(report.SourceId, report.Strength);
if (!entry) return false;
}
else
{
// Keep closed identities as well: replay cannot resurrect a retired patrol
// or count the same cumulative casualty total again under its old source.
if (m_Entries.Count() >= 128)
{
if (!m_CapLogged) Print("Rereintroduced [watch]: 128-formation check-in cap reached.");
m_CapLogged = true;
return false;
}
entry = new ERR_HQWatchEntry();
entry.SourceId = report.SourceId;
m_Entries.Insert(entry);
m_BySource.Set(entry.SourceId, entry);
}
m_ConfirmedLosses += report.ConfirmedLosses - entry.ConfirmedLosses;
entry.MessageId = report.MessageId;
entry.Position = report.Position;
entry.Strength = report.Strength;
entry.Count = report.Count;
entry.Fit = report.Fit;
entry.ConfirmedLosses = report.ConfirmedLosses;
entry.ObservedAt = report.ObservedAt;
entry.LastDeliveredAt = now;
entry.AskedAt = -1;
entry.NextAttempt = 0;
entry.Investigating = false;
entry.Closed = entry.ConfirmedLosses == entry.Strength;
if (!entry.Casualties.AcceptStatus(report.DeathSlots, report.ConfirmedLosses, report.Count)) return false;
entry.MessageId = report.MessageId; entry.Position = report.Position;
entry.Count = report.Count; entry.Fit = report.Fit;
entry.ObservedAt = report.ObservedAt; entry.LastDeliveredAt = now;
entry.AskedAt = -1; entry.NextAttempt = 0; entry.Investigating = false;
ApplyLosses(entry);
return true;
}
// Called only from delivered BODY intake; scalar evidence survives native target cleanup.
// Corpse coordinates and body message IDs never replace a formation status/check-in.
bool IngestBody(ERR_HQReport report, float now)
{
if (!report || report.TravelMode != "body" || report.Distress || report.MessageId <= 0
|| report.CasualtySourceId.IsEmpty() || report.CasualtySourceId.Length() > 96
|| report.CasualtyStrength < 1 || report.CasualtyStrength > 32
|| report.CasualtySlot < 0 || report.CasualtySlot >= report.CasualtyStrength
|| !ERR_HQPursuitRules.Finite(now) || !ERR_HQPursuitRules.Finite(report.ObservedAt)
|| !ERR_HQPursuitRules.PositionValid(report.Position) || report.ObservedAt < 0
|| now < report.ObservedAt || now - report.ObservedAt > 120000) return false;
ERR_HQWatchEntry entry = Find(report.CasualtySourceId);
if (entry && (entry.Strength != report.CasualtyStrength || now < entry.LastBodyDeliveredAt)) return false;
if (!entry) entry = Create(report.CasualtySourceId, report.CasualtyStrength);
if (!entry || !entry.Casualties.AcceptBody(report.CasualtySlot)) return false;
entry.LastBodyDeliveredAt = now;
ApplyLosses(entry);
return true;
}
bool RequestDue(string sourceId, float now)