341 lines
12 KiB
C
341 lines
12 KiB
C
// Memory accepts radio-delivered reports at one boundary. Entity references are
|
|
// opaque identity keys: this module never reads a target's current world state.
|
|
enum ERR_HQMemoryKind
|
|
{
|
|
SIGHTING,
|
|
DISTRESS,
|
|
BODY
|
|
}
|
|
|
|
class ERR_HQMemoryRules
|
|
{
|
|
static bool Finite(float value) { return value == value && Math.AbsFloat(value) <= 1000000000000.0; }
|
|
static bool PositionValid(vector value)
|
|
{
|
|
for (int i = 0; i < 3; i++)
|
|
if (!Finite(value[i]) || Math.AbsFloat(value[i]) > 1000000) return false;
|
|
return true;
|
|
}
|
|
static bool DeliveryFresh(float observedAt, float now)
|
|
{
|
|
return Finite(observedAt) && Finite(now) && observedAt >= 0 && now >= observedAt && now - observedAt <= 120000;
|
|
}
|
|
static int Kind(ERR_HQReport report)
|
|
{
|
|
if (report.Distress) return ERR_HQMemoryKind.DISTRESS;
|
|
if (report.TravelMode == "body" || report.TravelMode == "dead body") return ERR_HQMemoryKind.BODY;
|
|
return ERR_HQMemoryKind.SIGHTING;
|
|
}
|
|
static float Confidence(float ageSeconds, float halfLife = 600)
|
|
{
|
|
if (!Finite(ageSeconds) || !Finite(halfLife) || ageSeconds < 0 || halfLife <= 0) return 0;
|
|
return Math.Pow(0.5, ageSeconds / halfLife);
|
|
}
|
|
static float BaseConfidence(int kind)
|
|
{
|
|
if (kind == ERR_HQMemoryKind.DISTRESS) return 0.3;
|
|
if (kind == ERR_HQMemoryKind.BODY) return 0.2;
|
|
return 1;
|
|
}
|
|
static float HalfLife(int kind)
|
|
{
|
|
if (kind == ERR_HQMemoryKind.DISTRESS) return 180;
|
|
if (kind == ERR_HQMemoryKind.BODY) return 300;
|
|
return 600;
|
|
}
|
|
static float RadiusFloor(int kind, string travel)
|
|
{
|
|
if (kind == ERR_HQMemoryKind.DISTRESS) return 150;
|
|
if (kind == ERR_HQMemoryKind.BODY) return 75;
|
|
if (travel == "aircraft") return 120;
|
|
if (travel == "vehicle" || travel == "in vehicle") return 60;
|
|
return 25;
|
|
}
|
|
static float RadiusGrowth(int kind, string travel)
|
|
{
|
|
if (kind == ERR_HQMemoryKind.DISTRESS) return 2;
|
|
if (kind == ERR_HQMemoryKind.BODY) return 1.2;
|
|
if (travel == "aircraft") return 8;
|
|
if (travel == "vehicle" || travel == "in vehicle") return 4;
|
|
return 1.2;
|
|
}
|
|
static float Radius(float ageSeconds, float growth, float floor)
|
|
{
|
|
if (!Finite(ageSeconds) || !Finite(growth) || !Finite(floor)) return 2001;
|
|
if (ageSeconds < 0 || growth < 0 || floor < 0) return 2001;
|
|
return floor + ageSeconds * growth;
|
|
}
|
|
static float SpeedLimit(string travel)
|
|
{
|
|
if (travel == "on foot") return 12;
|
|
if (travel == "vehicle" || travel == "in vehicle") return 55;
|
|
if (travel == "aircraft") return 120;
|
|
return 0;
|
|
}
|
|
static bool MotionCredible(float seconds, float distance, string previousMode, string currentMode)
|
|
{
|
|
if (!Finite(seconds) || !Finite(distance) || seconds < 2 || seconds > 120 || distance < 0) return false;
|
|
float limit = SpeedLimit(currentMode);
|
|
return previousMode == currentMode && limit > 0 && distance / seconds <= limit;
|
|
}
|
|
static vector Predict(vector position, vector velocity, float ageSeconds)
|
|
{
|
|
if (!PositionValid(position) || !PositionValid(velocity) || !Finite(ageSeconds) || ageSeconds < 0)
|
|
return position;
|
|
vector offset = velocity * Math.Clamp(ageSeconds, 0, 30);
|
|
offset[1] = 0;
|
|
if (offset.Length() > 300) offset = offset.Normalized() * 300;
|
|
return position + offset;
|
|
}
|
|
static bool Actionable(float confidence, float radius)
|
|
{
|
|
return Finite(confidence) && Finite(radius) && confidence >= 0.1 && confidence <= 1 && radius <= 2000 && radius >= 0;
|
|
}
|
|
static bool NewMessage(int id, bool alreadySeen)
|
|
{
|
|
if (id <= 0 || alreadySeen) return false;
|
|
return true;
|
|
}
|
|
static float Value(float confidence, float radius) { return confidence / (1 + radius / 500); }
|
|
}
|
|
|
|
// Detached read model. Mutating it (or Report) cannot alter the stored history.
|
|
class ERR_HQBelief
|
|
{
|
|
int BeliefId;
|
|
int BeliefRevision;
|
|
float EvaluatedAt;
|
|
IEntity Target;
|
|
vector Position;
|
|
vector PredictedPosition;
|
|
vector Velocity;
|
|
bool HasVelocity;
|
|
float Speed;
|
|
float HeadingDegrees;
|
|
float Confidence;
|
|
float Radius;
|
|
float ObservedAt;
|
|
float LastDeliveredAt;
|
|
float PredictionSeconds;
|
|
int MessageId;
|
|
int SampleCount;
|
|
int Kind;
|
|
string PredictionSource;
|
|
string TravelMode;
|
|
ref ERR_HQReport Report;
|
|
bool Actionable() { return Kind == ERR_HQMemoryKind.SIGHTING && ERR_HQMemoryRules.Actionable(Confidence, Radius); }
|
|
}
|
|
|
|
class ERR_HQMemoryTrack
|
|
{
|
|
int BeliefId;
|
|
int BeliefRevision;
|
|
int NegativeRevision = -1;
|
|
float SearchConfidence = 1;
|
|
IEntity Key;
|
|
int Kind;
|
|
float DeliveredAt;
|
|
protected ref array<ref ERR_HQReport> m_Samples = {};
|
|
|
|
ERR_HQReport Latest() { return m_Samples[m_Samples.Count() - 1]; }
|
|
int Count() { return m_Samples.Count(); }
|
|
void Append(ERR_HQReport report, float now)
|
|
{
|
|
if (m_Samples.Count() >= 8) m_Samples.Remove(0);
|
|
m_Samples.Insert(report.Snapshot());
|
|
DeliveredAt = now;
|
|
BeliefRevision++;
|
|
SearchConfidence = 1;
|
|
}
|
|
float ConfidenceAt(float now)
|
|
{
|
|
float seconds = Math.Max(0, (now - Latest().ObservedAt) / 1000);
|
|
return SearchConfidence * ERR_HQMemoryRules.BaseConfidence(Kind)
|
|
* ERR_HQMemoryRules.Confidence(seconds, ERR_HQMemoryRules.HalfLife(Kind));
|
|
}
|
|
float RadiusAt(float now)
|
|
{
|
|
ERR_HQReport last = Latest();
|
|
return ERR_HQMemoryRules.Radius(Math.Max(0, (now - last.ObservedAt) / 1000),
|
|
ERR_HQMemoryRules.RadiusGrowth(Kind, last.TravelMode), ERR_HQMemoryRules.RadiusFloor(Kind, last.TravelMode));
|
|
}
|
|
ERR_HQBelief Evaluate(float now)
|
|
{
|
|
ERR_HQReport last = Latest();
|
|
ERR_HQBelief belief = new ERR_HQBelief();
|
|
belief.BeliefId = BeliefId;
|
|
belief.BeliefRevision = BeliefRevision;
|
|
belief.EvaluatedAt = now;
|
|
belief.Report = last.Snapshot();
|
|
belief.Target = last.Target;
|
|
belief.Position = last.Position;
|
|
belief.PredictedPosition = last.Position;
|
|
belief.ObservedAt = last.ObservedAt;
|
|
belief.LastDeliveredAt = DeliveredAt;
|
|
belief.MessageId = last.MessageId;
|
|
belief.SampleCount = Count();
|
|
belief.Kind = Kind;
|
|
belief.TravelMode = last.TravelMode;
|
|
belief.Confidence = ConfidenceAt(now);
|
|
belief.Radius = RadiusAt(now);
|
|
belief.PredictionSource = "delivered sighting; motion unknown";
|
|
if (Kind == ERR_HQMemoryKind.DISTRESS)
|
|
{
|
|
belief.Target = null;
|
|
belief.PredictionSource = "delivered distress; enemy position and motion unknown";
|
|
return belief;
|
|
}
|
|
if (Kind == ERR_HQMemoryKind.BODY)
|
|
{
|
|
belief.PredictionSource = "reported body location; no moving-target inference";
|
|
return belief;
|
|
}
|
|
if (Count() < 2) return belief;
|
|
ERR_HQReport previous = m_Samples[Count() - 2];
|
|
float seconds = (last.ObservedAt - previous.ObservedAt) / 1000;
|
|
float distance = vector.DistanceXZ(previous.Position, last.Position);
|
|
if (!ERR_HQMemoryRules.MotionCredible(seconds, distance, previous.TravelMode, last.TravelMode))
|
|
{
|
|
belief.PredictionSource = "delivered sighting; motion history not credible";
|
|
return belief;
|
|
}
|
|
belief.Velocity = (last.Position - previous.Position) / seconds;
|
|
belief.Velocity[1] = 0;
|
|
belief.Speed = belief.Velocity.Length();
|
|
belief.HasVelocity = true;
|
|
float age = Math.Max(0, (now - last.ObservedAt) / 1000);
|
|
belief.PredictionSeconds = Math.Clamp(age, 0, 30);
|
|
belief.PredictedPosition = ERR_HQMemoryRules.Predict(last.Position, belief.Velocity, age);
|
|
belief.HeadingDegrees = Math.Atan2(belief.Velocity[0], belief.Velocity[2]) * Math.RAD2DEG;
|
|
if (belief.HeadingDegrees < 0) belief.HeadingDegrees += 360;
|
|
belief.PredictionSource = "successive delivered sightings; 30s/300m prediction limit";
|
|
if (belief.Speed < 0.5) belief.PredictionSource = "successive delivered sightings; approximately stationary";
|
|
return belief;
|
|
}
|
|
}
|
|
|
|
class ERR_HQMemory
|
|
{
|
|
protected ref array<ref ERR_HQMemoryTrack> m_Tracks = {};
|
|
protected ref array<int> m_Seen = {};
|
|
protected ref array<float> m_SeenAt = {};
|
|
protected int m_NextBeliefId;
|
|
protected float m_Now = -1;
|
|
protected string m_LastRefusal;
|
|
|
|
string LastRefusal() { return m_LastRefusal; }
|
|
int Count() { return m_Tracks.Count(); }
|
|
protected bool Refuse(string reason) { m_LastRefusal = reason; return false; }
|
|
protected int Find(IEntity key, int kind)
|
|
{
|
|
for (int i = 0; i < m_Tracks.Count(); i++)
|
|
if (m_Tracks[i].Key == key && m_Tracks[i].Kind == kind) return i;
|
|
return -1;
|
|
}
|
|
void Clear()
|
|
{
|
|
m_Tracks.Clear(); m_Seen.Clear(); m_SeenAt.Clear();
|
|
m_Now = -1; m_LastRefusal = "";
|
|
// Do not recycle belief IDs: an order retained across Clear cannot bind to a new track.
|
|
}
|
|
// A slow HQ tick advances only the observation clock and bounded retirement.
|
|
// Confidence/radius are calculated on read; there is no per-frame decay work.
|
|
void Tick(float now)
|
|
{
|
|
if (!ERR_HQMemoryRules.Finite(now) || now < 0 || now < m_Now) return;
|
|
m_Now = now;
|
|
for (int message = m_Seen.Count() - 1; message >= 0; message--)
|
|
{
|
|
if (now - m_SeenAt[message] <= 120000) continue;
|
|
m_Seen.Remove(message); m_SeenAt.Remove(message);
|
|
}
|
|
for (int i = m_Tracks.Count() - 1; i >= 0; i--)
|
|
if (m_Tracks[i].RadiusAt(now) > 2000) m_Tracks.Remove(i);
|
|
}
|
|
// Call exclusively after the radio network accepts delivery. No other method
|
|
// can inject a report, and no stored report object is exposed to a caller.
|
|
bool Ingest(ERR_HQReport report, float now)
|
|
{
|
|
if (!ERR_HQMemoryRules.Finite(now) || now < m_Now || now < 0) return Refuse("invalid memory clock");
|
|
Tick(now);
|
|
if (!report || !ERR_HQMemoryRules.DeliveryFresh(report.ObservedAt, now)) return Refuse("stale or future report");
|
|
if (!ERR_HQMemoryRules.PositionValid(report.Position)) return Refuse("invalid observed position");
|
|
if (!ERR_HQMemoryRules.NewMessage(report.MessageId, m_Seen.Contains(report.MessageId)))
|
|
return Refuse("replayed or expired message");
|
|
if (m_Seen.Count() >= 4096) return Refuse("delivered-report replay guard full");
|
|
int kind = ERR_HQMemoryRules.Kind(report);
|
|
IEntity key = report.Target;
|
|
if (kind == ERR_HQMemoryKind.DISTRESS) key = report.Source;
|
|
if (!key) return Refuse("report has no observation identity");
|
|
int index = Find(key, kind);
|
|
if (index >= 0 && report.ObservedAt <= m_Tracks[index].Latest().ObservedAt)
|
|
return Refuse("observation does not advance trajectory");
|
|
if (index < 0 && m_NextBeliefId >= 2147483646) return Refuse("belief identity space exhausted");
|
|
if (index < 0 && m_Tracks.Count() >= 64)
|
|
{
|
|
int weakest = -1;
|
|
float least = 2;
|
|
for (int i = 0; i < m_Tracks.Count(); i++)
|
|
{
|
|
float value = ERR_HQMemoryRules.Value(m_Tracks[i].ConfidenceAt(now), m_Tracks[i].RadiusAt(now));
|
|
if (value < least) { least = value; weakest = i; }
|
|
}
|
|
float age = (now - report.ObservedAt) / 1000;
|
|
float incoming = ERR_HQMemoryRules.BaseConfidence(kind);
|
|
incoming *= ERR_HQMemoryRules.Confidence(age, ERR_HQMemoryRules.HalfLife(kind));
|
|
float radius = ERR_HQMemoryRules.Radius(age, ERR_HQMemoryRules.RadiusGrowth(kind, report.TravelMode),
|
|
ERR_HQMemoryRules.RadiusFloor(kind, report.TravelMode));
|
|
if (weakest < 0 || ERR_HQMemoryRules.Value(incoming, radius) <= least)
|
|
return Refuse("memory full; stronger observations retained");
|
|
m_Tracks.Remove(weakest);
|
|
}
|
|
ERR_HQMemoryTrack track;
|
|
if (index >= 0) track = m_Tracks[index];
|
|
else
|
|
{
|
|
track = new ERR_HQMemoryTrack();
|
|
track.BeliefId = ++m_NextBeliefId; track.Key = key; track.Kind = kind;
|
|
m_Tracks.Insert(track);
|
|
}
|
|
track.Append(report, now);
|
|
m_Seen.Insert(report.MessageId); m_SeenAt.Insert(report.ObservedAt);
|
|
m_LastRefusal = "";
|
|
return true;
|
|
}
|
|
ERR_HQBelief Get(IEntity target)
|
|
{
|
|
if (!target) return null;
|
|
int index = Find(target, ERR_HQMemoryKind.SIGHTING);
|
|
if (index < 0) return null;
|
|
return m_Tracks[index].Evaluate(m_Now);
|
|
}
|
|
ERR_HQBelief GetById(int beliefId)
|
|
{
|
|
for (int i = 0; i < m_Tracks.Count(); i++)
|
|
if (m_Tracks[i].BeliefId == beliefId) return m_Tracks[i].Evaluate(m_Now);
|
|
return null;
|
|
}
|
|
// This is negative evidence from an actually delivered sweep report, not a
|
|
// synthetic sighting. A duplicate cannot compound the same observation's loss.
|
|
bool NegativeSearch(int beliefId, int revision, float now)
|
|
{
|
|
if (!ERR_HQMemoryRules.Finite(now) || now < m_Now || now < 0) return false;
|
|
Tick(now);
|
|
for (int i = 0; i < m_Tracks.Count(); i++)
|
|
{
|
|
ERR_HQMemoryTrack track = m_Tracks[i];
|
|
if (track.BeliefId != beliefId || track.Kind != ERR_HQMemoryKind.SIGHTING) continue;
|
|
if (track.BeliefRevision != revision || track.NegativeRevision == revision) return false;
|
|
track.SearchConfidence = 0.65;
|
|
track.NegativeRevision = revision;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
ERR_HQBelief At(int index)
|
|
{
|
|
if (index < 0 || index >= m_Tracks.Count()) return null;
|
|
return m_Tracks[index].Evaluate(m_Now);
|
|
}
|
|
}
|