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