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 store = factory.Services.GetRequiredService<ICombatEntityHealthStore>();
|
||||
var threatStore = factory.Services.GetRequiredService<IThreatStateStore>();
|
||||
var runtimeStore = factory.Services.GetRequiredService<INpcRuntimeStateStore>();
|
||||
_ = 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]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
"/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
|
||||
|
|
|
|||
|
|
@ -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.AddNpcBehaviorDefinitionCatalog(builder.Configuration);
|
||||
builder.Services.AddThreatStateStore();
|
||||
builder.Services.AddNpcRuntimeStateStore();
|
||||
builder.Services.AddMasteryCatalog(builder.Configuration);
|
||||
|
||||
var app = builder.Build();
|
||||
|
|
|
|||
Loading…
Reference in New Issue