NEO-93: Add NPC runtime state machine and lazy tick advance.

Co-authored-by: Cursor <cursoragent@cursor.com>
pull/131/head
VinPropane 2026-05-27 22:02:39 -04:00
parent 874c5720d9
commit 48813998d7
11 changed files with 762 additions and 1 deletions

View File

@ -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]

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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

View File

@ -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();
}

View File

@ -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,
};
}
}

View File

@ -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),
};
}

View File

@ -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));
}
}

View File

@ -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;
}
}

View File

@ -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);

View File

@ -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();