NEO-92: Add threat store and aggro acquire/leash-clear wiring.

Introduce IThreatStateStore with first-hit acquire on damaging casts
and deterministic leash clears on player move and pre-acquire checks.

Co-authored-by: Cursor <cursoragent@cursor.com>
pull/130/head
VinPropane 2026-05-27 21:45:57 -04:00
parent d0d5dbdf17
commit 843abf7126
16 changed files with 627 additions and 4 deletions

View File

@ -1,6 +1,7 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Combat;
using NeonSprawl.Server.Game.Gigs;
@ -568,6 +569,28 @@ public sealed class AbilityCastApiTests
Assert.Equal(0, body.CombatResolution!.DamageDealt);
Assert.Equal(100, body.CombatResolution.TargetRemainingHp);
Assert.False(body.CombatResolution.TargetDefeated);
var threatStore = factory.Services.GetRequiredService<IThreatStateStore>();
threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var threat);
Assert.Null(threat.AggroHolderPlayerId);
}
[Fact]
public async Task PostAbilityCast_ShouldSetAggroHolder_WhenDamagingCastAccepted()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var threatStore = factory.Services.GetRequiredService<IThreatStateStore>();
await BindSlot0PulseAsync(client);
await LockPrototypeTargetAlphaAsync(client);
// Act
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", PulseCastRequest());
response.EnsureSuccessStatusCode();
threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var threat);
// Assert
Assert.Equal("dev-local-1", threat.AggroHolderPlayerId);
}
[Fact]

View File

@ -20,7 +20,9 @@ public sealed class CombatTargetFixtureApiTests
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var store = factory.Services.GetRequiredService<ICombatEntityHealthStore>();
var threatStore = factory.Services.GetRequiredService<IThreatStateStore>();
_ = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _);
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1");
// Act
var response = await client.PostAsJsonAsync(
@ -38,6 +40,8 @@ public sealed class CombatTargetFixtureApiTests
store.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var melee);
Assert.Equal(100, melee.CurrentHp);
Assert.False(melee.Defeated);
threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var threat);
Assert.Null(threat.AggroHolderPlayerId);
}
[Fact]

View File

@ -0,0 +1,123 @@
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.PositionState;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Npc;
public sealed class AggroOperationsTests
{
private static InMemoryPositionStateStore CreatePositionStoreAt(double x, double y, double z)
{
var store = new InMemoryPositionStateStore(Options.Create(new GamePositionOptions
{
DevPlayerId = "dev-local-1",
DefaultPosition = new DefaultPositionOptions { X = x, Y = y, Z = z },
}));
return store;
}
[Fact]
public void TryAcquire_ShouldSetHolder_WhenRowEmpty()
{
// Arrange
var threatStore = new InMemoryThreatStateStore();
// Act
var acquired = AggroOperations.TryAcquire(
PrototypeNpcRegistry.PrototypeNpcMeleeId,
"dev-local-1",
threatStore);
threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
// Assert
Assert.True(acquired);
Assert.Equal("dev-local-1", snapshot.AggroHolderPlayerId);
}
[Fact]
public void TryAcquire_ShouldNoOp_WhenHolderAlreadySet()
{
// Arrange
var threatStore = new InMemoryThreatStateStore();
_ = AggroOperations.TryAcquire(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1", threatStore);
// Act
var secondAcquire = AggroOperations.TryAcquire(
PrototypeNpcRegistry.PrototypeNpcMeleeId,
"dev-local-1",
threatStore);
threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
// Assert
Assert.False(secondAcquire);
Assert.Equal("dev-local-1", snapshot.AggroHolderPlayerId);
}
[Fact]
public void TryClearOnLeashForPlayer_ShouldClearHolder_WhenPlayerBeyondLeashRadius()
{
// Arrange
var behaviorRegistry = PrototypeNpcTestFixtures.CreateBehaviorRegistry();
var threatStore = new InMemoryThreatStateStore();
var positions = CreatePositionStoreAt(0, 0, 0);
_ = AggroOperations.TryAcquire(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1", threatStore);
positions.TryApplyMoveTarget("dev-local-1", 12, 0, 12, out _);
// Act
AggroOperations.TryClearOnLeashForPlayer("dev-local-1", positions, behaviorRegistry, threatStore);
threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
// Assert
Assert.Null(snapshot.AggroHolderPlayerId);
}
[Fact]
public void TryClearOnLeashForPlayer_ShouldKeepHolder_WhenPlayerInsideLeashRadius()
{
// Arrange
var behaviorRegistry = PrototypeNpcTestFixtures.CreateBehaviorRegistry();
var threatStore = new InMemoryThreatStateStore();
var positions = CreatePositionStoreAt(0, 0, 0);
_ = AggroOperations.TryAcquire(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1", threatStore);
positions.TryApplyMoveTarget("dev-local-1", 1, 0, 1, out _);
// Act
AggroOperations.TryClearOnLeashForPlayer("dev-local-1", positions, behaviorRegistry, threatStore);
threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
// Assert
Assert.Equal("dev-local-1", snapshot.AggroHolderPlayerId);
}
[Fact]
public void TryClearOnLeashForPlayer_ShouldKeepHolder_WhenPlayerAtExactLeashRadius()
{
// Arrange
var behaviorRegistry = PrototypeNpcTestFixtures.CreateBehaviorRegistry();
var threatStore = new InMemoryThreatStateStore();
var positions = CreatePositionStoreAt(0, 0, 0);
_ = AggroOperations.TryAcquire(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1", threatStore);
// Melee anchor (-3, -3); leash 16.0 — X = 13 is exactly 16 m horizontal from anchor.
positions.TryApplyMoveTarget("dev-local-1", 13, 0, -3, out _);
// Act
AggroOperations.TryClearOnLeashForPlayer("dev-local-1", positions, behaviorRegistry, threatStore);
threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
// Assert
Assert.Equal("dev-local-1", snapshot.AggroHolderPlayerId);
}
[Fact]
public void TryAcquire_ShouldSetHolderAgain_AfterLeashClear()
{
// Arrange
var behaviorRegistry = PrototypeNpcTestFixtures.CreateBehaviorRegistry();
var threatStore = new InMemoryThreatStateStore();
var positions = CreatePositionStoreAt(0, 0, 0);
_ = AggroOperations.TryAcquire(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1", threatStore);
positions.TryApplyMoveTarget("dev-local-1", 12, 0, 12, out _);
AggroOperations.TryClearOnLeashForPlayer("dev-local-1", positions, behaviorRegistry, threatStore);
positions.TryApplyMoveTarget("dev-local-1", 0, 0, 0, out _);
// Act
var reacquired = AggroOperations.TryAcquire(
PrototypeNpcRegistry.PrototypeNpcMeleeId,
"dev-local-1",
threatStore);
threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
// Assert
Assert.True(reacquired);
Assert.Equal("dev-local-1", snapshot.AggroHolderPlayerId);
}
}

View File

@ -0,0 +1,113 @@
using System.Net.Http.Json;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Targeting;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Npc;
/// <summary>HTTP integration tests for NEO-92 aggro acquire + leash clear wiring.</summary>
public sealed class AggroThreatIntegrationTests
{
private static async Task BindSlot0PulseAsync(HttpClient client)
{
var post = await client.PostAsJsonAsync(
"/game/players/dev-local-1/hotbar-loadout",
new HotbarLoadoutUpdateRequest
{
SchemaVersion = HotbarLoadoutUpdateRequest.CurrentSchemaVersion,
Slots = [new HotbarSlotBindingJson { SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse }],
});
post.EnsureSuccessStatusCode();
}
private static async Task LockMeleeAsync(HttpClient client)
{
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/target/select",
new TargetSelectRequest
{
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
});
response.EnsureSuccessStatusCode();
}
private static async Task TeleportDevPlayerAsync(HttpClient client, double x, double y, double z)
{
var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/move-stream",
new MoveStreamRequest
{
SchemaVersion = MoveStreamRequest.CurrentSchemaVersion,
Targets = [new PositionVector { X = x, Y = y, Z = z }],
});
response.EnsureSuccessStatusCode();
}
[Fact]
public async Task CastThenMoveBeyondLeash_ShouldAcquireThenClearAggroHolder()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var threatStore = factory.Services.GetRequiredService<IThreatStateStore>();
await BindSlot0PulseAsync(client);
await LockMeleeAsync(client);
// Act
var castResponse = await client.PostAsJsonAsync(
"/game/players/dev-local-1/ability-cast",
new AbilityCastRequest
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
});
castResponse.EnsureSuccessStatusCode();
threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var afterCast);
await TeleportDevPlayerAsync(client, 12, 0, 12);
threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var afterMove);
// Assert
Assert.Equal("dev-local-1", afterCast.AggroHolderPlayerId);
Assert.Null(afterMove.AggroHolderPlayerId);
}
[Fact]
public async Task ReacquireAfterLeashClear_ShouldSetHolderOnNextDamagingCast()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
Assert.NotNull(factory.FakeClock);
var threatStore = factory.Services.GetRequiredService<IThreatStateStore>();
await BindSlot0PulseAsync(client);
await LockMeleeAsync(client);
var cast = new AbilityCastRequest
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
};
var firstCast = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
firstCast.EnsureSuccessStatusCode();
await TeleportDevPlayerAsync(client, 12, 0, 12);
await TeleportDevPlayerAsync(client, 0, 0, 0);
await LockMeleeAsync(client);
factory.FakeClock!.Advance(TimeSpan.FromSeconds(3) + TimeSpan.FromMilliseconds(50));
// Act
var secondCast = await client.PostAsJsonAsync("/game/players/dev-local-1/ability-cast", cast);
secondCast.EnsureSuccessStatusCode();
threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var afterSecondCast);
// Assert
Assert.Equal("dev-local-1", afterSecondCast.AggroHolderPlayerId);
}
}

View File

@ -0,0 +1,62 @@
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.PositionState;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.Npc;
public sealed class InMemoryThreatStateStoreTests
{
[Fact]
public void TryGet_ShouldLazyInitializeEmptyHolder_ForKnownNpcInstance()
{
// Arrange
var store = new InMemoryThreatStateStore();
// Act
var found = store.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
// Assert
Assert.True(found);
Assert.Equal(PrototypeNpcRegistry.PrototypeNpcMeleeId, snapshot.NpcInstanceId);
Assert.Null(snapshot.AggroHolderPlayerId);
}
[Fact]
public void TryGet_ShouldReturnFalse_ForUnknownNpcInstance()
{
// Arrange
var store = new InMemoryThreatStateStore();
// Act
var found = store.TryGet("prototype_target_alpha", out var snapshot);
// Assert
Assert.False(found);
Assert.Equal(default, snapshot);
}
[Fact]
public void TrySetHolder_ShouldPersistLowercaseHolder_AndClearOnNull()
{
// Arrange
var store = new InMemoryThreatStateStore();
// Act
var set = store.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, " Dev-Local-1 ");
store.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var withHolder);
var cleared = store.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId);
store.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var afterClear);
// Assert
Assert.True(set);
Assert.Equal("dev-local-1", withHolder.AggroHolderPlayerId);
Assert.True(cleared);
Assert.Null(afterClear.AggroHolderPlayerId);
}
[Fact]
public void TrySetHolder_ShouldReturnFalse_ForUnknownNpcInstance()
{
// Arrange
var store = new InMemoryThreatStateStore();
// Act
var set = store.TrySetHolder("unknown_npc", "dev-local-1");
// Assert
Assert.False(set);
}
}

View File

@ -3,7 +3,11 @@ using System.Net.Http.Json;
using System.Text;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using NeonSprawl.Server.Game.AbilityInput;
using NeonSprawl.Server.Game.Npc;
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.Targeting;
using NeonSprawl.Server.Tests;
using Xunit;
namespace NeonSprawl.Server.Tests.Game.PositionState;
@ -186,4 +190,52 @@ public class MoveCommandApiTests
var text = await response.Content.ReadAsStringAsync();
Assert.DoesNotContain("reasonCode", text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task PostMove_ShouldClearAggroHolder_WhenPlayerMovesBeyondLeashRadius()
{
// Arrange
await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient();
var threatStore = factory.Services.GetRequiredService<IThreatStateStore>();
await client.PostAsJsonAsync(
"/game/players/dev-local-1/hotbar-loadout",
new HotbarLoadoutUpdateRequest
{
SchemaVersion = HotbarLoadoutUpdateRequest.CurrentSchemaVersion,
Slots = [new HotbarSlotBindingJson { SlotIndex = 0, AbilityId = PrototypeAbilityRegistry.PrototypePulse }],
});
await client.PostAsJsonAsync(
"/game/players/dev-local-1/target/select",
new TargetSelectRequest
{
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
});
var cast = await client.PostAsJsonAsync(
"/game/players/dev-local-1/ability-cast",
new AbilityCastRequest
{
SchemaVersion = AbilityCastRequest.CurrentSchemaVersion,
SlotIndex = 0,
AbilityId = PrototypeAbilityRegistry.PrototypePulse,
TargetId = PrototypeNpcRegistry.PrototypeNpcMeleeId,
});
cast.EnsureSuccessStatusCode();
threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var afterCast);
var cmd = new MoveCommandRequest
{
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
Target = new PositionVector { X = 12, Y = 0, Z = 12 },
};
// Act
var moveResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/move", cmd);
threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var afterMove);
// Assert
Assert.Equal(HttpStatusCode.OK, moveResponse.StatusCode);
Assert.Equal("dev-local-1", afterCast.AggroHolderPlayerId);
Assert.Null(afterMove.AggroHolderPlayerId);
}
}

View File

@ -64,6 +64,8 @@ public static class AbilityCastApi
IPlayerAbilityCooldownStore cooldowns,
IAbilityDefinitionRegistry abilities,
ICombatEntityHealthStore healthStore,
IThreatStateStore threatStore,
INpcBehaviorDefinitionRegistry behaviorRegistry,
IPlayerGigProgressionStore gigStore,
ILoggerFactory loggerFactory,
TimeProvider clock) =>
@ -223,6 +225,16 @@ public static class AbilityCastApi
normalizedRequest);
}
if (combatResult.DamageDealt > 0)
{
AggroOperations.TryClearOnLeashForPlayer(
id,
positions,
behaviorRegistry,
threatStore);
AggroOperations.TryAcquire(lookupKey, id, threatStore);
}
cooldowns.StartCooldown(
id,
body.SlotIndex,

View File

@ -9,7 +9,7 @@ public static class CombatTargetFixtureApi
{
app.MapPost(
"/game/__dev/combat-targets-fixture",
(CombatTargetFixtureRequest? body, ICombatEntityHealthStore healthStore) =>
(CombatTargetFixtureRequest? body, ICombatEntityHealthStore healthStore, IThreatStateStore threatStore) =>
{
if (body is null || body.SchemaVersion != CombatTargetFixtureRequest.CurrentSchemaVersion)
{
@ -24,6 +24,8 @@ public static class CombatTargetFixtureApi
}
}
AggroOperations.ClearAllPrototypeHolders(threatStore);
return Results.Json(
new CombatTargetFixtureResponse
{

View File

@ -0,0 +1,108 @@
using NeonSprawl.Server.Game.PositionState;
using NeonSprawl.Server.Game.World;
namespace NeonSprawl.Server.Game.Npc;
/// <summary>Deterministic prototype aggro acquire and leash-clear rules (NEO-92).</summary>
public static class AggroOperations
{
/// <summary>
/// Sets aggro holder to <paramref name="playerId"/> when the NPC row is empty.
/// Returns <c>true</c> only when holder was empty and is now set.
/// </summary>
public static bool TryAcquire(string npcInstanceId, string playerId, IThreatStateStore threatStore)
{
var npcKey = NormalizeId(npcInstanceId);
var playerKey = NormalizeId(playerId);
if (npcKey.Length == 0 || playerKey.Length == 0 || !PrototypeNpcRegistry.TryGet(npcKey, out _))
{
return false;
}
if (!threatStore.TryGet(npcKey, out var current))
{
return false;
}
if (!string.IsNullOrEmpty(current.AggroHolderPlayerId))
{
return false;
}
return threatStore.TrySetHolder(npcKey, playerKey);
}
/// <summary>
/// Clears aggro on every prototype NPC instance where <paramref name="playerId"/> is holder
/// and horizontal distance from the NPC anchor exceeds catalog <c>leashRadius</c>.
/// </summary>
public static void TryClearOnLeashForPlayer(
string playerId,
IPositionStateStore positions,
INpcBehaviorDefinitionRegistry behaviorRegistry,
IThreatStateStore threatStore)
{
var playerKey = NormalizeId(playerId);
if (playerKey.Length == 0 || !positions.TryGetPosition(playerKey, out var playerSnap))
{
return;
}
foreach (var npcInstanceId in PrototypeNpcRegistry.GetInstanceIdsInOrder())
{
TryClearOnLeashForNpc(
npcInstanceId,
playerKey,
in playerSnap,
behaviorRegistry,
threatStore);
}
}
/// <summary>Clears aggro for one NPC when the holder is outside that NPC's leash radius.</summary>
public static void TryClearOnLeashForNpc(
string npcInstanceId,
string playerIdLowercase,
in PositionSnapshot playerSnap,
INpcBehaviorDefinitionRegistry behaviorRegistry,
IThreatStateStore threatStore)
{
var npcKey = NormalizeId(npcInstanceId);
if (npcKey.Length == 0 ||
!PrototypeNpcRegistry.TryGet(npcKey, out var entry) ||
!threatStore.TryGet(npcKey, out var threat) ||
string.IsNullOrEmpty(threat.AggroHolderPlayerId) ||
!string.Equals(threat.AggroHolderPlayerId, playerIdLowercase, StringComparison.Ordinal))
{
return;
}
if (!behaviorRegistry.TryGetDefinition(entry.BehaviorDefId, out var behavior))
{
return;
}
var distance = HorizontalReach.HorizontalDistance(
playerSnap.X,
playerSnap.Z,
entry.X,
entry.Z);
if (distance > behavior.LeashRadius)
{
threatStore.TryClearHolder(npcKey);
}
}
/// <summary>Clears aggro holders on all prototype NPC instances (dev fixture).</summary>
public static void ClearAllPrototypeHolders(IThreatStateStore threatStore)
{
foreach (var npcInstanceId in PrototypeNpcRegistry.GetInstanceIdsInOrder())
{
threatStore.TryClearHolder(npcInstanceId);
}
}
private static string NormalizeId(string? raw) =>
raw?.Trim().ToLowerInvariant() ?? string.Empty;
}

View File

@ -0,0 +1,19 @@
using System.Diagnostics.CodeAnalysis;
namespace NeonSprawl.Server.Game.Npc;
/// <summary>
/// Session in-memory aggro holder store keyed by prototype NPC instance id (NEO-92).
/// HTTP projection lands in NEO-94; NEO-93 reads holder for behavior state transitions.
/// </summary>
public interface IThreatStateStore
{
/// <summary>Reads threat for a known prototype NPC instance. Lazy-initializes an empty holder row.</summary>
bool TryGet(string? npcInstanceId, [NotNullWhen(true)] out ThreatStateSnapshot snapshot);
/// <summary>Sets or clears the aggro holder for a known prototype NPC instance.</summary>
bool TrySetHolder(string? npcInstanceId, string? playerIdLowercaseOrNull);
/// <summary>Clears the aggro holder for a known prototype NPC instance.</summary>
bool TryClearHolder(string? npcInstanceId);
}

View File

@ -0,0 +1,68 @@
using System.Collections.Concurrent;
namespace NeonSprawl.Server.Game.Npc;
/// <summary>Thread-safe in-memory aggro holders for prototype NPC instances (NEO-92).</summary>
public sealed class InMemoryThreatStateStore : IThreatStateStore
{
private readonly ConcurrentDictionary<string, string?> holderByNpcId = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, object> idLocks = new(StringComparer.Ordinal);
/// <inheritdoc />
public bool TryGet(string? npcInstanceId, out ThreatStateSnapshot snapshot)
{
var key = NormalizeNpcInstanceId(npcInstanceId);
if (key.Length == 0 || !PrototypeNpcRegistry.TryGet(key, out _))
{
snapshot = default;
return false;
}
lock (idLocks.GetOrAdd(key, _ => new object()))
{
if (!holderByNpcId.TryGetValue(key, out var holder))
{
holder = null;
holderByNpcId[key] = holder;
}
snapshot = new ThreatStateSnapshot(key, holder);
return true;
}
}
/// <inheritdoc />
public bool TrySetHolder(string? npcInstanceId, string? playerIdLowercaseOrNull)
{
var key = NormalizeNpcInstanceId(npcInstanceId);
if (key.Length == 0 || !PrototypeNpcRegistry.TryGet(key, out _))
{
return false;
}
var normalizedHolder = NormalizePlayerId(playerIdLowercaseOrNull);
lock (idLocks.GetOrAdd(key, _ => new object()))
{
holderByNpcId[key] = normalizedHolder;
return true;
}
}
/// <inheritdoc />
public bool TryClearHolder(string? npcInstanceId) => TrySetHolder(npcInstanceId, null);
private static string NormalizeNpcInstanceId(string? npcInstanceId) =>
npcInstanceId?.Trim().ToLowerInvariant() ?? string.Empty;
private static string? NormalizePlayerId(string? playerId)
{
if (string.IsNullOrWhiteSpace(playerId))
{
return null;
}
return playerId.Trim().ToLowerInvariant();
}
}

View File

@ -0,0 +1,12 @@
namespace NeonSprawl.Server.Game.Npc;
/// <summary>Registers prototype threat/aggro state store (NEO-92).</summary>
public static class ThreatStateServiceCollectionExtensions
{
/// <summary>Registers <see cref="IThreatStateStore"/> as an in-memory singleton.</summary>
public static IServiceCollection AddThreatStateStore(this IServiceCollection services)
{
services.AddSingleton<IThreatStateStore, InMemoryThreatStateStore>();
return services;
}
}

View File

@ -0,0 +1,6 @@
namespace NeonSprawl.Server.Game.Npc;
/// <summary>Per-NPC aggro holder snapshot (NEO-92).</summary>
/// <param name="NpcInstanceId">Lowercase prototype NPC instance id.</param>
/// <param name="AggroHolderPlayerId">Lowercase route player id holding aggro, or <see langword="null"/> when empty.</param>
public readonly record struct ThreatStateSnapshot(string NpcInstanceId, string? AggroHolderPlayerId);

View File

@ -1,4 +1,5 @@
using Microsoft.Extensions.Options;
using NeonSprawl.Server.Game.Npc;
namespace NeonSprawl.Server.Game.PositionState;
@ -28,7 +29,7 @@ public static class PositionStateApi
app.MapPost(
"/game/players/{id}/move",
(string id, MoveCommandRequest? body, IPositionStateStore store, IOptions<GamePositionOptions> gameOptions) =>
(string id, MoveCommandRequest? body, IPositionStateStore store, IThreatStateStore threatStore, INpcBehaviorDefinitionRegistry behaviorRegistry, IOptions<GamePositionOptions> gameOptions) =>
{
if (body is null || body.SchemaVersion != MoveCommandRequest.CurrentSchemaVersion)
{
@ -62,6 +63,8 @@ public static class PositionStateApi
return Results.NotFound();
}
AggroOperations.TryClearOnLeashForPlayer(id, store, behaviorRegistry, threatStore);
var response = new PositionStateResponse
{
SchemaVersion = PositionStateResponse.CurrentSchemaVersion,
@ -74,7 +77,7 @@ public static class PositionStateApi
app.MapPost(
"/game/players/{id}/move-stream",
(string id, MoveStreamRequest? body, IPositionStateStore store, IOptions<GamePositionOptions> gameOptions) =>
(string id, MoveStreamRequest? body, IPositionStateStore store, IThreatStateStore threatStore, INpcBehaviorDefinitionRegistry behaviorRegistry, IOptions<GamePositionOptions> gameOptions) =>
{
if (body is null || body.SchemaVersion != MoveStreamRequest.CurrentSchemaVersion)
{
@ -121,6 +124,8 @@ public static class PositionStateApi
lastSnap = snap;
}
AggroOperations.TryClearOnLeashForPlayer(id, store, behaviorRegistry, threatStore);
var response = new PositionStateResponse
{
SchemaVersion = PositionStateResponse.CurrentSchemaVersion,

View File

@ -25,6 +25,7 @@ builder.Services.AddResourceNodeCatalog(builder.Configuration);
builder.Services.AddRecipeDefinitionCatalog(builder.Configuration);
builder.Services.AddAbilityDefinitionCatalog(builder.Configuration);
builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration);
builder.Services.AddThreatStateStore();
builder.Services.AddMasteryCatalog(builder.Configuration);
var app = builder.Build();

View File

@ -152,7 +152,20 @@ Plan: [NEO-80 implementation plan](../../docs/plans/NEO-80-implementation-plan.m
curl -sS -i "http://localhost:5253/game/world/combat-targets"
```
**Dev combat-target fixture (Bruno/manual QA):** When `Game:EnableCombatTargetFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/__dev/combat-targets-fixture`** with `schemaVersion` **1** resets all **`PrototypeNpcRegistry`** instances to catalog full HP via **`TryResetToFull`**. **404** when disabled. Bruno: `scripts/combat-targets-reset-helper.js`.
**Dev combat-target fixture (Bruno/manual QA):** When `Game:EnableCombatTargetFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/__dev/combat-targets-fixture`** with `schemaVersion` **1** resets all **`PrototypeNpcRegistry`** instances to catalog full HP via **`TryResetToFull`** and clears all prototype aggro holders (**NEO-92**). **404** when disabled. Bruno: `scripts/combat-targets-reset-helper.js`.
## Threat / aggro state (NEO-92)
Per-NPC aggro holders live in **`Game/Npc/`** as **`IThreatStateStore`** + **`InMemoryThreatStateStore`**, keyed by **`PrototypeNpcRegistry`** instance id. Each row stores an optional **`AggroHolderPlayerId`** (lowercase route player id).
| Rule | Behavior |
|------|----------|
| **Acquire** | First **damaging** cast on an NPC sets holder to the casting player when the row is empty (**`AggroOperations.TryAcquire`** from **`AbilityCastApi`**). Zero-damage abilities do not acquire. |
| **Leash clear** | Holder clears when that player moves beyond the bound behavior def **`leashRadius`** from the NPC anchor (horizontal X/Z distance). Wired on **`POST /move`**, **`POST /move-stream`**, and before acquire on cast. |
| **Re-aggro** | After clear, the same first-hit rule applies — no proximity auto-aggro in this slice. |
| **HTTP read** | Not exposed yet — **`GET /game/world/npc-runtime-snapshot`** lands in [NEO-94](https://linear.app/neon-sprawl/issue/NEO-94). |
Plan: [NEO-92 implementation plan](../../docs/plans/NEO-92-implementation-plan.md).
## Combat engine (NEO-81)