/ GUNRPG.Core / Operators / Operator.cs
Operator.cs
  1  using GUNRPG.Core.Combat;
  2  using GUNRPG.Core.Weapons;
  3  
  4  namespace GUNRPG.Core.Operators;
  5  
  6  /// <summary>
  7  /// Represents an operator (player or AI) in the simulation.
  8  /// Maintains all state relevant to combat and decision-making.
  9  /// </summary>
 10  public class Operator
 11  {
 12      public Guid Id { get; }
 13      public string Name { get; set; }
 14  
 15      // Physical State
 16      public float Health { get; set; }
 17      public float MaxHealth { get; set; }
 18      public float Stamina { get; set; }
 19      public float MaxStamina { get; set; }
 20      public float Fatigue { get; set; }
 21      public float MaxFatigue { get; set; }
 22  
 23      // State Machines
 24      public MovementState MovementState { get; set; }
 25      public AimState AimState { get; set; }
 26      public WeaponState WeaponState { get; set; }
 27  
 28      // Movement System
 29      public MovementState CurrentMovement { get; set; }
 30      public CoverState CurrentCover { get; set; }
 31      public MovementDirection CurrentDirection { get; set; }
 32      public long? MovementEndTimeMs { get; set; }
 33      public bool IsMoving => MovementEndTimeMs.HasValue;
 34  
 35      // Equipment
 36      public Weapon? EquippedWeapon { get; set; }
 37      public int CurrentAmmo { get; set; }
 38  
 39      // Operator Skills
 40      private float _accuracy;
 41      /// <summary>
 42      /// Operator accuracy (0.0 to 1.0). Affects standard deviation of aim acquisition error.
 43      /// Higher values = more accurate shooting. Values are clamped to [0.0, 1.0] range.
 44      /// </summary>
 45      public float Accuracy 
 46      { 
 47          get => _accuracy;
 48          set => _accuracy = Math.Clamp(value, 0.0f, 1.0f);
 49      }
 50  
 51      private float _accuracyProficiency;
 52      /// <summary>
 53      /// Minimum recommended accuracy proficiency to ensure meaningful recoil control.
 54      /// Values below this threshold result in very poor recoil control.
 55      /// </summary>
 56      public const float MinRecommendedAccuracyProficiency = 0.1f;
 57  
 58      /// <summary>
 59      /// Operator accuracy proficiency (0.0 to 1.0). Determines how effectively the operator
 60      /// counteracts recoil and stabilizes aim. This is applied AFTER weapon recoil is calculated.
 61      /// 
 62      /// - 0.0 = no recoil control, poor aim stabilization (not recommended)
 63      /// - 1.0 = excellent recoil control and fast recovery (capped well below perfect)
 64      /// 
 65      /// Affects:
 66      /// 1. Initial aim acquisition error (higher proficiency = tighter distribution)
 67      /// 2. Recoil counteraction (reduces effective vertical recoil per shot)
 68      /// 3. Gun kick recovery (faster recovery toward baseline aim)
 69      /// 
 70      /// Does NOT affect damage, fire rate, or body-part bands.
 71      /// 
 72      /// Note: A minimum proficiency of MinRecommendedAccuracyProficiency (0.1) is recommended.
 73      /// Zero proficiency results in very poor recoil control and slow recovery.
 74      /// </summary>
 75      public float AccuracyProficiency
 76      {
 77          get => _accuracyProficiency;
 78          set => _accuracyProficiency = Math.Clamp(value, 0.0f, 1.0f);
 79      }
 80  
 81      /// <summary>
 82      /// Returns true if AccuracyProficiency is below the minimum recommended threshold.
 83      /// Use this to validate operators before combat.
 84      /// </summary>
 85      public bool HasLowAccuracyProficiency => _accuracyProficiency < MinRecommendedAccuracyProficiency;
 86  
 87      private float _responseProficiency;
 88      /// <summary>
 89      /// Operator response proficiency (0.0 to 1.0). Determines how quickly the operator can
 90      /// transition between actions under pressure. This affects commitment cost scaling.
 91      /// 
 92      /// - 0.0 = slow transitions, higher commitment costs (clunky)
 93      /// - 1.0 = fast transitions, lower commitment costs (smooth)
 94      /// 
 95      /// Affects:
 96      /// 1. Cover transition delays (faster entry/exit from cover)
 97      /// 2. ADS initiation time after movement
 98      /// 3. Sprint-to-fire delay
 99      /// 4. Suppression recovery rate
100      /// 5. Movement-to-stationary settle time
101      /// 
102      /// This completes the operator skill triangle:
103      /// - Reaction Proficiency → how fast actions start (via AccuracyProficiency recognition)
104      /// - Accuracy Proficiency → how well actions perform
105      /// - Response Proficiency → how fast actions switch
106      /// 
107      /// Applied identically to player and AI operators.
108      /// </summary>
109      public float ResponseProficiency
110      {
111          get => _responseProficiency;
112          set => _responseProficiency = Math.Clamp(value, 0.0f, 1.0f);
113      }
114  
115      // Position
116      public float DistanceToOpponent { get; set; }
117  
118      // Timing for actions
119      public long? ActionCompletionTimeMs { get; set; }
120      
121      // Health Regeneration
122      public long? LastDamageTimeMs { get; set; }
123      public float HealthRegenDelayMs { get; set; }
124      public float HealthRegenRate { get; set; } // Health per second
125  
126      // Stamina Regeneration
127      public float StaminaRegenRate { get; set; } // Stamina per second
128      public float SprintStaminaDrainRate { get; set; } // Stamina per second
129      public float SlideStaminaCost { get; set; }
130  
131      // Movement speeds (meters per second)
132      public float WalkSpeed { get; set; }
133      public float SprintSpeed { get; set; }
134      public float SlideDistance { get; set; }
135      public float SlideDurationMs { get; set; }
136  
137      // Recoil tracking
138      public float CurrentRecoilX { get; set; }
139      public float CurrentRecoilY { get; set; }
140      public long? RecoilRecoveryStartMs { get; set; }
141      public float RecoilRecoveryRate { get; set; } // Per second
142  
143      // Flinch tracking
144      public float FlinchSeverity { get; private set; }
145      public int FlinchShotsRemaining { get; private set; }
146      public int FlinchDurationShots { get; set; }
147  
148      // Suppression tracking
149      private float _suppressionLevel;
150      /// <summary>
151      /// Current suppression level (0.0 - 1.0). Suppression is caused by near-misses
152      /// and incoming fire that threatens the operator without dealing damage.
153      /// Higher values = more severe performance penalties.
154      /// </summary>
155      public float SuppressionLevel
156      {
157          get => _suppressionLevel;
158          private set => _suppressionLevel = Math.Clamp(value, 0f, 1f);
159      }
160  
161      /// <summary>
162      /// Returns true if the operator is currently suppressed (suppression level above threshold).
163      /// </summary>
164      public bool IsSuppressed => _suppressionLevel >= SuppressionModel.SuppressionThreshold;
165  
166      /// <summary>
167      /// Time when suppression decay should start (after suppression ends).
168      /// </summary>
169      public long? SuppressionDecayStartMs { get; set; }
170  
171      /// <summary>
172      /// Time of the last suppression application (for tracking "under fire" state).
173      /// </summary>
174      public long? LastSuppressionApplicationMs { get; set; }
175  
176      // Shot telemetry tracking
177      public int ShotsFiredCount { get; private set; }
178  
179      // Commitment tracking for reaction windows
180      public int BulletsFiredSinceLastReaction { get; set; }
181      public float MetersMovedSinceLastReaction { get; set; }
182  
183      // ADS Transition tracking
184      public long? ADSTransitionStartMs { get; set; }
185      public float ADSTransitionDurationMs { get; set; }
186      public bool IsActivelyFiring { get; set; } // Track if currently in firing burst
187  
188      // Cover Transition tracking
189      public bool IsCoverTransitioning { get; set; }
190      public CoverState CoverTransitionFromState { get; set; }
191      public CoverState CoverTransitionToState { get; set; }
192      public long? CoverTransitionStartMs { get; set; }
193      public long? CoverTransitionEndMs { get; set; }
194  
195      // Awareness / Recognition tracking
196      /// <summary>
197      /// When the recognition delay will end (opponent will recognize this operator's exposure).
198      /// </summary>
199      public long? RecognitionDelayEndMs { get; set; }
200      
201      /// <summary>
202      /// The target operator ID that is being recognized (for tracking recognition state).
203      /// </summary>
204      public Guid? RecognitionTargetId { get; set; }
205  
206      /// <summary>
207      /// Time when the opponent was last visible (for suppressive fire decision making).
208      /// </summary>
209      public long? LastTargetVisibleMs { get; set; }
210  
211      /// <summary>
212      /// When recognition started (for calculating progress).
213      /// </summary>
214      public long? RecognitionStartMs { get; set; }
215  
216      /// <summary>
217      /// Whether the target was visible in the previous check (for detecting visibility changes).
218      /// </summary>
219      public bool WasTargetVisible { get; set; }
220  
221      public Operator(string name, Guid? id = null)
222      {
223          Id = id ?? Guid.NewGuid();
224          Name = name;
225          
226          // Default values
227          MaxHealth = 100f;
228          Health = MaxHealth;
229          MaxStamina = 100f;
230          Stamina = MaxStamina;
231          MaxFatigue = 100f;
232          Fatigue = 0f;
233          
234          MovementState = MovementState.Idle;
235          AimState = AimState.Hip;
236          WeaponState = WeaponState.Ready;
237          
238          // Initialize movement system
239          CurrentMovement = MovementState.Stationary;
240          CurrentCover = CoverState.None;
241          CurrentDirection = MovementDirection.Holding;
242          MovementEndTimeMs = null;
243          
244          // Default regeneration values (Call of Duty style)
245          HealthRegenDelayMs = 5000f; // 5 seconds
246          HealthRegenRate = 40f; // 40 HP per second
247          StaminaRegenRate = 20f; // 20 stamina per second
248          SprintStaminaDrainRate = 10f; // 10 stamina per second
249          SlideStaminaCost = 30f;
250          RecoilRecoveryRate = 5f; // Arbitrary units per second
251          FlinchDurationShots = 1; // Default: 1 round (one shot's worth of flinch)
252          
253          // Operator skills (using property setter for validation)
254          Accuracy = 0.7f; // Default 70% accuracy
255          AccuracyProficiency = 0.5f; // Default 50% proficiency (mid-range skill)
256          ResponseProficiency = 0.5f; // Default 50% response proficiency (mid-range transitions)
257          
258          // Movement defaults
259          WalkSpeed = 4f; // meters per second
260          SprintSpeed = 6f;
261          SlideDistance = 3f;
262          SlideDurationMs = 500f;
263      }
264  
265      /// <summary>
266      /// Checks if the operator is alive.
267      /// </summary>
268      public bool IsAlive => Health > 0;
269  
270      /// <summary>
271      /// Applies damage to the operator.
272      /// </summary>
273      public void TakeDamage(float damage, long currentTimeMs)
274      {
275          Health = Math.Max(0, Health - damage);
276          LastDamageTimeMs = currentTimeMs;
277      }
278  
279      /// <summary>
280      /// Applies a flinch severity debuff that persists for a set number of shots.
281      /// </summary>
282      public void ApplyFlinch(float severity)
283      {
284          severity = Math.Clamp(severity, 0f, 1f);
285          if (FlinchDurationShots <= 0)
286          {
287              FlinchSeverity = 0f;
288              FlinchShotsRemaining = 0;
289              return;
290          }
291  
292          FlinchSeverity = Math.Clamp(Math.Max(FlinchSeverity, severity), 0f, 1f);
293          FlinchShotsRemaining = FlinchDurationShots;
294      }
295  
296      /// <summary>
297      /// Consumes flinch duration for a shot, clearing when depleted.
298      /// </summary>
299      public void ConsumeFlinchShot()
300      {
301          if (FlinchShotsRemaining <= 0)
302              return;
303  
304          FlinchShotsRemaining = Math.Max(FlinchShotsRemaining - 1, 0);
305  
306          if (FlinchShotsRemaining == 0)
307              FlinchSeverity = 0f;
308      }
309  
310      /// <summary>
311      /// Applies suppression from a near-miss or threatening shot.
312      /// Suppression stacks up to the maximum level.
313      /// </summary>
314      /// <param name="severity">Suppression severity to apply (0.0 - 1.0)</param>
315      /// <param name="currentTimeMs">Current simulation time</param>
316      /// <returns>True if suppression was newly applied (operator became suppressed)</returns>
317      public bool ApplySuppression(float severity, long currentTimeMs)
318      {
319          severity = Math.Clamp(severity, 0f, 1f);
320          if (severity <= 0f)
321              return false;
322  
323          bool wasNotSuppressed = !IsSuppressed;
324  
325          SuppressionLevel = SuppressionModel.CombineSuppression(SuppressionLevel, severity);
326          LastSuppressionApplicationMs = currentTimeMs;
327          SuppressionDecayStartMs = null; // Reset decay while under fire
328  
329          return wasNotSuppressed && IsSuppressed;
330      }
331  
332      /// <summary>
333      /// Updates suppression decay over time.
334      /// Should be called during time advancement.
335      /// </summary>
336      /// <param name="deltaMs">Time elapsed since last update</param>
337      /// <param name="currentTimeMs">Current simulation time</param>
338      /// <returns>True if suppression ended (operator is no longer suppressed)</returns>
339      public bool UpdateSuppressionDecay(long deltaMs, long currentTimeMs)
340      {
341          if (SuppressionLevel <= 0f)
342              return false;
343  
344          bool wasSuppressed = IsSuppressed;
345  
346          // Determine if under continued fire
347          bool isUnderFire = LastSuppressionApplicationMs.HasValue &&
348              (currentTimeMs - LastSuppressionApplicationMs.Value) < SuppressionModel.ContinuedFireWindowMs;
349  
350          // Apply decay with movement state and response proficiency modifiers
351          SuppressionLevel = SuppressionModel.ApplyDecay(
352              SuppressionLevel, 
353              deltaMs, 
354              isUnderFire, 
355              CurrentMovement,
356              _responseProficiency);
357  
358          // Check if suppression ended
359          if (wasSuppressed && !IsSuppressed)
360          {
361              SuppressionDecayStartMs = null;
362              LastSuppressionApplicationMs = null;
363              return true;
364          }
365  
366          return false;
367      }
368  
369      /// <summary>
370      /// Clears all suppression immediately.
371      /// </summary>
372      public void ClearSuppression()
373      {
374          SuppressionLevel = 0f;
375          SuppressionDecayStartMs = null;
376          LastSuppressionApplicationMs = null;
377      }
378  
379      public void RestoreSuppression(float level, long? lastApplicationMs, long? decayStartMs)
380      {
381          SuppressionLevel = level;
382          LastSuppressionApplicationMs = lastApplicationMs;
383          SuppressionDecayStartMs = decayStartMs;
384      }
385  
386      public void RestoreFlinch(float severity, int shotsRemaining, int durationShots)
387      {
388          FlinchSeverity = Math.Clamp(severity, 0f, 1f);
389          FlinchShotsRemaining = Math.Max(0, shotsRemaining);
390          FlinchDurationShots = durationShots;
391      }
392  
393      public void RestoreShotsFired(int shotsFired)
394      {
395          ShotsFiredCount = Math.Max(0, shotsFired);
396      }
397  
398      /// <summary>
399      /// Gets the effective accuracy proficiency considering both flinch and suppression.
400      /// </summary>
401      /// <returns>Effective accuracy proficiency after all penalties</returns>
402      public float GetEffectiveAccuracyProficiency()
403      {
404          // First apply flinch to base proficiency
405          float afterFlinch = AccuracyModel.CalculateEffectiveAccuracyProficiency(
406              AccuracyProficiency, FlinchSeverity);
407  
408          // Then apply suppression
409          return SuppressionModel.CalculateEffectiveAccuracyProficiency(
410              afterFlinch, SuppressionLevel);
411      }
412  
413      public int IncrementShotsFired()
414      {
415          ShotsFiredCount++;
416          return ShotsFiredCount;
417      }
418  
419      /// <summary>
420      /// Checks if health regeneration should be active.
421      /// </summary>
422      public bool CanRegenerateHealth(long currentTimeMs)
423      {
424          if (!LastDamageTimeMs.HasValue)
425              return false;
426          
427          return (currentTimeMs - LastDamageTimeMs.Value) >= HealthRegenDelayMs;
428      }
429  
430      /// <summary>
431      /// Updates regeneration for a time delta.
432      /// </summary>
433      public void UpdateRegeneration(long deltaMs, long currentTimeMs)
434      {
435          float deltaSeconds = deltaMs / 1000f;
436  
437          // Health regeneration
438          if (Health < MaxHealth && CanRegenerateHealth(currentTimeMs))
439          {
440              Health = Math.Min(MaxHealth, Health + HealthRegenRate * deltaSeconds);
441          }
442  
443          // Stamina regeneration (always active when not sprinting)
444          if (MovementState != MovementState.Sprinting && Stamina < MaxStamina)
445          {
446              Stamina = Math.Min(MaxStamina, Stamina + StaminaRegenRate * deltaSeconds);
447          }
448  
449          // Stamina drain during sprint
450          if (MovementState == MovementState.Sprinting)
451          {
452              Stamina = Math.Max(0, Stamina - SprintStaminaDrainRate * deltaSeconds);
453              
454              // Auto-exit sprint if stamina depleted
455              if (Stamina <= 0)
456              {
457                  MovementState = MovementState.Walking;
458              }
459          }
460  
461          // Recoil recovery (affected by AccuracyProficiency)
462          if (RecoilRecoveryStartMs.HasValue && currentTimeMs >= RecoilRecoveryStartMs.Value)
463          {
464              // Use AccuracyModel.CalculateRecoveryRateMultiplier for consistency
465              float recoveryMultiplier = AccuracyModel.CalculateRecoveryRateMultiplier(AccuracyProficiency);
466              float recoveryAmount = RecoilRecoveryRate * deltaSeconds * recoveryMultiplier;
467              CurrentRecoilX = RecoverRecoilAxis(CurrentRecoilX, recoveryAmount);
468              CurrentRecoilY = RecoverRecoilAxis(CurrentRecoilY, recoveryAmount);
469          }
470      }
471  
472      private static float RecoverRecoilAxis(float recoilValue, float recoveryAmount)
473      {
474          if (recoilValue > 0)
475              return Math.Max(0, recoilValue - recoveryAmount);
476          if (recoilValue < 0)
477              return Math.Min(0, recoilValue + recoveryAmount);
478          return recoilValue;
479      }
480  
481      /// <summary>
482      /// Gets the ADS transition progress (0.0 = hip, 1.0 = full ADS).
483      /// </summary>
484      public float GetADSProgress(long currentTimeMs)
485      {
486          if (AimState == AimState.Hip)
487              return 0f;
488          
489          if (AimState == AimState.ADS)
490              return 1f;
491  
492          if (AimState == AimState.TransitioningToADS && ADSTransitionStartMs.HasValue)
493          {
494              float elapsed = currentTimeMs - ADSTransitionStartMs.Value;
495              float duration = ADSTransitionDurationMs <= 0f ? 1f : ADSTransitionDurationMs;
496              float progress = Math.Clamp(elapsed / duration, 0f, 1f);
497              return progress;
498          }
499  
500          if (AimState == AimState.TransitioningToHip && ADSTransitionStartMs.HasValue)
501          {
502              float elapsed = currentTimeMs - ADSTransitionStartMs.Value;
503              float duration = ADSTransitionDurationMs <= 0f ? 1f : ADSTransitionDurationMs;
504              float progress = 1f - Math.Clamp(elapsed / duration, 0f, 1f);
505              return progress;
506          }
507  
508          return 0f;
509      }
510  
511      /// <summary>
512      /// Gets the current weapon spread based on ADS progress.
513      /// Interpolates between hipfire and ADS spread.
514      /// </summary>
515      public float GetCurrentSpread(long currentTimeMs)
516      {
517          if (EquippedWeapon == null)
518              return 10f; // Default high spread if no weapon
519  
520          float adsProgress = GetADSProgress(currentTimeMs);
521          float hipSpread = EquippedWeapon.HipfireSpreadDegrees;
522          float adsSpread = EquippedWeapon.ADSSpreadDegrees;
523  
524          // Linear interpolation between hip and ADS spread
525          return hipSpread + (adsSpread - hipSpread) * adsProgress;
526      }
527  
528      /// <summary>
529      /// Starts a movement action. Cancels any existing movement.
530      /// </summary>
531      /// <param name="movementType">Type of movement to start</param>
532      /// <param name="durationMs">Duration of the movement in milliseconds</param>
533      /// <param name="currentTimeMs">Current simulation time</param>
534      /// <param name="eventQueue">Optional event queue for emitting events</param>
535      /// <returns>True if movement was started, false if invalid</returns>
536      public bool StartMovement(MovementState movementType, long durationMs, long currentTimeMs, Events.EventQueue? eventQueue = null)
537      {
538          // Can't start stationary/idle movement explicitly
539          if (movementType == MovementState.Stationary || movementType == MovementState.Idle)
540              return false;
541  
542          // Cancel existing movement if any
543          if (IsMoving && MovementEndTimeMs.HasValue)
544          {
545              long remainingMs = MovementEndTimeMs.Value - currentTimeMs;
546              if (remainingMs > 0 && eventQueue != null)
547              {
548                  eventQueue.Schedule(new Events.MovementCancelledEvent(
549                      currentTimeMs,
550                      this,
551                      CurrentMovement,
552                      remainingMs,
553                      eventQueue.GetNextSequenceNumber()));
554              }
555          }
556  
557          // Start new movement
558          CurrentMovement = movementType;
559          MovementEndTimeMs = currentTimeMs + durationMs;
560  
561          // Emit movement started event
562          if (eventQueue != null)
563          {
564              eventQueue.Schedule(new Events.MovementStartedEvent(
565                  currentTimeMs,
566                  this,
567                  movementType,
568                  MovementEndTimeMs.Value,
569                  eventQueue.GetNextSequenceNumber()));
570  
571              // Schedule movement ended event
572              eventQueue.Schedule(new Events.MovementEndedEvent(
573                  MovementEndTimeMs.Value,
574                  this,
575                  movementType,
576                  eventQueue.GetNextSequenceNumber()));
577          }
578  
579          return true;
580      }
581  
582      /// <summary>
583      /// Cancels the current movement immediately.
584      /// </summary>
585      /// <param name="currentTimeMs">Current simulation time</param>
586      /// <param name="eventQueue">Optional event queue for emitting events</param>
587      /// <returns>True if movement was cancelled, false if no movement was active</returns>
588      public bool CancelMovement(long currentTimeMs, Events.EventQueue? eventQueue = null)
589      {
590          if (!IsMoving || !MovementEndTimeMs.HasValue)
591              return false;
592  
593          long remainingMs = MovementEndTimeMs.Value - currentTimeMs;
594          MovementState cancelledType = CurrentMovement;
595  
596          // Update state
597          CurrentMovement = MovementState.Stationary;
598          MovementEndTimeMs = null;
599  
600          // Emit cancellation event
601          if (eventQueue != null)
602          {
603              eventQueue.Schedule(new Events.MovementCancelledEvent(
604                  currentTimeMs,
605                  this,
606                  cancelledType,
607                  Math.Max(0, remainingMs),
608                  eventQueue.GetNextSequenceNumber()));
609          }
610  
611          return true;
612      }
613  
614      /// <summary>
615      /// Attempts to enter cover. Only succeeds if movement allows it.
616      /// </summary>
617      /// <param name="coverType">Type of cover to enter</param>
618      /// <param name="currentTimeMs">Current simulation time</param>
619      /// <param name="eventQueue">Optional event queue for emitting events</param>
620      /// <returns>True if cover was entered, false otherwise</returns>
621      public bool EnterCover(CoverState coverType, long currentTimeMs, Events.EventQueue? eventQueue = null)
622      {
623          if (coverType == CoverState.None)
624              return false;
625  
626          if (!Combat.MovementModel.CanEnterCover(CurrentMovement))
627              return false;
628  
629          CoverState previousCover = CurrentCover;
630          CurrentCover = coverType;
631  
632          // Emit cover entered event
633          if (eventQueue != null)
634          {
635              if (previousCover != CoverState.None)
636              {
637                  eventQueue.Schedule(new Events.CoverExitedEvent(
638                      currentTimeMs,
639                      this,
640                      previousCover,
641                      eventQueue.GetNextSequenceNumber()));
642              }
643  
644              eventQueue.Schedule(new Events.CoverEnteredEvent(
645                  currentTimeMs,
646                  this,
647                  coverType,
648                  eventQueue.GetNextSequenceNumber()));
649          }
650  
651          return true;
652      }
653  
654      /// <summary>
655      /// Exits the current cover.
656      /// </summary>
657      /// <param name="currentTimeMs">Current simulation time</param>
658      /// <param name="eventQueue">Optional event queue for emitting events</param>
659      /// <returns>True if cover was exited, false if no cover was active</returns>
660      public bool ExitCover(long currentTimeMs, Events.EventQueue? eventQueue = null)
661      {
662          if (CurrentCover == CoverState.None)
663              return false;
664  
665          CoverState exitedType = CurrentCover;
666          CurrentCover = CoverState.None;
667  
668          // Emit cover exited event
669          if (eventQueue != null)
670          {
671              eventQueue.Schedule(new Events.CoverExitedEvent(
672                  currentTimeMs,
673                  this,
674                  exitedType,
675                  eventQueue.GetNextSequenceNumber()));
676          }
677  
678          return true;
679      }
680  
681      /// <summary>
682      /// Updates movement state based on current time.
683      /// Should be called during time advancement.
684      /// </summary>
685      /// <param name="currentTimeMs">Current simulation time</param>
686      public void UpdateMovement(long currentTimeMs)
687      {
688          if (IsMoving && MovementEndTimeMs.HasValue && currentTimeMs >= MovementEndTimeMs.Value)
689          {
690              CurrentMovement = MovementState.Stationary;
691              MovementEndTimeMs = null;
692          }
693      }
694  
695      /// <summary>
696      /// Checks if the operator can shoot based on their current cover state.
697      /// Shooting is blocked when in Full Cover (complete concealment).
698      /// </summary>
699      /// <returns>True if shooting is allowed, false otherwise</returns>
700      public bool CanShoot()
701      {
702          return CurrentCover != CoverState.Full;
703      }
704  
705      /// <summary>
706      /// Checks if the operator can advance (move forward) based on their current cover state.
707      /// Advancing is blocked when in Full Cover (must exit to Partial or None first).
708      /// </summary>
709      /// <returns>True if advancing is allowed, false otherwise</returns>
710      public bool CanAdvance()
711      {
712          return CurrentCover != CoverState.Full;
713      }
714  
715      /// <summary>
716      /// Gets the effective cover state, accounting for cover transitions.
717      /// During transitions, treats the operator as being in Partial cover.
718      /// </summary>
719      /// <param name="currentTimeMs">Current simulation time</param>
720      /// <returns>Effective cover state</returns>
721      public CoverState GetEffectiveCoverState(long currentTimeMs)
722      {
723          if (!IsCoverTransitioning || !CoverTransitionEndMs.HasValue)
724              return CurrentCover;
725  
726          // During transition, return partial cover (exposed)
727          if (currentTimeMs < CoverTransitionEndMs.Value)
728              return CoverState.Partial;
729  
730          // After the scheduled end time, only treat the transition as complete
731          // once the transition flag has been cleared by the completion event.
732          if (!IsCoverTransitioning)
733              return CoverTransitionToState;
734  
735          // Transition end time has passed, but the completion event has not yet
736          // updated the underlying state; remain consistent with CurrentCover.
737          return CurrentCover;
738      }
739  
740      /// <summary>
741      /// Checks if an opponent can see this operator based on cover state.
742      /// </summary>
743      /// <param name="currentTimeMs">Current simulation time</param>
744      /// <returns>True if this operator is visible to opponents</returns>
745      public bool IsVisibleToOpponents(long currentTimeMs)
746      {
747          var effectiveCover = GetEffectiveCoverState(currentTimeMs);
748          return AwarenessModel.CanSeeTarget(effectiveCover);
749      }
750  
751      /// <summary>
752      /// Checks if this operator is currently in a recognition delay for a target.
753      /// </summary>
754      /// <param name="targetId">Target operator ID</param>
755      /// <param name="currentTimeMs">Current simulation time</param>
756      /// <returns>True if still in recognition delay</returns>
757      public bool IsInRecognitionDelay(Guid targetId, long currentTimeMs)
758      {
759          if (!RecognitionDelayEndMs.HasValue || !RecognitionStartMs.HasValue || RecognitionTargetId != targetId)
760              return false;
761  
762          return currentTimeMs < RecognitionDelayEndMs.Value;
763      }
764  
765      /// <summary>
766      /// Gets the recognition progress for a target (0.0 = just started, 1.0 = complete).
767      /// </summary>
768      /// <param name="targetId">Target operator ID</param>
769      /// <param name="currentTimeMs">Current simulation time</param>
770      /// <param name="recognitionStartMs">When recognition started</param>
771      /// <returns>Progress from 0.0 to 1.0</returns>
772      public float GetRecognitionProgress(Guid targetId, long currentTimeMs, long recognitionStartMs)
773      {
774          if (!RecognitionDelayEndMs.HasValue || RecognitionTargetId != targetId)
775              return 1.0f; // No recognition delay active = fully recognized
776  
777          if (currentTimeMs >= RecognitionDelayEndMs.Value)
778              return 1.0f;
779  
780          long totalDuration = RecognitionDelayEndMs.Value - recognitionStartMs;
781          if (totalDuration <= 0)
782              return 1.0f;
783  
784          long elapsed = currentTimeMs - recognitionStartMs;
785          return Math.Clamp((float)elapsed / totalDuration, 0f, 1f);
786      }
787  
788      /// <summary>
789      /// Updates target visibility tracking and handles recognition delay.
790      /// Call when the target's visibility changes.
791      /// </summary>
792      /// <param name="targetId">Target operator ID</param>
793      /// <param name="targetIsVisible">Whether the target is currently visible</param>
794      /// <param name="currentTimeMs">Current simulation time</param>
795      public void UpdateTargetVisibility(Guid targetId, bool targetIsVisible, long currentTimeMs)
796      {
797          if (targetIsVisible)
798          {
799              LastTargetVisibleMs = currentTimeMs;
800  
801              // Check if target just became visible (was not visible before)
802              if (!WasTargetVisible)
803              {
804                  // Start recognition delay
805                  float recognitionDelay = AwarenessModel.CalculateRecognitionDelayMs(
806                      AccuracyProficiency, SuppressionLevel);
807                  RecognitionTargetId = targetId;
808                  RecognitionStartMs = currentTimeMs;
809                  RecognitionDelayEndMs = currentTimeMs + (long)recognitionDelay;
810              }
811          }
812          else
813          {
814              // Target no longer visible - clear recognition tracking
815              if (RecognitionTargetId == targetId)
816              {
817                  RecognitionTargetId = null;
818                  RecognitionStartMs = null;
819                  RecognitionDelayEndMs = null;
820              }
821          }
822  
823          WasTargetVisible = targetIsVisible;
824      }
825  
826      /// <summary>
827      /// Updates target visibility tracking.
828      /// Call when the target's visibility changes.
829      /// </summary>
830      /// <param name="targetIsVisible">Whether the target is currently visible</param>
831      /// <param name="currentTimeMs">Current simulation time</param>
832      [Obsolete("Use UpdateTargetVisibility(Guid, bool, long) for recognition delay support")]
833      public void UpdateTargetVisibility(bool targetIsVisible, long currentTimeMs)
834      {
835          if (targetIsVisible)
836          {
837              LastTargetVisibleMs = currentTimeMs;
838          }
839          WasTargetVisible = targetIsVisible;
840      }
841  }