threat better

This commit is contained in:
Boki 2026-03-07 12:27:25 -05:00
parent 05bbcb244f
commit 703cfbfdee
12 changed files with 581 additions and 228 deletions

View file

@ -4,12 +4,12 @@ Size=400,400
Collapsed=0
[Window][Simulator Controls]
Pos=60,60
Size=219,425
Pos=29,51
Size=432,649
Collapsed=0
[Window][Simulator]
Pos=341,232
Pos=499,177
Size=1200,681
Collapsed=0

View file

@ -27,8 +27,9 @@ public class GameState
/// <summary>In-progress quests from the quest linked list with target areas and paths.</summary>
public IReadOnlyList<QuestInfo> Quests { get; set; } = [];
// Derived (computed once per tick by GameStateEnricher)
// Derived (computed once per tick by GameStateEnricher / ThreatSystem)
public ThreatMap Threats { get; set; } = new();
public ThreatAssessment ThreatAssessment { get; set; } = new();
public IReadOnlyList<EntitySnapshot> NearestEnemies { get; set; } = [];
public IReadOnlyList<GroundEffect> GroundEffects { get; set; } = [];
}

View file

@ -28,15 +28,14 @@ public sealed class MovementBlender
// Snap decision based on INTENT change (pre-terrain), not terrain output — prevents
// terrain probe noise from bypassing the EMA via the snap threshold.
private Vector2? _smoothedDirection;
private Vector2? _lastIntentDir; // pre-terrain direction from previous frame
private const float SmoothingAlpha = 0.12f; // 12% new, 88% previous
private const float SmoothingAlpha = 0.20f; // 20% new, 80% previous
// Terrain validation cache — prevents re-probing within the same grid cell,
// Terrain validation cache — prevents re-probing within a small radius,
// breaking the position↔direction feedback loop that causes zigzag oscillation
private Vector2 _cachedTerrainInputDir;
private Vector2 _cachedTerrainResult;
private int _cachedTerrainGridX = int.MinValue;
private int _cachedTerrainGridY = int.MinValue;
private Vector2 _cachedTerrainPos;
private const float TerrainCacheRadius = 20f; // don't re-probe within 20 world units
public Vector2? Direction { get; private set; }
public Vector2? RawDirection { get; private set; }
@ -130,19 +129,16 @@ public sealed class MovementBlender
// Normalize the blended result
var rawDir = Vector2.Normalize(result);
var intentDir = rawDir; // save pre-terrain direction for snap decision
// Terrain validation with grid-cell caching.
// Re-probe only when the raw direction changes (>~14°) or the player enters a new grid cell.
// This prevents the feedback loop: direction jitter → zigzag movement → crosses cell boundary → more jitter.
if (terrain is not null)
{
var gx = (int)(playerPos.X * worldToGrid);
var gy = (int)(playerPos.Y * worldToGrid);
var dirSimilar = Vector2.Dot(rawDir, _cachedTerrainInputDir) > 0.97f;
var sameCell = gx == _cachedTerrainGridX && gy == _cachedTerrainGridY;
var nearbyPos = Vector2.DistanceSquared(playerPos, _cachedTerrainPos) < TerrainCacheRadius * TerrainCacheRadius;
if (dirSimilar && sameCell)
if (dirSimilar && nearbyPos)
{
rawDir = _cachedTerrainResult;
}
@ -152,28 +148,23 @@ public sealed class MovementBlender
rawDir = TerrainQuery.FindWalkableDirection(terrain, playerPos, rawDir, worldToGrid);
_cachedTerrainInputDir = preTerrainDir;
_cachedTerrainResult = rawDir;
_cachedTerrainGridX = gx;
_cachedTerrainGridY = gy;
_cachedTerrainPos = playerPos;
}
}
RawDirection = rawDir;
// EMA smoothing. Snap decision based on whether the INTENT (pre-terrain) changed,
// not the terrain output. This prevents terrain probe noise (which can produce 90°+ swings)
// from bypassing the EMA via the snap threshold.
// EMA smoothing. Only snap (bypass smoothing) on urgent flee (L0),
// which needs instant response. All other direction changes (orbit flips,
// terrain jitter, waypoint changes) get smoothed to prevent oscillation.
if (_smoothedDirection.HasValue)
{
var intentChanged = _lastIntentDir.HasValue &&
Vector2.Dot(_lastIntentDir.Value, intentDir) < 0f;
if (intentChanged)
if (IsUrgentFlee)
{
// Genuine intent reversal (flee, new waypoint) — snap immediately
// Emergency flee — snap immediately, no smoothing
}
else
{
// Intent is stable — all direction change is terrain noise, always smooth
var smoothed = Vector2.Lerp(_smoothedDirection.Value, rawDir, SmoothingAlpha);
if (smoothed.LengthSquared() > 0.0001f)
rawDir = Vector2.Normalize(smoothed);
@ -181,7 +172,6 @@ public sealed class MovementBlender
}
_smoothedDirection = rawDir;
_lastIntentDir = intentDir;
Direction = rawDir;
}
@ -199,9 +189,7 @@ public sealed class MovementBlender
_stuckFrames = 0;
_lastResolvePos = Vector2.Zero;
_smoothedDirection = null;
_lastIntentDir = null;
_cachedTerrainGridX = int.MinValue;
_cachedTerrainGridY = int.MinValue;
_cachedTerrainPos = new Vector2(float.MinValue, float.MinValue);
}
/// <summary>

View file

@ -14,6 +14,8 @@ public sealed class MovementKeyTracker
private bool _wHeld, _aHeld, _sHeld, _dHeld;
private long _wDownAt, _aDownAt, _sDownAt, _dDownAt;
private int _wMinHold, _aMinHold, _sMinHold, _dMinHold;
private long _wUpAt, _aUpAt, _sUpAt, _dUpAt;
private int _wRepress, _aRepress, _sRepress, _dRepress;
private Vector2? _lastPlayerPos;
private static readonly Random Rng = new();
@ -55,10 +57,10 @@ public sealed class MovementKeyTracker
}
var now = Environment.TickCount64;
SetKey(input, ScanCodes.W, ref _wHeld, ref _wDownAt, ref _wMinHold, wantW, now, _lastPlayerPos);
SetKey(input, ScanCodes.A, ref _aHeld, ref _aDownAt, ref _aMinHold, wantA, now, _lastPlayerPos);
SetKey(input, ScanCodes.S, ref _sHeld, ref _sDownAt, ref _sMinHold, wantS, now, _lastPlayerPos);
SetKey(input, ScanCodes.D, ref _dHeld, ref _dDownAt, ref _dMinHold, wantD, now, _lastPlayerPos);
SetKey(input, ScanCodes.W, ref _wHeld, ref _wDownAt, ref _wMinHold, ref _wUpAt, ref _wRepress, wantW, now, _lastPlayerPos);
SetKey(input, ScanCodes.A, ref _aHeld, ref _aDownAt, ref _aMinHold, ref _aUpAt, ref _aRepress, wantA, now, _lastPlayerPos);
SetKey(input, ScanCodes.S, ref _sHeld, ref _sDownAt, ref _sMinHold, ref _sUpAt, ref _sRepress, wantS, now, _lastPlayerPos);
SetKey(input, ScanCodes.D, ref _dHeld, ref _dDownAt, ref _dMinHold, ref _dUpAt, ref _dRepress, wantD, now, _lastPlayerPos);
}
/// <summary>
@ -78,10 +80,14 @@ public sealed class MovementKeyTracker
};
private static void SetKey(IInputController input, ushort scanCode,
ref bool held, ref long downAt, ref int minHold, bool want, long now, Vector2? pos)
ref bool held, ref long downAt, ref int minHold,
ref long upAt, ref int repressDelay, bool want, long now, Vector2? pos)
{
if (want && !held)
{
// Enforce re-press cooldown after release
if (now - upAt < repressDelay) return;
input.KeyDown(scanCode);
held = true;
downAt = now;
@ -96,8 +102,11 @@ public sealed class MovementKeyTracker
{
var elapsed = now - downAt;
if (elapsed < minHold) return; // enforce minimum hold
input.KeyUp(scanCode);
held = false;
upAt = now;
repressDelay = RepressMs();
if (pos.HasValue)
Log.Information("[WASD] {Key} UP (held={Elapsed}ms, min={MinHold}ms) pos=({X:F0},{Y:F0})",
KeyName(scanCode), elapsed, minHold, pos.Value.X, pos.Value.Y);
@ -119,4 +128,18 @@ public sealed class MovementKeyTracker
var g = u * Math.Sqrt(-2.0 * Math.Log(s) / s);
return Math.Clamp((int)Math.Round(55.0 + g * 6.0), 44, 76);
}
/// <summary>Gaussian re-press cooldown peaked at 40ms, range [25, 65].</summary>
private static int RepressMs()
{
double u, v, s;
do
{
u = Rng.NextDouble() * 2.0 - 1.0;
v = Rng.NextDouble() * 2.0 - 1.0;
s = u * u + v * v;
} while (s >= 1.0 || s == 0.0);
var g = u * Math.Sqrt(-2.0 * Math.Log(s) / s);
return Math.Clamp((int)Math.Round(40.0 + g * 8.0), 25, 65);
}
}

View file

@ -0,0 +1,51 @@
using System.Numerics;
namespace Nexus.Core;
public enum ThreatCategory
{
Ignore, // score ≤ 2 — not worth reacting to
Monitor, // score 26 — track but don't change behavior
Engage, // score 615 — fight, stay mobile
Flee, // score 1525 — kite aggressively
Emergency, // score > 25 OR player HP critical — run, pop flasks
}
public class ThreatEntry
{
public uint EntityId { get; init; }
public Vector2 Position { get; set; }
public float DistanceToPlayer { get; set; }
public float ThreatScore { get; set; }
public float PerceivedDanger { get; set; } // normalized 0..1
public ThreatCategory Category { get; set; }
public bool HasLineOfSight { get; set; }
public MonsterRarity Rarity { get; init; }
public float HpPercent { get; set; }
public bool IsAlive { get; set; }
}
public class ThreatAssessment
{
public List<ThreatEntry> Entries { get; set; } = [];
// Precomputed aggregates — consumed by steering, combat, state machine
public float ZoneThreatLevel { get; set; } // sum of all scores
public ThreatEntry? PrimaryTarget { get; set; } // best kill target
public ThreatEntry? MostDangerous { get; set; } // highest threat score
public Vector2 ThreatCentroid { get; set; } // score-weighted center
public Vector2 SafestDirection { get; set; } // away from centroid
public bool AnyEmergency { get; set; }
public bool ShouldFlee { get; set; } // zone threat > flee threshold
public bool AreaClear { get; set; } // no Monitor+ threats remain
public float ClosestDistance { get; set; }
/// <summary>Continuous 0..1 flee weight for steering blend.</summary>
public float FleeWeight { get; set; }
// Convenience — backward compatibility
public int CloseRange { get; set; } // < 300
public int MidRange { get; set; } // 300600
public int FarRange { get; set; } // 6001200
public bool HasRareOrUnique { get; set; }
}

View file

@ -8,7 +8,7 @@ public class ThreatMap
public int CloseRange { get; init; } // < 300 units
public int MidRange { get; init; } // 300600
public int FarRange { get; init; } // 6001200
public float ClosestDistance { get; init; } = float.MaxValue;
public float ClosestDistance { get; init; }
public Vector2 ThreatCentroid { get; init; }
public bool HasRareOrUnique { get; init; }
}

View file

@ -4,16 +4,14 @@ using Nexus.Core;
namespace Nexus.Data;
/// <summary>
/// Computes all derived fields on GameState once per tick.
/// Static methods, no allocations beyond the sorted list.
/// Computes derived fields on GameState once per tick.
/// Threat scoring is now handled by ThreatSystem (runs as ISystem).
/// </summary>
public static class GameStateEnricher
{
public static void Enrich(GameState state)
{
state.NearestEnemies = ComputeNearestEnemies(state.HostileMonsters);
state.Threats = ComputeThreatMap(state.HostileMonsters);
state.Danger = ComputeDangerLevel(state);
state.GroundEffects = []; // stub until memory reads ground effects
}
@ -25,143 +23,4 @@ public static class GameStateEnricher
sorted.Sort((a, b) => a.DistanceToPlayer.CompareTo(b.DistanceToPlayer));
return sorted;
}
private static ThreatMap ComputeThreatMap(IReadOnlyList<EntitySnapshot> hostiles)
{
if (hostiles.Count == 0) return new ThreatMap();
int close = 0, mid = 0, far = 0;
float closest = float.MaxValue;
var weightedSum = Vector2.Zero;
bool hasRareOrUnique = false;
foreach (var m in hostiles)
{
var d = m.DistanceToPlayer;
if (d < closest) closest = d;
if (d < 300f) close++;
else if (d < 600f) mid++;
else if (d < 1200f) far++;
weightedSum += m.Position;
if (m.ThreatLevel is MonsterThreatLevel.Rare or MonsterThreatLevel.Unique)
hasRareOrUnique = true;
}
return new ThreatMap
{
TotalHostiles = hostiles.Count,
CloseRange = close,
MidRange = mid,
FarRange = far,
ClosestDistance = closest,
ThreatCentroid = weightedSum / hostiles.Count,
HasRareOrUnique = hasRareOrUnique,
};
}
/// <summary>
/// Computes danger using effective HP (life + energy shield) and a weighted threat score.
/// Close enemies count more, rares/uniques escalate significantly.
/// Hysteresis: de-escalation requires a larger margin than escalation to prevent oscillation.
/// </summary>
private static DangerLevel _previousDanger = DangerLevel.Safe;
private static float _smoothedThreatScore;
private static long _lastEscalationMs;
private static DangerLevel ComputeDangerLevel(GameState state)
{
// Effective HP = life + ES combined
var effectiveHp = state.Player.LifeCurrent + state.Player.EsCurrent;
var effectiveMax = state.Player.LifeTotal + state.Player.EsTotal;
var effectivePercent = effectiveMax > 0 ? (float)effectiveHp / effectiveMax * 100f : 0f;
// Pure life check — if ES is gone and life is low, it's critical (no hysteresis)
if (state.Player.LifePercent < 25f)
{
_previousDanger = DangerLevel.Critical;
return DangerLevel.Critical;
}
if (effectivePercent < 35f)
{
_previousDanger = DangerLevel.Critical;
return DangerLevel.Critical;
}
if (effectivePercent < 50f)
{
var hpLevel = DangerLevel.High;
if (hpLevel < _previousDanger)
hpLevel = _previousDanger; // don't de-escalate from HP alone
_previousDanger = hpLevel;
return hpLevel;
}
// Weighted threat score: smooth distance falloff × rarity multiplier
var threatScore = 0f;
foreach (var m in state.HostileMonsters)
{
var d = m.DistanceToPlayer;
if (d > 800f) continue;
// Smooth distance weight: linear falloff from 3.0 at d=0 to 0.5 at d=800
var distWeight = 3f - 2.5f * (d / 800f);
// Rarity multiplier
var rarityMul = m.Rarity switch
{
MonsterRarity.Unique => 5f,
MonsterRarity.Rare => 3f,
MonsterRarity.Magic => 1.5f,
_ => 1f,
};
threatScore += distWeight * rarityMul;
}
// EMA smoothing — prevents single-frame score spikes from causing oscillation.
// Snap upward (escalation is instant), smooth downward (de-escalation is gradual).
const float deescalationAlpha = 0.08f;
if (threatScore >= _smoothedThreatScore)
_smoothedThreatScore = threatScore; // snap up — react instantly to new threats
else
_smoothedThreatScore += (threatScore - _smoothedThreatScore) * deescalationAlpha;
threatScore = _smoothedThreatScore;
// Escalation thresholds
var level = DangerLevel.Safe;
if (threatScore >= 15f) level = DangerLevel.Critical;
else if (threatScore >= 8f) level = DangerLevel.High;
else if (threatScore >= 4f) level = DangerLevel.Medium;
else if (threatScore > 0f) level = DangerLevel.Low;
// Hysteresis: minimum hold time + score margins prevent oscillation
var now = Environment.TickCount64;
if (level != _previousDanger)
{
// Hold any level for at least 1.5 seconds before allowing ANY transition
if (now - _lastEscalationMs < 1500)
{
level = _previousDanger;
}
else if (level < _previousDanger)
{
// Score-based hysteresis — only drop one level at a time
if (_previousDanger >= DangerLevel.Critical)
level = DangerLevel.High;
else if (_previousDanger >= DangerLevel.High)
level = DangerLevel.Medium;
else if (_previousDanger >= DangerLevel.Medium && threatScore >= 2f)
level = DangerLevel.Medium;
}
}
// Track any transition
if (level != _previousDanger)
_lastEscalationMs = now;
_previousDanger = level;
return level;
}
}

View file

@ -22,7 +22,7 @@ public class SimConfig
public float EnemyAggroRange { get; set; } = 600f;
public float EnemyMeleeAttackRange { get; set; } = 100f;
public float EnemyMoveSpeedFactor { get; set; } = 0.75f;
public int EnemyBaseHealth { get; set; } = 200;
public int EnemyBaseHealth { get; set; } = 500;
public int EnemyMeleeBaseDamage { get; set; } = 60;
public float EnemyMeleeAttackCooldown { get; set; } = 1.2f;
@ -42,8 +42,8 @@ public class SimConfig
public float EnemySpawnMinDist { get; set; } = 800f;
public float EnemySpawnMaxDist { get; set; } = 2000f;
public float EnemyCullDist { get; set; } = 3000f;
public int EnemyGroupMin { get; set; } = 3;
public int EnemyGroupMax { get; set; } = 7;
public int EnemyGroupMin { get; set; } = 7;
public int EnemyGroupMax { get; set; } = 18;
public float EnemyGroupSpread { get; set; } = 120f;
// Player skills

View file

@ -18,6 +18,12 @@ Log.Logger = new LoggerConfiguration()
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss.fff} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.CreateLogger();
// Parse --ticks N for headless auto-exit
int? maxTicks = null;
for (int i = 0; i < args.Length - 1; i++)
if (args[i] == "--ticks" && int.TryParse(args[i + 1], out var t))
maxTicks = t;
Log.Information("Nexus Simulator starting...");
// ── Configuration ──
@ -75,6 +81,7 @@ var movementBlender = new MovementBlender();
var moveTracker = new MovementKeyTracker();
var botRunning = true;
var lastStatusLogMs = 0L;
var botTickCount = 0;
var botThread = new Thread(() =>
{
@ -91,6 +98,13 @@ var botThread = new Thread(() =>
var resolved = BotTick.Run(state, systems, actionQueue, movementBlender, nav, botConfig);
ActionExecutor.Execute(resolved, input, moveTracker, movementBlender, state.Player.Position);
botTickCount++;
if (maxTicks.HasValue && botTickCount >= maxTicks.Value)
{
botRunning = false;
break;
}
// Periodic status log (every 2 seconds)
var nowMs = Environment.TickCount64;
if (nowMs - lastStatusLogMs >= 2000)
@ -110,12 +124,15 @@ var botThread = new Thread(() =>
}));
if (actions.Length == 0) actions = "none";
var ta = state.ThreatAssessment;
Log.Information(
"Status: HP={HP}/{MaxHP} ES={ES}/{MaxES} Mana={MP}/{MaxMP} Danger={Danger} " +
"Status: HP={HP}/{MaxHP} ES={ES}/{MaxES} Mana={MP}/{MaxMP} " +
"Threat={Zone:F1}(fw={Fw:F2}) " +
"Enemies={Total}({Melee}m/{Ranged}r) Nav={NavMode} Actions=[{Actions}] " +
"Move=[{Blender}]",
p.LifeCurrent, p.LifeTotal, p.EsCurrent, p.EsTotal, p.ManaCurrent, p.ManaTotal,
state.Danger, melee + ranged, melee, ranged,
ta.ZoneThreatLevel, ta.FleeWeight,
melee + ranged, melee, ranged,
nav.Mode, actions, movementBlender.DiagnosticSummary());
}
}
@ -172,7 +189,7 @@ window.Resized += () =>
var renderSw = System.Diagnostics.Stopwatch.StartNew();
var lastRenderMs = 0.0;
while (window.Exists)
while (window.Exists && botRunning)
{
var nowMs = renderSw.Elapsed.TotalMilliseconds;
var deltaSeconds = (float)((nowMs - lastRenderMs) / 1000.0);

View file

@ -16,6 +16,15 @@ public class DebugPanel
private int _spawnRarity; // 0=Normal, 1=Magic, 2=Rare, 3=Unique
// Cached threat display — updated at ~4Hz to prevent visual flicker
private long _lastThreatUpdateMs;
private string _cachedZoneLine = "";
private string _cachedDangerLine = "";
private string _cachedRangeLine = "";
private string _cachedClosestLine = "";
private string _cachedTopThreatLine = "Top Threat: None";
private string _cachedKillTargetLine = "Kill Target: None";
public DebugPanel(SimConfig config, SimWorld world, NavigationController nav, IReadOnlyList<ISystem> systems)
{
_config = config;
@ -108,14 +117,32 @@ public class DebugPanel
}
}
// Threat info
if (state is not null && ImGui.CollapsingHeader("Threat"))
// Threat info — cached at ~4Hz to prevent visual flicker
if (state is not null && ImGui.CollapsingHeader("Threat", ImGuiTreeNodeFlags.DefaultOpen))
{
ImGui.Text($"Danger: {state.Danger}");
var threats = state.Threats;
ImGui.Text($"Close: {threats.CloseRange} Mid: {threats.MidRange} Far: {threats.FarRange}");
ImGui.Text($"Closest: {threats.ClosestDistance:F0}");
ImGui.Text($"Has Rare/Unique: {threats.HasRareOrUnique}");
var nowMs = Environment.TickCount64;
if (nowMs - _lastThreatUpdateMs >= 250)
{
_lastThreatUpdateMs = nowMs;
var ta = state.ThreatAssessment;
_cachedZoneLine = $"Zone Threat: {ta.ZoneThreatLevel:F1} Flee Weight: {ta.FleeWeight:F2}";
_cachedDangerLine = $"Danger: {state.Danger} Flee: {ta.ShouldFlee} Emergency: {ta.AnyEmergency}";
_cachedRangeLine = $"Close: {ta.CloseRange} Mid: {ta.MidRange} Far: {ta.FarRange}";
_cachedClosestLine = $"Closest: {ta.ClosestDistance:F0} Rare/Unique: {ta.HasRareOrUnique}";
_cachedTopThreatLine = ta.MostDangerous is { } top
? $"Top Threat: #{top.EntityId} ({top.Rarity}) score={top.ThreatScore:F1} cat={top.Category}"
: "Top Threat: None";
_cachedKillTargetLine = ta.PrimaryTarget is { } pt
? $"Kill Target: #{pt.EntityId} ({pt.Rarity}) HP={pt.HpPercent:P0} dist={pt.DistanceToPlayer:F0}"
: "Kill Target: None";
}
ImGui.Text(_cachedZoneLine);
ImGui.Text(_cachedDangerLine);
ImGui.Text(_cachedRangeLine);
ImGui.Text(_cachedClosestLine);
ImGui.Text(_cachedTopThreatLine);
ImGui.Text(_cachedKillTargetLine);
}
// Action queue

View file

@ -176,7 +176,8 @@ public class SimRenderer
if (state is not null)
{
drawList.AddText(textPos, color, $"Danger: {state.Danger} Enemies: {state.HostileMonsters.Count}");
var ta = state.ThreatAssessment;
drawList.AddText(textPos, color, $"Threat: {ta.ZoneThreatLevel:F0} (fw={ta.FleeWeight:F1}) Enemies: {state.HostileMonsters.Count}");
textPos.Y += 16;
}

View file

@ -5,9 +5,9 @@ using Serilog;
namespace Nexus.Systems;
/// <summary>
/// Emergency-only threat response. Runs first (priority 50).
/// Only fires on Critical danger (low HP or overwhelming threat score).
/// Normal combat (High/Medium) is handled by MovementSystem orbiting + CombatSystem herding.
/// Per-entity threat scoring with continuous flee weights.
/// Runs first (priority 50). Builds ThreatAssessment on GameState,
/// then submits flee movement intents when warranted.
/// </summary>
public class ThreatSystem : ISystem
{
@ -15,54 +15,440 @@ public class ThreatSystem : ISystem
public string Name => "Threat";
public bool IsEnabled { get; set; } = true;
/// <summary>If closest enemy is within this range AND danger is Critical, escalate to urgent flee.</summary>
public float PointBlankRange { get; set; } = 120f;
/// <summary>World-to-grid conversion factor for terrain queries.</summary>
public float WorldToGrid { get; set; } = 23f / 250f;
private DangerLevel _prevDanger = DangerLevel.Safe;
// ── Config ──
public float MaxThreatRange { get; set; } = 900f;
public float PointBlankRange { get; set; } = 120f;
public float FleeThreshold { get; set; } = 180f;
public float PanicThreshold { get; set; } = 350f;
// Weights for score composition
private const float W_Distance = 1.0f;
private const float W_Rarity = 1.0f;
private const float W_PackSize = 0.3f;
// Score decay for smooth transitions
private float _smoothedZoneThreat;
private const float ZoneThreatUpAlpha = 0.12f; // ramp up over ~8 ticks (~130ms)
private const float ZoneThreatDownAlpha = 0.04f; // decay over ~25 ticks (~400ms)
// Top-threat debounce — locked entity must lose for N consecutive ticks before switching
private uint? _lockedTopThreatId;
private int _loseStreak;
private const int TopThreatDebounce = 20; // ~330ms at 60Hz
// Kill-target debounce
private uint? _lockedKillTargetId;
private int _killLoseStreak;
private const int KillTargetDebounce = 15; // ~250ms
// Logging
private ThreatCategory _prevMaxCategory = ThreatCategory.Ignore;
private uint? _prevTopThreatId;
public void Update(GameState state, ActionQueue actions, MovementBlender movement)
{
if (!state.Player.HasPosition) return;
var danger = state.Danger;
var threats = state.Threats;
var playerPos = state.Player.Position;
var playerHpFactor = 1f + (1f - state.Player.LifePercent / 100f) * 1.5f;
// Log danger transitions
if (danger != _prevDanger)
// ── 1. Score each hostile ──
var entries = new List<ThreatEntry>(state.HostileMonsters.Count);
foreach (var monster in state.HostileMonsters)
{
if (danger >= DangerLevel.High)
Log.Warning("Threat: {Prev} -> {Cur} (hostiles={Total}, close={Close}, closest={Dist:F0})",
_prevDanger, danger, threats.TotalHostiles, threats.CloseRange, threats.ClosestDistance);
else
Log.Debug("Threat: {Prev} -> {Cur}", _prevDanger, danger);
_prevDanger = danger;
if (!monster.IsAlive) continue;
var entry = ScoreEntity(monster, playerPos, playerHpFactor, state);
entries.Add(entry);
}
// Only respond to Critical danger — High is normal combat, handled by orbit/herd
if (danger != DangerLevel.Critical) return;
if (threats.TotalHostiles == 0) return;
// Compute flee direction: away from threat centroid
var fleeDir = state.Player.Position - threats.ThreatCentroid;
if (fleeDir.LengthSquared() < 0.0001f)
fleeDir = Vector2.UnitY;
fleeDir = Vector2.Normalize(fleeDir);
var isPointBlank = threats.ClosestDistance < PointBlankRange;
if (isPointBlank)
// ── 2. Pack context pass — count nearby allies per monster (capped) ──
for (var i = 0; i < entries.Count; i++)
{
// Layer 0: total override — pure flee, blocks casting
movement.Submit(new MovementIntent(0, fleeDir, 1.0f, "Threat"));
var nearby = 0;
for (var j = 0; j < entries.Count; j++)
{
if (i == j) continue;
if (Vector2.DistanceSquared(entries[i].Position, entries[j].Position) < 600f * 600f)
nearby++;
}
// Cap pack bonus at 5 allies so 50-mob groups don't dominate the score
entries[i].ThreatScore += Math.Min(nearby, 5) * W_PackSize;
}
// ── 3. Classify each entry ──
var anyEmergency = false;
ThreatEntry? rawTopThreat = null;
ThreatEntry? lockedEntry = null;
var closestDist = 0f;
var closestFound = false;
foreach (var entry in entries)
{
entry.Category = Classify(entry, state.Player);
entry.PerceivedDanger = Math.Clamp(entry.ThreatScore / PanicThreshold, 0f, 1f);
if (entry.Category == ThreatCategory.Emergency)
anyEmergency = true;
if (rawTopThreat is null || entry.ThreatScore > rawTopThreat.ThreatScore
|| (entry.ThreatScore == rawTopThreat.ThreatScore && entry.EntityId < rawTopThreat.EntityId))
rawTopThreat = entry;
if (_lockedTopThreatId.HasValue && entry.EntityId == _lockedTopThreatId.Value)
lockedEntry = entry;
if (!closestFound || entry.DistanceToPlayer < closestDist)
{
closestDist = entry.DistanceToPlayer;
closestFound = true;
}
}
// Debounce top-threat: locked entity stays until it's clearly outclassed for N ticks
ThreatEntry? mostDangerous;
if (lockedEntry is null)
{
// Locked entity is dead/despawned — accept raw winner immediately
mostDangerous = rawTopThreat;
_lockedTopThreatId = rawTopThreat?.EntityId;
_loseStreak = 0;
}
else if (rawTopThreat is not null && rawTopThreat.EntityId != _lockedTopThreatId)
{
// Only count as "losing" if the locked entry is significantly behind (>30%)
var significantlyBehind = rawTopThreat.ThreatScore > lockedEntry.ThreatScore * 1.3f;
if (significantlyBehind)
_loseStreak++;
// Still count slow drift if locked is behind at all, but at half rate
else if (rawTopThreat.ThreatScore > lockedEntry.ThreatScore)
_loseStreak = Math.Max(0, _loseStreak); // don't reset, just don't increment
else
_loseStreak = Math.Max(0, _loseStreak - 1); // locked is actually winning, cool down
if (_loseStreak >= TopThreatDebounce)
{
mostDangerous = rawTopThreat;
_lockedTopThreatId = rawTopThreat.EntityId;
_loseStreak = 0;
}
else
{
mostDangerous = lockedEntry;
}
}
else
{
// Layer 1: strong flee but allow some nav/orbit bleed-through
movement.Submit(new MovementIntent(1, fleeDir, 0.6f, "Threat"));
// Locked entity is still the raw winner — cool down streak
mostDangerous = lockedEntry;
_loseStreak = Math.Max(0, _loseStreak - 2); // cool down faster when winning
}
// ── Log top threat changes ──
var newTopId = mostDangerous?.EntityId;
if (newTopId != _prevTopThreatId)
{
if (mostDangerous is not null)
Log.Information("TopThreat: #{Id} ({Rarity}) score={Score:F1} cat={Cat} dist={Dist:F0} hp={Hp:P0} (prev=#{Prev})",
mostDangerous.EntityId, mostDangerous.Rarity, mostDangerous.ThreatScore,
mostDangerous.Category, mostDangerous.DistanceToPlayer, mostDangerous.HpPercent,
_prevTopThreatId?.ToString() ?? "none");
else
Log.Information("TopThreat: cleared (prev=#{Prev})", _prevTopThreatId?.ToString() ?? "none");
_prevTopThreatId = newTopId;
}
// ── 4. Aggregate into ThreatAssessment ──
var rawZone = 0f;
foreach (var e in entries)
rawZone += e.ThreatScore;
// Smooth zone threat — fast up, slow down
if (rawZone >= _smoothedZoneThreat)
_smoothedZoneThreat += (rawZone - _smoothedZoneThreat) * ZoneThreatUpAlpha;
else
_smoothedZoneThreat += (rawZone - _smoothedZoneThreat) * ZoneThreatDownAlpha;
var centroid = ComputeThreatCentroid(entries, playerPos);
var safest = playerPos - centroid;
if (safest.LengthSquared() < 0.0001f) safest = Vector2.UnitY;
safest = Vector2.Normalize(safest);
// Hysteresis on flee transition — require score to drop 15% below threshold to de-escalate
var wasFleeing = _prevMaxCategory >= ThreatCategory.Flee;
var fleeOffThreshold = wasFleeing ? FleeThreshold * 0.85f : FleeThreshold;
var shouldFlee = _smoothedZoneThreat > fleeOffThreshold || anyEmergency;
var areaClear = entries.TrueForAll(e => e.Category < ThreatCategory.Monitor);
// Range band counts (backward compat)
int close = 0, mid = 0, far = 0;
bool hasRareOrUnique = false;
foreach (var e in entries)
{
if (e.DistanceToPlayer < 300f) close++;
else if (e.DistanceToPlayer < 600f) mid++;
else if (e.DistanceToPlayer < 1200f) far++;
if (e.Rarity >= MonsterRarity.Rare) hasRareOrUnique = true;
}
var assessment = new ThreatAssessment
{
Entries = entries,
ZoneThreatLevel = _smoothedZoneThreat,
PrimaryTarget = SelectPrimaryTarget(entries),
MostDangerous = mostDangerous,
ThreatCentroid = centroid,
SafestDirection = safest,
AnyEmergency = anyEmergency,
ShouldFlee = shouldFlee,
AreaClear = areaClear,
ClosestDistance = closestDist,
FleeWeight = Math.Clamp(_smoothedZoneThreat / PanicThreshold, 0f, 1f),
CloseRange = close,
MidRange = mid,
FarRange = far,
HasRareOrUnique = hasRareOrUnique,
};
// Debounce kill target — same lose-streak pattern
var rawKillTarget = assessment.PrimaryTarget;
var lockedKillEntry = _lockedKillTargetId.HasValue
? entries.FirstOrDefault(e => e.EntityId == _lockedKillTargetId.Value
&& e.Category >= ThreatCategory.Engage && e.HasLineOfSight)
: null;
if (lockedKillEntry is null)
{
_lockedKillTargetId = rawKillTarget?.EntityId;
_killLoseStreak = 0;
}
else if (rawKillTarget is not null && rawKillTarget.EntityId != _lockedKillTargetId)
{
_killLoseStreak++;
if (_killLoseStreak >= KillTargetDebounce)
{
_lockedKillTargetId = rawKillTarget.EntityId;
_killLoseStreak = 0;
}
else
{
assessment.PrimaryTarget = lockedKillEntry;
}
}
else
{
_killLoseStreak = 0;
}
state.ThreatAssessment = assessment;
// Backward compat — keep DangerLevel for consumers that still read it
state.Danger = ToDangerLevel(assessment);
state.Threats = new ThreatMap
{
TotalHostiles = entries.Count,
CloseRange = close,
MidRange = mid,
FarRange = far,
ClosestDistance = closestDist,
ThreatCentroid = centroid,
HasRareOrUnique = hasRareOrUnique,
};
// ── 5. Log zone-level transitions with hysteresis ──
// Each threshold has a lower de-escalation point (15% below) to prevent bouncing
var zoneCat = ClassifyZone(_smoothedZoneThreat, anyEmergency, shouldFlee, _prevMaxCategory);
if (zoneCat != _prevMaxCategory)
{
if (zoneCat >= ThreatCategory.Flee)
Log.Warning("Threat: {Prev} -> {Cur} (zone={Zone:F1}, closest={Dist:F0}, hostiles={Count})",
_prevMaxCategory, zoneCat, _smoothedZoneThreat, closestDist, entries.Count);
else
Log.Debug("Threat: {Prev} -> {Cur} (zone={Zone:F1})",
_prevMaxCategory, zoneCat, _smoothedZoneThreat);
_prevMaxCategory = zoneCat;
}
// ── 6. Submit movement intents ──
if (!shouldFlee) return;
var isPointBlank = closestDist < PointBlankRange;
if (anyEmergency || isPointBlank)
{
// Layer 0: near-total override — flee, blocks casting. 0.85 lets wall push still help.
movement.Submit(new MovementIntent(0, safest, 0.85f, "Threat"));
}
else
{
// Layer 1: strong flee scaled by flee weight
var override1 = 0.3f + assessment.FleeWeight * 0.4f; // 0.30.7
movement.Submit(new MovementIntent(1, safest * assessment.FleeWeight, override1, "Threat"));
}
}
// ── Per-entity scoring ──
private ThreatEntry ScoreEntity(EntitySnapshot monster, Vector2 playerPos, float playerHpFactor, GameState state)
{
var dist = monster.DistanceToPlayer;
var score = 0f;
// Distance — nonlinear: spikes sharply as enemies close in
var distFactor = DistanceFactor(dist);
score += distFactor * W_Distance;
// Rarity — scaled by distance so far-away rares/uniques don't dominate
var rarityBase = monster.Rarity switch
{
MonsterRarity.Unique => 3f,
MonsterRarity.Rare => 2f,
MonsterRarity.Magic => 1.3f,
_ => 1f,
};
// At max range: rarity contributes 20% of its base; at close range: 100%
var rarityScale = 0.2f + 0.8f * Math.Clamp(distFactor / 10f, 0f, 1f);
score += rarityBase * rarityScale * W_Rarity;
// LOS — de-weight monsters behind walls
var hasLos = true;
if (state.Terrain is { } terrain)
{
hasLos = TerrainQuery.HasLineOfSight(terrain, playerPos, monster.Position, WorldToGrid);
if (!hasLos) score *= 0.4f;
}
// Low HP monsters are less threatening
var hpPct = monster.LifeTotal > 0 ? (float)monster.LifeCurrent / monster.LifeTotal : 1f;
if (hpPct < 0.1f) score *= 0.3f;
// Player HP context — same monster is scarier when you're low
score *= playerHpFactor;
return new ThreatEntry
{
EntityId = monster.Id,
Position = monster.Position,
DistanceToPlayer = dist,
ThreatScore = score,
HasLineOfSight = hasLos,
Rarity = monster.Rarity,
HpPercent = hpPct,
IsAlive = true,
};
}
private float DistanceFactor(float dist)
{
if (dist > MaxThreatRange) return 0f;
var t = 1f - dist / MaxThreatRange;
return t * t * 10f; // inverse square: 10 @ 0, 2.5 @ 450, 0 @ 900
}
// ── Classification ──
private ThreatCategory Classify(ThreatEntry entry, PlayerState player)
{
// Emergency overrides — certain conditions always trigger
if (player.LifePercent < 25f && entry.ThreatScore > 8f)
return ThreatCategory.Emergency;
return entry.ThreatScore switch
{
> 20f => ThreatCategory.Flee,
> 8f => ThreatCategory.Engage,
> 3f => ThreatCategory.Monitor,
> 0f => ThreatCategory.Ignore,
_ => ThreatCategory.Ignore,
};
}
// ── Centroid (score-weighted) ──
private static Vector2 ComputeThreatCentroid(List<ThreatEntry> entries, Vector2 playerPos)
{
var totalWeight = 0f;
var weighted = Vector2.Zero;
foreach (var e in entries)
{
if (e.Category < ThreatCategory.Monitor) continue;
weighted += e.Position * e.ThreatScore;
totalWeight += e.ThreatScore;
}
return totalWeight > 0f ? weighted / totalWeight : playerPos;
}
// ── Target selection (kill priority, not raw threat) ──
private static ThreatEntry? SelectPrimaryTarget(List<ThreatEntry> entries)
{
ThreatEntry? best = null;
var bestScore = float.MinValue;
foreach (var e in entries)
{
if (e.Category < ThreatCategory.Engage) continue;
if (!e.HasLineOfSight) continue;
var score = 0f;
// Prefer low HP targets — finish them off
score += (1f - e.HpPercent) * 3f;
// Prefer closer targets
score += (1f - Math.Clamp(e.DistanceToPlayer / 800f, 0f, 1f)) * 2f;
// Prefer dangerous rarity
if (e.Rarity >= MonsterRarity.Rare) score += 2f;
if (e.Rarity == MonsterRarity.Unique) score += 3f;
if (score > bestScore)
{
bestScore = score;
best = e;
}
}
return best;
}
// ── Backward compat ──
private static ThreatCategory ClassifyZone(float zone, bool anyEmergency, bool shouldFlee, ThreatCategory prev)
{
if (anyEmergency) return ThreatCategory.Emergency;
if (shouldFlee) return ThreatCategory.Flee;
// Escalation thresholds / de-escalation thresholds (15% gap)
// Engage: up at 100, down at 85
// Monitor: up at 30, down at 25
// Ignore: up at 5
return prev switch
{
ThreatCategory.Engage when zone >= 85f => ThreatCategory.Engage,
ThreatCategory.Monitor when zone >= 100f => ThreatCategory.Engage,
ThreatCategory.Monitor when zone >= 25f => ThreatCategory.Monitor,
_ when zone >= 100f => ThreatCategory.Engage,
_ when zone >= 30f => ThreatCategory.Monitor,
_ when zone > 5f => ThreatCategory.Ignore,
_ => ThreatCategory.Ignore,
};
}
private static DangerLevel ToDangerLevel(ThreatAssessment a)
{
if (a.AnyEmergency) return DangerLevel.Critical;
if (a.ShouldFlee) return DangerLevel.Critical;
if (a.FleeWeight > 0.5f) return DangerLevel.High;
if (a.FleeWeight > 0.15f) return DangerLevel.Medium;
if (a.ZoneThreatLevel > 5f) return DangerLevel.Low;
return DangerLevel.Safe;
}
}