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 }