NEO-93: Add NPC runtime state machine and lazy tick advance.
Co-authored-by: Cursor <cursoragent@cursor.com>pull/131/head
parent
874c5720d9
commit
48813998d7
|
|
@ -21,8 +21,17 @@ public sealed class CombatTargetFixtureApiTests
|
||||||
var client = factory.CreateClient();
|
var client = factory.CreateClient();
|
||||||
var store = factory.Services.GetRequiredService<ICombatEntityHealthStore>();
|
var store = factory.Services.GetRequiredService<ICombatEntityHealthStore>();
|
||||||
var threatStore = factory.Services.GetRequiredService<IThreatStateStore>();
|
var threatStore = factory.Services.GetRequiredService<IThreatStateStore>();
|
||||||
|
var runtimeStore = factory.Services.GetRequiredService<INpcRuntimeStateStore>();
|
||||||
_ = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _);
|
_ = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _);
|
||||||
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1");
|
_ = threatStore.TrySetHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId, "dev-local-1");
|
||||||
|
var windupStarted = new DateTimeOffset(2026, 5, 27, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
_ = runtimeStore.TryWrite(
|
||||||
|
PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||||
|
new NpcRuntimeStateSnapshot(
|
||||||
|
PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||||
|
NpcBehaviorState.TelegraphWindup,
|
||||||
|
windupStarted,
|
||||||
|
new ActiveTelegraphSnapshot("prototype_npc_melee:test", windupStarted)));
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var response = await client.PostAsJsonAsync(
|
var response = await client.PostAsJsonAsync(
|
||||||
|
|
@ -42,6 +51,9 @@ public sealed class CombatTargetFixtureApiTests
|
||||||
Assert.False(melee.Defeated);
|
Assert.False(melee.Defeated);
|
||||||
threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var threat);
|
threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var threat);
|
||||||
Assert.Null(threat.AggroHolderPlayerId);
|
Assert.Null(threat.AggroHolderPlayerId);
|
||||||
|
runtimeStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var runtime);
|
||||||
|
Assert.Equal(NpcBehaviorState.Idle, runtime.BehaviorState);
|
||||||
|
Assert.Null(runtime.ActiveTelegraph);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
using NeonSprawl.Server.Game.Npc;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Tests.Game.Npc;
|
||||||
|
|
||||||
|
public sealed class InMemoryNpcRuntimeStateStoreTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void TryGet_ShouldLazyInitializeIdleRow_ForKnownNpcInstance()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var store = new InMemoryNpcRuntimeStateStore();
|
||||||
|
// Act
|
||||||
|
var found = store.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
|
// Assert
|
||||||
|
Assert.True(found);
|
||||||
|
Assert.Equal(PrototypeNpcRegistry.PrototypeNpcMeleeId, snapshot.NpcInstanceId);
|
||||||
|
Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState);
|
||||||
|
Assert.Null(snapshot.ActiveTelegraph);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGet_ShouldReturnFalse_ForUnknownNpcInstance()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var store = new InMemoryNpcRuntimeStateStore();
|
||||||
|
// Act
|
||||||
|
var found = store.TryGet("prototype_target_alpha", out var snapshot);
|
||||||
|
// Assert
|
||||||
|
Assert.False(found);
|
||||||
|
Assert.Equal(default, snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryWrite_ShouldPersistRuntimeRow_ForKnownNpcInstance()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var store = new InMemoryNpcRuntimeStateStore();
|
||||||
|
var started = new DateTimeOffset(2026, 5, 27, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
var telegraph = new ActiveTelegraphSnapshot("prototype_npc_melee:1", started);
|
||||||
|
// Act
|
||||||
|
var written = store.TryWrite(
|
||||||
|
PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||||
|
new NpcRuntimeStateSnapshot(
|
||||||
|
PrototypeNpcRegistry.PrototypeNpcMeleeId,
|
||||||
|
NpcBehaviorState.TelegraphWindup,
|
||||||
|
started,
|
||||||
|
telegraph));
|
||||||
|
store.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
|
// Assert
|
||||||
|
Assert.True(written);
|
||||||
|
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
|
||||||
|
Assert.Equal(started, snapshot.PhaseStartedUtc);
|
||||||
|
Assert.NotNull(snapshot.ActiveTelegraph);
|
||||||
|
Assert.Equal("prototype_npc_melee:1", snapshot.ActiveTelegraph!.Value.TelegraphId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResetAllPrototypeRows_ShouldReturnEachInstanceToIdle()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var store = new InMemoryNpcRuntimeStateStore();
|
||||||
|
var started = new DateTimeOffset(2026, 5, 27, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
foreach (var npcId in PrototypeNpcRegistry.GetInstanceIdsInOrder())
|
||||||
|
{
|
||||||
|
_ = store.TryWrite(
|
||||||
|
npcId,
|
||||||
|
new NpcRuntimeStateSnapshot(npcId, NpcBehaviorState.Recover, started, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
store.LastAdvancedUtc = started.AddSeconds(10);
|
||||||
|
// Act
|
||||||
|
store.ResetAllPrototypeRows();
|
||||||
|
// Assert
|
||||||
|
foreach (var npcId in PrototypeNpcRegistry.GetInstanceIdsInOrder())
|
||||||
|
{
|
||||||
|
store.TryGet(npcId, out var snapshot);
|
||||||
|
Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState);
|
||||||
|
Assert.Null(snapshot.ActiveTelegraph);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LastAdvancedUtc_ShouldBeSettable()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var store = new InMemoryNpcRuntimeStateStore();
|
||||||
|
var marker = new DateTimeOffset(2026, 5, 27, 8, 0, 0, TimeSpan.Zero);
|
||||||
|
// Act
|
||||||
|
store.LastAdvancedUtc = marker;
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(marker, store.LastAdvancedUtc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,202 @@
|
||||||
|
using NeonSprawl.Server.Game.Npc;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Tests.Game.Npc;
|
||||||
|
|
||||||
|
public sealed class NpcRuntimeOperationsTests
|
||||||
|
{
|
||||||
|
private static readonly DateTimeOffset T0 =
|
||||||
|
new(2026, 5, 27, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
private static (INpcRuntimeStateStore Runtime, IThreatStateStore Threat, INpcBehaviorDefinitionRegistry Behavior)
|
||||||
|
CreateFixture()
|
||||||
|
{
|
||||||
|
return (
|
||||||
|
new InMemoryNpcRuntimeStateStore(),
|
||||||
|
new InMemoryThreatStateStore(),
|
||||||
|
PrototypeNpcTestFixtures.CreateBehaviorRegistry());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetHolder(IThreatStateStore threatStore, string npcId, string playerId = "dev-local-1") =>
|
||||||
|
_ = threatStore.TrySetHolder(npcId, playerId);
|
||||||
|
|
||||||
|
private static void Advance(
|
||||||
|
DateTimeOffset nowUtc,
|
||||||
|
INpcRuntimeStateStore runtimeStore,
|
||||||
|
IThreatStateStore threatStore,
|
||||||
|
INpcBehaviorDefinitionRegistry behaviorRegistry,
|
||||||
|
double maxDeltaSeconds = NpcRuntimeOperations.DefaultMaxDeltaSeconds) =>
|
||||||
|
NpcRuntimeOperations.AdvanceAll(nowUtc, runtimeStore, threatStore, behaviorRegistry, maxDeltaSeconds);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdvanceAll_ShouldKeepIdle_WhenNoAggroHolder()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (runtime, threat, behavior) = CreateFixture();
|
||||||
|
// Act
|
||||||
|
Advance(T0, runtime, threat, behavior);
|
||||||
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState);
|
||||||
|
Assert.Null(snapshot.ActiveTelegraph);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdvanceAll_ShouldEnterAggro_WhenHolderSetAndRowIdle()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (runtime, threat, behavior) = CreateFixture();
|
||||||
|
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
|
// Act
|
||||||
|
Advance(T0, runtime, threat, behavior);
|
||||||
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState);
|
||||||
|
Assert.Equal(T0, snapshot.PhaseStartedUtc);
|
||||||
|
Assert.Null(snapshot.ActiveTelegraph);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdvanceAll_ShouldNotTelegraph_WhenNoAggroHolder()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (runtime, threat, behavior) = CreateFixture();
|
||||||
|
runtime.LastAdvancedUtc = T0;
|
||||||
|
// Act
|
||||||
|
Advance(T0.AddSeconds(10), runtime, threat, behavior);
|
||||||
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState);
|
||||||
|
Assert.Null(snapshot.ActiveTelegraph);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdvanceAll_ShouldEnterTelegraphAfterMeleeAttackCooldownSeconds()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (runtime, threat, behavior) = CreateFixture();
|
||||||
|
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
|
Advance(T0, runtime, threat, behavior);
|
||||||
|
// Act
|
||||||
|
Advance(T0.AddSeconds(3), runtime, threat, behavior);
|
||||||
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
|
||||||
|
Assert.Equal(T0.AddSeconds(3), snapshot.PhaseStartedUtc);
|
||||||
|
Assert.NotNull(snapshot.ActiveTelegraph);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdvanceAll_ShouldRemainAggro_BeforeMeleeAttackCooldownCompletes()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (runtime, threat, behavior) = CreateFixture();
|
||||||
|
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
|
Advance(T0, runtime, threat, behavior);
|
||||||
|
// Act
|
||||||
|
Advance(T0.AddSeconds(2.9), runtime, threat, behavior);
|
||||||
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(NpcBehaviorState.Aggro, snapshot.BehaviorState);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdvanceAll_ShouldCompleteMeleeWindupDuration_BeforeAttackExecute()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (runtime, threat, behavior) = CreateFixture();
|
||||||
|
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
|
Advance(T0, runtime, threat, behavior);
|
||||||
|
Advance(T0.AddSeconds(3), runtime, threat, behavior);
|
||||||
|
// Act
|
||||||
|
Advance(T0.AddSeconds(4.4), runtime, threat, behavior);
|
||||||
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var duringWindup);
|
||||||
|
Advance(T0.AddSeconds(4.5), runtime, threat, behavior);
|
||||||
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var afterWindup);
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(NpcBehaviorState.TelegraphWindup, duringWindup.BehaviorState);
|
||||||
|
Assert.Equal(NpcBehaviorState.Recover, afterWindup.BehaviorState);
|
||||||
|
Assert.Equal(T0.AddSeconds(4.5), afterWindup.PhaseStartedUtc);
|
||||||
|
Assert.Null(afterWindup.ActiveTelegraph);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdvanceAll_ShouldRunFullMeleeCycle_ToSecondTelegraph()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (runtime, threat, behavior) = CreateFixture();
|
||||||
|
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
|
Advance(T0, runtime, threat, behavior);
|
||||||
|
Advance(T0.AddSeconds(3), runtime, threat, behavior);
|
||||||
|
Advance(T0.AddSeconds(4.5), runtime, threat, behavior);
|
||||||
|
// Act
|
||||||
|
Advance(T0.AddSeconds(7.5), runtime, threat, behavior);
|
||||||
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(NpcBehaviorState.TelegraphWindup, snapshot.BehaviorState);
|
||||||
|
Assert.Equal(T0.AddSeconds(7.5), snapshot.PhaseStartedUtc);
|
||||||
|
Assert.NotNull(snapshot.ActiveTelegraph);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdvanceAll_ShouldReturnToIdle_WhenHolderClearedMidWindup()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (runtime, threat, behavior) = CreateFixture();
|
||||||
|
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
|
Advance(T0, runtime, threat, behavior);
|
||||||
|
Advance(T0.AddSeconds(3), runtime, threat, behavior);
|
||||||
|
_ = threat.TryClearHolder(PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
|
// Act
|
||||||
|
Advance(T0.AddSeconds(3.5), runtime, threat, behavior);
|
||||||
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(NpcBehaviorState.Idle, snapshot.BehaviorState);
|
||||||
|
Assert.Null(snapshot.ActiveTelegraph);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("prototype_npc_ranged", 4.0, 2.0)]
|
||||||
|
[InlineData("prototype_npc_elite", 5.0, 2.5)]
|
||||||
|
public void AdvanceAll_ShouldUseCatalogTimings_ForEachArchetype(
|
||||||
|
string npcInstanceId,
|
||||||
|
double attackCooldownSeconds,
|
||||||
|
double telegraphWindupSeconds)
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (runtime, threat, behavior) = CreateFixture();
|
||||||
|
SetHolder(threat, npcInstanceId);
|
||||||
|
Advance(T0, runtime, threat, behavior);
|
||||||
|
// Act
|
||||||
|
Advance(T0.AddSeconds(attackCooldownSeconds), runtime, threat, behavior);
|
||||||
|
runtime.TryGet(npcInstanceId, out var telegraphSnapshot);
|
||||||
|
Advance(
|
||||||
|
T0.AddSeconds(attackCooldownSeconds + telegraphWindupSeconds),
|
||||||
|
runtime,
|
||||||
|
threat,
|
||||||
|
behavior);
|
||||||
|
runtime.TryGet(npcInstanceId, out var recoverSnapshot);
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(NpcBehaviorState.TelegraphWindup, telegraphSnapshot.BehaviorState);
|
||||||
|
Assert.Equal(T0.AddSeconds(attackCooldownSeconds), telegraphSnapshot.PhaseStartedUtc);
|
||||||
|
Assert.Equal(NpcBehaviorState.Recover, recoverSnapshot.BehaviorState);
|
||||||
|
Assert.Equal(
|
||||||
|
T0.AddSeconds(attackCooldownSeconds + telegraphWindupSeconds),
|
||||||
|
recoverSnapshot.PhaseStartedUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdvanceAll_ShouldCapSimulatedDelta_PerAdvanceCall()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var (runtime, threat, behavior) = CreateFixture();
|
||||||
|
SetHolder(threat, PrototypeNpcRegistry.PrototypeNpcMeleeId);
|
||||||
|
Advance(T0, runtime, threat, behavior);
|
||||||
|
// Act — 10s gap with 5s cap should not reach second telegraph (needs 7.5s from aggro start)
|
||||||
|
Advance(T0.AddSeconds(10), runtime, threat, behavior, maxDeltaSeconds: 5);
|
||||||
|
runtime.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var snapshot);
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(NpcBehaviorState.Recover, snapshot.BehaviorState);
|
||||||
|
Assert.Equal(T0.AddSeconds(4.5), snapshot.PhaseStartedUtc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,7 @@ public static class CombatTargetFixtureApi
|
||||||
{
|
{
|
||||||
app.MapPost(
|
app.MapPost(
|
||||||
"/game/__dev/combat-targets-fixture",
|
"/game/__dev/combat-targets-fixture",
|
||||||
(CombatTargetFixtureRequest? body, ICombatEntityHealthStore healthStore, IThreatStateStore threatStore) =>
|
(CombatTargetFixtureRequest? body, ICombatEntityHealthStore healthStore, IThreatStateStore threatStore, INpcRuntimeStateStore runtimeStore) =>
|
||||||
{
|
{
|
||||||
if (body is null || body.SchemaVersion != CombatTargetFixtureRequest.CurrentSchemaVersion)
|
if (body is null || body.SchemaVersion != CombatTargetFixtureRequest.CurrentSchemaVersion)
|
||||||
{
|
{
|
||||||
|
|
@ -25,6 +25,7 @@ public static class CombatTargetFixtureApi
|
||||||
}
|
}
|
||||||
|
|
||||||
AggroOperations.ClearAllPrototypeHolders(threatStore);
|
AggroOperations.ClearAllPrototypeHolders(threatStore);
|
||||||
|
NpcRuntimeOperations.ResetAllPrototypeRows(runtimeStore);
|
||||||
|
|
||||||
return Results.Json(
|
return Results.Json(
|
||||||
new CombatTargetFixtureResponse
|
new CombatTargetFixtureResponse
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Npc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Session in-memory NPC runtime behavior state (NEO-93).
|
||||||
|
/// HTTP projection lands in NEO-94; lazy tick advance reads/writes rows here.
|
||||||
|
/// </summary>
|
||||||
|
public interface INpcRuntimeStateStore
|
||||||
|
{
|
||||||
|
/// <summary>UTC instant of the last successful <see cref="NpcRuntimeOperations.AdvanceAll"/> call.</summary>
|
||||||
|
DateTimeOffset LastAdvancedUtc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Reads runtime state for a known prototype NPC instance. Lazy-initializes an idle row.</summary>
|
||||||
|
bool TryGet(string? npcInstanceId, [NotNullWhen(true)] out NpcRuntimeStateSnapshot snapshot);
|
||||||
|
|
||||||
|
/// <summary>Writes runtime state for a known prototype NPC instance.</summary>
|
||||||
|
bool TryWrite(string? npcInstanceId, in NpcRuntimeStateSnapshot snapshot);
|
||||||
|
|
||||||
|
/// <summary>Resets one prototype NPC runtime row to idle with no active telegraph.</summary>
|
||||||
|
bool TryResetPrototypeRow(string? npcInstanceId);
|
||||||
|
|
||||||
|
/// <summary>Resets all prototype NPC runtime rows to idle.</summary>
|
||||||
|
void ResetAllPrototypeRows();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
|
namespace NeonSprawl.Server.Game.Npc;
|
||||||
|
|
||||||
|
/// <summary>Thread-safe in-memory NPC runtime state for prototype instances (NEO-93).</summary>
|
||||||
|
public sealed class InMemoryNpcRuntimeStateStore : INpcRuntimeStateStore
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, NpcRuntimeRow> rowsByNpcId = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, object> idLocks = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public DateTimeOffset LastAdvancedUtc { get; set; } = DateTimeOffset.MinValue;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool TryGet(string? npcInstanceId, out NpcRuntimeStateSnapshot snapshot)
|
||||||
|
{
|
||||||
|
var key = NormalizeNpcInstanceId(npcInstanceId);
|
||||||
|
if (key.Length == 0 || !PrototypeNpcRegistry.TryGet(key, out _))
|
||||||
|
{
|
||||||
|
snapshot = default;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (idLocks.GetOrAdd(key, _ => new object()))
|
||||||
|
{
|
||||||
|
var row = rowsByNpcId.GetOrAdd(key, static id => NpcRuntimeRow.CreateIdle(id));
|
||||||
|
snapshot = row.ToSnapshot();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool TryWrite(string? npcInstanceId, in NpcRuntimeStateSnapshot snapshot)
|
||||||
|
{
|
||||||
|
var key = NormalizeNpcInstanceId(npcInstanceId);
|
||||||
|
if (key.Length == 0 || !PrototypeNpcRegistry.TryGet(key, out _))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (idLocks.GetOrAdd(key, _ => new object()))
|
||||||
|
{
|
||||||
|
rowsByNpcId[key] = NpcRuntimeRow.FromSnapshot(snapshot with { NpcInstanceId = key });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool TryResetPrototypeRow(string? npcInstanceId)
|
||||||
|
{
|
||||||
|
var key = NormalizeNpcInstanceId(npcInstanceId);
|
||||||
|
if (key.Length == 0 || !PrototypeNpcRegistry.TryGet(key, out _))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (idLocks.GetOrAdd(key, _ => new object()))
|
||||||
|
{
|
||||||
|
rowsByNpcId[key] = NpcRuntimeRow.CreateIdle(key);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void ResetAllPrototypeRows()
|
||||||
|
{
|
||||||
|
foreach (var npcInstanceId in PrototypeNpcRegistry.GetInstanceIdsInOrder())
|
||||||
|
{
|
||||||
|
_ = TryResetPrototypeRow(npcInstanceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeNpcInstanceId(string? npcInstanceId) =>
|
||||||
|
npcInstanceId?.Trim().ToLowerInvariant() ?? string.Empty;
|
||||||
|
|
||||||
|
private sealed class NpcRuntimeRow
|
||||||
|
{
|
||||||
|
public required string NpcInstanceId { get; init; }
|
||||||
|
|
||||||
|
public NpcBehaviorState BehaviorState { get; init; }
|
||||||
|
|
||||||
|
public DateTimeOffset PhaseStartedUtc { get; init; }
|
||||||
|
|
||||||
|
public ActiveTelegraphSnapshot? ActiveTelegraph { get; init; }
|
||||||
|
|
||||||
|
public static NpcRuntimeRow CreateIdle(string npcInstanceId) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
NpcInstanceId = npcInstanceId,
|
||||||
|
BehaviorState = NpcBehaviorState.Idle,
|
||||||
|
PhaseStartedUtc = DateTimeOffset.MinValue,
|
||||||
|
ActiveTelegraph = null,
|
||||||
|
};
|
||||||
|
|
||||||
|
public NpcRuntimeStateSnapshot ToSnapshot() =>
|
||||||
|
new(NpcInstanceId, BehaviorState, PhaseStartedUtc, ActiveTelegraph);
|
||||||
|
|
||||||
|
public static NpcRuntimeRow FromSnapshot(NpcRuntimeStateSnapshot snapshot) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
NpcInstanceId = snapshot.NpcInstanceId,
|
||||||
|
BehaviorState = snapshot.BehaviorState,
|
||||||
|
PhaseStartedUtc = snapshot.PhaseStartedUtc,
|
||||||
|
ActiveTelegraph = snapshot.ActiveTelegraph,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Npc;
|
||||||
|
|
||||||
|
/// <summary>Prototype NPC behavior states (NEO-93). Wire names are stable for NEO-94 JSON.</summary>
|
||||||
|
public enum NpcBehaviorState
|
||||||
|
{
|
||||||
|
Idle,
|
||||||
|
Aggro,
|
||||||
|
TelegraphWindup,
|
||||||
|
AttackExecute,
|
||||||
|
Recover,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Stable snake_case wire names for <see cref="NpcBehaviorState"/>.</summary>
|
||||||
|
public static class NpcBehaviorStateWire
|
||||||
|
{
|
||||||
|
/// <summary>Returns the JSON wire name for <paramref name="state"/>.</summary>
|
||||||
|
public static string ToWireName(NpcBehaviorState state) =>
|
||||||
|
state switch
|
||||||
|
{
|
||||||
|
NpcBehaviorState.Idle => "idle",
|
||||||
|
NpcBehaviorState.Aggro => "aggro",
|
||||||
|
NpcBehaviorState.TelegraphWindup => "telegraph_windup",
|
||||||
|
NpcBehaviorState.AttackExecute => "attack_execute",
|
||||||
|
NpcBehaviorState.Recover => "recover",
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(state), state, null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,262 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Npc;
|
||||||
|
|
||||||
|
/// <summary>Deterministic prototype NPC behavior state machine + lazy tick advance (NEO-93).</summary>
|
||||||
|
public static class NpcRuntimeOperations
|
||||||
|
{
|
||||||
|
/// <summary>Default per-advance simulation cap (seconds) for lazy poll ticks.</summary>
|
||||||
|
public const double DefaultMaxDeltaSeconds = 5.0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Advances all prototype NPC runtime rows by up to <paramref name="maxDeltaSeconds"/> since the last call.
|
||||||
|
/// </summary>
|
||||||
|
public static void AdvanceAll(
|
||||||
|
DateTimeOffset nowUtc,
|
||||||
|
INpcRuntimeStateStore runtimeStore,
|
||||||
|
IThreatStateStore threatStore,
|
||||||
|
INpcBehaviorDefinitionRegistry behaviorRegistry,
|
||||||
|
double maxDeltaSeconds = DefaultMaxDeltaSeconds)
|
||||||
|
{
|
||||||
|
if (maxDeltaSeconds <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastAdvanced = runtimeStore.LastAdvancedUtc;
|
||||||
|
if (lastAdvanced == DateTimeOffset.MinValue)
|
||||||
|
{
|
||||||
|
foreach (var npcInstanceId in PrototypeNpcRegistry.GetInstanceIdsInOrder())
|
||||||
|
{
|
||||||
|
AdvanceOne(
|
||||||
|
npcInstanceId,
|
||||||
|
nowUtc,
|
||||||
|
nowUtc,
|
||||||
|
runtimeStore,
|
||||||
|
threatStore,
|
||||||
|
behaviorRegistry);
|
||||||
|
}
|
||||||
|
|
||||||
|
runtimeStore.LastAdvancedUtc = nowUtc;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawDeltaSeconds = (nowUtc - lastAdvanced).TotalSeconds;
|
||||||
|
if (rawDeltaSeconds <= 0)
|
||||||
|
{
|
||||||
|
foreach (var npcInstanceId in PrototypeNpcRegistry.GetInstanceIdsInOrder())
|
||||||
|
{
|
||||||
|
AdvanceOne(
|
||||||
|
npcInstanceId,
|
||||||
|
nowUtc,
|
||||||
|
nowUtc,
|
||||||
|
runtimeStore,
|
||||||
|
threatStore,
|
||||||
|
behaviorRegistry);
|
||||||
|
}
|
||||||
|
|
||||||
|
runtimeStore.LastAdvancedUtc = nowUtc;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var deltaSeconds = Math.Min(rawDeltaSeconds, maxDeltaSeconds);
|
||||||
|
var windowStart = lastAdvanced;
|
||||||
|
var windowEnd = windowStart.AddSeconds(deltaSeconds);
|
||||||
|
|
||||||
|
foreach (var npcInstanceId in PrototypeNpcRegistry.GetInstanceIdsInOrder())
|
||||||
|
{
|
||||||
|
AdvanceOne(
|
||||||
|
npcInstanceId,
|
||||||
|
windowStart,
|
||||||
|
windowEnd,
|
||||||
|
runtimeStore,
|
||||||
|
threatStore,
|
||||||
|
behaviorRegistry);
|
||||||
|
}
|
||||||
|
|
||||||
|
runtimeStore.LastAdvancedUtc = nowUtc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Resets all prototype NPC runtime rows to idle (dev fixture).</summary>
|
||||||
|
public static void ResetAllPrototypeRows(INpcRuntimeStateStore runtimeStore) =>
|
||||||
|
runtimeStore.ResetAllPrototypeRows();
|
||||||
|
|
||||||
|
private static void AdvanceOne(
|
||||||
|
string npcInstanceId,
|
||||||
|
DateTimeOffset windowStart,
|
||||||
|
DateTimeOffset windowEnd,
|
||||||
|
INpcRuntimeStateStore runtimeStore,
|
||||||
|
IThreatStateStore threatStore,
|
||||||
|
INpcBehaviorDefinitionRegistry behaviorRegistry)
|
||||||
|
{
|
||||||
|
if (!PrototypeNpcRegistry.TryGet(npcInstanceId, out var entry) ||
|
||||||
|
!behaviorRegistry.TryGetDefinition(entry.BehaviorDefId, out var behavior))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!runtimeStore.TryGet(npcInstanceId, out var snapshot))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryHasAggroHolder(npcInstanceId, threatStore, out _))
|
||||||
|
{
|
||||||
|
if (snapshot.BehaviorState != NpcBehaviorState.Idle || snapshot.ActiveTelegraph is not null)
|
||||||
|
{
|
||||||
|
WriteIdle(runtimeStore, npcInstanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.BehaviorState == NpcBehaviorState.Idle)
|
||||||
|
{
|
||||||
|
WriteState(
|
||||||
|
runtimeStore,
|
||||||
|
npcInstanceId,
|
||||||
|
NpcBehaviorState.Aggro,
|
||||||
|
windowStart,
|
||||||
|
activeTelegraph: null);
|
||||||
|
// telemetry: npc_state_transition (NEO-96)
|
||||||
|
if (!runtimeStore.TryGet(npcInstanceId, out snapshot))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cursor = windowStart;
|
||||||
|
while (cursor < windowEnd)
|
||||||
|
{
|
||||||
|
if (!TryHasAggroHolder(npcInstanceId, threatStore, out _))
|
||||||
|
{
|
||||||
|
WriteIdle(runtimeStore, npcInstanceId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!runtimeStore.TryGet(npcInstanceId, out snapshot))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (snapshot.BehaviorState)
|
||||||
|
{
|
||||||
|
case NpcBehaviorState.Idle:
|
||||||
|
return;
|
||||||
|
|
||||||
|
case NpcBehaviorState.Aggro:
|
||||||
|
{
|
||||||
|
var phaseEnd = snapshot.PhaseStartedUtc.AddSeconds(behavior.AttackCooldownSeconds);
|
||||||
|
if (windowEnd < phaseEnd)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = phaseEnd;
|
||||||
|
var telegraph = CreateTelegraph(npcInstanceId, cursor);
|
||||||
|
WriteState(
|
||||||
|
runtimeStore,
|
||||||
|
npcInstanceId,
|
||||||
|
NpcBehaviorState.TelegraphWindup,
|
||||||
|
cursor,
|
||||||
|
telegraph);
|
||||||
|
// telemetry: npc_state_transition (NEO-96)
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
case NpcBehaviorState.TelegraphWindup:
|
||||||
|
{
|
||||||
|
var phaseEnd = snapshot.PhaseStartedUtc.AddSeconds(behavior.TelegraphWindupSeconds);
|
||||||
|
if (windowEnd < phaseEnd)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = phaseEnd;
|
||||||
|
WriteState(
|
||||||
|
runtimeStore,
|
||||||
|
npcInstanceId,
|
||||||
|
NpcBehaviorState.AttackExecute,
|
||||||
|
cursor,
|
||||||
|
activeTelegraph: null);
|
||||||
|
WriteState(
|
||||||
|
runtimeStore,
|
||||||
|
npcInstanceId,
|
||||||
|
NpcBehaviorState.Recover,
|
||||||
|
cursor,
|
||||||
|
activeTelegraph: null);
|
||||||
|
// telemetry: telegraph_fired / npc_state_transition (NEO-96); NEO-95 applies attackDamage at windup complete
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
case NpcBehaviorState.AttackExecute:
|
||||||
|
WriteState(
|
||||||
|
runtimeStore,
|
||||||
|
npcInstanceId,
|
||||||
|
NpcBehaviorState.Recover,
|
||||||
|
cursor,
|
||||||
|
activeTelegraph: null);
|
||||||
|
// telemetry: npc_state_transition (NEO-96); NEO-95 applies attackDamage here
|
||||||
|
continue;
|
||||||
|
|
||||||
|
case NpcBehaviorState.Recover:
|
||||||
|
{
|
||||||
|
var phaseEnd = snapshot.PhaseStartedUtc.AddSeconds(behavior.AttackCooldownSeconds);
|
||||||
|
if (windowEnd < phaseEnd)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = phaseEnd;
|
||||||
|
var telegraph = CreateTelegraph(npcInstanceId, cursor);
|
||||||
|
WriteState(
|
||||||
|
runtimeStore,
|
||||||
|
npcInstanceId,
|
||||||
|
NpcBehaviorState.TelegraphWindup,
|
||||||
|
cursor,
|
||||||
|
telegraph);
|
||||||
|
// telemetry: npc_state_transition (NEO-96)
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryHasAggroHolder(
|
||||||
|
string npcInstanceId,
|
||||||
|
IThreatStateStore threatStore,
|
||||||
|
out string holderPlayerId)
|
||||||
|
{
|
||||||
|
holderPlayerId = string.Empty;
|
||||||
|
if (!threatStore.TryGet(npcInstanceId, out var threat) ||
|
||||||
|
string.IsNullOrEmpty(threat.AggroHolderPlayerId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
holderPlayerId = threat.AggroHolderPlayerId;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ActiveTelegraphSnapshot CreateTelegraph(string npcInstanceId, DateTimeOffset windupStartedUtc)
|
||||||
|
{
|
||||||
|
var telegraphId = $"{npcInstanceId}:{windupStartedUtc.UtcTicks}";
|
||||||
|
return new ActiveTelegraphSnapshot(telegraphId, windupStartedUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteIdle(INpcRuntimeStateStore runtimeStore, string npcInstanceId) =>
|
||||||
|
WriteState(runtimeStore, npcInstanceId, NpcBehaviorState.Idle, DateTimeOffset.MinValue, activeTelegraph: null);
|
||||||
|
|
||||||
|
private static void WriteState(
|
||||||
|
INpcRuntimeStateStore runtimeStore,
|
||||||
|
string npcInstanceId,
|
||||||
|
NpcBehaviorState behaviorState,
|
||||||
|
DateTimeOffset phaseStartedUtc,
|
||||||
|
ActiveTelegraphSnapshot? activeTelegraph)
|
||||||
|
{
|
||||||
|
_ = runtimeStore.TryWrite(
|
||||||
|
npcInstanceId,
|
||||||
|
new NpcRuntimeStateSnapshot(npcInstanceId, behaviorState, phaseStartedUtc, activeTelegraph));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Npc;
|
||||||
|
|
||||||
|
/// <summary>Registers prototype NPC runtime behavior state store (NEO-93).</summary>
|
||||||
|
public static class NpcRuntimeServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>Registers <see cref="INpcRuntimeStateStore"/> as an in-memory singleton.</summary>
|
||||||
|
public static IServiceCollection AddNpcRuntimeStateStore(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddSingleton<INpcRuntimeStateStore, InMemoryNpcRuntimeStateStore>();
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
namespace NeonSprawl.Server.Game.Npc;
|
||||||
|
|
||||||
|
/// <summary>Active telegraph row while an NPC is in <see cref="NpcBehaviorState.TelegraphWindup"/> (NEO-93).</summary>
|
||||||
|
/// <param name="TelegraphId">Stable id for NEO-94 wire projection.</param>
|
||||||
|
/// <param name="WindupStartedUtc">UTC instant when windup phase began.</param>
|
||||||
|
public readonly record struct ActiveTelegraphSnapshot(string TelegraphId, DateTimeOffset WindupStartedUtc);
|
||||||
|
|
||||||
|
/// <summary>Per-NPC runtime behavior state snapshot (NEO-93).</summary>
|
||||||
|
/// <param name="NpcInstanceId">Lowercase prototype NPC instance id.</param>
|
||||||
|
/// <param name="BehaviorState">Current behavior state.</param>
|
||||||
|
/// <param name="PhaseStartedUtc">UTC instant when the current phase began.</param>
|
||||||
|
/// <param name="ActiveTelegraph">Non-null only during <see cref="NpcBehaviorState.TelegraphWindup"/>.</param>
|
||||||
|
public readonly record struct NpcRuntimeStateSnapshot(
|
||||||
|
string NpcInstanceId,
|
||||||
|
NpcBehaviorState BehaviorState,
|
||||||
|
DateTimeOffset PhaseStartedUtc,
|
||||||
|
ActiveTelegraphSnapshot? ActiveTelegraph);
|
||||||
|
|
@ -26,6 +26,7 @@ builder.Services.AddRecipeDefinitionCatalog(builder.Configuration);
|
||||||
builder.Services.AddAbilityDefinitionCatalog(builder.Configuration);
|
builder.Services.AddAbilityDefinitionCatalog(builder.Configuration);
|
||||||
builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration);
|
builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration);
|
||||||
builder.Services.AddThreatStateStore();
|
builder.Services.AddThreatStateStore();
|
||||||
|
builder.Services.AddNpcRuntimeStateStore();
|
||||||
builder.Services.AddMasteryCatalog(builder.Configuration);
|
builder.Services.AddMasteryCatalog(builder.Configuration);
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue