using NeonSprawl.Server.Game.Combat; 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, 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) => 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); } 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 aggroHolderPlayerId)) { 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; NpcAttackOperations.TryResolveTelegraphComplete( npcInstanceId, aggroHolderPlayerId, behavior.AttackDamage, playerHealthStore, threatStore); WriteState( runtimeStore, npcInstanceId, NpcBehaviorState.Recover, cursor, activeTelegraph: null); // telemetry: telegraph_fired / npc_state_transition (NEO-96); logical attack_execute → recover 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: npc_state_transition (NEO-96) 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; } } } /// 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)); } }