Files
EscapistsReintroduced/addons/ai/Scripts/Game/ERR/ERR_HQMemory.c
T

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);
}
}