diff --git a/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs b/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs index b7d7a56..b609f3a 100644 --- a/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs +++ b/server/NeonSprawl.Server.Tests/Game/Combat/CombatTargetFixtureApiTests.cs @@ -21,8 +21,17 @@ public sealed class CombatTargetFixtureApiTests var client = factory.CreateClient(); var store = factory.Services.GetRequiredService(); var threatStore = factory.Services.GetRequiredService(); + var runtimeStore = factory.Services.GetRequiredService(); _ = store.TryApplyDamage(PrototypeNpcRegistry.PrototypeNpcMeleeId, 100, out _); _ = 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 var response = await client.PostAsJsonAsync( @@ -42,6 +51,9 @@ public sealed class CombatTargetFixtureApiTests Assert.False(melee.Defeated); threatStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var threat); Assert.Null(threat.AggroHolderPlayerId); + runtimeStore.TryGet(PrototypeNpcRegistry.PrototypeNpcMeleeId, out var runtime); + Assert.Equal(NpcBehaviorState.Idle, runtime.BehaviorState); + Assert.Null(runtime.ActiveTelegraph); } [Fact] diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/InMemoryNpcRuntimeStateStoreTests.cs b/server/NeonSprawl.Server.Tests/Game/Npc/InMemoryNpcRuntimeStateStoreTests.cs new file mode 100644 index 0000000..c54a31a --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Npc/InMemoryNpcRuntimeStateStoreTests.cs @@ -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); + } +} diff --git a/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs b/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs new file mode 100644 index 0000000..88fcf47 --- /dev/null +++ b/server/NeonSprawl.Server.Tests/Game/Npc/NpcRuntimeOperationsTests.cs @@ -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); + } +} diff --git a/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs b/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs index 0d3b4ac..2d27d96 100644 --- a/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs +++ b/server/NeonSprawl.Server/Game/Combat/CombatTargetFixtureApi.cs @@ -9,7 +9,7 @@ public static class CombatTargetFixtureApi { app.MapPost( "/game/__dev/combat-targets-fixture", - (CombatTargetFixtureRequest? body, ICombatEntityHealthStore healthStore, IThreatStateStore threatStore) => + (CombatTargetFixtureRequest? body, ICombatEntityHealthStore healthStore, IThreatStateStore threatStore, INpcRuntimeStateStore runtimeStore) => { if (body is null || body.SchemaVersion != CombatTargetFixtureRequest.CurrentSchemaVersion) { @@ -25,6 +25,7 @@ public static class CombatTargetFixtureApi } AggroOperations.ClearAllPrototypeHolders(threatStore); + NpcRuntimeOperations.ResetAllPrototypeRows(runtimeStore); return Results.Json( new CombatTargetFixtureResponse diff --git a/server/NeonSprawl.Server/Game/Npc/INpcRuntimeStateStore.cs b/server/NeonSprawl.Server/Game/Npc/INpcRuntimeStateStore.cs new file mode 100644 index 0000000..69072a5 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/INpcRuntimeStateStore.cs @@ -0,0 +1,25 @@ +using System.Diagnostics.CodeAnalysis; + +namespace NeonSprawl.Server.Game.Npc; + +/// +/// Session in-memory NPC runtime behavior state (NEO-93). +/// HTTP projection lands in NEO-94; lazy tick advance reads/writes rows here. +/// +public interface INpcRuntimeStateStore +{ + /// UTC instant of the last successful call. + DateTimeOffset LastAdvancedUtc { get; set; } + + /// Reads runtime state for a known prototype NPC instance. Lazy-initializes an idle row. + bool TryGet(string? npcInstanceId, [NotNullWhen(true)] out NpcRuntimeStateSnapshot snapshot); + + /// Writes runtime state for a known prototype NPC instance. + bool TryWrite(string? npcInstanceId, in NpcRuntimeStateSnapshot snapshot); + + /// Resets one prototype NPC runtime row to idle with no active telegraph. + bool TryResetPrototypeRow(string? npcInstanceId); + + /// Resets all prototype NPC runtime rows to idle. + void ResetAllPrototypeRows(); +} diff --git a/server/NeonSprawl.Server/Game/Npc/InMemoryNpcRuntimeStateStore.cs b/server/NeonSprawl.Server/Game/Npc/InMemoryNpcRuntimeStateStore.cs new file mode 100644 index 0000000..6a9d712 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/InMemoryNpcRuntimeStateStore.cs @@ -0,0 +1,108 @@ +using System.Collections.Concurrent; + +namespace NeonSprawl.Server.Game.Npc; + +/// Thread-safe in-memory NPC runtime state for prototype instances (NEO-93). +public sealed class InMemoryNpcRuntimeStateStore : INpcRuntimeStateStore +{ + private readonly ConcurrentDictionary rowsByNpcId = new(StringComparer.Ordinal); + + private readonly ConcurrentDictionary idLocks = new(StringComparer.Ordinal); + + /// + public DateTimeOffset LastAdvancedUtc { get; set; } = DateTimeOffset.MinValue; + + /// + 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; + } + } + + /// + 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; + } + } + + /// + 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; + } + } + + /// + 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, + }; + } +} diff --git a/server/NeonSprawl.Server/Game/Npc/NpcBehaviorState.cs b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorState.cs new file mode 100644 index 0000000..b3c76bc --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/NpcBehaviorState.cs @@ -0,0 +1,27 @@ +namespace NeonSprawl.Server.Game.Npc; + +/// Prototype NPC behavior states (NEO-93). Wire names are stable for NEO-94 JSON. +public enum NpcBehaviorState +{ + Idle, + Aggro, + TelegraphWindup, + AttackExecute, + Recover, +} + +/// Stable snake_case wire names for . +public static class NpcBehaviorStateWire +{ + /// Returns the JSON wire name for . + 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), + }; +} diff --git a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs new file mode 100644 index 0000000..c095d54 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs @@ -0,0 +1,262 @@ +namespace NeonSprawl.Server.Game.Npc; + +/// Deterministic prototype NPC behavior state machine + lazy tick advance (NEO-93). +public static class NpcRuntimeOperations +{ + /// Default per-advance simulation cap (seconds) for lazy poll ticks. + public const double DefaultMaxDeltaSeconds = 5.0; + + /// + /// Advances all prototype NPC runtime rows by up to since the last call. + /// + 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; + } + + /// Resets all prototype NPC runtime rows to idle (dev fixture). + 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)); + } +} diff --git a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeServiceCollectionExtensions.cs b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeServiceCollectionExtensions.cs new file mode 100644 index 0000000..9740e56 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeServiceCollectionExtensions.cs @@ -0,0 +1,12 @@ +namespace NeonSprawl.Server.Game.Npc; + +/// Registers prototype NPC runtime behavior state store (NEO-93). +public static class NpcRuntimeServiceCollectionExtensions +{ + /// Registers as an in-memory singleton. + public static IServiceCollection AddNpcRuntimeStateStore(this IServiceCollection services) + { + services.AddSingleton(); + return services; + } +} diff --git a/server/NeonSprawl.Server/Game/Npc/NpcRuntimeStateSnapshot.cs b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeStateSnapshot.cs new file mode 100644 index 0000000..5d89db3 --- /dev/null +++ b/server/NeonSprawl.Server/Game/Npc/NpcRuntimeStateSnapshot.cs @@ -0,0 +1,17 @@ +namespace NeonSprawl.Server.Game.Npc; + +/// Active telegraph row while an NPC is in (NEO-93). +/// Stable id for NEO-94 wire projection. +/// UTC instant when windup phase began. +public readonly record struct ActiveTelegraphSnapshot(string TelegraphId, DateTimeOffset WindupStartedUtc); + +/// Per-NPC runtime behavior state snapshot (NEO-93). +/// Lowercase prototype NPC instance id. +/// Current behavior state. +/// UTC instant when the current phase began. +/// Non-null only during . +public readonly record struct NpcRuntimeStateSnapshot( + string NpcInstanceId, + NpcBehaviorState BehaviorState, + DateTimeOffset PhaseStartedUtc, + ActiveTelegraphSnapshot? ActiveTelegraph); diff --git a/server/NeonSprawl.Server/Program.cs b/server/NeonSprawl.Server/Program.cs index e0a4e1d..543d87e 100644 --- a/server/NeonSprawl.Server/Program.cs +++ b/server/NeonSprawl.Server/Program.cs @@ -26,6 +26,7 @@ builder.Services.AddRecipeDefinitionCatalog(builder.Configuration); builder.Services.AddAbilityDefinitionCatalog(builder.Configuration); builder.Services.AddNpcBehaviorDefinitionCatalog(builder.Configuration); builder.Services.AddThreatStateStore(); +builder.Services.AddNpcRuntimeStateStore(); builder.Services.AddMasteryCatalog(builder.Configuration); var app = builder.Build();