diff --git a/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs b/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs index f61a95a..1c5af70 100644 --- a/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/AbilityInput/AbilityCastApiTests.cs @@ -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(); + 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(); + 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] diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs index f3d748f..b7d7a56 100644 --- a/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs @@ -20,7 +20,9 @@ public sealed class CombatTargetFixtureApiTests await using var factory = new InMemoryWebApplicationFactory(); var client = factory.CreateClient(); var store = factory.Services.GetRequiredService(); + var threatStore = factory.Services.GetRequiredService(); _ = 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] diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/AggroOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Npc/AggroOperationsTests.cs new file mode 100644 index 0000000..bdd64e6 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Npc/AggroOperationsTests.cs @@ -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); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/AggroThreatIntegrationTests.cs b/server/NeonSprawl.Server.Tests/Game/Npc/AggroThreatIntegrationTests.cs new file mode 100644 index 0000000..fb33a1c --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Npc/AggroThreatIntegrationTests.cs @@ -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; + +/// HTTP integration tests for NEO-92 aggro acquire + leash clear wiring. +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(); + 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(); + 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); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/InMemoryThreatStateStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Npc/InMemoryThreatStateStoreTests.cs new file mode 100644 index 0000000..70b5492 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Npc/InMemoryThreatStateStoreTests.cs @@ -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); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandApiTests.cs b/server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandApiTests.cs index 583ac24..18fabe1 100644 --- a/server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandApiTests.cs @@ -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(); + 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); + } } diff --git a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs index cb18bda..323d5b5 100644 --- a/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs +++ b/server/NeonSprawl.Server/Game/AbilityInput/AbilityCastApi.cs @@ -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, diff --git a/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs b/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs index 4caee23..0d3b4ac 100644 --- a/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs +++ b/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs @@ -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 { diff --git a/server/NeonSprawl.Server/Game/Npc/AggroOperations.cs b/server/NeonSprawl.Server/Game/Npc/AggroOperations.cs new file mode 100644 index 0000000..6190cea --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/AggroOperations.cs @@ -0,0 +1,108 @@ +using NeonSprawl.Server.Game.PositionState; +using NeonSprawl.Server.Game.World; + +namespace NeonSprawl.Server.Game.Npc; + +/// Deterministic prototype aggro acquire and leash-clear rules (NEO-92). +public static class AggroOperations +{ + /// + /// Sets aggro holder to when the NPC row is empty. + /// Returns true only when holder was empty and is now set. + /// + 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); + } + + /// + /// Clears aggro on every prototype NPC instance where is holder + /// and horizontal distance from the NPC anchor exceeds catalog leashRadius. + /// + 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); + } + } + + /// Clears aggro for one NPC when the holder is outside that NPC's leash radius. + 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); + } + } + + /// Clears aggro holders on all prototype NPC instances (dev fixture). + 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; +} diff --git a/server/NeonSprawl.Server/Game/Npc/IThreatStateStore.cs b/server/NeonSprawl.Server/Game/Npc/IThreatStateStore.cs new file mode 100644 index 0000000..603f7b7 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/IThreatStateStore.cs @@ -0,0 +1,19 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Npc; + +/// +/// 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. +/// +public interface IThreatStateStore +{ + /// Reads threat for a known prototype NPC instance. Lazy-initializes an empty holder row. + bool TryGet(string? npcInstanceId, [NotNullWhen(true)] out ThreatStateSnapshot snapshot); + + /// Sets or clears the aggro holder for a known prototype NPC instance. + bool TrySetHolder(string? npcInstanceId, string? playerIdLowercaseOrNull); + + /// Clears the aggro holder for a known prototype NPC instance. + bool TryClearHolder(string? npcInstanceId); +} diff --git a/server/NeonSprawl.Server/Game/Npc/InMemoryThreatStateStore.cs b/server/NeonSprawl.Server/Game/Npc/InMemoryThreatStateStore.cs new file mode 100644 index 0000000..2a5ca80 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/InMemoryThreatStateStore.cs @@ -0,0 +1,68 @@ +using System.Collections.Concurrent; + +namespace NeonSprawl.Server.Game.Npc; + +/// Thread-safe in-memory aggro holders for prototype NPC instances (NEO-92). +public sealed class InMemoryThreatStateStore : IThreatStateStore +{ + private readonly ConcurrentDictionary holderByNpcId = new(StringComparer.Ordinal); + + private readonly ConcurrentDictionary idLocks = new(StringComparer.Ordinal); + + /// + 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; + } + } + + /// + 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; + } + } + + /// + 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(); + } +} diff --git a/server/NeonSprawl.Server/Game/Npc/ThreatStateServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Npc/ThreatStateServiceCollectionExtensions.cs new file mode 100644 index 0000000..1a3b354 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/ThreatStateServiceCollectionExtensions.cs @@ -0,0 +1,12 @@ +namespace NeonSprawl.Server.Game.Npc; + +/// Registers prototype threat/aggro state store (NEO-92). +public static class ThreatStateServiceCollectionExtensions +{ + /// Registers as an in-memory singleton. + public static IServiceCollection AddThreatStateStore(this IServiceCollection services) + { + services.AddSingleton(); + return services; + } +} diff --git a/server/NeonSprawl.Server/Game/Npc/ThreatStateSnapshot.cs b/server/NeonSprawl.Server/Game/Npc/ThreatStateSnapshot.cs new file mode 100644 index 0000000..6d21c01 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/ThreatStateSnapshot.cs @@ -0,0 +1,6 @@ +namespace NeonSprawl.Server.Game.Npc; + +/// Per-NPC aggro holder snapshot (NEO-92). +/// Lowercase prototype NPC instance id. +/// Lowercase route player id holding aggro, or when empty. +public readonly record struct ThreatStateSnapshot(string NpcInstanceId, string? AggroHolderPlayerId); diff --git a/server/NeonSprawl.Server/Game/PositionState/PositionStateApi.cs b/server/NeonSprawl.Server/Game/PositionState/PositionStateApi.cs index e27a3a9..9ff2f65 100644 --- a/server/NeonSprawl.Server/Game/PositionState/PositionStateApi.cs +++ b/server/NeonSprawl.Server/Game/PositionState/PositionStateApi.cs @@ -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 gameOptions) => + (string id, MoveCommandRequest? body, IPositionStateStore store, IThreatStateStore threatStore, INpcBehaviorDefinitionRegistry behaviorRegistry, IOptions 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 gameOptions) => + (string id, MoveStreamRequest? body, IPositionStateStore store, IThreatStateStore threatStore, INpcBehaviorDefinitionRegistry behaviorRegistry, IOptions 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, diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index 9178f58..e0a4e1d 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -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(); diff --git a/server/README.md b/server/README.md index d51bf23..80d588c 100644 --- a/server/README.md +++ b/server/README.md @@ -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)