neon-sprawl/server/NeonSprawl.Server/Game/Npc/NpcRuntimeOperations.cs

271 lines
8.9 KiB
C#

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);
}
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 = windowEnd;
}
/// <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 (!HasPositiveTimingDurations(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;
}
var loopCursorBefore = cursor;
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)
break;
}
case NpcBehaviorState.TelegraphWindup:
{
var phaseEnd = snapshot.PhaseStartedUtc.AddSeconds(behavior.TelegraphWindupSeconds);
if (windowEnd < phaseEnd)
{
return;
}
cursor = phaseEnd;
WriteState(
runtimeStore,
npcInstanceId,
NpcBehaviorState.Recover,
cursor,
activeTelegraph: null);
// telemetry: telegraph_fired / npc_state_transition (NEO-96); logical attack_execute → recover (NEO-95 applies attackDamage)
break;
}
case NpcBehaviorState.AttackExecute:
WriteState(
runtimeStore,
npcInstanceId,
NpcBehaviorState.Recover,
cursor,
activeTelegraph: null);
// telemetry: npc_state_transition (NEO-96); NEO-95 applies attackDamage here
break;
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)
break;
}
default:
return;
}
if (cursor <= loopCursorBefore)
{
return;
}
}
}
/// <summary>Catalog timings must be positive; CI/content gate enforces for prototype defs.</summary>
internal static bool HasPositiveTimingDurations(NpcBehaviorDefRow behavior) =>
behavior.AttackCooldownSeconds > 0 && behavior.TelegraphWindupSeconds > 0;
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));
}
}