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 }