diff --git a/imgui.ini b/imgui.ini
index 039b318..ef2059b 100644
--- a/imgui.ini
+++ b/imgui.ini
@@ -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
diff --git a/src/Nexus.Core/GameState.cs b/src/Nexus.Core/GameState.cs
index 7baf4c7..e7125c0 100644
--- a/src/Nexus.Core/GameState.cs
+++ b/src/Nexus.Core/GameState.cs
@@ -27,8 +27,9 @@ public class GameState
/// In-progress quests from the quest linked list with target areas and paths.
public IReadOnlyList 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 NearestEnemies { get; set; } = [];
public IReadOnlyList GroundEffects { get; set; } = [];
}
diff --git a/src/Nexus.Core/MovementBlender.cs b/src/Nexus.Core/MovementBlender.cs
index 0b25de0..8fa46ed 100644
--- a/src/Nexus.Core/MovementBlender.cs
+++ b/src/Nexus.Core/MovementBlender.cs
@@ -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);
}
///
diff --git a/src/Nexus.Core/MovementKeyTracker.cs b/src/Nexus.Core/MovementKeyTracker.cs
index 3f56c64..59204d1 100644
--- a/src/Nexus.Core/MovementKeyTracker.cs
+++ b/src/Nexus.Core/MovementKeyTracker.cs
@@ -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);
}
///
@@ -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);
}
+
+ /// Gaussian re-press cooldown peaked at 40ms, range [25, 65].
+ 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);
+ }
}
diff --git a/src/Nexus.Core/ThreatAssessment.cs b/src/Nexus.Core/ThreatAssessment.cs
new file mode 100644
index 0000000..f7a195b
--- /dev/null
+++ b/src/Nexus.Core/ThreatAssessment.cs
@@ -0,0 +1,51 @@
+using System.Numerics;
+
+namespace Nexus.Core;
+
+public enum ThreatCategory
+{
+ Ignore, // score ≤ 2 — not worth reacting to
+ Monitor, // score 2–6 — track but don't change behavior
+ Engage, // score 6–15 — fight, stay mobile
+ Flee, // score 15–25 — 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 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; }
+
+ /// Continuous 0..1 flee weight for steering blend.
+ public float FleeWeight { get; set; }
+
+ // Convenience — backward compatibility
+ public int CloseRange { get; set; } // < 300
+ public int MidRange { get; set; } // 300–600
+ public int FarRange { get; set; } // 600–1200
+ public bool HasRareOrUnique { get; set; }
+}
diff --git a/src/Nexus.Core/ThreatMap.cs b/src/Nexus.Core/ThreatMap.cs
index bfeac13..4d65d9c 100644
--- a/src/Nexus.Core/ThreatMap.cs
+++ b/src/Nexus.Core/ThreatMap.cs
@@ -8,7 +8,7 @@ public class ThreatMap
public int CloseRange { get; init; } // < 300 units
public int MidRange { get; init; } // 300–600
public int FarRange { get; init; } // 600–1200
- public float ClosestDistance { get; init; } = float.MaxValue;
+ public float ClosestDistance { get; init; }
public Vector2 ThreatCentroid { get; init; }
public bool HasRareOrUnique { get; init; }
}
diff --git a/src/Nexus.Data/GameStateEnricher.cs b/src/Nexus.Data/GameStateEnricher.cs
index 545dc47..2d68fe9 100644
--- a/src/Nexus.Data/GameStateEnricher.cs
+++ b/src/Nexus.Data/GameStateEnricher.cs
@@ -4,16 +4,14 @@ using Nexus.Core;
namespace Nexus.Data;
///
-/// 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).
///
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 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,
- };
- }
-
- ///
- /// 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.
- ///
- 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;
- }
}
diff --git a/src/Nexus.Simulator/Config/SimConfig.cs b/src/Nexus.Simulator/Config/SimConfig.cs
index e0c23e0..567f085 100644
--- a/src/Nexus.Simulator/Config/SimConfig.cs
+++ b/src/Nexus.Simulator/Config/SimConfig.cs
@@ -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
diff --git a/src/Nexus.Simulator/Program.cs b/src/Nexus.Simulator/Program.cs
index aecf079..891338d 100644
--- a/src/Nexus.Simulator/Program.cs
+++ b/src/Nexus.Simulator/Program.cs
@@ -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);
diff --git a/src/Nexus.Simulator/Rendering/DebugPanel.cs b/src/Nexus.Simulator/Rendering/DebugPanel.cs
index 9b67f9c..d7a5012 100644
--- a/src/Nexus.Simulator/Rendering/DebugPanel.cs
+++ b/src/Nexus.Simulator/Rendering/DebugPanel.cs
@@ -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 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
diff --git a/src/Nexus.Simulator/Rendering/SimRenderer.cs b/src/Nexus.Simulator/Rendering/SimRenderer.cs
index 79d3860..1b1a344 100644
--- a/src/Nexus.Simulator/Rendering/SimRenderer.cs
+++ b/src/Nexus.Simulator/Rendering/SimRenderer.cs
@@ -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;
}
diff --git a/src/Nexus.Systems/ThreatSystem.cs b/src/Nexus.Systems/ThreatSystem.cs
index 7810879..7dacac3 100644
--- a/src/Nexus.Systems/ThreatSystem.cs
+++ b/src/Nexus.Systems/ThreatSystem.cs
@@ -5,9 +5,9 @@ using Serilog;
namespace Nexus.Systems;
///
-/// 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.
///
public class ThreatSystem : ISystem
{
@@ -15,54 +15,440 @@ public class ThreatSystem : ISystem
public string Name => "Threat";
public bool IsEnabled { get; set; } = true;
- /// If closest enemy is within this range AND danger is Critical, escalate to urgent flee.
- public float PointBlankRange { get; set; } = 120f;
-
- /// World-to-grid conversion factor for terrain queries.
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(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.3–0.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 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 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;
+ }
}