using NeonSprawl.Server.Game.Combat;
namespace NeonSprawl.Server.Game.Npc;
///
/// Deterministic prototype NPC behavior state machine + lazy tick advance (NEO-93).
/// NEO-96 telemetry hook sites: state transition commits and telegraph starts in
/// (telegraph_fired, npc_state_transition).
///
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,
IPlayerCombatHealthStore playerHealthStore,
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,
playerHealthStore);
}
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,
playerHealthStore);
}
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,
playerHealthStore);
}
runtimeStore.LastAdvancedUtc = windowEnd;
}
/// Resets all prototype NPC runtime rows to idle (dev fixture).
public static void ResetAllPrototypeRows(INpcRuntimeStateStore runtimeStore)
{
// NEO-96: dev fixture reset writes idle directly — bypasses AdvanceOne npc_state_transition hooks.
// TODO(E9.M1): optional catalog emit anchor here if fixture reset transitions must be tracked.
runtimeStore.ResetAllPrototypeRows();
}
private static void AdvanceOne(
string npcInstanceId,
DateTimeOffset windowStart,
DateTimeOffset windowEnd,
INpcRuntimeStateStore runtimeStore,
IThreatStateStore threatStore,
INpcBehaviorDefinitionRegistry behaviorRegistry,
IPlayerCombatHealthStore playerHealthStore)
{
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 var aggroHolderPlayerId))
{
if (snapshot.BehaviorState != NpcBehaviorState.Idle || snapshot.ActiveTelegraph is not null)
{
WriteIdle(runtimeStore, npcInstanceId);
// --- Telemetry hook site (NEO-96): future E9.M1 catalog event `npc_state_transition` ---
// TODO(E9.M1): catalog emit — holder cleared during AdvanceAll (any → idle).
// Planned payload fields: npcInstanceId, behaviorDefId, fromState, toState (idle),
// phaseStartedUtc, optional aggroHolderPlayerId (previous holder when known).
}
return;
}
if (snapshot.BehaviorState == NpcBehaviorState.Idle)
{
WriteState(
runtimeStore,
npcInstanceId,
NpcBehaviorState.Aggro,
windowStart,
activeTelegraph: null);
// --- Telemetry hook site (NEO-96): future E9.M1 catalog event `npc_state_transition` ---
// TODO(E9.M1): catalog emit — idle → aggro when aggro holder is set.
// Planned payload fields: npcInstanceId, behaviorDefId, fromState (idle), toState (aggro),
// phaseStartedUtc, aggroHolderPlayerId.
if (!runtimeStore.TryGet(npcInstanceId, out snapshot))
{
return;
}
}
var cursor = windowStart;
while (cursor < windowEnd)
{
if (!TryHasAggroHolder(npcInstanceId, threatStore, out aggroHolderPlayerId))
{
WriteIdle(runtimeStore, npcInstanceId);
// --- Telemetry hook site (NEO-96): future E9.M1 catalog event `npc_state_transition` ---
// TODO(E9.M1): catalog emit — holder cleared mid-cycle during AdvanceAll (any → idle).
// Planned payload fields: npcInstanceId, behaviorDefId, fromState, toState (idle),
// phaseStartedUtc, optional aggroHolderPlayerId (previous holder).
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 hook site (NEO-96): future E9.M1 catalog event `npc_state_transition` ---
// TODO(E9.M1): catalog emit — aggro → telegraph_windup.
// Planned payload fields: npcInstanceId, behaviorDefId, fromState (aggro),
// toState (telegraph_windup), phaseStartedUtc, aggroHolderPlayerId, telegraphId.
// --- Telemetry hook site (NEO-96): future E9.M1 catalog event `telegraph_fired` ---
// TODO(E9.M1): catalog emit — once per telegraph start (each windup cycle).
// Planned payload fields: npcInstanceId, telegraphId, behaviorDefId, archetypeKind,
// aggroHolderPlayerId, windupStartedUtc, telegraphWindupSeconds.
break;
}
case NpcBehaviorState.TelegraphWindup:
{
var phaseEnd = snapshot.PhaseStartedUtc.AddSeconds(behavior.TelegraphWindupSeconds);
if (windowEnd < phaseEnd)
{
return;
}
cursor = phaseEnd;
NpcAttackOperations.TryResolveTelegraphComplete(
npcInstanceId,
aggroHolderPlayerId,
behavior.AttackDamage,
playerHealthStore,
threatStore);
WriteState(
runtimeStore,
npcInstanceId,
NpcBehaviorState.Recover,
cursor,
activeTelegraph: null);
// --- Telemetry hook site (NEO-96): future E9.M1 catalog event `npc_state_transition` ---
// TODO(E9.M1): catalog emit — telegraph_windup → recover (logical attack_execute instant).
// Planned payload fields: npcInstanceId, behaviorDefId, fromState (telegraph_windup),
// toState (recover), phaseStartedUtc, aggroHolderPlayerId, telegraphId (completed windup).
break;
}
case NpcBehaviorState.AttackExecute:
// Unreachable in normal windup→recover flow (NEO-93); defensive if execute becomes a visible phase.
NpcAttackOperations.TryResolveTelegraphComplete(
npcInstanceId,
aggroHolderPlayerId,
behavior.AttackDamage,
playerHealthStore,
threatStore);
WriteState(
runtimeStore,
npcInstanceId,
NpcBehaviorState.Recover,
cursor,
activeTelegraph: null);
// --- Telemetry hook site (NEO-96): future E9.M1 catalog event `npc_state_transition` ---
// TODO(E9.M1): catalog emit — attack_execute → recover (defensive visible phase).
// Planned payload fields: npcInstanceId, behaviorDefId, fromState (attack_execute),
// toState (recover), phaseStartedUtc, aggroHolderPlayerId.
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 hook site (NEO-96): future E9.M1 catalog event `npc_state_transition` ---
// TODO(E9.M1): catalog emit — recover → telegraph_windup.
// Planned payload fields: npcInstanceId, behaviorDefId, fromState (recover),
// toState (telegraph_windup), phaseStartedUtc, aggroHolderPlayerId, telegraphId.
// --- Telemetry hook site (NEO-96): future E9.M1 catalog event `telegraph_fired` ---
// TODO(E9.M1): catalog emit — once per telegraph start (each windup cycle).
// Planned payload fields: npcInstanceId, telegraphId, behaviorDefId, archetypeKind,
// aggroHolderPlayerId, windupStartedUtc, telegraphWindupSeconds.
break;
}
default:
return;
}
if (cursor <= loopCursorBefore)
{
return;
}
}
}
/// Catalog timings must be positive; CI/content gate enforces for prototype defs.
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));
}
}