threat better
This commit is contained in:
parent
05bbcb244f
commit
703cfbfdee
12 changed files with 581 additions and 228 deletions
|
|
@ -4,12 +4,12 @@ Size=400,400
|
||||||
Collapsed=0
|
Collapsed=0
|
||||||
|
|
||||||
[Window][Simulator Controls]
|
[Window][Simulator Controls]
|
||||||
Pos=60,60
|
Pos=29,51
|
||||||
Size=219,425
|
Size=432,649
|
||||||
Collapsed=0
|
Collapsed=0
|
||||||
|
|
||||||
[Window][Simulator]
|
[Window][Simulator]
|
||||||
Pos=341,232
|
Pos=499,177
|
||||||
Size=1200,681
|
Size=1200,681
|
||||||
Collapsed=0
|
Collapsed=0
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,9 @@ public class GameState
|
||||||
/// <summary>In-progress quests from the quest linked list with target areas and paths.</summary>
|
/// <summary>In-progress quests from the quest linked list with target areas and paths.</summary>
|
||||||
public IReadOnlyList<QuestInfo> Quests { get; set; } = [];
|
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 ThreatMap Threats { get; set; } = new();
|
||||||
|
public ThreatAssessment ThreatAssessment { get; set; } = new();
|
||||||
public IReadOnlyList<EntitySnapshot> NearestEnemies { get; set; } = [];
|
public IReadOnlyList<EntitySnapshot> NearestEnemies { get; set; } = [];
|
||||||
public IReadOnlyList<GroundEffect> GroundEffects { get; set; } = [];
|
public IReadOnlyList<GroundEffect> GroundEffects { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,15 +28,14 @@ public sealed class MovementBlender
|
||||||
// Snap decision based on INTENT change (pre-terrain), not terrain output — prevents
|
// Snap decision based on INTENT change (pre-terrain), not terrain output — prevents
|
||||||
// terrain probe noise from bypassing the EMA via the snap threshold.
|
// terrain probe noise from bypassing the EMA via the snap threshold.
|
||||||
private Vector2? _smoothedDirection;
|
private Vector2? _smoothedDirection;
|
||||||
private Vector2? _lastIntentDir; // pre-terrain direction from previous frame
|
private const float SmoothingAlpha = 0.20f; // 20% new, 80% previous
|
||||||
private const float SmoothingAlpha = 0.12f; // 12% new, 88% 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
|
// breaking the position↔direction feedback loop that causes zigzag oscillation
|
||||||
private Vector2 _cachedTerrainInputDir;
|
private Vector2 _cachedTerrainInputDir;
|
||||||
private Vector2 _cachedTerrainResult;
|
private Vector2 _cachedTerrainResult;
|
||||||
private int _cachedTerrainGridX = int.MinValue;
|
private Vector2 _cachedTerrainPos;
|
||||||
private int _cachedTerrainGridY = int.MinValue;
|
private const float TerrainCacheRadius = 20f; // don't re-probe within 20 world units
|
||||||
|
|
||||||
public Vector2? Direction { get; private set; }
|
public Vector2? Direction { get; private set; }
|
||||||
public Vector2? RawDirection { get; private set; }
|
public Vector2? RawDirection { get; private set; }
|
||||||
|
|
@ -130,19 +129,16 @@ public sealed class MovementBlender
|
||||||
|
|
||||||
// Normalize the blended result
|
// Normalize the blended result
|
||||||
var rawDir = Vector2.Normalize(result);
|
var rawDir = Vector2.Normalize(result);
|
||||||
var intentDir = rawDir; // save pre-terrain direction for snap decision
|
|
||||||
|
|
||||||
// Terrain validation with grid-cell caching.
|
// Terrain validation with grid-cell caching.
|
||||||
// Re-probe only when the raw direction changes (>~14°) or the player enters a new grid cell.
|
// 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.
|
// This prevents the feedback loop: direction jitter → zigzag movement → crosses cell boundary → more jitter.
|
||||||
if (terrain is not null)
|
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 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;
|
rawDir = _cachedTerrainResult;
|
||||||
}
|
}
|
||||||
|
|
@ -152,28 +148,23 @@ public sealed class MovementBlender
|
||||||
rawDir = TerrainQuery.FindWalkableDirection(terrain, playerPos, rawDir, worldToGrid);
|
rawDir = TerrainQuery.FindWalkableDirection(terrain, playerPos, rawDir, worldToGrid);
|
||||||
_cachedTerrainInputDir = preTerrainDir;
|
_cachedTerrainInputDir = preTerrainDir;
|
||||||
_cachedTerrainResult = rawDir;
|
_cachedTerrainResult = rawDir;
|
||||||
_cachedTerrainGridX = gx;
|
_cachedTerrainPos = playerPos;
|
||||||
_cachedTerrainGridY = gy;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
RawDirection = rawDir;
|
RawDirection = rawDir;
|
||||||
|
|
||||||
// EMA smoothing. Snap decision based on whether the INTENT (pre-terrain) changed,
|
// EMA smoothing. Only snap (bypass smoothing) on urgent flee (L0),
|
||||||
// not the terrain output. This prevents terrain probe noise (which can produce 90°+ swings)
|
// which needs instant response. All other direction changes (orbit flips,
|
||||||
// from bypassing the EMA via the snap threshold.
|
// terrain jitter, waypoint changes) get smoothed to prevent oscillation.
|
||||||
if (_smoothedDirection.HasValue)
|
if (_smoothedDirection.HasValue)
|
||||||
{
|
{
|
||||||
var intentChanged = _lastIntentDir.HasValue &&
|
if (IsUrgentFlee)
|
||||||
Vector2.Dot(_lastIntentDir.Value, intentDir) < 0f;
|
|
||||||
|
|
||||||
if (intentChanged)
|
|
||||||
{
|
{
|
||||||
// Genuine intent reversal (flee, new waypoint) — snap immediately
|
// Emergency flee — snap immediately, no smoothing
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Intent is stable — all direction change is terrain noise, always smooth
|
|
||||||
var smoothed = Vector2.Lerp(_smoothedDirection.Value, rawDir, SmoothingAlpha);
|
var smoothed = Vector2.Lerp(_smoothedDirection.Value, rawDir, SmoothingAlpha);
|
||||||
if (smoothed.LengthSquared() > 0.0001f)
|
if (smoothed.LengthSquared() > 0.0001f)
|
||||||
rawDir = Vector2.Normalize(smoothed);
|
rawDir = Vector2.Normalize(smoothed);
|
||||||
|
|
@ -181,7 +172,6 @@ public sealed class MovementBlender
|
||||||
}
|
}
|
||||||
|
|
||||||
_smoothedDirection = rawDir;
|
_smoothedDirection = rawDir;
|
||||||
_lastIntentDir = intentDir;
|
|
||||||
Direction = rawDir;
|
Direction = rawDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -199,9 +189,7 @@ public sealed class MovementBlender
|
||||||
_stuckFrames = 0;
|
_stuckFrames = 0;
|
||||||
_lastResolvePos = Vector2.Zero;
|
_lastResolvePos = Vector2.Zero;
|
||||||
_smoothedDirection = null;
|
_smoothedDirection = null;
|
||||||
_lastIntentDir = null;
|
_cachedTerrainPos = new Vector2(float.MinValue, float.MinValue);
|
||||||
_cachedTerrainGridX = int.MinValue;
|
|
||||||
_cachedTerrainGridY = int.MinValue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ public sealed class MovementKeyTracker
|
||||||
private bool _wHeld, _aHeld, _sHeld, _dHeld;
|
private bool _wHeld, _aHeld, _sHeld, _dHeld;
|
||||||
private long _wDownAt, _aDownAt, _sDownAt, _dDownAt;
|
private long _wDownAt, _aDownAt, _sDownAt, _dDownAt;
|
||||||
private int _wMinHold, _aMinHold, _sMinHold, _dMinHold;
|
private int _wMinHold, _aMinHold, _sMinHold, _dMinHold;
|
||||||
|
private long _wUpAt, _aUpAt, _sUpAt, _dUpAt;
|
||||||
|
private int _wRepress, _aRepress, _sRepress, _dRepress;
|
||||||
private Vector2? _lastPlayerPos;
|
private Vector2? _lastPlayerPos;
|
||||||
|
|
||||||
private static readonly Random Rng = new();
|
private static readonly Random Rng = new();
|
||||||
|
|
@ -55,10 +57,10 @@ public sealed class MovementKeyTracker
|
||||||
}
|
}
|
||||||
|
|
||||||
var now = Environment.TickCount64;
|
var now = Environment.TickCount64;
|
||||||
SetKey(input, ScanCodes.W, ref _wHeld, ref _wDownAt, ref _wMinHold, wantW, 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, wantA, 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, wantS, 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, wantD, now, _lastPlayerPos);
|
SetKey(input, ScanCodes.D, ref _dHeld, ref _dDownAt, ref _dMinHold, ref _dUpAt, ref _dRepress, wantD, now, _lastPlayerPos);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -78,10 +80,14 @@ public sealed class MovementKeyTracker
|
||||||
};
|
};
|
||||||
|
|
||||||
private static void SetKey(IInputController input, ushort scanCode,
|
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)
|
if (want && !held)
|
||||||
{
|
{
|
||||||
|
// Enforce re-press cooldown after release
|
||||||
|
if (now - upAt < repressDelay) return;
|
||||||
|
|
||||||
input.KeyDown(scanCode);
|
input.KeyDown(scanCode);
|
||||||
held = true;
|
held = true;
|
||||||
downAt = now;
|
downAt = now;
|
||||||
|
|
@ -96,8 +102,11 @@ public sealed class MovementKeyTracker
|
||||||
{
|
{
|
||||||
var elapsed = now - downAt;
|
var elapsed = now - downAt;
|
||||||
if (elapsed < minHold) return; // enforce minimum hold
|
if (elapsed < minHold) return; // enforce minimum hold
|
||||||
|
|
||||||
input.KeyUp(scanCode);
|
input.KeyUp(scanCode);
|
||||||
held = false;
|
held = false;
|
||||||
|
upAt = now;
|
||||||
|
repressDelay = RepressMs();
|
||||||
if (pos.HasValue)
|
if (pos.HasValue)
|
||||||
Log.Information("[WASD] {Key} UP (held={Elapsed}ms, min={MinHold}ms) pos=({X:F0},{Y:F0})",
|
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);
|
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);
|
var g = u * Math.Sqrt(-2.0 * Math.Log(s) / s);
|
||||||
return Math.Clamp((int)Math.Round(55.0 + g * 6.0), 44, 76);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
51
src/Nexus.Core/ThreatAssessment.cs
Normal file
51
src/Nexus.Core/ThreatAssessment.cs
Normal file
|
|
@ -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<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; } // 300–600
|
||||||
|
public int FarRange { get; set; } // 600–1200
|
||||||
|
public bool HasRareOrUnique { get; set; }
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,7 @@ public class ThreatMap
|
||||||
public int CloseRange { get; init; } // < 300 units
|
public int CloseRange { get; init; } // < 300 units
|
||||||
public int MidRange { get; init; } // 300–600
|
public int MidRange { get; init; } // 300–600
|
||||||
public int FarRange { get; init; } // 600–1200
|
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 Vector2 ThreatCentroid { get; init; }
|
||||||
public bool HasRareOrUnique { get; init; }
|
public bool HasRareOrUnique { get; init; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,14 @@ using Nexus.Core;
|
||||||
namespace Nexus.Data;
|
namespace Nexus.Data;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Computes all derived fields on GameState once per tick.
|
/// Computes derived fields on GameState once per tick.
|
||||||
/// Static methods, no allocations beyond the sorted list.
|
/// Threat scoring is now handled by ThreatSystem (runs as ISystem).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class GameStateEnricher
|
public static class GameStateEnricher
|
||||||
{
|
{
|
||||||
public static void Enrich(GameState state)
|
public static void Enrich(GameState state)
|
||||||
{
|
{
|
||||||
state.NearestEnemies = ComputeNearestEnemies(state.HostileMonsters);
|
state.NearestEnemies = ComputeNearestEnemies(state.HostileMonsters);
|
||||||
state.Threats = ComputeThreatMap(state.HostileMonsters);
|
|
||||||
state.Danger = ComputeDangerLevel(state);
|
|
||||||
state.GroundEffects = []; // stub until memory reads ground effects
|
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));
|
sorted.Sort((a, b) => a.DistanceToPlayer.CompareTo(b.DistanceToPlayer));
|
||||||
return sorted;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ public class SimConfig
|
||||||
public float EnemyAggroRange { get; set; } = 600f;
|
public float EnemyAggroRange { get; set; } = 600f;
|
||||||
public float EnemyMeleeAttackRange { get; set; } = 100f;
|
public float EnemyMeleeAttackRange { get; set; } = 100f;
|
||||||
public float EnemyMoveSpeedFactor { get; set; } = 0.75f;
|
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 int EnemyMeleeBaseDamage { get; set; } = 60;
|
||||||
public float EnemyMeleeAttackCooldown { get; set; } = 1.2f;
|
public float EnemyMeleeAttackCooldown { get; set; } = 1.2f;
|
||||||
|
|
||||||
|
|
@ -42,8 +42,8 @@ public class SimConfig
|
||||||
public float EnemySpawnMinDist { get; set; } = 800f;
|
public float EnemySpawnMinDist { get; set; } = 800f;
|
||||||
public float EnemySpawnMaxDist { get; set; } = 2000f;
|
public float EnemySpawnMaxDist { get; set; } = 2000f;
|
||||||
public float EnemyCullDist { get; set; } = 3000f;
|
public float EnemyCullDist { get; set; } = 3000f;
|
||||||
public int EnemyGroupMin { get; set; } = 3;
|
public int EnemyGroupMin { get; set; } = 7;
|
||||||
public int EnemyGroupMax { get; set; } = 7;
|
public int EnemyGroupMax { get; set; } = 18;
|
||||||
public float EnemyGroupSpread { get; set; } = 120f;
|
public float EnemyGroupSpread { get; set; } = 120f;
|
||||||
|
|
||||||
// Player skills
|
// Player skills
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,12 @@ Log.Logger = new LoggerConfiguration()
|
||||||
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss.fff} {Level:u3}] {Message:lj}{NewLine}{Exception}")
|
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss.fff} {Level:u3}] {Message:lj}{NewLine}{Exception}")
|
||||||
.CreateLogger();
|
.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...");
|
Log.Information("Nexus Simulator starting...");
|
||||||
|
|
||||||
// ── Configuration ──
|
// ── Configuration ──
|
||||||
|
|
@ -75,6 +81,7 @@ var movementBlender = new MovementBlender();
|
||||||
var moveTracker = new MovementKeyTracker();
|
var moveTracker = new MovementKeyTracker();
|
||||||
var botRunning = true;
|
var botRunning = true;
|
||||||
var lastStatusLogMs = 0L;
|
var lastStatusLogMs = 0L;
|
||||||
|
var botTickCount = 0;
|
||||||
|
|
||||||
var botThread = new Thread(() =>
|
var botThread = new Thread(() =>
|
||||||
{
|
{
|
||||||
|
|
@ -91,6 +98,13 @@ var botThread = new Thread(() =>
|
||||||
var resolved = BotTick.Run(state, systems, actionQueue, movementBlender, nav, botConfig);
|
var resolved = BotTick.Run(state, systems, actionQueue, movementBlender, nav, botConfig);
|
||||||
ActionExecutor.Execute(resolved, input, moveTracker, movementBlender, state.Player.Position);
|
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)
|
// Periodic status log (every 2 seconds)
|
||||||
var nowMs = Environment.TickCount64;
|
var nowMs = Environment.TickCount64;
|
||||||
if (nowMs - lastStatusLogMs >= 2000)
|
if (nowMs - lastStatusLogMs >= 2000)
|
||||||
|
|
@ -110,12 +124,15 @@ var botThread = new Thread(() =>
|
||||||
}));
|
}));
|
||||||
if (actions.Length == 0) actions = "none";
|
if (actions.Length == 0) actions = "none";
|
||||||
|
|
||||||
|
var ta = state.ThreatAssessment;
|
||||||
Log.Information(
|
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}] " +
|
"Enemies={Total}({Melee}m/{Ranged}r) Nav={NavMode} Actions=[{Actions}] " +
|
||||||
"Move=[{Blender}]",
|
"Move=[{Blender}]",
|
||||||
p.LifeCurrent, p.LifeTotal, p.EsCurrent, p.EsTotal, p.ManaCurrent, p.ManaTotal,
|
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());
|
nav.Mode, actions, movementBlender.DiagnosticSummary());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -172,7 +189,7 @@ window.Resized += () =>
|
||||||
var renderSw = System.Diagnostics.Stopwatch.StartNew();
|
var renderSw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
var lastRenderMs = 0.0;
|
var lastRenderMs = 0.0;
|
||||||
|
|
||||||
while (window.Exists)
|
while (window.Exists && botRunning)
|
||||||
{
|
{
|
||||||
var nowMs = renderSw.Elapsed.TotalMilliseconds;
|
var nowMs = renderSw.Elapsed.TotalMilliseconds;
|
||||||
var deltaSeconds = (float)((nowMs - lastRenderMs) / 1000.0);
|
var deltaSeconds = (float)((nowMs - lastRenderMs) / 1000.0);
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,15 @@ public class DebugPanel
|
||||||
|
|
||||||
private int _spawnRarity; // 0=Normal, 1=Magic, 2=Rare, 3=Unique
|
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)
|
public DebugPanel(SimConfig config, SimWorld world, NavigationController nav, IReadOnlyList<ISystem> systems)
|
||||||
{
|
{
|
||||||
_config = config;
|
_config = config;
|
||||||
|
|
@ -108,14 +117,32 @@ public class DebugPanel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Threat info
|
// Threat info — cached at ~4Hz to prevent visual flicker
|
||||||
if (state is not null && ImGui.CollapsingHeader("Threat"))
|
if (state is not null && ImGui.CollapsingHeader("Threat", ImGuiTreeNodeFlags.DefaultOpen))
|
||||||
{
|
{
|
||||||
ImGui.Text($"Danger: {state.Danger}");
|
var nowMs = Environment.TickCount64;
|
||||||
var threats = state.Threats;
|
if (nowMs - _lastThreatUpdateMs >= 250)
|
||||||
ImGui.Text($"Close: {threats.CloseRange} Mid: {threats.MidRange} Far: {threats.FarRange}");
|
{
|
||||||
ImGui.Text($"Closest: {threats.ClosestDistance:F0}");
|
_lastThreatUpdateMs = nowMs;
|
||||||
ImGui.Text($"Has Rare/Unique: {threats.HasRareOrUnique}");
|
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
|
// Action queue
|
||||||
|
|
|
||||||
|
|
@ -176,7 +176,8 @@ public class SimRenderer
|
||||||
|
|
||||||
if (state is not null)
|
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;
|
textPos.Y += 16;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@ using Serilog;
|
||||||
namespace Nexus.Systems;
|
namespace Nexus.Systems;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Emergency-only threat response. Runs first (priority 50).
|
/// Per-entity threat scoring with continuous flee weights.
|
||||||
/// Only fires on Critical danger (low HP or overwhelming threat score).
|
/// Runs first (priority 50). Builds ThreatAssessment on GameState,
|
||||||
/// Normal combat (High/Medium) is handled by MovementSystem orbiting + CombatSystem herding.
|
/// then submits flee movement intents when warranted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ThreatSystem : ISystem
|
public class ThreatSystem : ISystem
|
||||||
{
|
{
|
||||||
|
|
@ -15,54 +15,440 @@ public class ThreatSystem : ISystem
|
||||||
public string Name => "Threat";
|
public string Name => "Threat";
|
||||||
public bool IsEnabled { get; set; } = true;
|
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;
|
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)
|
public void Update(GameState state, ActionQueue actions, MovementBlender movement)
|
||||||
{
|
{
|
||||||
if (!state.Player.HasPosition) return;
|
if (!state.Player.HasPosition) return;
|
||||||
|
|
||||||
var danger = state.Danger;
|
var playerPos = state.Player.Position;
|
||||||
var threats = state.Threats;
|
var playerHpFactor = 1f + (1f - state.Player.LifePercent / 100f) * 1.5f;
|
||||||
|
|
||||||
// Log danger transitions
|
// ── 1. Score each hostile ──
|
||||||
if (danger != _prevDanger)
|
var entries = new List<ThreatEntry>(state.HostileMonsters.Count);
|
||||||
|
|
||||||
|
foreach (var monster in state.HostileMonsters)
|
||||||
{
|
{
|
||||||
if (danger >= DangerLevel.High)
|
if (!monster.IsAlive) continue;
|
||||||
Log.Warning("Threat: {Prev} -> {Cur} (hostiles={Total}, close={Close}, closest={Dist:F0})",
|
|
||||||
_prevDanger, danger, threats.TotalHostiles, threats.CloseRange, threats.ClosestDistance);
|
var entry = ScoreEntity(monster, playerPos, playerHpFactor, state);
|
||||||
else
|
entries.Add(entry);
|
||||||
Log.Debug("Threat: {Prev} -> {Cur}", _prevDanger, danger);
|
|
||||||
_prevDanger = danger;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only respond to Critical danger — High is normal combat, handled by orbit/herd
|
// ── 2. Pack context pass — count nearby allies per monster (capped) ──
|
||||||
if (danger != DangerLevel.Critical) return;
|
for (var i = 0; i < entries.Count; i++)
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
// Layer 0: total override — pure flee, blocks casting
|
var nearby = 0;
|
||||||
movement.Submit(new MovementIntent(0, fleeDir, 1.0f, "Threat"));
|
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
|
else
|
||||||
{
|
{
|
||||||
// Layer 1: strong flee but allow some nav/orbit bleed-through
|
// Locked entity is still the raw winner — cool down streak
|
||||||
movement.Submit(new MovementIntent(1, fleeDir, 0.6f, "Threat"));
|
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<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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue