/ GUNRPG.Core / Combat / CombatSystemV2.cs
CombatSystemV2.cs
  1  using GUNRPG.Core.Events;
  2  using GUNRPG.Core.Intents;
  3  using GUNRPG.Core.Operators;
  4  using GUNRPG.Core.Time;
  5  using GUNRPG.Core.Rendering;
  6  
  7  namespace GUNRPG.Core.Combat;
  8  
  9  /// <summary>
 10  /// Combat phase state.
 11  /// </summary>
 12  public enum CombatPhase
 13  {
 14      Planning,   // Time paused, accepting intents
 15      Executing,  // Time running, events executing
 16      Ended       // Combat complete
 17  }
 18  
 19  /// <summary>
 20  /// Controls optional debug output for combat simulation.
 21  /// </summary>
 22  public sealed class CombatDebugOptions
 23  {
 24      public bool VerboseShotLogs { get; set; }
 25  }
 26  
 27  /// <summary>
 28  /// Main combat system orchestrator with support for simultaneous intents.
 29  /// Manages event queue, time, and operator state during combat.
 30  /// </summary>
 31  public class CombatSystemV2
 32  {
 33      private readonly SimulationTime _time;
 34      private readonly EventQueue _eventQueue;
 35      private readonly TrackedRandom _random;
 36      private readonly List<ISimulationEvent> _executedEvents = new();
 37      private readonly List<CombatEventTimelineEntry> _timelineEntries = new();
 38      private readonly Dictionary<Guid, long> _activeAdsStarts = new();
 39  
 40      // Prevent double-scheduling shots at the same timestamp for a single operator.
 41      private readonly Dictionary<Guid, long> _nextScheduledShotTimeMs = new();
 42      
 43      // Prevent double-scheduling movement updates at the same timestamp for a single operator.
 44      private readonly Dictionary<Guid, long> _nextScheduledMovementTimeMs = new();
 45  
 46      // Track misses in current round to end when both miss
 47      private readonly HashSet<Guid> _missedInCurrentRound = new();
 48  
 49      public Operator Player { get; }
 50      public Operator Enemy { get; }
 51      public int Seed { get; }
 52      public CombatPhase Phase { get; private set; }
 53      public IReadOnlyList<ISimulationEvent> ExecutedEvents => _executedEvents;
 54      public IReadOnlyList<CombatEventTimelineEntry> TimelineEntries => _timelineEntries;
 55      public long CurrentTimeMs => _time.CurrentTimeMs;
 56  
 57      private SimultaneousIntents? _playerIntents;
 58      private SimultaneousIntents? _enemyIntents;
 59  
 60      private const int MOVEMENT_UPDATE_INTERVAL_MS = 100; // Update distance every 100ms
 61      private const int DISABLE_MOVEMENT_REACTIONS = 10; // High threshold to effectively disable movement-based reactions
 62      
 63  
 64      public CombatDebugOptions DebugOptions { get; } = new() { VerboseShotLogs = true };
 65  
 66      public CombatSystemV2(Operator player, Operator enemy, int? seed = null, CombatDebugOptions? debugOptions = null, TrackedRandom? randomOverride = null)
 67      {
 68          _time = new SimulationTime();
 69          _eventQueue = new EventQueue();
 70          var resolvedSeed = randomOverride?.Seed ?? seed ?? Random.Shared.Next();
 71          _random = randomOverride ?? new TrackedRandom(resolvedSeed);
 72          Seed = resolvedSeed;
 73          
 74          Player = player;
 75          Enemy = enemy;
 76          if (debugOptions != null)
 77              DebugOptions = debugOptions;
 78          Phase = CombatPhase.Planning;
 79      }
 80  
 81      public (int Seed, int CallCount) GetRandomState()
 82      {
 83          return (Seed, _random.CallCount);
 84      }
 85  
 86      public (SimultaneousIntents? player, SimultaneousIntents? enemy) GetPendingIntents()
 87      {
 88          return (CloneIntents(_playerIntents), CloneIntents(_enemyIntents));
 89      }
 90  
 91      public static CombatSystemV2 FromState(
 92          Operator player,
 93          Operator enemy,
 94          CombatPhase phase,
 95          long currentTimeMs,
 96          SimultaneousIntents? playerIntents,
 97          SimultaneousIntents? enemyIntents,
 98          int seed,
 99          int randomCallCount,
100          CombatDebugOptions? debugOptions = null)
101      {
102          var random = new TrackedRandom(seed, randomCallCount);
103          var system = new CombatSystemV2(player, enemy, seed, debugOptions, random);
104          if (currentTimeMs > 0)
105          {
106              system._time.Advance(currentTimeMs);
107          }
108  
109          system._playerIntents = CloneIntents(playerIntents);
110          system._enemyIntents = CloneIntents(enemyIntents);
111          system.Phase = phase;
112          return system;
113      }
114  
115      private static SimultaneousIntents? CloneIntents(SimultaneousIntents? intents)
116      {
117          if (intents == null)
118              return null;
119  
120          return new SimultaneousIntents(intents.OperatorId)
121          {
122              Primary = intents.Primary,
123              Movement = intents.Movement,
124              Stance = intents.Stance,
125              Cover = intents.Cover,
126              CancelMovement = intents.CancelMovement,
127              SubmittedAtMs = intents.SubmittedAtMs
128          };
129      }
130  
131      /// <summary>
132      /// Submits simultaneous intents for an operator.
133      /// Can only be called during Planning phase.
134      /// </summary>
135      public (bool success, string? errorMessage) SubmitIntents(Operator op, SimultaneousIntents intents)
136      {
137          if (Phase != CombatPhase.Planning)
138              return (false, "Can only submit intents during planning phase");
139  
140          var validation = intents.Validate(op);
141          if (!validation.isValid)
142              return (false, validation.errorMessage);
143  
144          intents.SubmittedAtMs = _time.CurrentTimeMs;
145  
146          if (op == Player)
147              _playerIntents = intents;
148          else if (op == Enemy)
149              _enemyIntents = intents;
150  
151          return (true, null);
152      }
153  
154      /// <summary>
155      /// Begins execution phase, processing intents and running events.
156      /// </summary>
157      public void BeginExecution()
158      {
159          if (Phase != CombatPhase.Planning)
160              return;
161  
162          // Round execution ends when all events are processed. Starting a new planning->execution
163          // cycle should honor ONLY newly submitted intents, but we preserve in-flight bullets
164          // (DamageAppliedEvent, ShotMissedEvent) so they can land even across planning phases.
165          _eventQueue.ClearExceptInFlightBullets();
166          _nextScheduledShotTimeMs.Clear();
167          _nextScheduledMovementTimeMs.Clear();
168          _missedInCurrentRound.Clear();
169          _activeAdsStarts.Clear();
170  
171          Phase = CombatPhase.Executing;
172  
173          // Process both operators' intents to schedule initial events
174          if (_playerIntents != null && _playerIntents.HasAnyAction())
175              ProcessSimultaneousIntents(Player, _playerIntents);
176          
177          if (_enemyIntents != null && _enemyIntents.HasAnyAction())
178              ProcessSimultaneousIntents(Enemy, _enemyIntents);
179  
180          Console.WriteLine($"\n=== EXECUTION PHASE STARTED at {_time.CurrentTimeMs}ms ===\n");
181      }
182  
183      /// <summary>
184      /// Executes events until round end conditions are met:
185      /// - Either player or enemy is hit, OR
186      /// - Both players miss their shots.
187      /// Special case: when the enemy dies but has already-fired hit impacts queued
188      /// against the player, execution continues until those in-flight bullet impacts resolve.
189      /// </summary>
190      public bool ExecuteUntilReactionWindow()
191      {
192          while (_eventQueue.Count > 0)
193          {
194              var evt = _eventQueue.DequeueNext();
195              if (evt == null)
196                  break;
197  
198              // Advance time to event
199              long deltaMs = evt.EventTimeMs - _time.CurrentTimeMs;
200              if (deltaMs > 0)
201              {
202                  // Update regeneration for both operators
203                  Player.UpdateRegeneration(deltaMs, evt.EventTimeMs);
204                  Enemy.UpdateRegeneration(deltaMs, evt.EventTimeMs);
205  
206                  // Update suppression decay for both operators
207                  UpdateSuppressionDecay(Player, deltaMs, evt.EventTimeMs);
208                  UpdateSuppressionDecay(Enemy, deltaMs, evt.EventTimeMs);
209                  
210                  _time.Advance(deltaMs);
211              }
212  
213              // Execute event - round end is determined by specific event types
214              bool shouldEndRound = false;
215              evt.Execute();
216              _executedEvents.Add(evt);
217  
218              // Check for death
219              if (!Player.IsAlive || !Enemy.IsAlive)
220              {
221                  // If the enemy is dead but already-fired enemy bullets are still expected to hit,
222                  // let those in-flight damage events resolve before ending combat.
223                  if (Player.IsAlive
224                      && !Enemy.IsAlive
225                      && _eventQueue.HasPendingDamageEvent(Enemy.Id, Player.Id))
226                  {
227                      // Keep only already-scheduled bullet impact events (DamageAppliedEvent/ShotMissedEvent);
228                      // this drops non-bullet events (movement/stance/shot-fire scheduling) after the enemy dies.
229                      // The current event was already dequeued/executed above, so the loop continues forward.
230                      _eventQueue.ClearExceptInFlightBullets();
231                      continue;
232                  }
233  
234                  Phase = CombatPhase.Ended;
235                  
236                  // Synchronize distance at end of combat
237                  float averageDistance = (Player.DistanceToOpponent + Enemy.DistanceToOpponent) / 2f;
238                  Player.DistanceToOpponent = averageDistance;
239                  Enemy.DistanceToOpponent = averageDistance;
240                  
241                  Console.WriteLine($"\n=== COMBAT ENDED at {_time.CurrentTimeMs}ms ===");
242                  
243                  if (!Player.IsAlive)
244                      Console.WriteLine($"{Player.Name} was defeated!");
245                  if (!Enemy.IsAlive)
246                      Console.WriteLine($"{Enemy.Name} was defeated!");
247                      
248                  return false;
249              }
250  
251              // A hit (damage applied) always ends the round
252              if (evt is DamageAppliedEvent)
253              {
254                  shouldEndRound = true;
255              }
256  
257              // Suppressive fire completion ends the round early
258              if (evt is SuppressiveFireCompletedEvent)
259              {
260                  shouldEndRound = true;
261              }
262  
263              // Track misses for "both miss" round end condition
264              if (evt is ShotMissedEvent)
265              {
266                  _missedInCurrentRound.Add(evt.OperatorId);
267                  
268                  // If both operators have missed, end the round
269                  if (_missedInCurrentRound.Contains(Player.Id) && _missedInCurrentRound.Contains(Enemy.Id))
270                  {
271                      shouldEndRound = true;
272                  }
273              }
274  
275              if (evt is DamageAppliedEvent damageEvent)
276              {
277                  var target = damageEvent.TargetId == Player.Id ? Player : Enemy;
278                  if (target.FlinchShotsRemaining > 0)
279                  {
280                      float flinchWindowMs = target.EquippedWeapon?.GetTimeBetweenShotsMs() ?? 100f;
281                      var flinchEnd = damageEvent.EventTimeMs + (long)flinchWindowMs;
282                      _timelineEntries.Add(new CombatEventTimelineEntry(
283                          "Flinch",
284                          (int)damageEvent.EventTimeMs,
285                          (int)flinchEnd,
286                          target.Name,
287                          $"Severity {target.FlinchSeverity:0.00}"));
288                  }
289              }
290  
291              // Check if round should end (hit occurred or both missed)
292              if (shouldEndRound)
293              {
294                  Phase = CombatPhase.Planning;
295                  
296                  // Synchronize distance between both operators at end of round
297                  // In 1v1 combat, both operators should have the same distance value
298                  // Use the average in case of any drift during event processing
299                  float averageDistance = (Player.DistanceToOpponent + Enemy.DistanceToOpponent) / 2f;
300                  Player.DistanceToOpponent = averageDistance;
301                  Enemy.DistanceToOpponent = averageDistance;
302                  
303                  Console.WriteLine($"\n=== ROUND COMPLETE at {_time.CurrentTimeMs}ms ===");
304                  float plADS = Player.GetADSProgress(_time.CurrentTimeMs);
305                  float enADS = Enemy.GetADSProgress(_time.CurrentTimeMs);
306                  Console.WriteLine($"Player: HP {Player.Health:F0}/{Player.MaxHealth:F0}, Ammo {Player.CurrentAmmo}, Distance {Player.DistanceToOpponent:F1}m, ADS {plADS*100:F0}%");
307                  Console.WriteLine($"Enemy:  HP {Enemy.Health:F0}/{Enemy.MaxHealth:F0}, Ammo {Enemy.CurrentAmmo}, Distance {Enemy.DistanceToOpponent:F1}m, ADS {enADS*100:F0}%");
308                  Console.WriteLine();
309                  
310                  return true;
311              }
312  
313              // Continue existing continuous intents only for repeating action events (shots, movement)
314              // Only continue for the operator whose event just executed
315              if (evt is ShotFiredEvent || evt is MovementIntervalEvent)
316              {
317                  Operator eventOp = evt.OperatorId == Player.Id ? Player : Enemy;
318                  SimultaneousIntents? intents = evt.OperatorId == Player.Id ? _playerIntents : _enemyIntents;
319                  
320                  if (intents != null)
321                  {
322                      ContinueOperatorIntents(eventOp, intents);
323                  }
324              }
325  
326              if (evt is ADSTransitionUpdateEvent && _activeAdsStarts.TryGetValue(evt.OperatorId, out long adsStart))
327              {
328                  var eventOp = evt.OperatorId == Player.Id ? Player : Enemy;
329                  _timelineEntries.Add(new CombatEventTimelineEntry(
330                      "ADS",
331                      (int)adsStart,
332                      (int)_time.CurrentTimeMs,
333                      eventOp.Name,
334                      "Complete"));
335                  _activeAdsStarts.Remove(evt.OperatorId);
336              }
337          }
338  
339          // All events processed, round complete
340          Phase = CombatPhase.Planning;
341          
342          // Show round summary
343          Console.WriteLine($"\n=== ROUND COMPLETE at {_time.CurrentTimeMs}ms ===");
344          float playerADS = Player.GetADSProgress(_time.CurrentTimeMs);
345          float enemyADS = Enemy.GetADSProgress(_time.CurrentTimeMs);
346          Console.WriteLine($"Player: HP {Player.Health:F0}/{Player.MaxHealth:F0}, Ammo {Player.CurrentAmmo}, Distance {Player.DistanceToOpponent:F1}m, ADS {playerADS*100:F0}%");
347          Console.WriteLine($"Enemy:  HP {Enemy.Health:F0}/{Enemy.MaxHealth:F0}, Ammo {Enemy.CurrentAmmo}, Distance {Enemy.DistanceToOpponent:F1}m, ADS {enemyADS*100:F0}%");
348          Console.WriteLine();
349          
350          return true;
351      }
352  
353      /// <summary>
354      /// Cancels the current intents for an operator.
355      /// </summary>
356      public void CancelIntents(Operator op)
357      {
358          _eventQueue.RemoveEventsForOperator(op.Id);
359          
360          if (op == Player)
361              _playerIntents = null;
362          else if (op == Enemy)
363              _enemyIntents = null;
364              
365          // Stop active firing
366          op.IsActivelyFiring = false;
367      }
368  
369      private void ProcessSimultaneousIntents(Operator op, SimultaneousIntents intents)
370      {
371          // Process movement cancellation first (immediate)
372          if (intents.CancelMovement && op.IsMoving)
373          {
374              op.CancelMovement(_time.CurrentTimeMs, _eventQueue);
375              Console.WriteLine($"[{_time.CurrentTimeMs}ms] {op.Name} cancelled movement");
376          }
377  
378          // Process stance first (ADS changes)
379          if (intents.Stance != StanceAction.None)
380          {
381              ProcessStanceAction(op, intents.Stance);
382          }
383  
384          // Process movement
385          if (intents.Movement != MovementAction.Stand)
386          {
387              ProcessMovementAction(op, intents.Movement);
388          }
389  
390          // Process cover actions
391          if (intents.Cover != CoverAction.None)
392          {
393              ProcessCoverAction(op, intents.Cover);
394          }
395  
396          // Process primary action last
397          if (intents.Primary != PrimaryAction.None)
398          {
399              ProcessPrimaryAction(op, intents.Primary);
400          }
401      }
402  
403      private void ProcessStanceAction(Operator op, StanceAction stance)
404      {
405          switch (stance)
406          {
407              case StanceAction.EnterADS:
408                  if (op.EquippedWeapon == null)
409                      return;
410  
411                  // Cannot initiate ADS while actively firing
412                  if (op.IsActivelyFiring)
413                  {
414                      Console.WriteLine($"[{_time.CurrentTimeMs}ms] {op.Name} cannot enter ADS while firing");
415                      return;
416                  }
417  
418                  // Calculate base ADS time with movement modifier
419                  float baseAdsTime = op.EquippedWeapon.ADSTimeMs;
420                  float movementMultiplier = MovementModel.GetADSTimeMultiplier(op.CurrentMovement);
421                  float movementAdjustedTime = baseAdsTime * movementMultiplier;
422                  
423                  // Apply response proficiency scaling
424                  float effectiveAdsTime = ResponseProficiencyModel.CalculateEffectiveDelay(
425                      movementAdjustedTime, op.ResponseProficiency);
426                  
427                  op.AimState = AimState.TransitioningToADS;
428                  op.ADSTransitionStartMs = _time.CurrentTimeMs;
429                  op.ADSTransitionDurationMs = effectiveAdsTime;
430                  _activeAdsStarts[op.Id] = _time.CurrentTimeMs;
431                  
432                  // Schedule completion event - use consistent rounding for both completionTime and actionDurationMs
433                  int roundedDurationMs = (int)Math.Round(op.ADSTransitionDurationMs);
434                  long completionTime = _time.CurrentTimeMs + roundedDurationMs;
435                  var adsEvent = new ADSTransitionUpdateEvent(
436                      completionTime,
437                      op,
438                      _eventQueue.GetNextSequenceNumber(),
439                      actionDurationMs: roundedDurationMs);
440                  _eventQueue.Schedule(adsEvent);
441                  
442                  // Track in timeline with movement and response proficiency info
443                  float responseMultiplier = ResponseProficiencyModel.GetDelayMultiplier(op.ResponseProficiency);
444                  bool hasMovementScaling = Math.Abs(movementMultiplier - 1.0f) > 0.01f;
445                  bool hasResponseScaling = Math.Abs(responseMultiplier - 1.0f) > ResponseProficiencyModel.MultiplierDisplayThreshold;
446                  string detail;
447                  if (hasMovementScaling || hasResponseScaling)
448                  {
449                      if (hasMovementScaling && hasResponseScaling)
450                      {
451                          detail = $"EnterADS ({baseAdsTime:F0}ms × {movementMultiplier:F2} × {responseMultiplier:F2} = {effectiveAdsTime:F0}ms)";
452                      }
453                      else if (hasMovementScaling)
454                      {
455                          detail = $"EnterADS ({baseAdsTime:F0}ms × {movementMultiplier:F2} = {effectiveAdsTime:F0}ms)";
456                      }
457                      else // hasResponseScaling only
458                      {
459                          detail = $"EnterADS ({movementAdjustedTime:F0}ms × {responseMultiplier:F2} = {effectiveAdsTime:F0}ms)";
460                      }
461                  }
462                  else
463                  {
464                      detail = "EnterADS";
465                  }
466                  _timelineEntries.Add(new CombatEventTimelineEntry(
467                      "ADS",
468                      (int)_time.CurrentTimeMs,
469                      (int)completionTime,
470                      op.Name,
471                      detail));
472                  
473                  Console.WriteLine($"[{_time.CurrentTimeMs}ms] {op.Name} started entering ADS (will complete at {completionTime}ms)");
474                  break;
475  
476              case StanceAction.ExitADS:
477                  if (_activeAdsStarts.TryGetValue(op.Id, out long adsStart))
478                  {
479                      _timelineEntries.Add(new CombatEventTimelineEntry(
480                          "ADS",
481                          (int)adsStart,
482                          (int)_time.CurrentTimeMs,
483                          op.Name,
484                          "ExitADS"));
485                      _activeAdsStarts.Remove(op.Id);
486                  }
487  
488                  op.AimState = AimState.Hip;
489                  op.ADSTransitionStartMs = null;
490                  Console.WriteLine($"[{_time.CurrentTimeMs}ms] {op.Name} exited ADS");
491                  break;
492          }
493      }
494  
495      private void ProcessMovementAction(Operator op, MovementAction movement)
496      {
497          // Check if operator can advance based on cover state (for forward movement actions)
498          if ((movement == MovementAction.WalkToward || movement == MovementAction.SprintToward || movement == MovementAction.SlideToward) 
499              && !op.CanAdvance())
500          {
501              Console.WriteLine($"[{_time.CurrentTimeMs}ms] {op.Name} cannot advance while in Full Cover (concealed)");
502              return;
503          }
504  
505          // Sprinting auto-exits ADS
506          if ((movement == MovementAction.SprintToward || movement == MovementAction.SprintAway) &&
507              (op.AimState == AimState.ADS || op.AimState == AimState.TransitioningToADS))
508          {
509              op.AimState = AimState.Hip;
510              op.ADSTransitionStartMs = null;
511              Console.WriteLine($"[{_time.CurrentTimeMs}ms] {op.Name} auto-exited ADS due to sprint");
512          }
513  
514          switch (movement)
515          {
516              // Directional movement
517              case MovementAction.WalkToward:
518                  op.MovementState = MovementState.Walking;
519                  op.CurrentDirection = MovementDirection.Advancing;
520                  ScheduleMovementUpdate(op, true, op.WalkSpeed);
521                  break;
522  
523              case MovementAction.WalkAway:
524                  op.MovementState = MovementState.Walking;
525                  op.CurrentDirection = MovementDirection.Retreating;
526                  ScheduleMovementUpdate(op, false, op.WalkSpeed);
527                  break;
528  
529              case MovementAction.SprintToward:
530                  op.MovementState = MovementState.Sprinting;
531                  op.CurrentDirection = MovementDirection.Advancing;
532                  ScheduleMovementUpdate(op, true, op.SprintSpeed);
533                  break;
534  
535              case MovementAction.SprintAway:
536                  op.MovementState = MovementState.Sprinting;
537                  op.CurrentDirection = MovementDirection.Retreating;
538                  ScheduleMovementUpdate(op, false, op.SprintSpeed);
539                  break;
540  
541              case MovementAction.SlideToward:
542                  op.CurrentDirection = MovementDirection.Advancing;
543                  ProcessSlide(op, true);
544                  break;
545                  
546              case MovementAction.SlideAway:
547                  op.CurrentDirection = MovementDirection.Retreating;
548                  ProcessSlide(op, false);
549                  break;
550  
551              // State-based movement (non-directional)
552              case MovementAction.Crouch:
553                  op.CurrentDirection = MovementDirection.Holding;
554                  op.StartMovement(MovementState.Crouching, -1, _time.CurrentTimeMs, _eventQueue);
555                  Console.WriteLine($"[{_time.CurrentTimeMs}ms] {op.Name} started crouching");
556                  break;
557                  
558              case MovementAction.Stand:
559                  op.CurrentDirection = MovementDirection.Holding;
560                  break;
561          }
562      }
563  
564      private void ProcessCoverAction(Operator op, CoverAction cover)
565      {
566          CoverState targetCover = cover switch
567          {
568              CoverAction.EnterPartial => CoverState.Partial,
569              CoverAction.EnterFull => CoverState.Full,
570              CoverAction.Exit => CoverState.None,
571              _ => op.CurrentCover
572          };
573  
574          if (targetCover == op.CurrentCover)
575              return;
576  
577          // Get effective transition delay scaled by response proficiency
578          var (effectiveDelayMs, baseDelayMs, multiplier) = CoverTransitionModel.GetEffectiveTransitionDelayWithInfo(
579              op.CurrentCover, targetCover, op.ResponseProficiency);
580  
581          // Schedule cover transition with scaled delay
582          StartCoverTransition(op, op.CurrentCover, targetCover, effectiveDelayMs, baseDelayMs, multiplier);
583      }
584  
585      /// <summary>
586      /// Starts a cover transition with delay.
587      /// </summary>
588      private void StartCoverTransition(Operator op, CoverState fromCover, CoverState toCover, int durationMs, int baseDelayMs = 0, float responseMultiplier = 1.0f)
589      {
590          long completionTime = _time.CurrentTimeMs + durationMs;
591  
592          // Schedule transition started event
593          _eventQueue.Schedule(new CoverTransitionStartedEvent(
594              _time.CurrentTimeMs,
595              op,
596              fromCover,
597              toCover,
598              completionTime,
599              _eventQueue.GetNextSequenceNumber()));
600  
601          // Schedule transition completed event
602          _eventQueue.Schedule(new CoverTransitionCompletedEvent(
603              completionTime,
604              op,
605              fromCover,
606              toCover,
607              _eventQueue.GetNextSequenceNumber()));
608  
609          // Track transition in timeline with response proficiency info
610          string detail = baseDelayMs > 0 && Math.Abs(responseMultiplier - 1.0f) > ResponseProficiencyModel.MultiplierDisplayThreshold
611              ? $"{fromCover} → {toCover} ({baseDelayMs}ms × {responseMultiplier:F2} = {durationMs}ms)"
612              : $"{fromCover} → {toCover}";
613          
614          _timelineEntries.Add(new CombatEventTimelineEntry(
615              "Cover",
616              (int)_time.CurrentTimeMs,
617              (int)completionTime,
618              op.Name,
619              detail));
620      }
621  
622      private void ProcessPrimaryAction(Operator op, PrimaryAction primary)
623      {
624          switch (primary)
625          {
626              case PrimaryAction.Fire:
627                  ProcessFireAction(op);
628                  break;
629  
630              case PrimaryAction.Reload:
631                  ProcessReloadAction(op);
632                  break;
633          }
634      }
635  
636      private void ProcessFireAction(Operator op)
637      {
638          var weapon = op.EquippedWeapon;
639          if (weapon == null || op.CurrentAmmo <= 0)
640              return;
641  
642          // Check if operator can shoot based on cover state
643          if (!op.CanShoot())
644          {
645              Console.WriteLine($"[{_time.CurrentTimeMs}ms] {op.Name} cannot shoot while in Full Cover (concealed)");
646              return;
647          }
648  
649          // Get the target
650          var target = (op == Player) ? Enemy : Player;
651  
652          // Update target visibility tracking with recognition delay support
653          bool targetVisible = target.IsVisibleToOpponents(_time.CurrentTimeMs);
654          op.UpdateTargetVisibility(target.Id, targetVisible, _time.CurrentTimeMs);
655  
656          // Check if we should use suppressive fire (target in full cover)
657          if (SuppressiveFireModel.ShouldUseSuppressiveFire(
658              op.CurrentAmmo,
659              target.GetEffectiveCoverState(_time.CurrentTimeMs),
660              op.LastTargetVisibleMs,
661              _time.CurrentTimeMs))
662          {
663              ProcessSuppressiveFireAction(op, target);
664              return;
665          }
666  
667          // Mark as actively firing
668          op.IsActivelyFiring = true;
669  
670          // Handle sprint-to-fire delay with response proficiency scaling
671          long fireTime = _time.CurrentTimeMs;
672          if (op.MovementState == MovementState.Sprinting)
673          {
674              // Scale sprint-to-fire delay by response proficiency
675              float baseSprintToFireMs = weapon.SprintToFireTimeMs;
676              float effectiveSprintToFireMs = ResponseProficiencyModel.CalculateEffectiveDelay(
677                  baseSprintToFireMs, op.ResponseProficiency);
678              fireTime += (long)effectiveSprintToFireMs;
679              op.MovementState = MovementState.Walking; // Transition from sprint to walk when firing
680          }
681  
682          // Check if in recognition delay and emit event when completed
683          if (op.IsInRecognitionDelay(target.Id, _time.CurrentTimeMs) && 
684              op.RecognitionDelayEndMs.HasValue && 
685              op.RecognitionStartMs.HasValue &&
686              fireTime >= op.RecognitionDelayEndMs.Value)
687          {
688              // Schedule recognition completed event
689              _eventQueue.Schedule(new TargetRecognizedEvent(
690                  op.RecognitionDelayEndMs.Value,
691                  op,
692                  target,
693                  op.RecognitionDelayEndMs.Value - op.RecognitionStartMs.Value,
694                  _eventQueue.GetNextSequenceNumber()));
695          }
696  
697          ScheduleShotIfNeeded(op, fireTime);
698      }
699  
700      /// <summary>
701      /// Processes suppressive fire against a concealed target.
702      /// </summary>
703      private void ProcessSuppressiveFireAction(Operator shooter, Operator target)
704      {
705          var weapon = shooter.EquippedWeapon;
706          if (weapon == null)
707              return;
708  
709          // Calculate burst size
710          int burstSize = SuppressiveFireModel.CalculateSuppressiveBurstSize(weapon, shooter.CurrentAmmo);
711  
712          // Consume ammo immediately
713          shooter.CurrentAmmo -= burstSize;
714  
715          // Mark as actively firing (will be cleared by completed event)
716          shooter.IsActivelyFiring = true;
717  
718          // Schedule suppressive fire started event
719          _eventQueue.Schedule(new SuppressiveFireStartedEvent(
720              _time.CurrentTimeMs,
721              shooter,
722              target,
723              burstSize,
724              weapon.Name,
725              _eventQueue.GetNextSequenceNumber()));
726  
727          // Calculate burst duration and suppression
728          long burstDurationMs = SuppressiveFireModel.CalculateBurstDurationMs(weapon, burstSize);
729          float suppressionSeverity = SuppressiveFireModel.CalculateSuppressiveBurstSeverity(
730              weapon,
731              burstSize,
732              shooter.DistanceToOpponent,
733              target.CurrentMovement);
734  
735          // Schedule suppressive fire completed event (applies suppression and ends round)
736          long completionTime = _time.CurrentTimeMs + burstDurationMs + SuppressiveFireModel.PostSuppressiveCooldownMs;
737          _eventQueue.Schedule(new SuppressiveFireCompletedEvent(
738              completionTime,
739              shooter,
740              target,
741              burstSize,
742              suppressionSeverity,
743              weapon.Name,
744              _eventQueue.GetNextSequenceNumber(),
745              _eventQueue));
746  
747          // Add timeline entry
748          _timelineEntries.Add(new CombatEventTimelineEntry(
749              "SuppFire",
750              (int)_time.CurrentTimeMs,
751              (int)completionTime,
752              shooter.Name,
753              $"{burstSize} rounds"));
754      }
755  
756      private void ProcessReloadAction(Operator op)
757      {
758          if (op.EquippedWeapon == null)
759              return;
760  
761          op.WeaponState = WeaponState.Reloading;
762          long completionTime = _time.CurrentTimeMs + op.EquippedWeapon.ReloadTimeMs;
763          var reloadEvent = new ReloadCompleteEvent(
764              completionTime,
765              op,
766              _eventQueue.GetNextSequenceNumber(),
767              actionDurationMs: op.EquippedWeapon.ReloadTimeMs);
768          _eventQueue.Schedule(reloadEvent);
769          
770          Console.WriteLine($"[{_time.CurrentTimeMs}ms] {op.Name} started reloading (will complete at {completionTime}ms)");
771      }
772  
773      private void ProcessSlide(Operator op, bool towardOpponent)
774      {
775          // Consume stamina
776          op.Stamina -= op.SlideStaminaCost;
777          
778          // Calculate slide distance
779          float slideDistance = op.SlideDistance * (towardOpponent ? -1 : 1);
780          
781          op.MovementState = MovementState.Sliding;
782          
783          // Schedule slide completion
784          long completionTime = _time.CurrentTimeMs + (long)op.SlideDurationMs;
785          var slideEvent = new SlideCompleteEvent(
786              completionTime,
787              op,
788              _eventQueue.GetNextSequenceNumber(),
789              actionDurationMs: (int)op.SlideDurationMs);
790          _eventQueue.Schedule(slideEvent);
791          
792          // Immediately apply distance change
793          op.DistanceToOpponent += slideDistance;
794          
795          Console.WriteLine($"[{_time.CurrentTimeMs}ms] {op.Name} slid {slideDistance:F1}m (distance now: {op.DistanceToOpponent:F1}m)");
796      }
797  
798      private void ScheduleMovementUpdate(Operator op, bool towardOpponent, float speed)
799      {
800          long nextUpdateTime = _time.CurrentTimeMs + MOVEMENT_UPDATE_INTERVAL_MS;
801          
802          // Check if we've already scheduled a movement for this operator at this time
803          if (_nextScheduledMovementTimeMs.TryGetValue(op.Id, out long scheduledTime) && scheduledTime >= nextUpdateTime)
804          {
805              // Already have a movement scheduled at or after this time, don't double-schedule
806              return;
807          }
808          
809          // Calculate distance moved in this interval
810          float intervalSeconds = MOVEMENT_UPDATE_INTERVAL_MS / 1000f;
811          float distanceMoved = speed * intervalSeconds;
812          
813          // Negative if toward, positive if away
814          float signedDistance = distanceMoved * (towardOpponent ? -1 : 1);
815          
816          // Determine opponent for distance synchronization
817          Operator opponent = (op == Player) ? Enemy : Player;
818          
819          var moveEvent = new MovementIntervalEvent(
820              nextUpdateTime,
821              op,
822              signedDistance,
823              _eventQueue.GetNextSequenceNumber(),
824              intervalDurationMs: MOVEMENT_UPDATE_INTERVAL_MS,
825              metersPerCommitmentUnit: DISABLE_MOVEMENT_REACTIONS,
826              opponent: opponent);
827          _eventQueue.Schedule(moveEvent);
828          
829          // Track that we've scheduled this movement
830          _nextScheduledMovementTimeMs[op.Id] = nextUpdateTime;
831      }
832  
833      private void ContinueActiveIntents()
834      {
835          // Continue player intents
836          if (_playerIntents != null)
837          {
838              ContinueOperatorIntents(Player, _playerIntents);
839          }
840  
841          // Continue enemy intents
842          if (_enemyIntents != null)
843          {
844              ContinueOperatorIntents(Enemy, _enemyIntents);
845          }
846      }
847  
848      private void ContinueOperatorIntents(Operator op, SimultaneousIntents intents)
849      {
850          // Continue primary action (firing)
851          if (intents.Primary == PrimaryAction.Fire && 
852              op.CurrentAmmo > 0 && 
853              op.WeaponState == WeaponState.Ready && 
854              op.EquippedWeapon != null &&
855              op.IsActivelyFiring)
856          {
857              long nextShotTime = _time.CurrentTimeMs + (long)op.EquippedWeapon.GetTimeBetweenShotsMs();
858              ScheduleShotIfNeeded(op, nextShotTime);
859          }
860          else if (intents.Primary == PrimaryAction.Fire)
861          {
862              // Stop firing if conditions no longer met
863              op.IsActivelyFiring = false;
864          }
865  
866          // Continue movement
867          if (intents.Movement != MovementAction.Stand && 
868              intents.Movement != MovementAction.SlideToward && 
869              intents.Movement != MovementAction.SlideAway)
870          {
871              bool towardOpponent = intents.Movement == MovementAction.WalkToward || 
872                                    intents.Movement == MovementAction.SprintToward;
873              float speed = (intents.Movement == MovementAction.SprintToward || 
874                            intents.Movement == MovementAction.SprintAway) ? op.SprintSpeed : op.WalkSpeed;
875              
876              // Check if still valid
877              if ((intents.Movement == MovementAction.SprintToward || intents.Movement == MovementAction.SprintAway) &&
878                  (op.Stamina <= 0 || op.MovementState != MovementState.Sprinting))
879              {
880                  return; // Stop sprinting
881              }
882  
883              ScheduleMovementUpdate(op, towardOpponent, speed);
884          }
885      }
886  
887      private void ScheduleShotIfNeeded(Operator op, long shotTime)
888      {
889          if (op.EquippedWeapon == null || op.CurrentAmmo <= 0 || op.WeaponState != WeaponState.Ready)
890              return;
891  
892          if (_nextScheduledShotTimeMs.TryGetValue(op.Id, out long existingTime) && shotTime <= existingTime)
893              return;
894  
895          _nextScheduledShotTimeMs[op.Id] = shotTime;
896  
897          var target = (op == Player) ? Enemy : Player;
898          var shotEvent = new ShotFiredEvent(shotTime, op, target, _eventQueue.GetNextSequenceNumber(), _random, _eventQueue, DebugOptions);
899          _eventQueue.Schedule(shotEvent);
900      }
901  
902      // Suppression tracking
903      private readonly Dictionary<Guid, long> _suppressionStartTimes = new();
904      private readonly Dictionary<Guid, float> _peakSuppressionLevels = new();
905  
906      /// <summary>
907      /// Updates suppression decay and emits suppression ended events when appropriate.
908      /// </summary>
909      private void UpdateSuppressionDecay(Operator op, long deltaMs, long currentTimeMs)
910      {
911          if (op.SuppressionLevel <= 0f)
912              return;
913  
914          // Track suppression start time for timeline - use the first suppression application time
915          if (!_suppressionStartTimes.ContainsKey(op.Id))
916          {
917              // Use LastSuppressionApplicationMs as the start time if available,
918              // otherwise fall back to current time (shouldn't happen normally)
919              _suppressionStartTimes[op.Id] = op.LastSuppressionApplicationMs ?? currentTimeMs;
920              _peakSuppressionLevels[op.Id] = op.SuppressionLevel;
921          }
922          else
923          {
924              // Track peak suppression
925              if (op.SuppressionLevel > _peakSuppressionLevels[op.Id])
926              {
927                  _peakSuppressionLevels[op.Id] = op.SuppressionLevel;
928              }
929          }
930  
931          bool suppressionEnded = op.UpdateSuppressionDecay(deltaMs, currentTimeMs);
932  
933          if (suppressionEnded && _suppressionStartTimes.TryGetValue(op.Id, out long startTime))
934          {
935              // Emit suppression ended event
936              long duration = currentTimeMs - startTime;
937              float peakSeverity = _peakSuppressionLevels.GetValueOrDefault(op.Id, 0f);
938  
939              _eventQueue.Schedule(new SuppressionEndedEvent(
940                  currentTimeMs,
941                  op,
942                  duration,
943                  peakSeverity,
944                  _eventQueue.GetNextSequenceNumber()));
945  
946              // Add timeline entry for suppression period
947              _timelineEntries.Add(new CombatEventTimelineEntry(
948                  "Suppression",
949                  (int)startTime,
950                  (int)currentTimeMs,
951                  op.Name,
952                  $"Peak {peakSeverity:0.00}"));
953  
954              _suppressionStartTimes.Remove(op.Id);
955              _peakSuppressionLevels.Remove(op.Id);
956          }
957          else if (op.SuppressionLevel <= 0f)
958          {
959              // Suppression fully decayed without ever crossing the suppression threshold.
960              // Clean up tracking dictionaries without emitting an event or timeline entry.
961              _suppressionStartTimes.Remove(op.Id);
962              _peakSuppressionLevels.Remove(op.Id);
963          }
964      }
965  }