using NeonSprawl.Server.Game.Combat; using NeonSprawl.Server.Game.PositionState; 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, IAbilityDefinitionRegistry abilityRegistry, ICombatEntityHealthStore combatHealthStore, IPositionStateStore positionStore, 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, abilityRegistry, combatHealthStore, positionStore, 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, abilityRegistry, combatHealthStore, positionStore, 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, abilityRegistry, combatHealthStore, positionStore, 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(); } /// /// Clears aggro and runtime combat state when the NPC combat-target row is . /// public static void TryStopOnTargetDefeat( string npcInstanceId, ICombatEntityHealthStore combatHealthStore, IThreatStateStore threatStore, INpcRuntimeStateStore runtimeStore) { var npcKey = NormalizeNpcId(npcInstanceId); if (npcKey.Length == 0 || !combatHealthStore.TryGet(npcKey, out var combatHp) || !combatHp.Defeated) { return; } threatStore.TryClearHolder(npcKey); WriteIdle(runtimeStore, npcKey); } private static void AdvanceOne( string npcInstanceId, DateTimeOffset windowStart, DateTimeOffset windowEnd, INpcRuntimeStateStore runtimeStore, IThreatStateStore threatStore, INpcBehaviorDefinitionRegistry behaviorRegistry, IAbilityDefinitionRegistry abilityRegistry, ICombatEntityHealthStore combatHealthStore, IPositionStateStore positionStore, 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 (combatHealthStore.TryGet(npcInstanceId, out var combatHp) && combatHp.Defeated) { if (TryHasAggroHolder(npcInstanceId, threatStore, out _) || snapshot.BehaviorState != NpcBehaviorState.Idle || snapshot.ActiveTelegraph is not null) { threatStore.TryClearHolder(npcInstanceId); WriteIdle(runtimeStore, npcInstanceId); } return; } if (!TryHasAggroHolder(npcInstanceId, threatStore, out _)) { 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. } var cursor = windowStart; while (cursor < windowEnd) { if (!TryHasAggroHolder(npcInstanceId, threatStore, out var 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 var loopSnapshot)) { return; } snapshot = loopSnapshot; 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.AttackAbilityId, abilityRegistry, positionStore, 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.AttackAbilityId, abilityRegistry, positionStore, 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)); } private static string NormalizeNpcId(string? raw) => raw?.Trim().ToLowerInvariant() ?? string.Empty; }