work on sim bot

This commit is contained in:
Boki 2026-04-04 16:44:32 -04:00
parent 8ca257bc79
commit f09ee5d106
29 changed files with 889 additions and 60 deletions

View file

@ -10,6 +10,6 @@ Collapsed=0
[Window][Simulator]
Pos=564,96
Size=893,571
Size=1023,810
Collapsed=0

View file

@ -4,11 +4,26 @@ namespace Nexus.Core;
public static class ActionExecutor
{
// Screen center (half of 2560x1440)
private const float ScreenCenterX = 1280f;
private const float ScreenCenterY = 720f;
// How far ahead of the player (in screen pixels) the idle cursor sits
private const float IdleCursorDistance = 200f;
public static void Execute(List<BotAction> resolved, IInputController input,
MovementKeyTracker moveTracker, MovementBlender blender, Vector2? playerPos = null)
MovementKeyTracker moveTracker, MovementBlender blender, Vector2? playerPos = null,
Matrix4x4? camera = null)
{
if (!input.IsInitialized) return;
var hasCast = false;
// Filter out physically impossible key combos (same finger)
resolved = HandModel.Filter(resolved,
moveTracker.IsWHeld, moveTracker.IsAHeld,
moveTracker.IsSHeld, moveTracker.IsDHeld);
// Discrete actions
foreach (var action in resolved)
{
@ -19,6 +34,7 @@ public static class ActionExecutor
break;
case CastAction cast:
hasCast = true;
if (cast.TargetScreenPos.HasValue)
input.SmoothMoveTo((int)cast.TargetScreenPos.Value.X, (int)cast.TargetScreenPos.Value.Y);
input.KeyPress(cast.SkillScanCode);
@ -43,6 +59,27 @@ public static class ActionExecutor
case KeyActionType.Up: input.KeyUp(key.ScanCode); break;
}
break;
case DodgeRollAction dodge:
input.SetDodgeDirection(dodge.Direction);
input.KeyPress(0x39); // Space bar
break;
}
}
// Idle mouse tracking: when not casting, keep cursor ahead of player in movement direction.
// This prevents jarring jumps from target to target and gives smooth cursor flow.
if (!hasCast && blender.Direction is { } moveDir && camera.HasValue && playerPos.HasValue)
{
// Project a point slightly ahead of the player in the movement direction
var aheadWorld = playerPos.Value + moveDir * 300f;
var screenAhead = WorldToScreen.Project(aheadWorld, 0f, camera.Value);
if (screenAhead.HasValue)
{
// Clamp to reasonable screen bounds
var sx = Math.Clamp(screenAhead.Value.X, 100f, 2460f);
var sy = Math.Clamp(screenAhead.Value.Y, 100f, 1340f);
input.SmoothMoveTo((int)sx, (int)sy);
}
}

View file

@ -25,3 +25,5 @@ public record FlaskAction(int Priority, ushort FlaskScanCode) : BotAction(Priori
public record ChatAction(int Priority, string Message) : BotAction(Priority);
public record WaitAction(int Priority, int DurationMs) : BotAction(Priority);
public record DodgeRollAction(int Priority, Vector2 Direction) : BotAction(Priority);

View file

@ -12,6 +12,7 @@ public enum DangerLevel
public static class SystemPriority
{
public const int Threat = 50;
public const int Dodge = 75;
public const int Movement = 100;
public const int Navigation = 200;
public const int Combat = 300;

View file

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

110
src/Nexus.Core/HandModel.cs Normal file
View file

@ -0,0 +1,110 @@
namespace Nexus.Core;
public enum Finger { Pinky, Ring, Middle, Index, Thumb }
/// <summary>
/// Maps left-hand keys to physical fingers and filters out actions that would
/// require the same finger simultaneously. Dropped actions retry next tick (16ms).
/// Right hand (mouse) is unconstrained.
/// </summary>
public static class HandModel
{
private static readonly Dictionary<ushort, Finger> FingerMap = new()
{
// Pinky: 1, Q, A
[ScanCodes.Key1] = Finger.Pinky,
[ScanCodes.Q] = Finger.Pinky,
[ScanCodes.A] = Finger.Pinky,
// Ring: 2, W, S
[ScanCodes.Key2] = Finger.Ring,
[ScanCodes.W] = Finger.Ring,
[ScanCodes.S] = Finger.Ring,
// Middle: 3, E, D
[ScanCodes.Key3] = Finger.Middle,
[ScanCodes.E] = Finger.Middle,
[ScanCodes.D] = Finger.Middle,
// Index: 4, 5, R, T, F
[ScanCodes.Key4] = Finger.Index,
[ScanCodes.Key5] = Finger.Index,
[ScanCodes.R] = Finger.Index,
[ScanCodes.T] = Finger.Index,
[ScanCodes.F] = Finger.Index,
// Thumb: Space, LAlt
[ScanCodes.Space] = Finger.Thumb,
[ScanCodes.LAlt] = Finger.Thumb,
};
// Lower = higher priority when two actions compete for the same finger
private static int ActionTypePriority(BotAction a) => a switch
{
DodgeRollAction => 0,
FlaskAction => 1,
CastAction => 2,
KeyAction => 3,
_ => 4,
};
public static List<BotAction> Filter(List<BotAction> resolved,
bool wHeld, bool aHeld, bool sHeld, bool dHeld)
{
// Build occupied set from currently held WASD keys
var occupied = new HashSet<Finger>();
if (wHeld) occupied.Add(Finger.Ring);
if (aHeld) occupied.Add(Finger.Pinky);
if (sHeld) occupied.Add(Finger.Ring);
if (dHeld) occupied.Add(Finger.Middle);
// Sort by action type priority (dodge > flask > cast > key)
resolved.Sort((a, b) => ActionTypePriority(a).CompareTo(ActionTypePriority(b)));
var result = new List<BotAction>(resolved.Count);
foreach (var action in resolved)
{
var scanCode = GetScanCode(action);
// No scan code (ClickAction, ChatAction, WaitAction, MoveAction) → always pass
if (scanCode is null)
{
result.Add(action);
continue;
}
// Key releases always pass — they free a finger
if (action is KeyAction { Type: KeyActionType.Up })
{
result.Add(action);
continue;
}
// No finger mapping for this scan code → pass (right-hand or unmapped key)
if (!FingerMap.TryGetValue(scanCode.Value, out var finger))
{
result.Add(action);
continue;
}
// Finger free → accept and mark occupied
if (occupied.Add(finger))
{
result.Add(action);
}
// else: finger already occupied → drop, will retry next tick
}
return result;
}
private static ushort? GetScanCode(BotAction action) => action switch
{
DodgeRollAction => ScanCodes.Space,
FlaskAction f => f.FlaskScanCode,
CastAction c => c.SkillScanCode,
KeyAction k => k.ScanCode,
_ => null,
};
}

View file

@ -1,3 +1,5 @@
using System.Numerics;
namespace Nexus.Core;
public interface IInputController
@ -16,4 +18,10 @@ public interface IInputController
void LeftUp();
void RightDown();
void RightUp();
/// <summary>
/// Sets the direction for the next dodge roll. Called before KeyPress(0x21).
/// Default no-op for real input controllers (direction comes from game state).
/// </summary>
void SetDodgeDirection(Vector2 direction) { }
}

View file

@ -84,15 +84,19 @@ public sealed class MovementBlender
if (IsStuck)
{
// Keep only flee (L0, L1), navigation (L3), and wall push (L5) — drop orbit (L2) and herd (L4)
// Drop orbit (L2) and herd (L4) — they don't help when stuck
_intents.RemoveAll(i => i.Layer == 2 || i.Layer == 4);
// After 1s stuck, inject a random perpendicular nudge to break free
// Drop flee (L0, L1) too — if we're stuck, flee is pointing into a wall.
// Let wall push and navigation guide us out instead.
_intents.RemoveAll(i => i.Layer <= 1);
// After 750ms stuck, inject a random nudge at high priority to break free
if (_stuckFrames > StuckRecoveryThreshold)
{
var angle = StuckRng.NextDouble() * Math.PI * 2;
var nudge = new System.Numerics.Vector2((float)Math.Cos(angle), (float)Math.Sin(angle));
_intents.Add(new MovementIntent(1, nudge, 0.7f, "StuckEscape"));
var nudge = new Vector2((float)Math.Cos(angle), (float)Math.Sin(angle));
_intents.Add(new MovementIntent(0, nudge, 0.6f, "StuckEscape"));
// Reset counter so we try a new direction periodically
if (_stuckFrames % 30 == 0)
_stuckFrames = StuckRecoveryThreshold + 1;

View file

@ -12,6 +12,11 @@ namespace Nexus.Core;
public sealed class MovementKeyTracker
{
private bool _wHeld, _aHeld, _sHeld, _dHeld;
public bool IsWHeld => _wHeld;
public bool IsAHeld => _aHeld;
public bool IsSHeld => _sHeld;
public bool IsDHeld => _dHeld;
private long _wDownAt, _aDownAt, _sDownAt, _dDownAt;
private int _wMinHold, _aMinHold, _sMinHold, _dMinHold;
private long _wUpAt, _aUpAt, _sUpAt, _dUpAt;

View file

@ -31,4 +31,8 @@ public record PlayerState
// Skill slots (populated by memory when available)
public IReadOnlyList<SkillState> Skills { get; init; } = [];
// Dodge roll state
public bool IsRolling { get; init; }
public float RollCooldownRemaining { get; init; }
}

View file

@ -0,0 +1,16 @@
using System.Numerics;
namespace Nexus.Core;
public record ProjectileSnapshot
{
public Vector2 Position { get; init; }
public Vector2 Direction { get; init; }
public float Speed { get; init; }
public float HitRadius { get; init; }
public float DistanceToPlayer { get; init; }
/// <summary>Seconds until impact. Null if projectile will miss.</summary>
public float? TimeToImpact { get; init; }
/// <summary>Closest distance the projectile's trajectory passes to the player center.</summary>
public float ClosestApproachDistance { get; init; }
}

View file

@ -42,6 +42,14 @@ public sealed class NavigationController
// Grace period after picking a new explore target — don't check stuck immediately
private int _stuckGraceTicks;
// Repeated stuck detection — force random walk after multiple failures at same spot
private int _repeatedStuckCount;
private Vector2 _lastStuckPos;
private const float RepeatedStuckRadius = 300f; // same-area detection
private const int RepeatedStuckLimit = 3; // failures before random walk
private int _randomWalkTicks; // countdown for forced random direction
private Vector2 _randomWalkDir;
public NavMode Mode { get; private set; } = NavMode.Idle;
public Vector2? DesiredDirection { get; private set; }
public IReadOnlyList<Vector2>? CurrentPath => _path;
@ -139,6 +147,8 @@ public sealed class NavigationController
_exploreBiasPoint = null;
_exploredGrid = null;
_pathFailCooldownMs = 0;
_repeatedStuckCount = 0;
_randomWalkTicks = 0;
IsExplorationComplete = false;
}
@ -195,6 +205,20 @@ public sealed class NavigationController
if (_stuckGraceTicks > 0)
_stuckGraceTicks--;
// Random walk override — forced escape from repeated stuck loops
if (_randomWalkTicks > 0)
{
_randomWalkTicks--;
DesiredDirection = _randomWalkDir;
Status = "Random walk (escape)";
if (_randomWalkTicks == 0)
{
_path = null; // force repath after random walk
_positionHistory.Clear();
}
return;
}
var isStuck = false;
if (_stuckGraceTicks <= 0 && _positionHistory.Count >= StuckWindowSize && _path is not null)
{
@ -202,6 +226,27 @@ public sealed class NavigationController
if (Vector2.Distance(oldest, playerPos) < StuckThreshold)
{
isStuck = true;
// Track repeated stuck at the same location
if (Vector2.Distance(playerPos, _lastStuckPos) < RepeatedStuckRadius)
_repeatedStuckCount++;
else
_repeatedStuckCount = 1;
_lastStuckPos = playerPos;
if (_repeatedStuckCount >= RepeatedStuckLimit)
{
// Force random walk to break free
var angle = (float)(_rng.NextDouble() * Math.PI * 2);
_randomWalkDir = new Vector2(MathF.Cos(angle), MathF.Sin(angle));
_randomWalkTicks = 120; // ~2 seconds of random walk
_repeatedStuckCount = 0;
Log.Information("NavigationController: repeated stuck at ({X:F0},{Y:F0}), forcing random walk",
playerPos.X, playerPos.Y);
DesiredDirection = _randomWalkDir;
return;
}
if (Mode == NavMode.Exploring)
{
Log.Information("NavigationController: stuck while exploring, picking new target");

View file

@ -28,6 +28,9 @@ public class SimInputController : IInputController
private readonly float[] _mouseTimers = new float[3];
private const float FlashDuration = 0.3f;
// Dodge roll
private Vector2? _pendingDodgeDirection;
// Smooth mouse interpolation
private Vector2 _mouseMoveStartPos;
private Vector2 _mouseTargetPos;
@ -129,12 +132,32 @@ public class SimInputController : IInputController
}
}
public void SetDodgeDirection(Vector2 direction)
{
lock (_lock) { _pendingDodgeDirection = direction; }
}
public void KeyPress(ushort scanCode, int holdMs = 50)
{
lock (_lock)
{
_keyTimers[scanCode] = FlashDuration;
}
// Intercept dodge roll key (Space = 0x39)
if (scanCode == 0x39)
{
Vector2 dir;
lock (_lock)
{
dir = _pendingDodgeDirection ?? _world.MoveDirection;
_pendingDodgeDirection = null;
}
if (dir.LengthSquared() > 0.001f)
_world.QueueDodgeRoll(dir);
return;
}
// Queue as skill cast
var target = ScreenToWorld(_mouseScreenPos);
_world.QueueSkill(scanCode, target);

View file

@ -18,6 +18,7 @@ public static class SimStateBuilder
var entities = new List<EntitySnapshot>();
var hostiles = new List<EntitySnapshot>();
var nearbyLoot = new List<EntitySnapshot>();
foreach (var enemy in world.Enemies)
{
@ -44,8 +45,65 @@ public static class SimStateBuilder
hostiles.Add(snap);
}
// Add area transition entity at dungeon exit so BotTick exit avoidance works
var exitSnap = new EntitySnapshot
{
Id = uint.MaxValue,
Path = "Metadata/Terrain/AreaTransition",
Category = EntityCategory.AreaTransition,
Position = world.EndWorldPos,
Z = 0f,
DistanceToPlayer = Vector2.Distance(world.EndWorldPos, player.Position),
IsAlive = false,
IsTargetable = true,
TransitionName = "DungeonExit",
};
entities.Add(exitSnap);
// Build loot snapshots
foreach (var item in world.Items)
{
var (rarity, isQuest) = item.Category switch
{
LootCategory.Magic => (MonsterRarity.Magic, false),
LootCategory.Rare => (MonsterRarity.Rare, false),
LootCategory.Unique => (MonsterRarity.Unique, false),
LootCategory.Quest => (MonsterRarity.White, true),
_ => (MonsterRarity.White, false),
};
var label = item.Category == LootCategory.Currency
? $"Currency:{item.Label}"
: item.Label;
var snap = new EntitySnapshot
{
Id = item.Id,
Path = "Metadata/MiscellaneousObjects/WorldItem",
Category = EntityCategory.WorldItem,
Rarity = rarity,
Position = item.Position,
Z = 0f,
DistanceToPlayer = Vector2.Distance(item.Position, player.Position),
IsAlive = true,
IsTargetable = true,
ItemBaseName = label,
IsQuestItem = isQuest,
};
entities.Add(snap);
// Only add filtered items (currency/rare/unique/quest) to NearbyLoot
if (item.Category is LootCategory.Currency or LootCategory.Rare
or LootCategory.Unique or LootCategory.Quest)
{
nearbyLoot.Add(snap);
}
}
var cameraMatrix = BuildCameraMatrix(player.Position);
var projectiles = BuildProjectileSnapshots(world, player.Position);
return new GameState
{
TickNumber = _tickNumber,
@ -60,7 +118,8 @@ public static class SimStateBuilder
Terrain = world.Terrain,
Entities = entities,
HostileMonsters = hostiles,
NearbyLoot = [],
NearbyLoot = nearbyLoot,
EnemyProjectiles = projectiles,
Player = new PlayerState
{
CharacterName = "SimPlayer",
@ -74,6 +133,8 @@ public static class SimStateBuilder
EsCurrent = player.Es,
EsTotal = player.MaxEs,
Skills = BuildSkillStates(),
IsRolling = player.IsRolling,
RollCooldownRemaining = player.RollCooldownRemaining,
},
};
}
@ -104,6 +165,55 @@ public static class SimStateBuilder
-playerPos.X / halfW, -playerPos.Y / halfH, 0, 1);
}
private const float PlayerRadius = 20f;
private static List<ProjectileSnapshot> BuildProjectileSnapshots(SimWorld world, Vector2 playerPos)
{
var snapshots = new List<ProjectileSnapshot>();
foreach (var proj in world.Projectiles)
{
if (!proj.IsEnemyProjectile || proj.IsExpired) continue;
var toPlayer = playerPos - proj.Position;
var dist = toPlayer.Length();
// Dot product: how far ahead of the projectile the player is (along travel direction)
var dot = Vector2.Dot(toPlayer, proj.Direction);
if (dot < 0) continue; // Moving away from player
// Perpendicular distance from player center to projectile trajectory line
var closestDist = MathF.Abs(toPlayer.X * proj.Direction.Y - toPlayer.Y * proj.Direction.X);
var collisionRadius = proj.HitRadius + PlayerRadius;
float? timeToImpact = null;
if (closestDist < collisionRadius)
{
// Will hit — compute entry time via circle-line intersection
var discriminant = collisionRadius * collisionRadius - closestDist * closestDist;
var entryDist = dot - MathF.Sqrt(discriminant);
if (entryDist > 0)
timeToImpact = entryDist / proj.Speed;
else
timeToImpact = 0f; // Already overlapping
}
snapshots.Add(new ProjectileSnapshot
{
Position = proj.Position,
Direction = proj.Direction,
Speed = proj.Speed,
HitRadius = proj.HitRadius,
DistanceToPlayer = dist,
TimeToImpact = timeToImpact,
ClosestApproachDistance = closestDist,
});
}
return snapshots;
}
private static List<SkillState> BuildSkillStates()
{
return

View file

@ -54,6 +54,11 @@ public class SimConfig
public int EnemyGroupMax { get; set; } = 18;
public float EnemyGroupSpread { get; set; } = 120f;
// Dodge roll
public float DodgeRollDistance { get; set; } = 100f; // world units traveled per roll
public float DodgeRollDuration { get; set; } = 0.25f; // 250ms
public float DodgeRollCooldown { get; set; } = 1.0f; // 1s between rolls
// Player skills
public float MeleeRange { get; set; } = 350f;
public float MeleeConeAngle { get; set; } = 120f;

View file

@ -72,8 +72,8 @@ foreach (var sys in systems)
// ── Start simulation poller ──
poller.Start();
// ── Navigate to dungeon end ──
nav.NavigateTo(world.EndWorldPos);
// ── Explore the dungeon (not beeline to exit) ──
nav.Explore();
// ── Bot logic thread ──
var actionQueue = new ActionQueue();
@ -96,13 +96,13 @@ var botThread = new Thread(() =>
if (state is not null && !state.IsLoading && !state.IsEscapeOpen)
{
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, state.CameraMatrix);
// Check if dungeon end reached — regenerate and re-navigate
// Check if dungeon end reached — regenerate and explore new dungeon
if (world.ReachedEnd)
{
world.RegenerateTerrain();
nav.NavigateTo(world.EndWorldPos);
nav.Explore();
}
botTickCount++;

View file

@ -12,8 +12,19 @@ public static class EntityRenderer
var screenPos = vt.WorldToScreen(player.Position);
var radius = 8f;
drawList.AddCircleFilled(screenPos, radius, 0xFF00FF00); // Green
drawList.AddCircle(screenPos, radius + 1, 0xFF00AA00);
if (player.IsRolling)
{
drawList.AddCircleFilled(screenPos, radius, 0xFFFFAA00); // Cyan when rolling
drawList.AddCircle(screenPos, radius + 1, 0xFFFFDD44);
// "R" centered in the circle
var textSize = ImGui.CalcTextSize("R");
drawList.AddText(screenPos - textSize * 0.5f, 0xFF000000, "R");
}
else
{
drawList.AddCircleFilled(screenPos, radius, 0xFF00FF00); // Green
drawList.AddCircle(screenPos, radius + 1, 0xFF00AA00);
}
var barY = radius + 8;
@ -95,6 +106,43 @@ public static class EntityRenderer
}
}
public static void DrawItems(ImDrawListPtr drawList, IReadOnlyList<SimItem> items,
ViewTransform vt, Vector2 canvasMin, Vector2 canvasMax)
{
foreach (var item in items)
{
var screenPos = vt.WorldToScreen(item.Position);
// Cull off-screen
if (screenPos.X < canvasMin.X - 60 || screenPos.X > canvasMax.X + 60 ||
screenPos.Y < canvasMin.Y - 20 || screenPos.Y > canvasMax.Y + 20)
continue;
var color = item.Category switch
{
LootCategory.Currency => 0xFF0000FF, // Red
LootCategory.Magic => 0xFFFF8800, // Blue
LootCategory.Rare => 0xFF00FFFF, // Yellow
LootCategory.Unique => 0xFF00AAFF, // Orange
LootCategory.Quest => 0xFF00FF00, // Green
_ => 0xFFCCCCCC, // White
};
var textSize = ImGui.CalcTextSize(item.Label);
var labelPos = screenPos - new Vector2(textSize.X * 0.5f, 16f);
// Background
var pad = new Vector2(3, 1);
drawList.AddRectFilled(labelPos - pad, labelPos + textSize + pad, 0xBB000000);
// Label text
drawList.AddText(labelPos, color, item.Label);
// Small dot at item position
drawList.AddCircleFilled(screenPos, 3f, color);
}
}
private static void DrawHealthBar(ImDrawListPtr drawList, Vector2 pos, float width, float height,
int current, int max, uint color)
{

View file

@ -24,6 +24,7 @@ public static class InputOverlayRenderer
private const uint ScrollBg = 0xFF333333;
private const uint CursorDot = 0xFF00DDFF; // Cyan dot for cursor position
private const uint CrosshairColor = 0x44FFFFFF; // Dim crosshair
private const float SpaceBarHeight = 20f;
// Keyboard rows: (label, scanCode, column offset)
private static readonly (string L, ushort S, float C)[] Row0 =
@ -44,7 +45,7 @@ public static class InputOverlayRenderer
{
var padSize = 80f;
var mouseH = 64f;
var kbH = 3 * Stride;
var kbH = 3 * Stride + SpaceBarHeight + Gap;
var totalH = kbH + 6 + mouseH + 6 + padSize;
var origin = canvasOrigin + new Vector2(15, canvasSize.Y - totalH - 15);
@ -52,6 +53,7 @@ public static class InputOverlayRenderer
DrawKeyRow(drawList, origin, Row0, 0, input);
DrawKeyRow(drawList, origin, Row1, 1, input);
DrawKeyRow(drawList, origin, Row2, 2, input);
DrawSpaceBar(drawList, origin + new Vector2(0, 3 * Stride), input);
// Mouse to the right of keyboard
var kbW = 4.25f * Stride + KeySize;
@ -79,6 +81,19 @@ public static class InputOverlayRenderer
}
}
private static void DrawSpaceBar(ImDrawListPtr drawList, Vector2 origin, InputSnapshot input)
{
const ushort spaceScan = 0x39;
var on = input.IsKeyActive(spaceScan);
// Wide bar spanning roughly the ASDF row width
var pos = origin + new Vector2(0.5f * Stride, 0);
var size = new Vector2(3.5f * Stride, SpaceBarHeight);
drawList.AddRectFilled(pos, pos + size, on ? ActiveBg : DarkBg, 3f);
drawList.AddRect(pos, pos + size, on ? Yellow : Outline, 3f);
}
private static void DrawMouse(ImDrawListPtr drawList, InputSnapshot input, Vector2 o)
{
const float w = 44, h = 64, hw = w / 2, bh = 26;

View file

@ -75,17 +75,24 @@ public class SimRenderer
var effects = _world.ActiveEffects.ToArray();
var projectiles = _world.Projectiles.ToArray();
var enemies = _world.Enemies.ToArray();
var items = _world.Items.ToArray();
EffectRenderer.DrawEffects(drawList, effects, vt);
EffectRenderer.DrawProjectiles(drawList, projectiles, vt);
// 4. Enemies
// 4. Items (ground loot labels)
EntityRenderer.DrawItems(drawList, items, vt, canvasOrigin, canvasOrigin + canvasSize);
// 5. Enemies
EntityRenderer.DrawEnemies(drawList, enemies, vt, canvasOrigin, canvasOrigin + canvasSize);
// 5. Player
// 6. Player
EntityRenderer.DrawPlayer(drawList, _world.Player, vt);
// 6. Mock cursor — shows where the bot's mouse is pointing in the world
// 7. Monitor viewport outline — shows what a 2560x1440 monitor would see
DrawMonitorBounds(drawList, vt);
// 8. Mock cursor — shows where the bot's mouse is pointing in the world
DrawMockCursor(drawList, vt);
drawList.PopClipRect();
@ -139,6 +146,29 @@ public class SimRenderer
}
}
/// <summary>
/// Draws an axis-aligned rectangle centered on the player showing the approximate
/// field of view of a 2560x1440 monitor. Scales with sim zoom.
/// </summary>
private void DrawMonitorBounds(ImDrawListPtr drawList, ViewTransform vt)
{
// Approximate visible world area on a real monitor (~1400x788 world units at 16:9).
// Aggro range (600) sits comfortably inside.
const float halfW = 700f;
const float halfH = 394f;
var center = vt.WorldToScreen(_world.Player.Position);
var scale = vt.WorldScale;
var sw = halfW * scale;
var sh = halfH * scale;
var tl = center + new Vector2(-sw, -sh);
var br = center + new Vector2(sw, sh);
const uint color = 0x44AAAAAA;
drawList.AddRect(tl, br, color);
}
private void DrawMockCursor(ImDrawListPtr drawList, ViewTransform vt)
{
// Convert the bot's mouse screen position to world, then to our viewport

View file

@ -0,0 +1,15 @@
using System.Numerics;
namespace Nexus.Simulator.World;
public enum LootCategory { Currency, Normal, Magic, Rare, Unique, Quest }
public class SimItem
{
private static uint _nextId = 50000;
public uint Id { get; } = Interlocked.Increment(ref _nextId);
public Vector2 Position { get; init; }
public LootCategory Category { get; init; }
public string Label { get; init; } = "";
}

View file

@ -17,6 +17,13 @@ public class SimPlayer
public float EsRegen { get; set; }
public float EsRechargeDelay { get; set; }
// Dodge roll state
public bool IsRolling { get; set; }
public Vector2 RollDirection { get; set; }
public float RollElapsed { get; set; }
public float RollDuration { get; set; }
public float RollCooldownRemaining { get; set; }
// Accumulate fractional regen
private float _healthRegenAccum;
private float _manaRegenAccum;
@ -41,8 +48,21 @@ public class SimPlayer
_timeSinceLastDamage = esRechargeDelay; // Start with ES recharging
}
public void StartDodgeRoll(Vector2 direction, float duration)
{
if (IsRolling || RollCooldownRemaining > 0) return;
IsRolling = true;
RollDirection = Vector2.Normalize(direction);
RollElapsed = 0f;
RollDuration = duration;
}
public void Update(float dt)
{
// Tick dodge cooldown
if (RollCooldownRemaining > 0)
RollCooldownRemaining = MathF.Max(0, RollCooldownRemaining - dt);
_timeSinceLastDamage += dt;
// Health regen (always active)

View file

@ -14,6 +14,7 @@ public class SimWorld
public List<SimEnemy> Enemies { get; } = [];
public List<SimProjectile> Projectiles { get; } = [];
public List<SimSkillEffect> ActiveEffects { get; } = [];
public List<SimItem> Items { get; } = [];
public WalkabilitySnapshot Terrain { get; private set; }
public long TickNumber { get; private set; }
public Vector2 StartWorldPos { get; private set; }
@ -30,6 +31,10 @@ public class SimWorld
public Vector2 MouseWorldPos { get; set; }
private readonly Queue<(ushort scanCode, Vector2 targetWorldPos)> _skillQueue = new();
// Dodge roll
private Vector2? _pendingDodgeDirection;
private Vector2 _lastFacingDirection = Vector2.UnitX;
public SimWorld(SimConfig config)
{
_config = config;
@ -78,6 +83,7 @@ public class SimWorld
Enemies.Clear();
Projectiles.Clear();
ActiveEffects.Clear();
Items.Clear();
_respawnQueue.Clear();
SpawnEnemiesInRooms(dungeon.Rooms);
Log.Information("Dungeon regenerated (seed={Seed}), {Rooms} rooms", _dungeonSeed, dungeon.Rooms.Count);
@ -88,6 +94,11 @@ public class SimWorld
_skillQueue.Enqueue((scanCode, targetWorldPos));
}
public void QueueDodgeRoll(Vector2 direction)
{
_pendingDodgeDirection = direction;
}
public void Tick(float dt)
{
if (_config.IsPaused) return;
@ -102,6 +113,17 @@ public class SimWorld
Log.Information("Reached dungeon end!");
}
// 0.5. Start queued dodge roll
if (_pendingDodgeDirection.HasValue && !Player.IsRolling && Player.RollCooldownRemaining <= 0)
{
Player.StartDodgeRoll(_pendingDodgeDirection.Value, _config.DodgeRollDuration);
_pendingDodgeDirection = null;
}
else
{
_pendingDodgeDirection = null;
}
// 1. Move player
MovePlayer(dt);
@ -120,7 +142,10 @@ public class SimWorld
// 6. Process respawn queue
UpdateRespawns(dt);
// 7. Player regen
// 7. Pickup items near player
PickupNearbyItems();
// 8. Player regen
Player.Update(dt);
}
@ -134,9 +159,17 @@ public class SimWorld
private void MovePlayer(float dt)
{
// Dodge roll overrides normal movement
if (Player.IsRolling)
{
MovePlayerDodgeRoll(dt);
return;
}
if (MoveDirection.LengthSquared() < 0.001f) return;
var dir = Vector2.Normalize(MoveDirection);
_lastFacingDirection = dir; // Track for stationary dodge
var step = Player.MoveSpeed * dt;
// Try full direction
@ -166,6 +199,46 @@ public class SimWorld
}
}
/// <summary>
/// Ease-out dodge roll: v(t) = (2D/T) * (1 - t/T).
/// Peak speed at start = 2D/T, decelerates to 0 over duration T.
/// Total distance traveled = D (integral of v over [0,T]).
/// </summary>
private void MovePlayerDodgeRoll(float dt)
{
Player.RollElapsed += dt;
var t = Player.RollElapsed;
var T = Player.RollDuration;
var D = _config.DodgeRollDistance;
if (t >= T)
{
// Roll complete
Player.IsRolling = false;
Player.RollCooldownRemaining = _config.DodgeRollCooldown;
return;
}
// Quadratic ease-out speed: peaks at 2D/T, drops to 0
var speed = (2f * D / T) * (1f - t / T);
var step = speed * dt;
var dir = Player.RollDirection;
// Try full direction, then wall-slide fallback
if (!TryMove(dir, step))
{
// Try axis-aligned slides
if (MathF.Abs(dir.X) > 0.01f && TryMove(new Vector2(dir.X > 0 ? 1 : -1, 0), step)) { }
else if (MathF.Abs(dir.Y) > 0.01f && TryMove(new Vector2(0, dir.Y > 0 ? 1 : -1), step)) { }
else
{
// Completely blocked — end roll early
Player.IsRolling = false;
Player.RollCooldownRemaining = _config.DodgeRollCooldown;
}
}
}
private bool TryMove(Vector2 dir, float step)
{
var newPos = Player.Position + dir * step;
@ -246,7 +319,10 @@ public class SimWorld
enemy.TakeDamage(_config.SkillBaseDamage);
if (!enemy.IsAlive)
{
Log.Information("Kill: {Rarity} {Type} #{Id}", enemy.Rarity, enemy.Type, enemy.Id);
SpawnLoot(enemy);
}
}
}
@ -269,7 +345,10 @@ public class SimWorld
{
enemy.TakeDamage(_config.SkillBaseDamage);
if (!enemy.IsAlive)
{
Log.Information("Kill: {Rarity} {Type} #{Id}", enemy.Rarity, enemy.Type, enemy.Id);
SpawnLoot(enemy);
}
}
}
}
@ -337,7 +416,10 @@ public class SimWorld
{
enemy.TakeDamage(proj.Damage);
if (!enemy.IsAlive)
{
Log.Information("Kill: {Rarity} {Type} #{Id}", enemy.Rarity, enemy.Type, enemy.Id);
SpawnLoot(enemy);
}
proj.IsExpired = true;
break;
}
@ -602,4 +684,77 @@ public class SimWorld
if (roll < _config.UniqueChance + _config.RareChance + _config.MagicChance) return MonsterRarity.Magic;
return MonsterRarity.White;
}
// --- Loot ---
private void SpawnLoot(SimEnemy enemy)
{
var (dropChance, minItems, maxItems) = enemy.Rarity switch
{
MonsterRarity.Unique => (1f, 3, 5),
MonsterRarity.Rare => (0.8f, 2, 3),
MonsterRarity.Magic => (0.5f, 1, 2),
_ => (0.3f, 1, 1),
};
if (_rng.NextSingle() > dropChance) return;
var count = _rng.Next(minItems, maxItems + 1);
for (var i = 0; i < count; i++)
{
var category = RollLootCategory();
var offset = new Vector2(
(_rng.NextSingle() - 0.5f) * 60f,
(_rng.NextSingle() - 0.5f) * 60f);
Items.Add(new SimItem
{
Position = enemy.Position + offset,
Category = category,
Label = PickLootName(category),
});
}
}
private LootCategory RollLootCategory()
{
var roll = _rng.NextSingle();
if (roll < 0.15f) return LootCategory.Currency;
if (roll < 0.50f) return LootCategory.Normal;
if (roll < 0.75f) return LootCategory.Magic;
if (roll < 0.90f) return LootCategory.Rare;
if (roll < 0.95f) return LootCategory.Unique;
return LootCategory.Quest;
}
private static readonly string[] CurrencyNames = ["Gold", "Scroll of Wisdom", "Exalted Orb", "Chaos Orb", "Transmutation Orb"];
private static readonly string[] NormalNames = ["Iron Sword", "Leather Cap", "Bone Shield", "Short Bow", "Cloth Robe"];
private static readonly string[] MagicNames = ["Runic Hatchet", "Serpent Wand", "Chain Gloves", "Wolf Pelt", "Jade Amulet"];
private static readonly string[] RareNames = ["Dread Edge", "Soul Render", "Viper Strike", "Storm Circlet", "Blood Greaves"];
private static readonly string[] UniqueNames = ["Headhunter", "Mageblood", "Ashes of the Stars", "Aegis Aurora"];
private static readonly string[] QuestNames = ["Ancient Tablet", "Relic Shard", "Quest Gem", "Sealed Letter"];
private string PickLootName(LootCategory category)
{
var pool = category switch
{
LootCategory.Currency => CurrencyNames,
LootCategory.Normal => NormalNames,
LootCategory.Magic => MagicNames,
LootCategory.Rare => RareNames,
LootCategory.Unique => UniqueNames,
LootCategory.Quest => QuestNames,
_ => NormalNames,
};
return pool[_rng.Next(pool.Length)];
}
private void PickupNearbyItems()
{
for (var i = Items.Count - 1; i >= 0; i--)
{
if (Vector2.Distance(Items[i].Position, Player.Position) < 80f)
Items.RemoveAt(i);
}
}
}

View file

@ -182,19 +182,12 @@ public sealed class AreaProgressionSystem : ISystem
private void UpdateExploring(GameState state, ActionQueue actions)
{
// ── Check 1: Yield for elite combat ──
const float EliteEngagementRange = 800f;
foreach (var m in state.HostileMonsters)
// ── Check 1: Yield for combat — stop exploring to clear nearby monsters ──
if (HasNearbyHostiles(state))
{
if (m.Rarity >= MonsterRarity.Rare && m.DistanceToPlayer < EliteEngagementRange)
{
if (_nav.Mode != NavMode.Idle)
{
Log.Information("Progression: yielding for {Rarity} (dist={Dist:F0})", m.Rarity, m.DistanceToPlayer);
_nav.Stop();
}
return;
}
if (_nav.Mode != NavMode.Idle)
_nav.Stop();
return;
}
// ── Check 2: Quest chest interaction ──
@ -462,6 +455,16 @@ public sealed class AreaProgressionSystem : ISystem
private void UpdateNavigatingToTransition(GameState state, ActionQueue actions)
{
// Hostiles nearby — abort transition, go fight
if (HasNearbyHostiles(state))
{
Log.Debug("Progression: hostiles near transition, aborting to fight");
_targetTransitionEntityId = 0;
_phase = Phase.Exploring;
_nav.Stop();
return;
}
// Check if the entity is still visible and close enough
foreach (var e in state.Entities)
{
@ -488,6 +491,15 @@ public sealed class AreaProgressionSystem : ISystem
private void UpdateInteracting(GameState state, ActionQueue actions)
{
// Hostiles nearby — abort transition, go fight
if (HasNearbyHostiles(state))
{
Log.Debug("Progression: hostiles near exit, aborting click to fight");
_targetTransitionEntityId = 0;
_phase = Phase.Exploring;
return;
}
// Project entity to screen and click
foreach (var e in state.Entities)
{
@ -546,6 +558,23 @@ public sealed class AreaProgressionSystem : ISystem
return best.Area.Id;
}
private bool HasNearbyHostiles(GameState state)
{
const float EliteEngagementRange = 800f;
var combatRange = _config.CombatEngagementRange;
foreach (var m in state.HostileMonsters)
{
if (!m.IsAlive) continue;
var range = m.Rarity >= MonsterRarity.Rare ? EliteEngagementRange : combatRange;
if (m.DistanceToPlayer >= range) continue;
if (state.Terrain is { } t &&
!TerrainQuery.HasLineOfSight(t, state.Player.Position, m.Position, _config.WorldToGrid))
continue;
return true;
}
return false;
}
private bool HasQuestInThisArea(GameState state)
{
return state.Quests.Any(q => q.TargetAreas?.Any(a =>

View file

@ -83,10 +83,28 @@ public static class BotTick
movementBlender.Submit(new MovementIntent(4, steer, 0.4f, "WallSteer"));
}
// Exit avoidance: when fighting near area transitions, push away to prevent
// accidentally walking into them (they auto-trigger on proximity)
const float ExitAvoidRange = 300f;
if (shouldEngage && state.Player.HasPosition)
{
foreach (var e in state.Entities)
{
if (e.Category != EntityCategory.AreaTransition) continue;
var away = state.Player.Position - e.Position;
var dist = away.Length();
if (dist >= ExitAvoidRange || dist < 0.1f) continue;
var strength = 1.0f - (dist / ExitAvoidRange);
movementBlender.Submit(new MovementIntent(6, Vector2.Normalize(away) * strength, 0.9f, "ExitAvoid"));
break;
}
}
movementBlender.Resolve(state.Terrain, state.Player.Position, config.WorldToGrid);
var resolved = actionQueue.Resolve();
if (movementBlender.IsUrgentFlee)
// Block casting during emergency flee or dodge roll — focus on escaping
if (state.ThreatAssessment is { AnyEmergency: true } || state.Player.IsRolling)
resolved.RemoveAll(a => a is CastAction);
return resolved;

View file

@ -0,0 +1,93 @@
using System.Numerics;
using Nexus.Core;
namespace Nexus.Systems;
/// <summary>
/// Detects threatening enemy projectiles and triggers dodge rolls perpendicular to their trajectory.
/// Priority 75 — between Threat (50) and Movement (100).
/// </summary>
public class DodgeSystem : ISystem
{
public int Priority => SystemPriority.Dodge;
public string Name => "Dodge";
public bool IsEnabled { get; set; } = true;
public float WorldToGrid { get; set; } = 23f / 250f;
// Reaction window — only dodge projectiles arriving within this time
private const float ReactionWindow = 0.4f;
// Only dodge projectiles whose closest approach is within this distance
private const float DodgeThreshold = 80f;
// Minimum time between dodge decisions (prevents flip-flopping when multiple projectiles arrive)
private float _decisionCooldown;
private const float DecisionCooldownDuration = 0.1f;
public void Update(GameState state, ActionQueue actions, MovementBlender movement)
{
var dt = state.DeltaTime;
if (_decisionCooldown > 0)
{
_decisionCooldown -= dt;
return;
}
// Skip if already rolling, on cooldown, or no projectiles
if (state.Player.IsRolling) return;
if (state.Player.RollCooldownRemaining > 0) return;
if (state.EnemyProjectiles.Count == 0) return;
if (!state.Player.HasPosition) return;
// Find most urgent threatening projectile
ProjectileSnapshot? urgent = null;
foreach (var proj in state.EnemyProjectiles)
{
if (proj.TimeToImpact is not { } tti) continue; // Will miss
if (tti > ReactionWindow) continue; // Too far away to react
if (proj.ClosestApproachDistance > DodgeThreshold) continue;
if (urgent is null || tti < urgent.TimeToImpact)
urgent = proj;
}
if (urgent is null) return;
// Compute dodge direction: perpendicular to projectile trajectory
var projDir = urgent.Direction;
var perp1 = new Vector2(-projDir.Y, projDir.X); // Left perpendicular
var perp2 = new Vector2(projDir.Y, -projDir.X); // Right perpendicular
// Choose the side that moves us AWAY from the projectile trajectory
var toPlayer = state.Player.Position - urgent.Position;
var dodgeDir = Vector2.Dot(toPlayer, perp1) > 0 ? perp1 : perp2;
// Terrain validation: check if dodge destination is walkable
var dodgeDist = 100f; // approximate roll distance
var playerPos = state.Player.Position;
var dest1 = playerPos + dodgeDir * dodgeDist;
var gx1 = (int)(dest1.X * WorldToGrid);
var gy1 = (int)(dest1.Y * WorldToGrid);
if (state.Terrain is { } terrain && !terrain.IsWalkable(gx1, gy1))
{
// Try the other perpendicular
dodgeDir = -dodgeDir;
var dest2 = playerPos + dodgeDir * dodgeDist;
var gx2 = (int)(dest2.X * WorldToGrid);
var gy2 = (int)(dest2.Y * WorldToGrid);
if (!terrain.IsWalkable(gx2, gy2))
return; // Both sides blocked — can't dodge
}
// Submit dodge action
actions.Submit(new DodgeRollAction(SystemPriority.Dodge, dodgeDir));
// Also submit movement bias so other systems know we want to go this way
movement.Submit(new MovementIntent(1, dodgeDir, 0.8f, "Dodge"));
_decisionCooldown = DecisionCooldownDuration;
}
}

View file

@ -1,3 +1,4 @@
using System.Numerics;
using Nexus.Core;
namespace Nexus.Systems;
@ -6,10 +7,40 @@ public class LootSystem : ISystem
{
public int Priority => SystemPriority.Loot;
public string Name => "Loot";
public bool IsEnabled { get; set; } = false;
public bool IsEnabled { get; set; } = true;
private const float LootRange = 400f;
private const float SafeRange = 600f;
public void Update(GameState state, ActionQueue actions, MovementBlender movement)
{
// STUB: loot detection and pickup logic
if (state.NearbyLoot.Count == 0) return;
// Don't loot if hostiles are nearby
foreach (var hostile in state.HostileMonsters)
{
if (hostile.IsAlive && hostile.DistanceToPlayer < SafeRange)
return;
}
// Find nearest loot within range
EntitySnapshot? nearest = null;
var nearestDist = float.MaxValue;
foreach (var loot in state.NearbyLoot)
{
if (loot.DistanceToPlayer < nearestDist && loot.DistanceToPlayer < LootRange)
{
nearest = loot;
nearestDist = loot.DistanceToPlayer;
}
}
if (nearest is null) return;
// Steer toward the item — L3 same as navigation so it replaces explore direction
var dir = nearest.Position - state.Player.Position;
if (dir.LengthSquared() > 1f)
movement.Submit(new MovementIntent(3, Vector2.Normalize(dir), 0f, "Loot"));
}
}

View file

@ -22,7 +22,7 @@ public class MovementSystem : ISystem
public float WorldToGrid { get; set; } = 23f / 250f;
/// <summary>Minimum distance before radial push kicks in hard.</summary>
public float MinComfortDistance { get; set; } = 80f;
public float MinComfortDistance { get; set; } = 150f;
private int _orbitSign = 1;
@ -65,16 +65,19 @@ public class MovementSystem : ISystem
var tangent = new Vector2(-centroidDir.Y, centroidDir.X) * _orbitSign;
// Radial component — push away from centroid, strength based on proximity
// Close < MinComfort: gentle push out (avoid stacking on top of enemies)
// MinComfort..SafeDistance*0.5: slight push out
// SafeDistance*0.7+: pull inward to maintain engagement instead of drifting away
// Close < MinComfort: strong push out (don't let enemies stack on us)
// MinComfort..SafeDistance*0.6: moderate push out (keep distance)
// SafeDistance*0.6..0.8: pure orbit (sweet spot)
// SafeDistance*0.8+: gentle pull inward to maintain engagement
float radialStrength;
if (closestDist < MinComfortDistance)
radialStrength = -0.25f; // too close — gentle push outward
else if (closestDist < SafeDistance * 0.5f)
radialStrength = -0.1f; // somewhat close — slight push outward
else if (closestDist > SafeDistance * 0.7f)
radialStrength = 0.4f; // at edge — pull inward to maintain engagement
radialStrength = -0.6f; // too close — strong push outward
else if (closestDist < SafeDistance * 0.4f)
radialStrength = -0.35f; // close — firm push outward
else if (closestDist < SafeDistance * 0.6f)
radialStrength = -0.15f; // moderate — gentle push outward
else if (closestDist > SafeDistance * 0.8f)
radialStrength = 0.3f; // at edge — pull inward to maintain engagement
else
radialStrength = 0f; // sweet spot — pure orbit

View file

@ -15,6 +15,7 @@ public static class SystemFactory
systems.Add(new AreaProgressionSystem(config, nav, AreaGraph.Load()));
systems.Add(new ThreatSystem { WorldToGrid = config.WorldToGrid });
systems.Add(new DodgeSystem { WorldToGrid = config.WorldToGrid });
systems.Add(new MovementSystem
{
SafeDistance = config.SafeDistance,

View file

@ -43,6 +43,10 @@ public class ThreatSystem : ISystem
private int _killLoseStreak;
private const int KillTargetDebounce = 15; // ~250ms
// Flee commitment — once flee starts, hold for minimum duration to prevent oscillation
private int _fleeCommitTicks;
private const int FleeCommitDuration = 60; // ~1 second at 60Hz
// Logging
private ThreatCategory _prevMaxCategory = ThreatCategory.Ignore;
private uint? _prevTopThreatId;
@ -181,7 +185,14 @@ public class ThreatSystem : ISystem
// 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 rawShouldFlee = _smoothedZoneThreat > fleeOffThreshold || anyEmergency;
// Flee commitment: once triggered, hold for minimum duration to prevent oscillation
if (rawShouldFlee)
_fleeCommitTicks = FleeCommitDuration;
else if (_fleeCommitTicks > 0)
_fleeCommitTicks--;
var shouldFlee = _fleeCommitTicks > 0;
var areaClear = entries.TrueForAll(e => e.Category < ThreatCategory.Monitor);
// Range band counts (backward compat)
@ -274,22 +285,10 @@ public class ThreatSystem : ISystem
_prevMaxCategory = zoneCat;
}
// ── 6. Submit movement intents ──
if (!shouldFlee) return;
var isPointBlank = closestDist < PointBlankRange;
if (anyEmergency || isPointBlank)
{
// Layer 0: near-total override — flee, blocks casting. 0.85 lets wall push still help.
movement.Submit(new MovementIntent(0, safest, 0.85f, "Threat"));
}
else
{
// Layer 1: strong flee scaled by flee weight
var override1 = 0.3f + assessment.FleeWeight * 0.4f; // 0.30.7
movement.Submit(new MovementIntent(1, safest * assessment.FleeWeight, override1, "Threat"));
}
// ── 6. Movement ──
// No raw flee intents — all movement goes through pathfinding (NavigationController)
// which routes around walls. ThreatAssessment.ShouldFlee/SafestDirection are available
// for BotTick to adjust navigation target if needed.
}
// ── Per-entity scoring ──