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
parent
d0d5dbdf17
commit
843abf7126
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue